From 95107361b1e0724136b5264f7a0b1ed861bab5c7 Mon Sep 17 00:00:00 2001 From: Amatack Date: Thu, 12 Feb 2026 15:09:45 -0500 Subject: [PATCH 01/14] feat: add eCash (XEC) signer type and network definition --- packages/types/src/index.ts | 1 + packages/types/src/networks.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 5cf205151..9c75e89c6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -48,6 +48,7 @@ enum SignerType { sr25519 = "sr25519", // polkadot secp256k1 = "secp256k1", // ethereum secp256k1btc = "secp256k1-btc", // bitcoin + secp256k1ecash = "secp256k1-ecash", // ecash ed25519kda = "ed25519-kda", // kadena ed25519sol = "ed25519-sol", // solana ed25519mas = "ed25519-mas", // massa diff --git a/packages/types/src/networks.ts b/packages/types/src/networks.ts index c26ed6888..97e2cb44b 100755 --- a/packages/types/src/networks.ts +++ b/packages/types/src/networks.ts @@ -99,6 +99,7 @@ export enum NetworkNames { Massa = "Massa", MassaBuildnet = "MassaBuildnet", TAC = "TAC", + ECash = "XEC", } export enum CoingeckoPlatform { From e55112f4453e6b4d0424286c921617e2f9c89438 Mon Sep 17 00:00:00 2001 From: Amatack Date: Thu, 12 Feb 2026 15:14:47 -0500 Subject: [PATCH 02/14] feat: add eCash keyring support and key derivation --- .../extension/src/libs/keyring/keyring.ts | 3 ++ .../src/libs/utils/initialize-wallet.ts | 11 ++++ packages/keyring/src/index.ts | 43 +++++++++++++++ packages/keyring/tests/generate.test.ts | 54 +++++++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/packages/extension/src/libs/keyring/keyring.ts b/packages/extension/src/libs/keyring/keyring.ts index 4fc5f98e7..9f4931187 100644 --- a/packages/extension/src/libs/keyring/keyring.ts +++ b/packages/extension/src/libs/keyring/keyring.ts @@ -97,5 +97,8 @@ export class KeyRingBase { deleteAccount(address: string): Promise { return this.#keyring.deleteAccount(address); } + async getPrivateKeyForECash(account: EnkryptAccount): Promise { + return this.#keyring.getPrivateKeyForECash(account); + } } export default KeyRingBase; diff --git a/packages/extension/src/libs/utils/initialize-wallet.ts b/packages/extension/src/libs/utils/initialize-wallet.ts index 8456ad8a3..d4bdcf656 100644 --- a/packages/extension/src/libs/utils/initialize-wallet.ts +++ b/packages/extension/src/libs/utils/initialize-wallet.ts @@ -2,6 +2,7 @@ import KeyRing from '@/libs/keyring/keyring'; import EthereumNetworks from '@/providers/ethereum/networks'; import PolkadotNetworks from '@/providers/polkadot/networks'; import BitcoinNetworks from '@/providers/bitcoin/networks'; +import ECashNetworks from '@/providers/ecash/networks'; import SolanaNetworks from '@/providers/solana/networks'; import KadenaNetworks from '@/providers/kadena/networks'; import MassaNetworks from '@/providers/massa/networks'; @@ -12,6 +13,9 @@ export const initAccounts = async (keyring: KeyRing) => { const secp256k1btc = ( await getAccountsByNetworkName(NetworkNames.Bitcoin) ).filter(acc => !acc.isTestWallet); + const ecashAccounts = ( + await getAccountsByNetworkName(NetworkNames.ECash) + ).filter(acc => !acc.isTestWallet); const secp256k1 = ( await getAccountsByNetworkName(NetworkNames.Ethereum) ).filter(acc => !acc.isTestWallet); @@ -48,6 +52,13 @@ export const initAccounts = async (keyring: KeyRing) => { signerType: BitcoinNetworks.bitcoin.signer[0], walletType: WalletType.mnemonic, }); + if (ecashAccounts.length == 0) + await keyring.saveNewAccount({ + basePath: ECashNetworks[NetworkNames.ECash].basePath, + name: 'eCash Account 1', + signerType: ECashNetworks[NetworkNames.ECash].signer[0], + walletType: WalletType.mnemonic, + }); if (ed25519kda.length == 0) await keyring.saveNewAccount({ basePath: KadenaNetworks.kadena.basePath, diff --git a/packages/keyring/src/index.ts b/packages/keyring/src/index.ts index 9200005a9..ebc05480e 100644 --- a/packages/keyring/src/index.ts +++ b/packages/keyring/src/index.ts @@ -50,6 +50,7 @@ class KeyRing { [SignerType.ed25519]: new PolkadotSigner(SignerType.ed25519), [SignerType.sr25519]: new PolkadotSigner(SignerType.sr25519), [SignerType.secp256k1btc]: new BitcoinSigner(), + [SignerType.secp256k1ecash]: new BitcoinSigner(), [SignerType.ed25519kda]: new KadenaSigner(), [SignerType.ed25519sol]: new KadenaSigner(), [SignerType.ed25519mas]: new MassaSigner(), @@ -385,6 +386,48 @@ class KeyRing { this.#privkeys = {}; this.#isLocked = true; } + + /** + * Get private key for eCash wallet operations + * This generates the keypair and returns only the private key + * Used for eCash transactions which need the raw private key for ecash-wallet library + * + * @param account - The account to get the private key for + * @returns Buffer containing the private key bytes + */ + async getPrivateKeyForECash(account: EnkryptAccount): Promise { + assert(!this.#isLocked, Errors.KeyringErrors.Locked); + this.#resetTimeout(); + assert( + account.signerType === SignerType.secp256k1ecash, + Errors.KeyringErrors.CannotUseKeyring, + ); + assert( + !Object.values(HWwalletType).includes( + account.walletType as unknown as HWwalletType, + ), + Errors.KeyringErrors.CannotUseKeyring, + ); + + let keypair: KeyPair; + if (account.walletType === WalletType.privkey) { + const pubKey = (await this.getKeysArray()).find( + (i) => + i.basePath === account.basePath && i.pathIndex === account.pathIndex, + ).publicKey; + keypair = { + privateKey: this.#privkeys[account.pathIndex.toString()], + publicKey: pubKey, + }; + } else { + keypair = await this.#signers[account.signerType].generate( + this.#mnemonic, + pathParser(account.basePath, account.pathIndex, account.signerType), + ); + } + + return hexToBuffer(keypair.privateKey); + } } export default KeyRing; diff --git a/packages/keyring/tests/generate.test.ts b/packages/keyring/tests/generate.test.ts index dd51fe164..a956a9096 100644 --- a/packages/keyring/tests/generate.test.ts +++ b/packages/keyring/tests/generate.test.ts @@ -382,4 +382,58 @@ describe("Keyring create tests", () => { expect(deletedAccount).equals(undefined); }, ); + + it( + "keyring should generate secp256k1ecash keys", + { timeout: 20_000 }, + async () => { + const memStorage = new MemoryStorage(); + const storage = new Storage("keyring", { storage: memStorage }); + const keyring = new KeyRing(storage); + await keyring.init(password, { mnemonic: MNEMONIC }); + const keyAdd: KeyRecordAdd = { + basePath: "m/44'/1899'/0'/0", + signerType: SignerType.secp256k1ecash, + walletType: WalletType.mnemonic, + name: "ecash-account", + }; + await keyring.unlockMnemonic(password); + const pair = await keyring.createKey(keyAdd); + + expect(pair.signerType).equals(SignerType.secp256k1ecash); + expect(pair.pathIndex).equals(0); + expect(pair.address).equals( + "0x031c6d8a90254cc6dda7012c184bcde32e8d53f6fd6c882b2b12bd3fca1bd7372f", + ); + }, + ); + + it( + "keyring should generate secp256k1ecash keys with extra word", + { timeout: 20_000 }, + async () => { + const memStorage = new MemoryStorage(); + const storage = new Storage("keyring", { storage: memStorage }); + const keyring = new KeyRing(storage); + await keyring.init(password, { + mnemonic: MNEMONIC, + extraWord: EXTRA_WORD, + }); + const keyAdd: KeyRecordAdd = { + basePath: "m/44'/1899'/0'/0", + signerType: SignerType.secp256k1ecash, + walletType: WalletType.mnemonic, + name: "ecash-account", + }; + await keyring.unlockMnemonic(password); + const pair = await keyring.createKey(keyAdd); + + expect(pair.signerType).equals(SignerType.secp256k1ecash); + expect(pair.pathIndex).equals(0); + expect(pair.address).equals( + "0x0269a09af1fd626ae396ee7b546e76d145bd39924dbe51c0161153361daa729387", + ); + keyring.lock(); + }, + ); }); From d6775a72923d54fd7226fd0a402a60cd05b3d977 Mon Sep 17 00:00:00 2001 From: Amatack Date: Thu, 12 Feb 2026 15:17:59 -0500 Subject: [PATCH 03/14] feat: add eCash provider with network config, API, activity handlers and UI components --- .../providers/ecash/libs/activity-handlers.ts | 129 +++++ .../src/providers/ecash/libs/api-chronik.ts | 185 +++++++ .../src/providers/ecash/libs/utils.ts | 130 +++++ .../providers/ecash/networks/ecash-base.ts | 228 +++++++++ .../providers/ecash/networks/icons/ecash.svg | 1 + .../src/providers/ecash/networks/index.ts | 6 + .../tests/ecash.address.derivation.test.ts | 13 + .../src/providers/ecash/tests/utils.test.ts | 302 +++++++++++ .../providers/ecash/types/ecash-chronik.ts | 46 ++ .../providers/ecash/types/ecash-network.ts | 62 +++ .../src/providers/ecash/types/ecash-token.ts | 16 + .../providers/ecash/ui/libs/fee-calculator.ts | 175 +++++++ .../src/providers/ecash/ui/libs/send-utils.ts | 56 ++ .../components/send-address-input.vue | 185 +++++++ .../components/send-alert.vue | 79 +++ .../ecash/ui/send-transaction/index.vue | 480 ++++++++++++++++++ .../verify-transaction/index.vue | 419 +++++++++++++++ 17 files changed, 2512 insertions(+) create mode 100644 packages/extension/src/providers/ecash/libs/activity-handlers.ts create mode 100644 packages/extension/src/providers/ecash/libs/api-chronik.ts create mode 100644 packages/extension/src/providers/ecash/libs/utils.ts create mode 100644 packages/extension/src/providers/ecash/networks/ecash-base.ts create mode 100644 packages/extension/src/providers/ecash/networks/icons/ecash.svg create mode 100644 packages/extension/src/providers/ecash/networks/index.ts create mode 100644 packages/extension/src/providers/ecash/tests/ecash.address.derivation.test.ts create mode 100644 packages/extension/src/providers/ecash/tests/utils.test.ts create mode 100644 packages/extension/src/providers/ecash/types/ecash-chronik.ts create mode 100644 packages/extension/src/providers/ecash/types/ecash-network.ts create mode 100644 packages/extension/src/providers/ecash/types/ecash-token.ts create mode 100644 packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts create mode 100644 packages/extension/src/providers/ecash/ui/libs/send-utils.ts create mode 100644 packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue create mode 100644 packages/extension/src/providers/ecash/ui/send-transaction/components/send-alert.vue create mode 100644 packages/extension/src/providers/ecash/ui/send-transaction/index.vue create mode 100644 packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue diff --git a/packages/extension/src/providers/ecash/libs/activity-handlers.ts b/packages/extension/src/providers/ecash/libs/activity-handlers.ts new file mode 100644 index 000000000..d788f774e --- /dev/null +++ b/packages/extension/src/providers/ecash/libs/activity-handlers.ts @@ -0,0 +1,129 @@ +import type { Activity, BTCRawInfo } from '@/types/activity'; +import { ActivityStatus, ActivityType } from '@/types/activity'; +import type { ActivityHandlerType } from '@/libs/activity-state/types'; +import { ChronikAPI } from './api-chronik'; +import MarketData from '@/libs/market-data'; +import { + scriptToAddress, + extractSats, + calculateTransactionValue, + calculateOnchainTxFee, + getTransactionAddresses, + getTransactionTimestamp, + getAddressWithoutPrefix, +} from './utils'; + +export const chronikHandler: ActivityHandlerType = async ( + network, + address, +): Promise => { + try { + const normalizedAddress = getAddressWithoutPrefix(address); + + const api = (await network.api()) as unknown as ChronikAPI; + + const txHistory = await api.getTransactionHistory(normalizedAddress); + + if (!txHistory || txHistory.length === 0) { + return []; + } + + let currentPrice = 0; + if (network.coingeckoID) { + try { + const market = new MarketData(); + const marketData = await market.getMarketData([network.coingeckoID]); + currentPrice = marketData[0]?.current_price ?? 0; + } catch (priceError) { + console.error('[chronikHandler] Error getting price:', priceError); + } + } + + const activities: Activity[] = []; + + for (const tx of txHistory) { + try { + const isReceive = tx.outputs.some((output: any) => { + const outputAddress = scriptToAddress(output.outputScript); + return outputAddress === normalizedAddress; + }); + + const isSend = tx.inputs.some((input: any) => { + const inputAddress = scriptToAddress(input.outputScript); + return inputAddress === normalizedAddress; + }); + + const value = isReceive + ? calculateTransactionValue(tx.outputs, normalizedAddress, true) + : calculateTransactionValue(tx.outputs, normalizedAddress, false); + + const { fromAddress, toAddress } = getTransactionAddresses( + tx, + normalizedAddress, + isReceive, + isSend, + ); + + const fee = isSend ? calculateOnchainTxFee(tx) : 0; + + const status = + tx.block || tx.isFinal + ? ActivityStatus.success + : ActivityStatus.pending; + + const timestamp = getTransactionTimestamp(tx); + + const rawInfo: BTCRawInfo = { + blockNumber: tx.block?.height || 0, + fee, + transactionHash: tx.txid, + timestamp: tx.block?.timestamp || Math.floor(timestamp / 1000), + inputs: tx.inputs.map((input: any) => ({ + address: scriptToAddress(input.outputScript), + value: Number(extractSats(input)), + })), + outputs: tx.outputs.map((output: any) => ({ + address: scriptToAddress(output.outputScript), + value: Number(extractSats(output)), + pkscript: output.outputScript || '', + })), + }; + + const tokenInfo = { + decimals: network.decimals, + icon: network.icon, + symbol: network.currencyName, + name: network.currencyNameLong, + price: currentPrice.toString(), + }; + + const activity: Activity = { + from: fromAddress, + to: toAddress, + isIncoming: isReceive, + network: network.name, + status, + type: ActivityType.transaction, + value, + transactionHash: tx.txid, + timestamp, + token: tokenInfo, + rawInfo, + }; + + activities.push(activity); + } catch (txError) { + console.error(`Error parsing transaction ${tx.txid}:`, txError); + } + } + + activities.sort((a, b) => b.timestamp - a.timestamp); + + return activities; + } catch (error) { + console.error('Error in chronikHandler:', error); + return []; + } +}; + +export default chronikHandler; diff --git a/packages/extension/src/providers/ecash/libs/api-chronik.ts b/packages/extension/src/providers/ecash/libs/api-chronik.ts new file mode 100644 index 000000000..39035ee32 --- /dev/null +++ b/packages/extension/src/providers/ecash/libs/api-chronik.ts @@ -0,0 +1,185 @@ +import { ProviderAPIInterface } from '@/types/provider'; +import { BTCRawInfo } from '@/types/activity'; +import { ChronikClient } from 'chronik-client'; +import { getAddress } from '../types/ecash-network'; +import { ECashNetworkInfo, ChronikTx } from '../types/ecash-chronik'; +import { Script, Address } from 'ecash-lib'; +import { NetworkNames } from '@enkryptcom/types'; + +export class ChronikAPI extends ProviderAPIInterface { + node: string; + networkInfo: ECashNetworkInfo; + private chronik: ChronikClient; + + public decimals: number; + public name: NetworkNames; + + constructor( + node: string, + networkInfo: ECashNetworkInfo, + decimals: number = 2, + name: NetworkNames = NetworkNames.ECash, + ) { + super(node); + this.node = node; + this.networkInfo = networkInfo; + this.chronik = new ChronikClient([node]); + this.decimals = decimals; + this.name = name; + } + + async init(): Promise { + return this.withErrorHandling( + 'init', + async () => { + await this.chronik.blockchainInfo(); + }, + () => { + throw new Error('Failed to initialize Chronik API'); + }, + ); + } + + private ensurePrefix(address: string): string { + if (address.startsWith('ecash:') || address.startsWith('ectest:')) { + return address; + } + return `${this.networkInfo.cashAddrPrefix}:${address}`; + } + + private async withErrorHandling( + method: string, + operation: () => Promise, + fallback: () => T, + ): Promise { + try { + return await operation(); + } catch (error) { + console.error(`❌ [${method}] Error:`, error); + return fallback(); + } + } + + private calculateUTXOBalance(utxos: any[]): bigint { + return utxos.reduce((total, utxo) => { + if (!utxo.token) { + const value = BigInt((utxo as any).sats || utxo.value || 0); + return total + value; + } + return total; + }, BigInt(0)); + } + + async getBalance(pubkey: string): Promise { + return this.withErrorHandling( + 'getBalance', + async () => { + const address = getAddress(pubkey); + + const addressWithPrefix = this.ensurePrefix(address); + const utxoResponse = await this.chronik + .address(addressWithPrefix) + .utxos(); + + const totalSatoshis = this.calculateUTXOBalance(utxoResponse.utxos); + + return totalSatoshis.toString(); + }, + () => '0', + ); + } + + async getUTXOs(address: string): Promise { + return this.withErrorHandling( + 'getUTXOs', + async () => { + const addressWithPrefix = this.ensurePrefix(address); + const utxoResponse = await this.chronik + .address(addressWithPrefix) + .utxos(); + return utxoResponse.utxos || []; + }, + () => [], + ); + } + + async getTransactionHistory(address: string): Promise { + return this.withErrorHandling( + 'getTransactionHistory', + async () => { + const addressWithPrefix = this.ensurePrefix(address); + + const history = await this.chronik.address(addressWithPrefix).history(); + return history.txs || []; + }, + () => [], + ); + } + + async getTransactionStatus(hash: string): Promise { + return this.withErrorHandling( + 'getTransactionStatus', + async () => { + const tx = await this.chronik.tx(hash); + + if (!tx.block) { + return null; // Transaction is in mempool + } + + const rawInfo: BTCRawInfo = { + blockNumber: tx.block.height, + fee: this.calculateFee(tx as any), + transactionHash: tx.txid, + timestamp: tx.block.timestamp, + inputs: tx.inputs.map((input: any) => ({ + address: this.scriptToAddress(input.outputScript || ''), + value: input.value || '0', + pkscript: input.outputScript || '', + })), + outputs: tx.outputs.map((output: any) => ({ + address: this.scriptToAddress(output.outputScript), + value: output.value, + pkscript: output.outputScript, + })), + }; + + return rawInfo; + }, + () => null, + ); + } + + private calculateFee(tx: ChronikTx): number { + const inputSum = tx.inputs.reduce( + (sum, input) => sum + BigInt(input.value || 0), + BigInt(0), + ); + const outputSum = tx.outputs.reduce( + (sum, output) => sum + BigInt(output.value || 0), + BigInt(0), + ); + return Number(inputSum - outputSum); + } + + private scriptToAddress(scriptHex: string): string { + if (!scriptHex) return ''; + + try { + const scriptBytes = Buffer.from(scriptHex, 'hex'); + const script = new Script(scriptBytes); + const address = Address.fromScript(script); + const fullAddress = address.toString(); + + return fullAddress.split(':')[1] || fullAddress; + } catch (error) { + console.error( + '[scriptToAddress] Invalid script:', + scriptHex.slice(0, 20), + error, + ); + return ''; + } + } +} + +export default ChronikAPI; diff --git a/packages/extension/src/providers/ecash/libs/utils.ts b/packages/extension/src/providers/ecash/libs/utils.ts new file mode 100644 index 000000000..c8353a2fd --- /dev/null +++ b/packages/extension/src/providers/ecash/libs/utils.ts @@ -0,0 +1,130 @@ +import { Address } from 'ecash-lib'; +import { toBN } from 'web3-utils'; + +export const isValidECashAddress = (address: string): boolean => { + try { + const addr = Address.parse(address); + return Boolean(addr); + } catch { + return false; + } +}; + +const scriptAddressCache = new Map(); + +export function scriptToAddress(script: string): string { + if (!script) return 'Unknown'; + + if (scriptAddressCache.has(script)) { + return scriptAddressCache.get(script)!; + } + + try { + const address = Address.fromScriptHex(script); + const addressWithoutPrefix = getAddressWithoutPrefix(address); + + scriptAddressCache.set(script, addressWithoutPrefix); + return addressWithoutPrefix; + } catch (error) { + console.error('[scriptToAddress] Error:', error, script.slice(0, 20)); + + const fallback = + script.length > 20 + ? `${script.slice(0, 8)}...${script.slice(-8)}` + : script; + + scriptAddressCache.set(script, fallback); + return fallback; + } +} + +export function clearScriptAddressCache(): void { + scriptAddressCache.clear(); +} + +export function extractSats(item: any): string { + return item?.sats ? item.sats.toString() : '0'; +} + +export function sumSatoshis(items: any[]): string { + return items.reduce((sum, item) => { + return toBN(sum) + .add(toBN(extractSats(item))) + .toString(); + }, '0'); +} + +/** + * Calculate transaction value for receive or send + * @param outputs - Array of transaction outputs + * @param normalizedAddress - The address to check against + * @param isReceive - true for received funds, false for sent funds + */ +export function calculateTransactionValue( + outputs: any[], + normalizedAddress: string, + isReceive: boolean, +): string { + return outputs + .filter((output: any) => { + const outputAddress = scriptToAddress(output.outputScript); + return isReceive + ? outputAddress === normalizedAddress + : outputAddress !== normalizedAddress; + }) + .reduce((sum: string, output: any) => { + return toBN(sum) + .add(toBN(extractSats(output))) + .toString(); + }, '0'); +} + +export function calculateOnchainTxFee(tx: any): number { + const totalInput = sumSatoshis(tx.inputs); + const totalOutput = sumSatoshis(tx.outputs); + return Number(toBN(totalInput).sub(toBN(totalOutput)).toString()); +} + +export function getTransactionAddresses( + tx: any, + normalizedAddress: string, + isReceive: boolean, + isSend: boolean, +): { fromAddress: string; toAddress: string } { + let fromAddress = 'Unknown'; + let toAddress = 'Unknown'; + + if (isReceive) { + fromAddress = tx.inputs[0]?.outputScript + ? scriptToAddress(tx.inputs[0].outputScript) + : 'Unknown'; + toAddress = normalizedAddress; + } else if (isSend) { + fromAddress = normalizedAddress; + const recipientOutput = tx.outputs.find((output: any) => { + const outputAddress = scriptToAddress(output.outputScript); + return outputAddress !== normalizedAddress; + }); + toAddress = recipientOutput + ? scriptToAddress(recipientOutput.outputScript) + : 'Unknown'; + } + + return { fromAddress, toAddress }; +} + +export function getTransactionTimestamp(tx: any): number { + if (tx.block?.timestamp) { + return tx.block.timestamp * 1000; + } + if (tx.timeFirstSeen) { + return parseInt(tx.timeFirstSeen) * 1000; + } + return Date.now(); +} + +export function getAddressWithoutPrefix(address: Address | string): string { + const fullAddress = + typeof address === 'string' ? address : address.toString(); + return fullAddress.replace(/^ecash:/, ''); +} diff --git a/packages/extension/src/providers/ecash/networks/ecash-base.ts b/packages/extension/src/providers/ecash/networks/ecash-base.ts new file mode 100644 index 000000000..744531475 --- /dev/null +++ b/packages/extension/src/providers/ecash/networks/ecash-base.ts @@ -0,0 +1,228 @@ +import { NetworkNames, SignerType } from '@enkryptcom/types'; +import wrapActivityHandler from '@/libs/activity-state/wrap-activity-handler'; +import { chronikHandler } from '../libs/activity-handlers'; +import { ECashNetworkOptions, getAddress } from '../types/ecash-network'; +import { ECashNetworkInfo } from '../types/ecash-chronik'; +import { GasPriceTypes } from '@/providers/common/types'; +import { BaseNetwork, BaseNetworkOptions } from '@/types/base-network'; +import { AssetsType } from '@/types/provider'; +import { BaseToken } from '@/types/base-token'; +import { ECashToken } from '../types/ecash-token'; +import { ProviderName } from '@/types/provider'; +import { Activity } from '@/types/activity'; +import { NFTCollection } from '@/types/nft'; +import { ChronikAPI } from '../libs/api-chronik'; +import { fromBase } from '@enkryptcom/utils'; +import { formatFloatingPointValue } from '@/libs/utils/number-formatter'; +import MarketData from '@/libs/market-data'; +import BigNumber from 'bignumber.js'; +import { CoinGeckoTokenMarket } from '@/libs/market-data/types'; +import Sparkline from '@/libs/sparkline'; +import createIcon from '../../bitcoin/libs/blockies'; +import icon from './icons/ecash.svg'; + +const ecashNetworkInfo: ECashNetworkInfo = { + messagePrefix: '\x18eCash Signed Message:\n', + bech32: '', + bip32: { + public: 0x0488b21e, + private: 0x0488ade4, + }, + pubKeyHash: 0x00, + scriptHash: 0x05, + wif: 0x80, + cashAddrPrefix: 'ecash', +}; + +export const createECashNetworkOptions = ( + options: Partial, +): ECashNetworkOptions => { + return { + name: options.name || NetworkNames.ECash, + name_long: options.name_long || 'eCash', + homePage: options.homePage || 'https://e.cash/', + blockExplorerTX: + options.blockExplorerTX || 'https://explorer.e.cash/tx/[[txHash]]', + blockExplorerAddr: + options.blockExplorerAddr || + 'https://explorer.e.cash/address/[[address]]', + isTestNetwork: options.isTestNetwork ?? false, + currencyName: options.currencyName || 'XEC', + currencyNameLong: options.currencyNameLong || 'eCash', + icon: options.icon || icon, + decimals: options.decimals ?? 2, + node: options.node || 'https://chronik-native1.fabien.cash', + coingeckoID: options.coingeckoID || 'ecash', + networkInfo: options.networkInfo || ecashNetworkInfo, + dust: options.dust ?? 546, + feeHandler: options.feeHandler, + activityHandler: + options.activityHandler || wrapActivityHandler(chronikHandler), + NFTHandler: options.NFTHandler, + cashAddrPrefix: options.cashAddrPrefix || 'ecash', + } as ECashNetworkOptions; +}; + +export class ECashNetwork extends BaseNetwork { + public assets: BaseToken[] = []; + public networkInfo: ECashNetworkInfo; + public dust: number; + private activityHandler: ( + network: BaseNetwork, + address: string, + ) => Promise; + feeHandler: () => Promise>; + NFTHandler?: ( + network: BaseNetwork, + address: string, + ) => Promise; + public cashAddrPrefix: string; + + constructor(options: ECashNetworkOptions) { + const api = async () => { + const chronikApi = new ChronikAPI( + options.node, + options.networkInfo, + options.decimals, + options.name, + ); + await chronikApi.init(); + return chronikApi as ChronikAPI; + }; + + const baseOptions: BaseNetworkOptions = { + identicon: createIcon, + signer: [SignerType.secp256k1ecash], + provider: ProviderName.ecash, + displayAddress: (pubkey: string) => getAddress(pubkey), + api, + basePath: `m/44'/1899'/0'/0`, + ...options, + }; + + super(baseOptions); + this.activityHandler = options.activityHandler; + this.networkInfo = options.networkInfo; + this.feeHandler = options.feeHandler; + this.NFTHandler = options.NFTHandler; + this.dust = options.dust; + this.cashAddrPrefix = + options.cashAddrPrefix || options.networkInfo.cashAddrPrefix; + } + + public async getAllTokens(pubkey: string): Promise { + const assets: AssetsType[] = await this.getAllTokenInfo(pubkey); + return assets.map( + (token: AssetsType): BaseToken => + new ECashToken({ + name: token.name, + symbol: token.symbol, + icon: token.icon, + balance: token.balance, + decimals: token.decimals, + price: token.value, + coingeckoID: this.coingeckoID, + }), + ); + } + + public async getAllTokenInfo(pubkey: string): Promise { + try { + const api: ChronikAPI = (await this.api()) as unknown as ChronikAPI; + + const balanceInSatoshis: string = await api.getBalance(pubkey); + + const userBalance: string = fromBase(balanceInSatoshis, this.decimals); + + let marketData: (CoinGeckoTokenMarket | null)[] = []; + let currentPrice: number = 0; + + if (this.coingeckoID) { + try { + const market: MarketData = new MarketData(); + marketData = await market.getMarketData([this.coingeckoID]); + currentPrice = marketData[0]?.current_price ?? 0; + } catch (priceError) { + console.error( + '⚠️ [getAllTokenInfo] Error getting price, using 0:', + priceError, + ); + currentPrice = 0; + } + } + + const usdBalance: BigNumber = new BigNumber(userBalance).times( + currentPrice, + ); + + const nativeAsset: AssetsType = { + balance: balanceInSatoshis, + balancef: formatFloatingPointValue(userBalance).value, + balanceUSD: usdBalance.toNumber(), + balanceUSDf: usdBalance.toFixed(2), + icon: this.icon, + name: this.name_long, + symbol: this.currencyName, + value: currentPrice.toString(), + valuef: + currentPrice < 0.01 + ? currentPrice.toFixed(8).replace(/\.?0+$/, '') + : currentPrice.toFixed(2), + contract: '', + decimals: this.decimals, + sparkline: marketData.length + ? new Sparkline(marketData[0]!.sparkline_in_24h.price, 25).dataValues + : '', + priceChangePercentage: marketData.length + ? marketData[0]!.price_change_percentage_24h_in_currency + : 0, + }; + + const allAssets: AssetsType[] = [nativeAsset]; + + return allAssets; + } catch (error) { + console.error('❌ [getAllTokenInfo] FATAL ERROR:', error); + console.error('❌ [getAllTokenInfo] Stack:', (error as Error).stack); + + const fallbackAsset: AssetsType = { + balance: '0', + balancef: '0.00', + balanceUSD: 0, + balanceUSDf: '0.00', + icon: this.icon, + name: this.name_long, + symbol: this.currencyName, + value: '0', + valuef: '0.00', + contract: '', + decimals: this.decimals, + sparkline: '', + priceChangePercentage: 0, + }; + return [fallbackAsset]; + } + } + + public getAllActivity(address: string): Promise { + return this.activityHandler(this, address); + } +} + +const ecashOptions = createECashNetworkOptions({ + name: NetworkNames.ECash, + name_long: 'eCash', + homePage: 'https://e.cash/', + blockExplorerTX: 'https://explorer.e.cash/tx/[[txHash]]', + blockExplorerAddr: 'https://explorer.e.cash/address/[[address]]', + isTestNetwork: false, + currencyName: 'XEC', + currencyNameLong: 'eCash', + coingeckoID: 'ecash', + node: 'https://chronik-native1.fabien.cash', + dust: 546, +}); + +const ecash = new ECashNetwork(ecashOptions); + +export default ecash; diff --git a/packages/extension/src/providers/ecash/networks/icons/ecash.svg b/packages/extension/src/providers/ecash/networks/icons/ecash.svg new file mode 100644 index 000000000..703e9fc11 --- /dev/null +++ b/packages/extension/src/providers/ecash/networks/icons/ecash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/extension/src/providers/ecash/networks/index.ts b/packages/extension/src/providers/ecash/networks/index.ts new file mode 100644 index 000000000..3c1076393 --- /dev/null +++ b/packages/extension/src/providers/ecash/networks/index.ts @@ -0,0 +1,6 @@ +import ecash from './ecash-base'; +import { NetworkNames } from '@enkryptcom/types'; + +export default { + [NetworkNames.ECash]: ecash, +}; diff --git a/packages/extension/src/providers/ecash/tests/ecash.address.derivation.test.ts b/packages/extension/src/providers/ecash/tests/ecash.address.derivation.test.ts new file mode 100644 index 000000000..50a08131b --- /dev/null +++ b/packages/extension/src/providers/ecash/tests/ecash.address.derivation.test.ts @@ -0,0 +1,13 @@ +import { describe, it, expect } from 'vitest'; +import ecash from '../networks'; + +const pubkey = + '0x031c6d8a90254cc6dda7012c184bcde32e8d53f6fd6c882b2b12bd3fca1bd7372f'; +describe('Should derive proper ecash addresses', () => { + it('should derive address', async () => { + const ecashMain = ecash.XEC; + expect(ecashMain.displayAddress(pubkey)).to.be.eq( + 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63', + ); + }); +}); diff --git a/packages/extension/src/providers/ecash/tests/utils.test.ts b/packages/extension/src/providers/ecash/tests/utils.test.ts new file mode 100644 index 000000000..343b6868e --- /dev/null +++ b/packages/extension/src/providers/ecash/tests/utils.test.ts @@ -0,0 +1,302 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + isValidECashAddress, + scriptToAddress, + clearScriptAddressCache, + extractSats, + sumSatoshis, + calculateTransactionValue, + calculateOnchainTxFee, + getTransactionAddresses, + getTransactionTimestamp, +} from '../libs/utils'; + +describe('ECash Utils Tests', () => { + describe('isValidECashAddress', () => { + it('should validate correct eCash address', () => { + const validAddress = 'ecash:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + expect(isValidECashAddress(validAddress)).toBe(true); + }); + + it('should validate correct eCash address without prefix', () => { + const validAddress = 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + expect(isValidECashAddress(validAddress)).toBe(true); + }); + + it('should reject invalid eCash address', () => { + const invalidAddress = 'invalid_address_123'; + expect(isValidECashAddress(invalidAddress)).toBe(false); + }); + + it('should reject empty string', () => { + expect(isValidECashAddress('')).toBe(false); + }); + }); + + describe('scriptToAddress', () => { + beforeEach(() => { + clearScriptAddressCache(); + }); + + it('should convert script hex to address', () => { + const scriptHex = '76a9142aa5b50d61a930bc280c9a53165c0dbbc46daef488ac'; + const address = scriptToAddress(scriptHex); + expect(address).toBe('qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'); + }); + + it('should return Unknown for empty script', () => { + expect(scriptToAddress('')).to.equal('Unknown'); + }); + + it('should return fallback for invalid script', () => { + const invalidScript = 'invalid'; + const result = scriptToAddress(invalidScript); + expect(result).to.equal('invalid'); + }); + + it('should truncate long invalid scripts', () => { + const longInvalidScript = '1234567890abcdef1234567890abcdef1234567890'; + const result = scriptToAddress(longInvalidScript); + expect(result).to.include('...'); + expect(result.length).to.be.lessThan(longInvalidScript.length); + }); + }); + + describe('extractSats', () => { + it('should extract sats from item with sats property', () => { + const item = { sats: 1000 }; + expect(extractSats(item)).to.equal('1000'); + }); + + it('should return 0 for item without sats', () => { + const item = {}; + expect(extractSats(item)).to.equal('0'); + }); + + it('should return 0 for null item', () => { + expect(extractSats(null)).to.equal('0'); + }); + + it('should return 0 for undefined item', () => { + expect(extractSats(undefined)).to.equal('0'); + }); + }); + + describe('sumSatoshis', () => { + it('should sum satoshis from multiple items', () => { + const items = [{ sats: 1000 }, { sats: 2000 }, { sats: 3000 }]; + expect(sumSatoshis(items)).to.equal('6000'); + }); + + it('should return 0 for empty array', () => { + expect(sumSatoshis([])).to.equal('0'); + }); + + it('should handle items without sats property', () => { + const items = [{ sats: 1000 }, {}, { sats: 2000 }]; + expect(sumSatoshis(items)).to.equal('3000'); + }); + + it('should handle large values', () => { + const items = [ + { sats: 999999999999 }, + { sats: 888888888888 }, + { sats: 777777777777 }, + ]; + expect(sumSatoshis(items)).to.equal('2666666666664'); + }); + }); + + describe('calculateTransactionValue', () => { + beforeEach(() => { + clearScriptAddressCache(); + }); + + it('should calculate received value', () => { + const outputs = [ + { + outputScript: '76a914a8a56cc4f5a2e2a3e6b7f8c9d0e1f2a3b4c5d6e788ac', + sats: 1000, + }, + { + outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + sats: 2000, + }, + ]; + const normalizedAddress = scriptToAddress(outputs[0].outputScript); + const value = calculateTransactionValue(outputs, normalizedAddress, true); + expect(value).to.equal('1000'); + }); + + it('should calculate sent value', () => { + const outputs = [ + { + outputScript: '76a914a8a56cc4f5a2e2a3e6b7f8c9d0e1f2a3b4c5d6e788ac', + sats: 1000, + }, + { + outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + sats: 2000, + }, + ]; + const normalizedAddress = scriptToAddress(outputs[0].outputScript); + const value = calculateTransactionValue( + outputs, + normalizedAddress, + false, + ); + expect(value).to.equal('2000'); + }); + + it('should return 0 for no matching outputs', () => { + const outputs = [ + { + outputScript: '76a914a8a56cc4f5a2e2a3e6b7f8c9d0e1f2a3b4c5d6e788ac', + sats: 1000, + }, + ]; + const value = calculateTransactionValue( + outputs, + 'nonexistent_address', + true, + ); + expect(value).to.equal('0'); + }); + }); + + describe('calculateOnchainTxFee', () => { + it('should calculate transaction fee correctly', () => { + const tx = { + inputs: [{ sats: 10000 }, { sats: 5000 }], + outputs: [{ sats: 7000 }, { sats: 7500 }], + }; + const fee = calculateOnchainTxFee(tx); + expect(fee).to.equal(500); + }); + + it('should return 0 fee when inputs equal outputs', () => { + const tx = { + inputs: [{ sats: 10000 }], + outputs: [{ sats: 10000 }], + }; + const fee = calculateOnchainTxFee(tx); + expect(fee).to.equal(0); + }); + + it('should handle large fee calculations', () => { + const tx = { + inputs: [{ sats: 1000000000 }], + outputs: [{ sats: 999990000 }], + }; + const fee = calculateOnchainTxFee(tx); + expect(fee).to.equal(10000); + }); + }); + + describe('getTransactionAddresses', () => { + beforeEach(() => { + clearScriptAddressCache(); + }); + + it('should get addresses for received transaction', () => { + const tx = { + inputs: [ + { + outputScript: '76a914a8a56cc4f5a2e2a3e6b7f8c9d0e1f2a3b4c5d6e788ac', + }, + ], + outputs: [ + { + outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + sats: 1000, + }, + ], + }; + const normalizedAddress = 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + const { fromAddress, toAddress } = getTransactionAddresses( + tx, + normalizedAddress, + true, + false, + ); + expect(fromAddress).to.be.a('string'); + expect(toAddress).to.equal(normalizedAddress); + }); + + it('should get addresses for sent transaction', () => { + const tx = { + inputs: [ + { + outputScript: '76a914a8a56cc4f5a2e2a3e6b7f8c9d0e1f2a3b4c5d6e788ac', + }, + ], + outputs: [ + { + outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + sats: 1000, + }, + { + outputScript: '76a914c0c78ee6f7c4f4c5f8d9e0f1a2b3c4d5e6f788ac', + sats: 2000, + }, + ], + }; + const normalizedAddress = scriptToAddress(tx.outputs[0].outputScript); + const { fromAddress, toAddress } = getTransactionAddresses( + tx, + normalizedAddress, + false, + true, + ); + expect(fromAddress).to.equal(normalizedAddress); + expect(toAddress).to.be.a('string'); + expect(toAddress).to.not.equal(normalizedAddress); + }); + + it('should return Unknown for missing data', () => { + const tx = { + inputs: [], + outputs: [], + }; + const { fromAddress, toAddress } = getTransactionAddresses( + tx, + 'someaddress', + false, + false, + ); + expect(fromAddress).to.equal('Unknown'); + expect(toAddress).to.equal('Unknown'); + }); + }); + + describe('getTransactionTimestamp', () => { + it('should get timestamp from block', () => { + const tx = { + block: { timestamp: 1640000000 }, + }; + expect(getTransactionTimestamp(tx)).to.equal(1640000000000); + }); + + it('should get timestamp from timeFirstSeen', () => { + const tx = { + timeFirstSeen: '1640000000', + }; + expect(getTransactionTimestamp(tx)).to.equal(1640000000000); + }); + + it('should prefer block timestamp over timeFirstSeen', () => { + const tx = { + block: { timestamp: 1640000000 }, + timeFirstSeen: '1630000000', + }; + expect(getTransactionTimestamp(tx)).to.equal(1640000000000); + }); + + it('should return current time for transaction without timestamp', () => { + const tx = {}; + const timestamp = getTransactionTimestamp(tx); + const now = Date.now(); + expect(timestamp).to.be.closeTo(now, 1000); + }); + }); +}); diff --git a/packages/extension/src/providers/ecash/types/ecash-chronik.ts b/packages/extension/src/providers/ecash/types/ecash-chronik.ts new file mode 100644 index 000000000..bd336ac43 --- /dev/null +++ b/packages/extension/src/providers/ecash/types/ecash-chronik.ts @@ -0,0 +1,46 @@ +export interface ECashNetworkInfo { + messagePrefix: string; + bech32: string; + bip32: { + public: number; + private: number; + }; + pubKeyHash: number; + scriptHash: number; + wif: number; + cashAddrPrefix: string; +} + +export interface ChronikTx { + txid: string; + version: number; + inputs: Array<{ + prevOut: { + txid: string; + outIdx: number; + }; + inputScript: string; + outputScript: string; + value: string; + sequenceNo: number; + token?: any; + }>; + outputs: Array<{ + value: string; + outputScript: string; + token?: any; + spentBy?: { + txid: string; + outIdx: number; + }; + }>; + lockTime: number; + timeFirstSeen: string; + size: number; + isCoinbase: boolean; + block?: { + height: number; + hash: string; + timestamp: string; + }; +} diff --git a/packages/extension/src/providers/ecash/types/ecash-network.ts b/packages/extension/src/providers/ecash/types/ecash-network.ts new file mode 100644 index 000000000..374d90dbd --- /dev/null +++ b/packages/extension/src/providers/ecash/types/ecash-network.ts @@ -0,0 +1,62 @@ +import { BaseNetwork } from '@/types/base-network'; +import { NetworkNames } from '@enkryptcom/types'; +import { Activity } from '@/types/activity'; +import { GasPriceTypes } from '@/providers/common/types'; +import { ECashNetworkInfo } from '../types/ecash-chronik'; +import { NFTCollection } from '@/types/nft'; +import { Address } from 'ecash-lib'; +import * as bitcoin from 'bitcoinjs-lib'; +import { getAddressWithoutPrefix } from '../libs/utils'; + +export interface ECashNetworkOptions { + name: NetworkNames; + name_long: string; + homePage: string; + blockExplorerTX: string; + blockExplorerAddr: string; + isTestNetwork: boolean; + currencyName: string; + currencyNameLong: string; + icon: string; + decimals: number; + node: string; + coingeckoID?: string; + networkInfo: ECashNetworkInfo; + dust: number; + feeHandler: () => Promise>; + activityHandler: ( + network: BaseNetwork, + address: string, + ) => Promise; + NFTHandler?: ( + network: BaseNetwork, + address: string, + ) => Promise; + cashAddrPrefix?: string; +} + +export const getAddress = (pubkey: string): string => { + if (pubkey.length < 64) return pubkey; + + try { + let cleanPubkey = pubkey; + if (pubkey.startsWith('0x') || pubkey.startsWith('0X')) { + cleanPubkey = pubkey.slice(2); + } + + const pubkeyBuffer = Buffer.from(cleanPubkey, 'hex'); + + const pubkeyHash = bitcoin.crypto.hash160(pubkeyBuffer); + + const scriptHex = '76a914' + pubkeyHash.toString('hex') + '88ac'; + + const address = Address.fromScriptHex(scriptHex); + + const addressWithoutPrefix = getAddressWithoutPrefix(address); + + return addressWithoutPrefix; + } catch (error) { + console.error('Error converting pubkey to cashaddr:', error); + return pubkey; + } +}; diff --git a/packages/extension/src/providers/ecash/types/ecash-token.ts b/packages/extension/src/providers/ecash/types/ecash-token.ts new file mode 100644 index 000000000..3543ae77c --- /dev/null +++ b/packages/extension/src/providers/ecash/types/ecash-token.ts @@ -0,0 +1,16 @@ +import { BaseToken, BaseTokenOptions } from '@/types/base-token'; +import { ChronikAPI } from '../libs/api-chronik'; + +export class ECashToken extends BaseToken { + constructor(options: BaseTokenOptions) { + super(options); + } + + public async getLatestUserBalance(api: any, pubkey: string): Promise { + return (api as ChronikAPI).getBalance(pubkey); + } + + public async send(): Promise { + throw new Error('ECash-send is not implemented here'); + } +} diff --git a/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts b/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts new file mode 100644 index 000000000..557844dbd --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts @@ -0,0 +1,175 @@ +import { toBN } from 'web3-utils'; +import { toBase, fromBase } from '@enkryptcom/utils'; +import BigNumber from 'bignumber.js'; +import { GasPriceTypes, GasFeeType } from '@/providers/common/types'; +import { extractSats } from '../../libs/utils'; + +interface UTXO { + sats?: number; + value?: number; + token?: any; +} + +interface FeeCalculationParams { + sendAmount: string; + accountUTXOs: UTXO[]; + isEToken: boolean; + selectedAsset: { + balance?: string; + decimals: number; + }; + networkDecimals: number; + fallbackByteSize?: number; +} + +interface FeeCalculationResult { + feeInXEC: string; + txSize?: number; + realFee?: number; +} + +export const calculateTransactionFee = ( + params: FeeCalculationParams, +): FeeCalculationResult => { + const { + sendAmount, + accountUTXOs, + selectedAsset, + networkDecimals, + fallbackByteSize = 219, + } = params; + + let feeInXEC: string = ''; + let txSize: number; + + if (!sendAmount || sendAmount === '0' || accountUTXOs.length === 0) { + return { + feeInXEC: fromBase(fallbackByteSize.toString(), networkDecimals), + txSize: fallbackByteSize, + }; + } + + try { + const nonTokenUTXOs = accountUTXOs + .filter((utxo: UTXO) => !utxo.token) + .sort((a, b) => { + const aSats = toBN(extractSats(a)); + const bSats = toBN(extractSats(b)); + if (bSats.gt(aSats)) return 1; + if (bSats.lt(aSats)) return -1; + return 0; + }); + + const result = calculateNativeXECFee( + sendAmount, + nonTokenUTXOs, + selectedAsset.decimals, + networkDecimals, + ); + + txSize = result.txSize!; + feeInXEC = result.feeInXEC; + + return { feeInXEC, txSize }; + } catch (error) { + console.warn( + '⚠️ [calculateTransactionFee] Error calculating fee, using estimate:', + error, + ); + return { + feeInXEC: fromBase(fallbackByteSize.toString(), networkDecimals), + txSize: fallbackByteSize, + }; + } +}; + +const calculateNativeXECFee = ( + sendAmount: string, + sortedUTXOs: UTXO[], + assetDecimals: number, + networkDecimals: number, +): FeeCalculationResult & { leftover?: string } => { + const amountSats = toBase(sendAmount, assetDecimals); + + let accumulated = toBN(0); + let numInputs = 0; + let estimatedFee = 10 + 1 * 141 + 2 * 34; + + let prevNumInputs = 0; + for (let iteration = 0; iteration < 3; iteration++) { + accumulated = toBN(0); + numInputs = 0; + const target = toBN(amountSats).add(toBN(estimatedFee)); + + for (const utxo of sortedUTXOs) { + accumulated = accumulated.add(toBN(extractSats(utxo))); + numInputs++; + if (accumulated.gte(target)) break; + } + + const newFee = 10 + numInputs * 141 + 2 * 34; + if (numInputs === prevNumInputs) break; // Converged + prevNumInputs = numInputs; + estimatedFee = newFee; + } + + const txSizeWithChange = 10 + numInputs * 141 + 2 * 34; + const txSizeNoChange = 10 + numInputs * 141 + 1 * 34; + + // Check if change would be sub-dust (< 546 sats) + const leftover = accumulated + .sub(toBN(amountSats)) + .sub(toBN(txSizeWithChange)) + .toString(); + + if (toBN(leftover).lt(toBN(546)) && toBN(leftover).gte(toBN(0))) { + // Sub-dust change: all leftover goes to miner + const realFee = accumulated.sub(toBN(amountSats)).toString(); + return { + feeInXEC: fromBase(realFee, networkDecimals), + txSize: txSizeNoChange, + realFee: Number(realFee), + leftover, + }; + } + + return { + feeInXEC: fromBase(txSizeWithChange.toString(), networkDecimals), + txSize: txSizeWithChange, + }; +}; + +export const buildGasCostValues = ( + feeInXEC: string, + assetPrice: string, + currencyName: string, +): GasFeeType => { + const feeUSD = new BigNumber(feeInXEC).times(assetPrice || '0').toString(); + + return { + [GasPriceTypes.ECONOMY]: { + nativeValue: new BigNumber(feeInXEC).times(0.8).toString(), + fiatValue: new BigNumber(feeUSD).times(0.8).toString(), + nativeSymbol: currencyName, + fiatSymbol: 'USD', + }, + [GasPriceTypes.REGULAR]: { + nativeValue: feeInXEC, + fiatValue: feeUSD, + nativeSymbol: currencyName, + fiatSymbol: 'USD', + }, + [GasPriceTypes.FAST]: { + nativeValue: new BigNumber(feeInXEC).times(1.2).toString(), + fiatValue: new BigNumber(feeUSD).times(1.2).toString(), + nativeSymbol: currencyName, + fiatSymbol: 'USD', + }, + [GasPriceTypes.FASTEST]: { + nativeValue: new BigNumber(feeInXEC).times(1.5).toString(), + fiatValue: new BigNumber(feeUSD).times(1.5).toString(), + nativeSymbol: currencyName, + fiatSymbol: 'USD', + }, + }; +}; diff --git a/packages/extension/src/providers/ecash/ui/libs/send-utils.ts b/packages/extension/src/providers/ecash/ui/libs/send-utils.ts new file mode 100644 index 000000000..7c4042584 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/libs/send-utils.ts @@ -0,0 +1,56 @@ +import { toBN } from 'web3-utils'; +import { toBase, fromBase } from '@enkryptcom/utils'; + +interface UTXO { + sats?: number; + value?: number; + token?: any; +} + +export const calculateUTXOBalance = ( + accountUTXOs: UTXO[], +): ReturnType => { + const nonTokenUTXOs = accountUTXOs.filter((utxo: UTXO) => !utxo.token); + return toBN( + nonTokenUTXOs.reduce( + (acc, utxo) => acc + Number(utxo.sats || utxo.value || 0), + 0, + ), + ); +}; + +export const calculateBalanceAfterTransaction = ( + sendAmount: string, + utxoBalance: ReturnType, + fee: string, + assetDecimals: number, + networkDecimals: number, + isValidAmount: boolean, +): ReturnType => { + if (!isValidAmount) { + return toBN(0); + } + + return utxoBalance + .sub(toBN(toBase(sendAmount, assetDecimals))) + .sub(toBN(toBase(fee, networkDecimals))); +}; + +export const isBelowDustLimit = ( + sendAmount: string, + assetDecimals: number, + dustLimit: number, +): boolean => { + const amountInSatoshis = toBase(sendAmount, assetDecimals); + return Number(amountInSatoshis) < dustLimit && Number(sendAmount) > 0; +}; + +export const calculateMaxSendableValue = ( + utxoBalance: ReturnType, + fee: string, + networkDecimals: number, + assetDecimals: number, +): string => { + const maxValue = utxoBalance.sub(toBN(toBase(fee, networkDecimals))); + return fromBase(maxValue.toString(), assetDecimals); +}; diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue new file mode 100644 index 000000000..d6bef95b9 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/components/send-alert.vue b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-alert.vue new file mode 100644 index 000000000..a240a299c --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-alert.vue @@ -0,0 +1,79 @@ + + + + + diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/index.vue b/packages/extension/src/providers/ecash/ui/send-transaction/index.vue new file mode 100644 index 000000000..4847f8613 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/index.vue @@ -0,0 +1,480 @@ + + + + diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue b/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue new file mode 100644 index 000000000..a9f61f970 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue @@ -0,0 +1,419 @@ + + + + + From 1878e814171b048ffec8a2131a8fc034d98fe2ca Mon Sep 17 00:00:00 2001 From: Amatack Date: Thu, 12 Feb 2026 15:20:36 -0500 Subject: [PATCH 04/14] feat: wire eCash provider into extension core --- .../activity-state/wrap-activity-handler.ts | 21 +- .../extension/src/libs/background/index.ts | 4 + .../background/internal/ecash-sign.test.ts | 336 ++++++++++++++++++ .../libs/background/internal/ecash-sign.ts | 114 ++++++ .../src/libs/background/internal/index.ts | 2 + packages/extension/src/libs/utils/networks.ts | 11 +- packages/extension/src/types/base-network.ts | 7 +- packages/extension/src/types/messenger.ts | 1 + packages/extension/src/types/provider.ts | 1 + 9 files changed, 490 insertions(+), 7 deletions(-) create mode 100644 packages/extension/src/libs/background/internal/ecash-sign.test.ts create mode 100644 packages/extension/src/libs/background/internal/ecash-sign.ts diff --git a/packages/extension/src/libs/activity-state/wrap-activity-handler.ts b/packages/extension/src/libs/activity-state/wrap-activity-handler.ts index 90f4cc396..5e19ea159 100644 --- a/packages/extension/src/libs/activity-state/wrap-activity-handler.ts +++ b/packages/extension/src/libs/activity-state/wrap-activity-handler.ts @@ -1,19 +1,32 @@ +import { getAddressWithoutPrefix } from '@/providers/ecash/libs/utils'; import ActivityState from '.'; import { ActivityHandlerType } from './types'; -const CACHE_TTL = 1000 * 60 * 5; // 5 mins + +const CACHE_TTL = 1000 * 60 * 5; +const ECASH_CACHE_TTL = 1000 * 3; + export default (activityHandler: ActivityHandlerType): ActivityHandlerType => { const returnFunction: ActivityHandlerType = async (network, address) => { const activityState = new ActivityState(); + + const cacheAddress = + typeof address === 'string' ? getAddressWithoutPrefix(address) : address; + const options = { - address: address, + address: cacheAddress, network: network.name, }; + + // Use shorter cache TTL for eCash due to faster finality + const cacheTTL = network.name === 'XEC' ? ECASH_CACHE_TTL : CACHE_TTL; + const [activities, cacheTime] = await Promise.all([ activityState.getAllActivities(options), activityState.getCacheTime(options), ]); - if (cacheTime + CACHE_TTL < new Date().getTime()) { - const liveActivities = await activityHandler(network, address); + + if (cacheTime + cacheTTL < new Date().getTime()) { + const liveActivities = await activityHandler(network, cacheAddress); if (!activities.length) { await activityState.addActivities(liveActivities, options); await activityState.setCacheTime(options); diff --git a/packages/extension/src/libs/background/index.ts b/packages/extension/src/libs/background/index.ts index a888c2290..9fffa2216 100644 --- a/packages/extension/src/libs/background/index.ts +++ b/packages/extension/src/libs/background/index.ts @@ -25,6 +25,7 @@ import { sendToTab, newAccount, lock, + ecashSign, } from './internal'; import { handlePersistentEvents } from './external'; import SettingsState from '../settings-state'; @@ -51,6 +52,7 @@ class BackgroundHandler { [ProviderName.kadena]: {}, [ProviderName.solana]: {}, [ProviderName.massa]: {}, + [ProviderName.ecash]: {}, }; this.#providers = Providers; this.#geoRestricted = undefined; @@ -186,6 +188,8 @@ class BackgroundHandler { case InternalMethods.getNewAccount: case InternalMethods.saveNewAccount: return newAccount(this.#keyring, message); + case InternalMethods.ecashSign: + return ecashSign(this.#keyring, message); default: return Promise.resolve({ error: getCustomError( diff --git a/packages/extension/src/libs/background/internal/ecash-sign.test.ts b/packages/extension/src/libs/background/internal/ecash-sign.test.ts new file mode 100644 index 000000000..d7c15005f --- /dev/null +++ b/packages/extension/src/libs/background/internal/ecash-sign.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { NetworkNames, SignerType, WalletType } from '@enkryptcom/types'; +import type { RPCRequestType, EnkryptAccount } from '@enkryptcom/types'; + +const { + mockBroadcast, + mockBuild, + mockAction, + mockSync, + mockUtxos, + mockSpendableSatsOnlyUtxos, + mockGetNetworkByName, +} = vi.hoisted(() => { + const mockBroadcast = vi.fn(); + const mockBuild = vi.fn().mockReturnValue({ broadcast: mockBroadcast }); + const mockAction = vi.fn().mockReturnValue({ build: mockBuild }); + const mockSync = vi.fn(); + const mockUtxos: any[] = []; + const mockSpendableSatsOnlyUtxos = vi.fn().mockReturnValue([]); + const mockGetNetworkByName = vi.fn(); + return { + mockBroadcast, + mockBuild, + mockAction, + mockSync, + mockUtxos, + mockSpendableSatsOnlyUtxos, + mockGetNetworkByName, + }; +}); + +vi.mock('ecash-wallet', () => ({ + Wallet: { + fromSk: vi.fn().mockReturnValue({ + sync: mockSync, + get utxos() { + return mockUtxos; + }, + spendableSatsOnlyUtxos: mockSpendableSatsOnlyUtxos, + action: mockAction, + }), + }, +})); + +vi.mock('chronik-client', () => ({ + ChronikClient: class MockChronikClient { + constructor() {} + }, +})); + +vi.mock('@/libs/utils/networks', () => ({ + getNetworkByName: mockGetNetworkByName, +})); + +import ecashSign from './ecash-sign'; + +const fakePrivateKey = Buffer.from( + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + 'hex', +); + +const baseAccount: EnkryptAccount = { + name: 'eCash Account', + address: 'ecash:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63', + basePath: "m/44'/1899'/0'/0", + pathIndex: 0, + publicKey: + '0x031c6d8a90254cc6dda7012c184bcde32e8d53f6fd6c882b2b12bd3fca1bd7372f', + signerType: SignerType.secp256k1ecash, + walletType: WalletType.mnemonic, + isHardware: false, +}; + +const makeMessage = (params?: any[]): RPCRequestType => ({ + method: 'enkrypt_ecash_sign', + params, +}); + +const createKeyring = (overrides: Record = {}) => + ({ + isLocked: vi.fn().mockReturnValue(false), + getPrivateKeyForECash: vi.fn().mockResolvedValue(fakePrivateKey), + ...overrides, + }) as any; + +describe('ecashSign', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUtxos.length = 0; + mockSpendableSatsOnlyUtxos.mockReturnValue([]); + mockBroadcast.mockResolvedValue({ + success: true, + broadcasted: ['abc123txid'], + }); + mockGetNetworkByName.mockResolvedValue({ + node: 'https://chronik-native1.fabien.cash', + name: NetworkNames.ECash, + }); + }); + + it('should return error when params is undefined', async () => { + const keyring = createKeyring(); + const result = await ecashSign(keyring, makeMessage(undefined)); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('invalid params'); + }); + + it('should return error when toAddress is missing', async () => { + const keyring = createKeyring(); + const result = await ecashSign( + keyring, + makeMessage([ + { + amount: '1000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('missing required parameters'); + }); + + it('should return error when keyring is locked', async () => { + const keyring = createKeyring({ + isLocked: vi.fn().mockReturnValue(true), + }); + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('keyring is locked'); + }); + + it('should return error when network is not found', async () => { + const keyring = createKeyring(); + mockGetNetworkByName.mockResolvedValue(undefined); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: baseAccount, + networkName: 'fake_network', + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('unknown network'); + }); + + it('should return error for hardware wallet (isHardware flag)', async () => { + const keyring = createKeyring(); + const hwAccount = { ...baseAccount, isHardware: true }; + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: hwAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain( + 'hardware wallets not yet supported', + ); + }); + + it('should build and broadcast a successful XEC transaction', async () => { + const keyring = createKeyring(); + mockSpendableSatsOnlyUtxos.mockReturnValue([{ sats: 50000n }]); + mockBroadcast.mockResolvedValue({ + success: true, + broadcasted: ['txid_xec_success'], + }); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '10000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toBeDefined(); + const parsed = JSON.parse(result.result!); + expect(parsed.txid).toBe('txid_xec_success'); + + expect(mockSync).toHaveBeenCalled(); + expect(mockAction).toHaveBeenCalledWith({ + outputs: [ + { + address: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + sats: 10000n, + }, + ], + }); + expect(mockBuild).toHaveBeenCalled(); + expect(mockBroadcast).toHaveBeenCalled(); + }); + + it('should return error when XEC balance is insufficient', async () => { + const keyring = createKeyring(); + mockSpendableSatsOnlyUtxos.mockReturnValue([{ sats: 500n }]); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '10000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('Insufficient balance'); + }); + + it('should return error when broadcast fails with errors array', async () => { + const keyring = createKeyring(); + mockSpendableSatsOnlyUtxos.mockReturnValue([{ sats: 50000n }]); + mockBroadcast.mockResolvedValue({ + success: false, + errors: ['tx-mempool-conflict', 'bad-txns-inputs-missingorspent'], + }); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('tx-mempool-conflict'); + expect(result.error!.message).toContain('bad-txns-inputs-missingorspent'); + }); + + it('should return generic error when broadcast fails without errors', async () => { + const keyring = createKeyring(); + mockSpendableSatsOnlyUtxos.mockReturnValue([{ sats: 50000n }]); + mockBroadcast.mockResolvedValue({ + success: false, + errors: [], + }); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('Broadcast failed'); + }); + + it('should return error when wallet.sync() throws', async () => { + const keyring = createKeyring(); + mockSync.mockRejectedValueOnce(new Error('Network unreachable')); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('Network unreachable'); + }); + + it('should return error when getPrivateKeyForECash throws', async () => { + const keyring = createKeyring({ + getPrivateKeyForECash: vi + .fn() + .mockRejectedValue(new Error('Keyring error')), + }); + + const result = await ecashSign( + keyring, + makeMessage([ + { + toAddress: 'ecash:qqq9wk7vze4dc4hk7mweafpyxh7d8sjr3ghh0wtn04', + amount: '1000', + account: baseAccount, + networkName: NetworkNames.ECash, + }, + ]), + ); + + expect(result.error).toBeDefined(); + expect(result.error!.message).toContain('Keyring error'); + }); +}); diff --git a/packages/extension/src/libs/background/internal/ecash-sign.ts b/packages/extension/src/libs/background/internal/ecash-sign.ts new file mode 100644 index 000000000..b56d81b9e --- /dev/null +++ b/packages/extension/src/libs/background/internal/ecash-sign.ts @@ -0,0 +1,114 @@ +import { getCustomError } from '@/libs/error'; +import KeyRingBase from '@/libs/keyring/keyring'; +import { InternalOnMessageResponse } from '@/types/messenger'; +import { + EnkryptAccount, + RPCRequestType, + HWwalletType, + NetworkNames, +} from '@enkryptcom/types'; +import { ChronikClient } from 'chronik-client'; +import { Wallet } from 'ecash-wallet'; +import { getNetworkByName } from '@/libs/utils/networks'; +import { isValidECashAddress } from '@/providers/ecash/libs/utils'; + +interface ECashSignParams { + toAddress: string; + amount: string; + account: EnkryptAccount; + networkName: NetworkNames; +} + +const ecashSign = async ( + keyring: KeyRingBase, + message: RPCRequestType, +): Promise => { + if (!message.params || message.params.length < 1) { + return { error: getCustomError('ecash-sign: invalid params') }; + } + + const params = message.params[0] as ECashSignParams; + + if ( + !params.toAddress || + !params.amount || + !params.account || + !params.networkName + ) { + return { error: getCustomError('ecash-sign: missing required parameters') }; + } + + if (!isValidECashAddress(params.toAddress)) { + return { error: getCustomError('ecash-sign: invalid destination address') }; + } + + if (keyring.isLocked()) { + return { error: getCustomError('ecash-sign: keyring is locked') }; + } + + if ( + params.account.isHardware || + Object.values(HWwalletType).includes( + params.account.walletType as unknown as HWwalletType, + ) + ) { + return { + error: getCustomError('ecash-sign: hardware wallets not yet supported'), + }; + } + + let privateKeyBuffer: Buffer | null = null; + let pkBytes: Uint8Array | null = null; + + try { + const network = await getNetworkByName(params.networkName); + if (!network) { + return { error: getCustomError('ecash-sign: unknown network') }; + } + + privateKeyBuffer = await keyring.getPrivateKeyForECash( + params.account, + ); + pkBytes = new Uint8Array(privateKeyBuffer); + const chronik = new ChronikClient([network.node]); + const wallet = Wallet.fromSk(pkBytes, chronik); + await wallet.sync(); + + const amountBigInt = BigInt(params.amount); + + const balance = wallet + .spendableSatsOnlyUtxos() + .reduce((total, utxo) => total + utxo.sats, 0n); + + if (amountBigInt > balance) { + throw new Error( + `Insufficient balance: ${balance} sats available, ${amountBigInt} sats requested`, + ); + } + + const action = wallet.action({ + outputs: [{ address: params.toAddress, sats: amountBigInt }], + }); + const built = action.build(); + const result = await built.broadcast(); + + if (!result.success) { + throw new Error( + result.errors?.length ? result.errors.join(', ') : 'Broadcast failed', + ); + } + + const txid = result.broadcasted[0] || ''; + return { result: JSON.stringify({ txid }) }; + } catch (e: any) { + console.error('[ecash-sign] Error:', e); + return { + error: getCustomError(e.message || 'eCash transaction signing failed'), + }; + } finally { + if (privateKeyBuffer) privateKeyBuffer.fill(0); + if (pkBytes) pkBytes.fill(0); + } +}; + +export default ecashSign; diff --git a/packages/extension/src/libs/background/internal/index.ts b/packages/extension/src/libs/background/internal/index.ts index b33949948..b5991741b 100644 --- a/packages/extension/src/libs/background/internal/index.ts +++ b/packages/extension/src/libs/background/internal/index.ts @@ -6,6 +6,7 @@ import changeNetwork from './change-network'; import sendToTab from './send-to-tab'; import newAccount from './new-account'; import lock from './lock'; +import ecashSign from './ecash-sign'; export { sign, getEthereumPubKey, @@ -15,4 +16,5 @@ export { sendToTab, newAccount, lock, + ecashSign, }; diff --git a/packages/extension/src/libs/utils/networks.ts b/packages/extension/src/libs/utils/networks.ts index 2eccad1da..b543e927c 100644 --- a/packages/extension/src/libs/utils/networks.ts +++ b/packages/extension/src/libs/utils/networks.ts @@ -15,6 +15,8 @@ import Kadena from '@/providers/kadena/networks/kadena'; import Solana from '@/providers/solana/networks/solana'; import MassaNetworks from '@/providers/massa/networks'; import Massa from '@/providers/massa/networks/mainnet'; +import ECashNetworks from '@/providers/ecash/networks'; +import ECash from '@/providers/ecash/networks/ecash-base'; const providerNetworks: Record> = { [ProviderName.ethereum]: EthereumNetworks, @@ -23,6 +25,7 @@ const providerNetworks: Record> = { [ProviderName.kadena]: KadenaNetworks, [ProviderName.solana]: SolanaNetworks, [ProviderName.massa]: MassaNetworks, + [ProviderName.ecash]: ECashNetworks, [ProviderName.enkrypt]: {}, }; const getAllNetworks = async ( @@ -38,7 +41,8 @@ const getAllNetworks = async ( .concat(Object.values(BitcoinNetworks) as BaseNetwork[]) .concat(Object.values(KadenaNetworks) as BaseNetwork[]) .concat(Object.values(SolanaNetworks) as BaseNetwork[]) - .concat(Object.values(MassaNetworks) as BaseNetwork[]); + .concat(Object.values(MassaNetworks) as BaseNetwork[]) + .concat(Object.values(ECashNetworks) as BaseNetwork[]); if (!includeCustom) { return allNetworks; @@ -67,6 +71,8 @@ const getProviderNetworkByName = async ( return networks.find(net => net.name === networkName); }; + +const DEFAULT_ECASH_NETWORK_NAME = NetworkNames.ECash; const DEFAULT_EVM_NETWORK_NAME = NetworkNames.Ethereum; const DEFAULT_SUBSTRATE_NETWORK_NAME = NetworkNames.Polkadot; const DEFAULT_BTC_NETWORK_NAME = NetworkNames.Bitcoin; @@ -75,6 +81,7 @@ const DEFAULT_SOLANA_NETWORK_NAME = NetworkNames.Solana; const DEFAULT_MASSA_NETWORK_NAME = NetworkNames.Massa; const DEFAULT_EVM_NETWORK = Ethereum; +const DEFAULT_ECASH_NETWORK = ECash; const DEFAULT_SUBSTRATE_NETWORK = Polkadot; const DEFAULT_BTC_NETWORK = Bitcoin; const DEFAULT_KADENA_NETWORK = Kadena; @@ -110,4 +117,6 @@ export { DEFAULT_SOLANA_NETWORK_NAME, DEFAULT_MASSA_NETWORK, DEFAULT_MASSA_NETWORK_NAME, + DEFAULT_ECASH_NETWORK, + DEFAULT_ECASH_NETWORK_NAME, }; diff --git a/packages/extension/src/types/base-network.ts b/packages/extension/src/types/base-network.ts index 3ca1af7a2..29e4b4c1c 100644 --- a/packages/extension/src/types/base-network.ts +++ b/packages/extension/src/types/base-network.ts @@ -9,6 +9,7 @@ import { Activity } from './activity'; import { BaseToken } from './base-token'; import { BNType } from '@/providers/common/types'; import MassaAPI from '../providers/massa/libs/api'; +import ChronikAPI from '@/providers/ecash/libs/api-chronik'; export interface SubNetworkOptions { id: string; @@ -40,7 +41,8 @@ export interface BaseNetworkOptions { | Promise | Promise | Promise - | Promise; + | Promise + | Promise; customTokens?: boolean; } @@ -83,7 +85,8 @@ export abstract class BaseNetwork { | Promise | Promise | Promise - | Promise; + | Promise + | Promise; public customTokens: boolean; constructor(options: BaseNetworkOptions) { diff --git a/packages/extension/src/types/messenger.ts b/packages/extension/src/types/messenger.ts index 276fb234b..b4847dd87 100644 --- a/packages/extension/src/types/messenger.ts +++ b/packages/extension/src/types/messenger.ts @@ -35,6 +35,7 @@ export enum InternalMethods { getNewAccount = 'enkrypt_getNewAccount', saveNewAccount = 'enkrypt_saveNewAccount', changeNetwork = 'enkrypt_changeNetwork', + ecashSign = 'enkrypt_ecash_sign', } export interface SendMessage { [key: string]: any; diff --git a/packages/extension/src/types/provider.ts b/packages/extension/src/types/provider.ts index b43819ba4..9ff09ed17 100644 --- a/packages/extension/src/types/provider.ts +++ b/packages/extension/src/types/provider.ts @@ -34,6 +34,7 @@ export enum ProviderName { kadena = 'kadena', solana = 'solana', massa = 'massa', + ecash = 'ecash', } export enum InternalStorageNamespace { keyring = 'KeyRing', From 767647893d5a3ad8fb3debb986c1dfa99b5b4b66 Mon Sep 17 00:00:00 2001 From: Amatack Date: Thu, 12 Feb 2026 15:43:41 -0500 Subject: [PATCH 05/14] feat: add eCash UI support for send/verify/deposit flows --- packages/extension/src/ui/action/views/deposit/index.vue | 3 ++- .../src/ui/action/views/network-activity/index.vue | 7 +++++++ .../src/ui/action/views/send-transaction/index.vue | 2 ++ .../src/ui/action/views/verify-transaction/index.vue | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/ui/action/views/deposit/index.vue b/packages/extension/src/ui/action/views/deposit/index.vue index cd1f67ea2..28a50de1f 100644 --- a/packages/extension/src/ui/action/views/deposit/index.vue +++ b/packages/extension/src/ui/action/views/deposit/index.vue @@ -16,7 +16,8 @@
{ activity.status = status; activity.rawInfo = massaInfo; updateActivitySync(activity).then(() => updateVisibleActivity(activity)); + } else if (props.network.provider === ProviderName.ecash) { + if (!info) return; + const xecInfo = info as BTCRawInfo; + if (isActivityUpdating) return; + activity.status = ActivityStatus.success; + activity.rawInfo = xecInfo; + updateActivitySync(activity).then(() => updateVisibleActivity(activity)); } // If we're this far in then the transaction has reached a terminal status diff --git a/packages/extension/src/ui/action/views/send-transaction/index.vue b/packages/extension/src/ui/action/views/send-transaction/index.vue index 4a796a729..a47ba995d 100644 --- a/packages/extension/src/ui/action/views/send-transaction/index.vue +++ b/packages/extension/src/ui/action/views/send-transaction/index.vue @@ -10,6 +10,7 @@ import SendTransactionSubstrate from '@/providers/polkadot/ui/send-transaction/index.vue'; import SendTransactionEVM from '@/providers/ethereum/ui/send-transaction/index.vue'; import SendTransactionBTC from '@/providers/bitcoin/ui/send-transaction/index.vue'; +import SendTransactionECash from '@/providers/ecash/ui/send-transaction/index.vue'; import SendTransactionKadena from '@/providers/kadena/ui/send-transaction/index.vue'; import SendTransactionSolana from '@/providers/solana/ui/send-transaction/index.vue'; import SendTransactionMassa from '@/providers/massa/ui/send-transaction/index.vue'; @@ -24,6 +25,7 @@ const sendLayouts: Record = { [ProviderName.ethereum]: SendTransactionEVM, [ProviderName.polkadot]: SendTransactionSubstrate, [ProviderName.bitcoin]: SendTransactionBTC, + [ProviderName.ecash]: SendTransactionECash, [ProviderName.kadena]: SendTransactionKadena, [ProviderName.solana]: SendTransactionSolana, [ProviderName.massa]: SendTransactionMassa, diff --git a/packages/extension/src/ui/action/views/verify-transaction/index.vue b/packages/extension/src/ui/action/views/verify-transaction/index.vue index 5760dbe73..25e953abb 100644 --- a/packages/extension/src/ui/action/views/verify-transaction/index.vue +++ b/packages/extension/src/ui/action/views/verify-transaction/index.vue @@ -6,6 +6,7 @@ import VerifyTransactionSubstrate from '@/providers/polkadot/ui/send-transaction/verify-transaction/index.vue'; import VerifyTransactionEVM from '@/providers/ethereum/ui/send-transaction/verify-transaction/index.vue'; import VerifyTransactionBTC from '@/providers/bitcoin/ui/send-transaction/verify-transaction/index.vue'; +import VerifyTransactionECash from '@/providers/ecash/ui/send-transaction/verify-transaction/index.vue'; import VerifyTransactionKadena from '@/providers/kadena/ui/send-transaction/verify-transaction/index.vue'; import VerifyTransactionSolana from '@/providers/solana/ui/send-transaction/verify-transaction/index.vue'; import VerifyTransactionMassa from '@/providers/massa/ui/send-transaction/verify-transaction/index.vue'; @@ -18,6 +19,7 @@ const sendLayouts: Record = { [ProviderName.ethereum]: VerifyTransactionEVM, [ProviderName.polkadot]: VerifyTransactionSubstrate, [ProviderName.bitcoin]: VerifyTransactionBTC, + [ProviderName.ecash]: VerifyTransactionECash, [ProviderName.kadena]: VerifyTransactionKadena, [ProviderName.solana]: VerifyTransactionSolana, [ProviderName.massa]: VerifyTransactionMassa, From d1ceb8b9c3f05ef5ab90775d9b21522c6508c003 Mon Sep 17 00:00:00 2001 From: Amatack Date: Thu, 12 Feb 2026 15:44:31 -0500 Subject: [PATCH 06/14] fix: improve currency formatting precision for small values --- .../extension/src/ui/action/utils/filters.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/extension/src/ui/action/utils/filters.ts b/packages/extension/src/ui/action/utils/filters.ts index b4307a356..7323aa51e 100644 --- a/packages/extension/src/ui/action/utils/filters.ts +++ b/packages/extension/src/ui/action/utils/filters.ts @@ -6,6 +6,7 @@ import { formatFloatingPointValue, } from '@/libs/utils/number-formatter'; import { useCurrencyStore } from '../views/settings/store'; + export const replaceWithEllipsis = ( value: string, keepLeft: number, @@ -35,8 +36,29 @@ export const parseCurrency = (value: string | number): string => { amount.isNaN() || amount.isZero() ? 0 : amount.times(exchangeRate).toNumber(); + const notation = BigNumber(finalValue).gt(999999) ? 'compact' : 'standard'; - return `${amount.lt(0.0000001) && amount.gt(0) ? '< ' : ''}${new Intl.NumberFormat(locale, { style: 'currency', currency: currency, notation }).format(finalValue)}`; + + let minimumFractionDigits = 2; + let maximumFractionDigits = 2; + + if (finalValue > 0 && finalValue < 0.01) { + minimumFractionDigits = 2; + maximumFractionDigits = 8; + } else if (finalValue >= 0.01 && finalValue < 1) { + minimumFractionDigits = 2; + maximumFractionDigits = 4; + } + + const formatted = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + notation, + minimumFractionDigits, + maximumFractionDigits, + }).format(finalValue); + + return `${amount.lt(0.0000001) && amount.gt(0) ? '< ' : ''}${formatted}`; }; export const truncate = (value: string, length: number): string => { @@ -60,4 +82,5 @@ export const formatDuration = ( return `${m.padStart(2, '0')}:${s.padStart(2, '0')} `; }; + export { formatFiatValue, formatFloatingPointValue }; From 529a2b6b5348b1e5cb68a43bf7125b9368b724b6 Mon Sep 17 00:00:00 2001 From: Amatack Date: Sat, 21 Mar 2026 22:05:57 -0500 Subject: [PATCH 07/14] feat(ecash): add testnet support --- .../activity-state/wrap-activity-handler.ts | 3 +- .../providers/ecash/libs/activity-handlers.ts | 32 ++++++--- .../src/providers/ecash/libs/api-chronik.ts | 68 +++++++------------ .../src/providers/ecash/libs/utils.ts | 56 ++++++++------- .../providers/ecash/networks/ecash-base.ts | 9 +-- .../providers/ecash/networks/ecash-testnet.ts | 40 +++++++++++ .../src/providers/ecash/networks/index.ts | 2 + .../src/providers/ecash/tests/utils.test.ts | 16 ++--- .../providers/ecash/types/ecash-chronik.ts | 11 +-- .../providers/ecash/types/ecash-network.ts | 17 +++-- .../src/ui/action/views/deposit/index.vue | 11 ++- packages/types/src/networks.ts | 1 + 12 files changed, 161 insertions(+), 105 deletions(-) create mode 100644 packages/extension/src/providers/ecash/networks/ecash-testnet.ts diff --git a/packages/extension/src/libs/activity-state/wrap-activity-handler.ts b/packages/extension/src/libs/activity-state/wrap-activity-handler.ts index 5e19ea159..a6e20ec61 100644 --- a/packages/extension/src/libs/activity-state/wrap-activity-handler.ts +++ b/packages/extension/src/libs/activity-state/wrap-activity-handler.ts @@ -18,7 +18,8 @@ export default (activityHandler: ActivityHandlerType): ActivityHandlerType => { }; // Use shorter cache TTL for eCash due to faster finality - const cacheTTL = network.name === 'XEC' ? ECASH_CACHE_TTL : CACHE_TTL; + const isECash = network.name === 'XEC' || network.name === 'XECTest'; + const cacheTTL = isECash ? ECASH_CACHE_TTL : CACHE_TTL; const [activities, cacheTime] = await Promise.all([ activityState.getAllActivities(options), diff --git a/packages/extension/src/providers/ecash/libs/activity-handlers.ts b/packages/extension/src/providers/ecash/libs/activity-handlers.ts index d788f774e..6e4984f4b 100644 --- a/packages/extension/src/providers/ecash/libs/activity-handlers.ts +++ b/packages/extension/src/providers/ecash/libs/activity-handlers.ts @@ -18,6 +18,7 @@ export const chronikHandler: ActivityHandlerType = async ( address, ): Promise => { try { + const cashAddrPrefix = (network as any).cashAddrPrefix ?? 'ecash'; const normalizedAddress = getAddressWithoutPrefix(address); const api = (await network.api()) as unknown as ChronikAPI; @@ -44,24 +45,37 @@ export const chronikHandler: ActivityHandlerType = async ( for (const tx of txHistory) { try { const isReceive = tx.outputs.some((output: any) => { - const outputAddress = scriptToAddress(output.outputScript); + const outputAddress = scriptToAddress( + output.outputScript, + cashAddrPrefix, + ); return outputAddress === normalizedAddress; }); const isSend = tx.inputs.some((input: any) => { - const inputAddress = scriptToAddress(input.outputScript); + const inputAddress = scriptToAddress( + input.outputScript ?? '', + cashAddrPrefix, + ); return inputAddress === normalizedAddress; }); - const value = isReceive - ? calculateTransactionValue(tx.outputs, normalizedAddress, true) - : calculateTransactionValue(tx.outputs, normalizedAddress, false); + const value = + isReceive || isSend + ? calculateTransactionValue( + tx.outputs, + normalizedAddress, + isReceive, + cashAddrPrefix, + ) + : '0'; const { fromAddress, toAddress } = getTransactionAddresses( tx, normalizedAddress, isReceive, isSend, + cashAddrPrefix, ); const fee = isSend ? calculateOnchainTxFee(tx) : 0; @@ -77,13 +91,13 @@ export const chronikHandler: ActivityHandlerType = async ( blockNumber: tx.block?.height || 0, fee, transactionHash: tx.txid, - timestamp: tx.block?.timestamp || Math.floor(timestamp / 1000), + timestamp, inputs: tx.inputs.map((input: any) => ({ - address: scriptToAddress(input.outputScript), + address: scriptToAddress(input.outputScript ?? '', cashAddrPrefix), value: Number(extractSats(input)), })), outputs: tx.outputs.map((output: any) => ({ - address: scriptToAddress(output.outputScript), + address: scriptToAddress(output.outputScript, cashAddrPrefix), value: Number(extractSats(output)), pkscript: output.outputScript || '', })), @@ -106,7 +120,7 @@ export const chronikHandler: ActivityHandlerType = async ( type: ActivityType.transaction, value, transactionHash: tx.txid, - timestamp, + timestamp: timestamp * 1000, token: tokenInfo, rawInfo, }; diff --git a/packages/extension/src/providers/ecash/libs/api-chronik.ts b/packages/extension/src/providers/ecash/libs/api-chronik.ts index 39035ee32..d6311b684 100644 --- a/packages/extension/src/providers/ecash/libs/api-chronik.ts +++ b/packages/extension/src/providers/ecash/libs/api-chronik.ts @@ -1,6 +1,7 @@ import { ProviderAPIInterface } from '@/types/provider'; import { BTCRawInfo } from '@/types/activity'; import { ChronikClient } from 'chronik-client'; +import { WatchOnlyWallet } from 'ecash-wallet'; import { getAddress } from '../types/ecash-network'; import { ECashNetworkInfo, ChronikTx } from '../types/ecash-chronik'; import { Script, Address } from 'ecash-lib'; @@ -32,7 +33,7 @@ export class ChronikAPI extends ProviderAPIInterface { return this.withErrorHandling( 'init', async () => { - await this.chronik.blockchainInfo(); + await this.chronik.chronikInfo(); }, () => { throw new Error('Failed to initialize Chronik API'); @@ -41,7 +42,7 @@ export class ChronikAPI extends ProviderAPIInterface { } private ensurePrefix(address: string): string { - if (address.startsWith('ecash:') || address.startsWith('ectest:')) { + if (address.includes(':')) { return address; } return `${this.networkInfo.cashAddrPrefix}:${address}`; @@ -50,40 +51,25 @@ export class ChronikAPI extends ProviderAPIInterface { private async withErrorHandling( method: string, operation: () => Promise, - fallback: () => T, + fallback?: () => T | Promise, ): Promise { try { return await operation(); } catch (error) { - console.error(`❌ [${method}] Error:`, error); - return fallback(); + console.error(`[ChronikAPI:${method}]`, error); + if (fallback) return await fallback(); + throw error; } } - private calculateUTXOBalance(utxos: any[]): bigint { - return utxos.reduce((total, utxo) => { - if (!utxo.token) { - const value = BigInt((utxo as any).sats || utxo.value || 0); - return total + value; - } - return total; - }, BigInt(0)); - } - async getBalance(pubkey: string): Promise { return this.withErrorHandling( 'getBalance', async () => { const address = getAddress(pubkey); - - const addressWithPrefix = this.ensurePrefix(address); - const utxoResponse = await this.chronik - .address(addressWithPrefix) - .utxos(); - - const totalSatoshis = this.calculateUTXOBalance(utxoResponse.utxos); - - return totalSatoshis.toString(); + const wallet = WatchOnlyWallet.fromAddress(address, this.chronik); + await wallet.sync(); + return wallet.balanceSats.toString(); }, () => '0', ); @@ -103,7 +89,7 @@ export class ChronikAPI extends ProviderAPIInterface { ); } - async getTransactionHistory(address: string): Promise { + async getTransactionHistory(address: string): Promise { return this.withErrorHandling( 'getTransactionHistory', async () => { @@ -122,23 +108,19 @@ export class ChronikAPI extends ProviderAPIInterface { async () => { const tx = await this.chronik.tx(hash); - if (!tx.block) { - return null; // Transaction is in mempool - } - const rawInfo: BTCRawInfo = { - blockNumber: tx.block.height, + blockNumber: tx.block?.height ?? 0, fee: this.calculateFee(tx as any), transactionHash: tx.txid, - timestamp: tx.block.timestamp, - inputs: tx.inputs.map((input: any) => ({ - address: this.scriptToAddress(input.outputScript || ''), - value: input.value || '0', - pkscript: input.outputScript || '', + timestamp: tx.block?.timestamp ?? Math.floor(Date.now() / 1000), + inputs: tx.inputs.map(input => ({ + address: this.scriptToAddress(input.outputScript ?? ''), + value: Number(input.sats), + pkscript: input.outputScript ?? '', })), - outputs: tx.outputs.map((output: any) => ({ + outputs: tx.outputs.map(output => ({ address: this.scriptToAddress(output.outputScript), - value: output.value, + value: Number(output.sats), pkscript: output.outputScript, })), }; @@ -151,11 +133,11 @@ export class ChronikAPI extends ProviderAPIInterface { private calculateFee(tx: ChronikTx): number { const inputSum = tx.inputs.reduce( - (sum, input) => sum + BigInt(input.value || 0), + (sum, input) => sum + input.sats, BigInt(0), ); const outputSum = tx.outputs.reduce( - (sum, output) => sum + BigInt(output.value || 0), + (sum, output) => sum + output.sats, BigInt(0), ); return Number(inputSum - outputSum); @@ -167,13 +149,15 @@ export class ChronikAPI extends ProviderAPIInterface { try { const scriptBytes = Buffer.from(scriptHex, 'hex'); const script = new Script(scriptBytes); - const address = Address.fromScript(script); - const fullAddress = address.toString(); + const fullAddress = Address.fromScript( + script, + this.networkInfo.cashAddrPrefix, + ).toString(); return fullAddress.split(':')[1] || fullAddress; } catch (error) { console.error( - '[scriptToAddress] Invalid script:', + '[scriptToAddress] Could not derive address from script, only p2pkh and p2sh are supported:', scriptHex.slice(0, 20), error, ); diff --git a/packages/extension/src/providers/ecash/libs/utils.ts b/packages/extension/src/providers/ecash/libs/utils.ts index c8353a2fd..fbc6a81e4 100644 --- a/packages/extension/src/providers/ecash/libs/utils.ts +++ b/packages/extension/src/providers/ecash/libs/utils.ts @@ -12,18 +12,22 @@ export const isValidECashAddress = (address: string): boolean => { const scriptAddressCache = new Map(); -export function scriptToAddress(script: string): string { +export function scriptToAddress( + script: string, + cashAddrPrefix: string = 'ecash', +): string { if (!script) return 'Unknown'; - if (scriptAddressCache.has(script)) { - return scriptAddressCache.get(script)!; + const cacheKey = `${cashAddrPrefix}:${script}`; + if (scriptAddressCache.has(cacheKey)) { + return scriptAddressCache.get(cacheKey)!; } try { - const address = Address.fromScriptHex(script); + const address = Address.fromScriptHex(script, cashAddrPrefix); const addressWithoutPrefix = getAddressWithoutPrefix(address); - scriptAddressCache.set(script, addressWithoutPrefix); + scriptAddressCache.set(cacheKey, addressWithoutPrefix); return addressWithoutPrefix; } catch (error) { console.error('[scriptToAddress] Error:', error, script.slice(0, 20)); @@ -33,7 +37,7 @@ export function scriptToAddress(script: string): string { ? `${script.slice(0, 8)}...${script.slice(-8)}` : script; - scriptAddressCache.set(script, fallback); + scriptAddressCache.set(cacheKey, fallback); return fallback; } } @@ -47,11 +51,9 @@ export function extractSats(item: any): string { } export function sumSatoshis(items: any[]): string { - return items.reduce((sum, item) => { - return toBN(sum) - .add(toBN(extractSats(item))) - .toString(); - }, '0'); + return items + .reduce((sum, item) => sum.add(toBN(extractSats(item))), toBN('0')) + .toString(); } /** @@ -59,24 +61,26 @@ export function sumSatoshis(items: any[]): string { * @param outputs - Array of transaction outputs * @param normalizedAddress - The address to check against * @param isReceive - true for received funds, false for sent funds + * @param cashAddrPrefix - The cash address prefix (default: 'ecash') */ export function calculateTransactionValue( outputs: any[], normalizedAddress: string, isReceive: boolean, + cashAddrPrefix: string = 'ecash', ): string { return outputs .filter((output: any) => { - const outputAddress = scriptToAddress(output.outputScript); + const outputAddress = scriptToAddress( + output.outputScript, + cashAddrPrefix, + ); return isReceive ? outputAddress === normalizedAddress : outputAddress !== normalizedAddress; }) - .reduce((sum: string, output: any) => { - return toBN(sum) - .add(toBN(extractSats(output))) - .toString(); - }, '0'); + .reduce((sum, output) => sum.add(toBN(extractSats(output))), toBN('0')) + .toString(); } export function calculateOnchainTxFee(tx: any): number { @@ -90,23 +94,27 @@ export function getTransactionAddresses( normalizedAddress: string, isReceive: boolean, isSend: boolean, + cashAddrPrefix: string = 'ecash', ): { fromAddress: string; toAddress: string } { let fromAddress = 'Unknown'; let toAddress = 'Unknown'; if (isReceive) { fromAddress = tx.inputs[0]?.outputScript - ? scriptToAddress(tx.inputs[0].outputScript) + ? scriptToAddress(tx.inputs[0].outputScript, cashAddrPrefix) : 'Unknown'; toAddress = normalizedAddress; } else if (isSend) { fromAddress = normalizedAddress; const recipientOutput = tx.outputs.find((output: any) => { - const outputAddress = scriptToAddress(output.outputScript); + const outputAddress = scriptToAddress( + output.outputScript, + cashAddrPrefix, + ); return outputAddress !== normalizedAddress; }); toAddress = recipientOutput - ? scriptToAddress(recipientOutput.outputScript) + ? scriptToAddress(recipientOutput.outputScript, cashAddrPrefix) : 'Unknown'; } @@ -115,16 +123,16 @@ export function getTransactionAddresses( export function getTransactionTimestamp(tx: any): number { if (tx.block?.timestamp) { - return tx.block.timestamp * 1000; + return tx.block.timestamp; } if (tx.timeFirstSeen) { - return parseInt(tx.timeFirstSeen) * 1000; + return Number(tx.timeFirstSeen); } - return Date.now(); + return Math.floor(Date.now() / 1000); } export function getAddressWithoutPrefix(address: Address | string): string { const fullAddress = typeof address === 'string' ? address : address.toString(); - return fullAddress.replace(/^ecash:/, ''); + return fullAddress.replace(/^\w+:/, ''); } diff --git a/packages/extension/src/providers/ecash/networks/ecash-base.ts b/packages/extension/src/providers/ecash/networks/ecash-base.ts index 744531475..31d26b975 100644 --- a/packages/extension/src/providers/ecash/networks/ecash-base.ts +++ b/packages/extension/src/providers/ecash/networks/ecash-base.ts @@ -45,14 +45,14 @@ export const createECashNetworkOptions = ( options.blockExplorerTX || 'https://explorer.e.cash/tx/[[txHash]]', blockExplorerAddr: options.blockExplorerAddr || - 'https://explorer.e.cash/address/[[address]]', + 'https://explorer.e.cash/address/ecash:[[address]]', isTestNetwork: options.isTestNetwork ?? false, currencyName: options.currencyName || 'XEC', currencyNameLong: options.currencyNameLong || 'eCash', icon: options.icon || icon, decimals: options.decimals ?? 2, node: options.node || 'https://chronik-native1.fabien.cash', - coingeckoID: options.coingeckoID || 'ecash', + coingeckoID: 'coingeckoID' in options ? options.coingeckoID : 'ecash', networkInfo: options.networkInfo || ecashNetworkInfo, dust: options.dust ?? 546, feeHandler: options.feeHandler, @@ -94,7 +94,8 @@ export class ECashNetwork extends BaseNetwork { identicon: createIcon, signer: [SignerType.secp256k1ecash], provider: ProviderName.ecash, - displayAddress: (pubkey: string) => getAddress(pubkey), + displayAddress: (pubkey: string) => + getAddress(pubkey, options.cashAddrPrefix || 'ecash'), api, basePath: `m/44'/1899'/0'/0`, ...options, @@ -214,7 +215,7 @@ const ecashOptions = createECashNetworkOptions({ name_long: 'eCash', homePage: 'https://e.cash/', blockExplorerTX: 'https://explorer.e.cash/tx/[[txHash]]', - blockExplorerAddr: 'https://explorer.e.cash/address/[[address]]', + blockExplorerAddr: 'https://explorer.e.cash/address/ecash:[[address]]', isTestNetwork: false, currencyName: 'XEC', currencyNameLong: 'eCash', diff --git a/packages/extension/src/providers/ecash/networks/ecash-testnet.ts b/packages/extension/src/providers/ecash/networks/ecash-testnet.ts new file mode 100644 index 000000000..32db041b1 --- /dev/null +++ b/packages/extension/src/providers/ecash/networks/ecash-testnet.ts @@ -0,0 +1,40 @@ +import { NetworkNames } from '@enkryptcom/types'; +import { ECashNetworkInfo } from '../types/ecash-chronik'; +import { ECashNetwork, createECashNetworkOptions } from './ecash-base'; +import icon from './icons/ecash.svg'; + +const ecashTestnetInfo: ECashNetworkInfo = { + messagePrefix: '\x18eCash Signed Message:\n', + bech32: '', + bip32: { + public: 0x043587cf, + private: 0x04358394, + }, + pubKeyHash: 0x6f, + scriptHash: 0xc4, + wif: 0xef, + cashAddrPrefix: 'ectest', +}; + +const ecashTestOptions = createECashNetworkOptions({ + name: NetworkNames.ECashTest, + name_long: 'eCash Testnet', + homePage: 'https://e.cash/', + blockExplorerTX: 'https://texplorer.e.cash/tx/[[txHash]]', + blockExplorerAddr: 'https://texplorer.e.cash/address/ectest:[[address]]', + isTestNetwork: true, + currencyName: 'tXEC', + currencyNameLong: 'Test eCash', + icon, + decimals: 2, + // Public Chronik chipnet (testnet) endpoint + node: 'https://chronik-testnet.fabien.cash', + coingeckoID: undefined, + dust: 546, + networkInfo: ecashTestnetInfo, + cashAddrPrefix: 'ectest', +}); + +const ecashTest = new ECashNetwork(ecashTestOptions); + +export default ecashTest; diff --git a/packages/extension/src/providers/ecash/networks/index.ts b/packages/extension/src/providers/ecash/networks/index.ts index 3c1076393..f5685a340 100644 --- a/packages/extension/src/providers/ecash/networks/index.ts +++ b/packages/extension/src/providers/ecash/networks/index.ts @@ -1,6 +1,8 @@ import ecash from './ecash-base'; +import ecashTest from './ecash-testnet'; import { NetworkNames } from '@enkryptcom/types'; export default { [NetworkNames.ECash]: ecash, + [NetworkNames.ECashTest]: ecashTest, }; diff --git a/packages/extension/src/providers/ecash/tests/utils.test.ts b/packages/extension/src/providers/ecash/tests/utils.test.ts index 343b6868e..e03babdff 100644 --- a/packages/extension/src/providers/ecash/tests/utils.test.ts +++ b/packages/extension/src/providers/ecash/tests/utils.test.ts @@ -270,18 +270,18 @@ describe('ECash Utils Tests', () => { }); describe('getTransactionTimestamp', () => { - it('should get timestamp from block', () => { + it('should get timestamp from block (in seconds)', () => { const tx = { block: { timestamp: 1640000000 }, }; - expect(getTransactionTimestamp(tx)).to.equal(1640000000000); + expect(getTransactionTimestamp(tx)).to.equal(1640000000); }); - it('should get timestamp from timeFirstSeen', () => { + it('should get timestamp from timeFirstSeen (in seconds)', () => { const tx = { timeFirstSeen: '1640000000', }; - expect(getTransactionTimestamp(tx)).to.equal(1640000000000); + expect(getTransactionTimestamp(tx)).to.equal(1640000000); }); it('should prefer block timestamp over timeFirstSeen', () => { @@ -289,14 +289,14 @@ describe('ECash Utils Tests', () => { block: { timestamp: 1640000000 }, timeFirstSeen: '1630000000', }; - expect(getTransactionTimestamp(tx)).to.equal(1640000000000); + expect(getTransactionTimestamp(tx)).to.equal(1640000000); }); - it('should return current time for transaction without timestamp', () => { + it('should return current time in seconds for transaction without timestamp', () => { const tx = {}; const timestamp = getTransactionTimestamp(tx); - const now = Date.now(); - expect(timestamp).to.be.closeTo(now, 1000); + const nowSeconds = Math.floor(Date.now() / 1000); + expect(timestamp).to.be.closeTo(nowSeconds, 2); }); }); }); diff --git a/packages/extension/src/providers/ecash/types/ecash-chronik.ts b/packages/extension/src/providers/ecash/types/ecash-chronik.ts index bd336ac43..31641a36f 100644 --- a/packages/extension/src/providers/ecash/types/ecash-chronik.ts +++ b/packages/extension/src/providers/ecash/types/ecash-chronik.ts @@ -20,13 +20,13 @@ export interface ChronikTx { outIdx: number; }; inputScript: string; - outputScript: string; - value: string; + outputScript?: string; + sats: bigint; sequenceNo: number; token?: any; }>; outputs: Array<{ - value: string; + sats: bigint; outputScript: string; token?: any; spentBy?: { @@ -35,12 +35,13 @@ export interface ChronikTx { }; }>; lockTime: number; - timeFirstSeen: string; + timeFirstSeen: number; size: number; isCoinbase: boolean; + isFinal?: boolean; block?: { height: number; hash: string; - timestamp: string; + timestamp: number; }; } diff --git a/packages/extension/src/providers/ecash/types/ecash-network.ts b/packages/extension/src/providers/ecash/types/ecash-network.ts index 374d90dbd..a2aac962d 100644 --- a/packages/extension/src/providers/ecash/types/ecash-network.ts +++ b/packages/extension/src/providers/ecash/types/ecash-network.ts @@ -6,7 +6,7 @@ import { ECashNetworkInfo } from '../types/ecash-chronik'; import { NFTCollection } from '@/types/nft'; import { Address } from 'ecash-lib'; import * as bitcoin from 'bitcoinjs-lib'; -import { getAddressWithoutPrefix } from '../libs/utils'; +import { getAddressWithoutPrefix, isValidECashAddress } from '../libs/utils'; export interface ECashNetworkOptions { name: NetworkNames; @@ -35,8 +35,11 @@ export interface ECashNetworkOptions { cashAddrPrefix?: string; } -export const getAddress = (pubkey: string): string => { - if (pubkey.length < 64) return pubkey; +export const getAddress = ( + pubkey: string, + cashAddrPrefix: string = 'ecash', +): string => { + if (isValidECashAddress(pubkey)) return getAddressWithoutPrefix(pubkey); try { let cleanPubkey = pubkey; @@ -48,13 +51,9 @@ export const getAddress = (pubkey: string): string => { const pubkeyHash = bitcoin.crypto.hash160(pubkeyBuffer); - const scriptHex = '76a914' + pubkeyHash.toString('hex') + '88ac'; + const address = Address.p2pkh(pubkeyHash.toString('hex'), cashAddrPrefix); - const address = Address.fromScriptHex(scriptHex); - - const addressWithoutPrefix = getAddressWithoutPrefix(address); - - return addressWithoutPrefix; + return getAddressWithoutPrefix(address); } catch (error) { console.error('Error converting pubkey to cashaddr:', error); return pubkey; diff --git a/packages/extension/src/ui/action/views/deposit/index.vue b/packages/extension/src/ui/action/views/deposit/index.vue index 28a50de1f..4a6ffe24b 100644 --- a/packages/extension/src/ui/action/views/deposit/index.vue +++ b/packages/extension/src/ui/action/views/deposit/index.vue @@ -16,10 +16,15 @@
Date: Mon, 30 Mar 2026 22:48:33 -0500 Subject: [PATCH 08/14] refactor(ecash): improve utility and helper functions --- .../src/providers/ecash/libs/utils.ts | 37 +++++++++---- .../src/providers/ecash/tests/utils.test.ts | 52 ++++++++++++++----- .../providers/ecash/types/ecash-network.ts | 5 +- .../providers/ecash/ui/libs/fee-calculator.ts | 45 +++++++--------- .../src/providers/ecash/ui/libs/send-utils.ts | 18 +++---- 5 files changed, 99 insertions(+), 58 deletions(-) diff --git a/packages/extension/src/providers/ecash/libs/utils.ts b/packages/extension/src/providers/ecash/libs/utils.ts index fbc6a81e4..352d11083 100644 --- a/packages/extension/src/providers/ecash/libs/utils.ts +++ b/packages/extension/src/providers/ecash/libs/utils.ts @@ -1,10 +1,15 @@ import { Address } from 'ecash-lib'; import { toBN } from 'web3-utils'; -export const isValidECashAddress = (address: string): boolean => { +export const isValidECashAddress = ( + address: string, + cashAddrPrefix: string = 'ecash', +): boolean => { try { const addr = Address.parse(address); - return Boolean(addr); + if (addr.prefix !== cashAddrPrefix) return false; + if (!addr.hash || addr.hash.length === 0) return false; + return true; } catch { return false; } @@ -65,10 +70,11 @@ export function sumSatoshis(items: any[]): string { */ export function calculateTransactionValue( outputs: any[], - normalizedAddress: string, + ownedAddresses: string[], isReceive: boolean, cashAddrPrefix: string = 'ecash', ): string { + const ownedSet = new Set(ownedAddresses); return outputs .filter((output: any) => { const outputAddress = scriptToAddress( @@ -76,8 +82,8 @@ export function calculateTransactionValue( cashAddrPrefix, ); return isReceive - ? outputAddress === normalizedAddress - : outputAddress !== normalizedAddress; + ? ownedSet.has(outputAddress) + : !ownedSet.has(outputAddress); }) .reduce((sum, output) => sum.add(toBN(extractSats(output))), toBN('0')) .toString(); @@ -91,27 +97,40 @@ export function calculateOnchainTxFee(tx: any): number { export function getTransactionAddresses( tx: any, - normalizedAddress: string, + ownedAddresses: string[], isReceive: boolean, isSend: boolean, cashAddrPrefix: string = 'ecash', ): { fromAddress: string; toAddress: string } { let fromAddress = 'Unknown'; let toAddress = 'Unknown'; + const ownedSet = new Set(ownedAddresses); if (isReceive) { + // From: first input (external sender) fromAddress = tx.inputs[0]?.outputScript ? scriptToAddress(tx.inputs[0].outputScript, cashAddrPrefix) : 'Unknown'; - toAddress = normalizedAddress; + // To: first owned address that received funds + const receivingOutput = tx.outputs.find((output: any) => { + const outputAddress = scriptToAddress( + output.outputScript, + cashAddrPrefix, + ); + return ownedSet.has(outputAddress); + }); + toAddress = receivingOutput + ? scriptToAddress(receivingOutput.outputScript, cashAddrPrefix) + : (ownedAddresses[0] ?? 'Unknown'); } else if (isSend) { - fromAddress = normalizedAddress; + fromAddress = ownedAddresses[0] ?? 'Unknown'; + // To: first EXTERNAL output (not owned = recipient, not change) const recipientOutput = tx.outputs.find((output: any) => { const outputAddress = scriptToAddress( output.outputScript, cashAddrPrefix, ); - return outputAddress !== normalizedAddress; + return !ownedSet.has(outputAddress); }); toAddress = recipientOutput ? scriptToAddress(recipientOutput.outputScript, cashAddrPrefix) diff --git a/packages/extension/src/providers/ecash/tests/utils.test.ts b/packages/extension/src/providers/ecash/tests/utils.test.ts index e03babdff..587e07ae4 100644 --- a/packages/extension/src/providers/ecash/tests/utils.test.ts +++ b/packages/extension/src/providers/ecash/tests/utils.test.ts @@ -13,16 +13,32 @@ import { describe('ECash Utils Tests', () => { describe('isValidECashAddress', () => { - it('should validate correct eCash address', () => { + it('should validate correct eCash address with prefix', () => { const validAddress = 'ecash:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; expect(isValidECashAddress(validAddress)).toBe(true); }); - it('should validate correct eCash address without prefix', () => { + it('should validate correct eCash address without prefix (default ecash)', () => { const validAddress = 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; expect(isValidECashAddress(validAddress)).toBe(true); }); + it('should validate correct ectest address without prefix', () => { + const validAddress = 'qz5fdmzx8cdqspevemxe20z94y6689zhdqm5xdfvsm'; + expect(isValidECashAddress(validAddress, 'ectest')).toBe(true); + }); + + it('should reject ecash address when ectest prefix is expected', () => { + const validEcashAddress = + 'ecash:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + expect(isValidECashAddress(validEcashAddress, 'ectest')).toBe(false); + }); + + it('should reject ectest address when ecash prefix is expected', () => { + const ectestAddress = 'ectest:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + expect(isValidECashAddress(ectestAddress, 'ecash')).toBe(false); + }); + it('should reject invalid eCash address', () => { const invalidAddress = 'invalid_address_123'; expect(isValidECashAddress(invalidAddress)).toBe(false); @@ -31,6 +47,12 @@ describe('ECash Utils Tests', () => { it('should reject empty string', () => { expect(isValidECashAddress('')).toBe(false); }); + + it('should use ecash as default prefix', () => { + const validAddress = 'ecash:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + expect(isValidECashAddress(validAddress)).toBe(true); + expect(isValidECashAddress(validAddress, 'ecash')).toBe(true); + }); }); describe('scriptToAddress', () => { @@ -124,7 +146,11 @@ describe('ECash Utils Tests', () => { }, ]; const normalizedAddress = scriptToAddress(outputs[0].outputScript); - const value = calculateTransactionValue(outputs, normalizedAddress, true); + const value = calculateTransactionValue( + outputs, + [normalizedAddress], + true, + ); expect(value).to.equal('1000'); }); @@ -142,7 +168,7 @@ describe('ECash Utils Tests', () => { const normalizedAddress = scriptToAddress(outputs[0].outputScript); const value = calculateTransactionValue( outputs, - normalizedAddress, + [normalizedAddress], false, ); expect(value).to.equal('2000'); @@ -157,7 +183,7 @@ describe('ECash Utils Tests', () => { ]; const value = calculateTransactionValue( outputs, - 'nonexistent_address', + ['nonexistent_address'], true, ); expect(value).to.equal('0'); @@ -207,15 +233,16 @@ describe('ECash Utils Tests', () => { ], outputs: [ { - outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + outputScript: '76a9142aa5b50d61a930bc280c9a53165c0dbbc46daef488ac', sats: 1000, }, ], }; + // The owned address matches the output script above const normalizedAddress = 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; const { fromAddress, toAddress } = getTransactionAddresses( tx, - normalizedAddress, + [normalizedAddress], true, false, ); @@ -232,19 +259,20 @@ describe('ECash Utils Tests', () => { ], outputs: [ { - outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + outputScript: '76a9142aa5b50d61a930bc280c9a53165c0dbbc46daef488ac', sats: 1000, }, { - outputScript: '76a914c0c78ee6f7c4f4c5f8d9e0f1a2b3c4d5e6f788ac', + outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', sats: 2000, }, ], }; - const normalizedAddress = scriptToAddress(tx.outputs[0].outputScript); + // owned address is the first output (change), recipient is the second + const normalizedAddress = 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; const { fromAddress, toAddress } = getTransactionAddresses( tx, - normalizedAddress, + [normalizedAddress], false, true, ); @@ -260,7 +288,7 @@ describe('ECash Utils Tests', () => { }; const { fromAddress, toAddress } = getTransactionAddresses( tx, - 'someaddress', + ['someaddress'], false, false, ); diff --git a/packages/extension/src/providers/ecash/types/ecash-network.ts b/packages/extension/src/providers/ecash/types/ecash-network.ts index a2aac962d..121539c7e 100644 --- a/packages/extension/src/providers/ecash/types/ecash-network.ts +++ b/packages/extension/src/providers/ecash/types/ecash-network.ts @@ -39,7 +39,8 @@ export const getAddress = ( pubkey: string, cashAddrPrefix: string = 'ecash', ): string => { - if (isValidECashAddress(pubkey)) return getAddressWithoutPrefix(pubkey); + if (isValidECashAddress(pubkey, cashAddrPrefix)) + return getAddressWithoutPrefix(pubkey); try { let cleanPubkey = pubkey; @@ -56,6 +57,6 @@ export const getAddress = ( return getAddressWithoutPrefix(address); } catch (error) { console.error('Error converting pubkey to cashaddr:', error); - return pubkey; + return ''; } }; diff --git a/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts b/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts index 557844dbd..1c6e912c3 100644 --- a/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts +++ b/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts @@ -59,7 +59,15 @@ export const calculateTransactionFee = ( if (bSats.lt(aSats)) return -1; return 0; }); - + if (nonTokenUTXOs.length === 0) { + console.warn( + '⚠️ [calculateTransactionFee] No spendable XEC UTXOs available', + ); + return { + feeInXEC: fromBase(fallbackByteSize.toString(), networkDecimals), + txSize: fallbackByteSize, + }; + } const result = calculateNativeXECFee( sendAmount, nonTokenUTXOs, @@ -146,30 +154,17 @@ export const buildGasCostValues = ( ): GasFeeType => { const feeUSD = new BigNumber(feeInXEC).times(assetPrice || '0').toString(); + const entry = { + nativeValue: feeInXEC, + fiatValue: feeUSD, + nativeSymbol: currencyName, + fiatSymbol: 'USD', + }; + return { - [GasPriceTypes.ECONOMY]: { - nativeValue: new BigNumber(feeInXEC).times(0.8).toString(), - fiatValue: new BigNumber(feeUSD).times(0.8).toString(), - nativeSymbol: currencyName, - fiatSymbol: 'USD', - }, - [GasPriceTypes.REGULAR]: { - nativeValue: feeInXEC, - fiatValue: feeUSD, - nativeSymbol: currencyName, - fiatSymbol: 'USD', - }, - [GasPriceTypes.FAST]: { - nativeValue: new BigNumber(feeInXEC).times(1.2).toString(), - fiatValue: new BigNumber(feeUSD).times(1.2).toString(), - nativeSymbol: currencyName, - fiatSymbol: 'USD', - }, - [GasPriceTypes.FASTEST]: { - nativeValue: new BigNumber(feeInXEC).times(1.5).toString(), - fiatValue: new BigNumber(feeUSD).times(1.5).toString(), - nativeSymbol: currencyName, - fiatSymbol: 'USD', - }, + [GasPriceTypes.ECONOMY]: entry, + [GasPriceTypes.REGULAR]: entry, + [GasPriceTypes.FAST]: entry, + [GasPriceTypes.FASTEST]: entry, }; }; diff --git a/packages/extension/src/providers/ecash/ui/libs/send-utils.ts b/packages/extension/src/providers/ecash/ui/libs/send-utils.ts index 7c4042584..9ea9117b9 100644 --- a/packages/extension/src/providers/ecash/ui/libs/send-utils.ts +++ b/packages/extension/src/providers/ecash/ui/libs/send-utils.ts @@ -2,8 +2,8 @@ import { toBN } from 'web3-utils'; import { toBase, fromBase } from '@enkryptcom/utils'; interface UTXO { - sats?: number; - value?: number; + sats?: number | bigint; + value?: number | bigint; token?: any; } @@ -11,12 +11,10 @@ export const calculateUTXOBalance = ( accountUTXOs: UTXO[], ): ReturnType => { const nonTokenUTXOs = accountUTXOs.filter((utxo: UTXO) => !utxo.token); - return toBN( - nonTokenUTXOs.reduce( - (acc, utxo) => acc + Number(utxo.sats || utxo.value || 0), - 0, - ), - ); + return nonTokenUTXOs.reduce((acc, utxo) => { + const sats = utxo.sats ?? utxo.value ?? 0; + return acc.add(toBN(sats.toString())); + }, toBN(0)); }; export const calculateBalanceAfterTransaction = ( @@ -41,8 +39,8 @@ export const isBelowDustLimit = ( assetDecimals: number, dustLimit: number, ): boolean => { - const amountInSatoshis = toBase(sendAmount, assetDecimals); - return Number(amountInSatoshis) < dustLimit && Number(sendAmount) > 0; + const amountInSats = toBN(toBase(sendAmount, assetDecimals)); + return amountInSats.lt(toBN(dustLimit)) && amountInSats.gt(toBN(0)); }; export const calculateMaxSendableValue = ( From 02be4b3d9d3c8b169aad23926d5f5d73a3ea0960 Mon Sep 17 00:00:00 2001 From: Amatack Date: Mon, 30 Mar 2026 22:50:01 -0500 Subject: [PATCH 09/14] refactor(ecash): improve send-transaction UI components --- .../components/send-address-input.vue | 7 +- .../ecash/ui/send-transaction/index.vue | 27 ++-- .../verify-transaction/index.vue | 127 ++++++++++++------ 3 files changed, 102 insertions(+), 59 deletions(-) diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue index d6bef95b9..2592f677e 100644 --- a/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue +++ b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue @@ -35,7 +35,7 @@ const props = defineProps({ }, network: { type: Object as PropType, - default: () => ({}), + required: true, }, from: { type: Boolean, @@ -60,7 +60,10 @@ const xecAddress = computed(() => { }); const isAddressValid = computed(() => { - return isValidECashAddress(xecAddress.value); + return isValidECashAddress( + xecAddress.value, + props.network.cashAddrPrefix ?? 'ecash', + ); }); const address = computed({ diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/index.vue b/packages/extension/src/providers/ecash/ui/send-transaction/index.vue index 4847f8613..947a1fe6e 100644 --- a/packages/extension/src/providers/ecash/ui/send-transaction/index.vue +++ b/packages/extension/src/providers/ecash/ui/send-transaction/index.vue @@ -51,8 +51,8 @@ ); const isMaxSelected = ref(false); -const selectedFee = ref(GasPriceTypes.REGULAR); const gasCostValues = ref(defaultGasCostVals); const addressFrom = ref( props.accountInfo.selectedAccount?.address ?? '', @@ -187,7 +185,9 @@ const belowDust = computed(() => { ); }); -const currentGasFee = computed(() => gasCostValues.value[selectedFee.value]); +const currentGasFee = computed( + () => gasCostValues.value[GasPriceTypes.REGULAR], +); const isBalanceZero = computed(() => { return UTXOBalance.value.isZero(); @@ -257,7 +257,13 @@ const sendButtonTitle = computed(() => { const isInputsValid = computed(() => { if (isCalculatingMax.value) return false; - if (!isValidECashAddress(addressTo.value)) return false; + if ( + !isValidECashAddress( + addressTo.value, + props.network.cashAddrPrefix ?? 'ecash', + ) + ) + return false; if (!isValidDecimals(sendAmount.value, selectedAsset.value.decimals!)) return false; @@ -375,14 +381,7 @@ const inputAmount = (inputAmount: string) => { amount.value = inputAmountBn.lt(0) ? '0' : inputAmount; }; -const recentlySentAddresses = new RecentlySentAddressesState(); - const sendAction = async () => { - await recentlySentAddresses.addRecentlySentAddress( - props.network, - addressTo.value, - ); - const keyring = new PublicKeyRing(); const fromAccountInfo = await keyring.getAccount(addressFrom.value); @@ -409,7 +408,7 @@ const sendAction = async () => { fromAddress: fromAccountInfo.address, fromAddressName: fromAccountInfo.name, gasFee: currentGasFee.value, - gasPriceType: selectedFee.value, + gasPriceType: GasPriceTypes.REGULAR, toAddress: addressTo.value, }; diff --git a/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue b/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue index a9f61f970..00c858bc8 100644 --- a/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue +++ b/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue @@ -1,6 +1,15 @@