diff --git a/packages/extension/package.json b/packages/extension/package.json index a2db162be..ff726bbc2 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -63,7 +63,10 @@ "bip39": "^3.1.0", "bitcoinjs-lib": "^6.1.7", "bs58": "^6.0.0", + "chronik-client": "^4.1.0", "concurrently": "^9.2.1", + "ecash-lib": "4.7.0", + "ecash-wallet": "5.1.0", "echarts": "^6.0.0", "ethereum-cryptography": "^2.2.1", "ethereumjs-abi": "^0.6.8", 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..267647246 100644 --- a/packages/extension/src/libs/activity-state/wrap-activity-handler.ts +++ b/packages/extension/src/libs/activity-state/wrap-activity-handler.ts @@ -1,18 +1,28 @@ 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 options = { address: address, network: network.name, }; + + // Use shorter cache TTL for eCash due to faster finality + 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), activityState.getCacheTime(options), ]); - if (cacheTime + CACHE_TTL < new Date().getTime()) { + + if (cacheTime + cacheTTL < new Date().getTime()) { const liveActivities = await activityHandler(network, address); if (!activities.length) { await activityState.addActivities(liveActivities, options); diff --git a/packages/extension/src/libs/background/index.ts b/packages/extension/src/libs/background/index.ts index a888c2290..04ea65933 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; @@ -106,6 +108,15 @@ class BackgroundHandler { error: JSON.stringify(getCustomError('Enkrypt: not implemented')), }; } + if (_provider === ProviderName.ecash) { + return { + error: JSON.stringify( + getCustomError( + 'Enkrypt: eCash does not support external requests in this wallet', + ), + ), + }; + } if (this.#geoRestricted !== undefined && this.#geoRestricted) { return { error: JSON.stringify( @@ -186,6 +197,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..7721ab034 --- /dev/null +++ b/packages/extension/src/libs/background/internal/ecash-sign.ts @@ -0,0 +1,119 @@ +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 || + typeof params !== 'object' || + !params.toAddress || + !params.amount || + !params.account || + !params.networkName + ) { + return { error: getCustomError('ecash-sign: missing required parameters') }; + } + + 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') }; + } + + const cashAddrPrefix = (network as any).cashAddrPrefix ?? 'ecash'; + if (!isValidECashAddress(params.toAddress, cashAddrPrefix)) { + return { + error: getCustomError('ecash-sign: invalid destination address'), + }; + } + + 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]; + if (!txid) { + throw new Error('Broadcast succeeded but no txid returned'); + } + 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/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/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/providers/ecash/libs/activity-handlers.ts b/packages/extension/src/providers/ecash/libs/activity-handlers.ts new file mode 100644 index 000000000..39d228e9a --- /dev/null +++ b/packages/extension/src/providers/ecash/libs/activity-handlers.ts @@ -0,0 +1,143 @@ +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 cashAddrPrefix = (network as any).cashAddrPrefix ?? 'ecash'; + 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, + cashAddrPrefix, + ); + return outputAddress === normalizedAddress; + }); + + const isSend = tx.inputs.some((input: any) => { + const inputAddress = scriptToAddress( + input.outputScript ?? '', + cashAddrPrefix, + ); + return inputAddress === normalizedAddress; + }); + + 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; + + 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, + inputs: tx.inputs.map((input: any) => ({ + address: scriptToAddress(input.outputScript ?? '', cashAddrPrefix), + value: Number(extractSats(input)), + })), + outputs: tx.outputs.map((output: any) => ({ + address: scriptToAddress(output.outputScript, cashAddrPrefix), + 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: timestamp * 1000, + 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..3e0e916e5 --- /dev/null +++ b/packages/extension/src/providers/ecash/libs/api-chronik.ts @@ -0,0 +1,171 @@ +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'; +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.chronikInfo(); + }, + () => { + throw new Error('Failed to initialize Chronik API'); + }, + ); + } + + private ensurePrefix(address: string): string { + if (address.includes(':')) { + return address; + } + return `${this.networkInfo.cashAddrPrefix}:${address}`; + } + + private async withErrorHandling( + method: string, + operation: () => Promise, + fallback?: () => T | Promise, + ): Promise { + try { + return await operation(); + } catch (error) { + console.error(`[ChronikAPI:${method}]`, error); + if (fallback) return await fallback(); + throw error; + } + } + + async getBalance(pubkey: string): Promise { + return this.withErrorHandling( + 'getBalance', + async () => { + const address = this.ensurePrefix( + getAddress(pubkey, this.networkInfo.cashAddrPrefix), + ); + const wallet = WatchOnlyWallet.fromAddress(address, this.chronik); + await wallet.sync(); + return wallet.balanceSats.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); + + const rawInfo: BTCRawInfo = { + blockNumber: tx.block?.height ?? 0, + fee: this.calculateFee(tx as any), + transactionHash: tx.txid, + 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 => ({ + address: this.scriptToAddress(output.outputScript), + value: Number(output.sats), + pkscript: output.outputScript, + })), + }; + + return rawInfo; + }, + () => null, + ); + } + + private calculateFee(tx: ChronikTx): number { + const inputSum = tx.inputs.reduce( + (sum, input) => sum + input.sats, + BigInt(0), + ); + const outputSum = tx.outputs.reduce( + (sum, output) => sum + output.sats, + 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 fullAddress = Address.fromScript( + script, + this.networkInfo.cashAddrPrefix, + ).toString(); + + return fullAddress.split(':')[1] || fullAddress; + } catch (error) { + console.error( + '[scriptToAddress] Could not derive address from script, only p2pkh and p2sh are supported:', + 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..352d11083 --- /dev/null +++ b/packages/extension/src/providers/ecash/libs/utils.ts @@ -0,0 +1,157 @@ +import { Address } from 'ecash-lib'; +import { toBN } from 'web3-utils'; + +export const isValidECashAddress = ( + address: string, + cashAddrPrefix: string = 'ecash', +): boolean => { + try { + const addr = Address.parse(address); + if (addr.prefix !== cashAddrPrefix) return false; + if (!addr.hash || addr.hash.length === 0) return false; + return true; + } catch { + return false; + } +}; + +const scriptAddressCache = new Map(); + +export function scriptToAddress( + script: string, + cashAddrPrefix: string = 'ecash', +): string { + if (!script) return 'Unknown'; + + const cacheKey = `${cashAddrPrefix}:${script}`; + if (scriptAddressCache.has(cacheKey)) { + return scriptAddressCache.get(cacheKey)!; + } + + try { + const address = Address.fromScriptHex(script, cashAddrPrefix); + const addressWithoutPrefix = getAddressWithoutPrefix(address); + + scriptAddressCache.set(cacheKey, 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(cacheKey, 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) => sum.add(toBN(extractSats(item))), toBN('0')) + .toString(); +} + +/** + * 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 + * @param cashAddrPrefix - The cash address prefix (default: 'ecash') + */ +export function calculateTransactionValue( + outputs: any[], + ownedAddresses: string[], + isReceive: boolean, + cashAddrPrefix: string = 'ecash', +): string { + const ownedSet = new Set(ownedAddresses); + return outputs + .filter((output: any) => { + const outputAddress = scriptToAddress( + output.outputScript, + cashAddrPrefix, + ); + return isReceive + ? ownedSet.has(outputAddress) + : !ownedSet.has(outputAddress); + }) + .reduce((sum, output) => sum.add(toBN(extractSats(output))), toBN('0')) + .toString(); +} + +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, + 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'; + // 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 = 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 !ownedSet.has(outputAddress); + }); + toAddress = recipientOutput + ? scriptToAddress(recipientOutput.outputScript, cashAddrPrefix) + : 'Unknown'; + } + + return { fromAddress, toAddress }; +} + +export function getTransactionTimestamp(tx: any): number { + if (tx.block?.timestamp) { + return tx.block.timestamp; + } + if (tx.timeFirstSeen) { + return Number(tx.timeFirstSeen); + } + return Math.floor(Date.now() / 1000); +} + +export function getAddressWithoutPrefix(address: Address | string): string { + const fullAddress = + typeof address === 'string' ? address : address.toString(); + 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 new file mode 100644 index 000000000..c13bb63b0 --- /dev/null +++ b/packages/extension/src/providers/ecash/networks/ecash-base.ts @@ -0,0 +1,232 @@ +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/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: 'coingeckoID' in options ? 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, options.cashAddrPrefix || 'ecash'), + 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 marketEntry = marketData[0]; + const sparkline = marketEntry?.sparkline_in_24h?.price + ? new Sparkline(marketEntry.sparkline_in_24h.price, 25).dataValues + : ''; + const priceChangePercentage = + marketEntry?.price_change_percentage_24h_in_currency ?? 0; + + 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, + priceChangePercentage, + }; + + 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/ecash:[[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/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/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..f5685a340 --- /dev/null +++ b/packages/extension/src/providers/ecash/networks/index.ts @@ -0,0 +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/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..587e07ae4 --- /dev/null +++ b/packages/extension/src/providers/ecash/tests/utils.test.ts @@ -0,0 +1,330 @@ +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 with prefix', () => { + const validAddress = 'ecash:qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + expect(isValidECashAddress(validAddress)).toBe(true); + }); + + 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); + }); + + 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', () => { + 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: '76a9142aa5b50d61a930bc280c9a53165c0dbbc46daef488ac', + sats: 1000, + }, + ], + }; + // The owned address matches the output script above + 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: '76a9142aa5b50d61a930bc280c9a53165c0dbbc46daef488ac', + sats: 1000, + }, + { + outputScript: '76a914b9b67dd5f6b3f3b4f7c8d9e0f1a2b3c4d5e6f788ac', + sats: 2000, + }, + ], + }; + // owned address is the first output (change), recipient is the second + const normalizedAddress = 'qq42tdgdvx5np0pgpjd9x9jupkaugmdw7sjp5dqa63'; + 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 (in seconds)', () => { + const tx = { + block: { timestamp: 1640000000 }, + }; + expect(getTransactionTimestamp(tx)).to.equal(1640000000); + }); + + it('should get timestamp from timeFirstSeen (in seconds)', () => { + const tx = { + timeFirstSeen: '1640000000', + }; + expect(getTransactionTimestamp(tx)).to.equal(1640000000); + }); + + it('should prefer block timestamp over timeFirstSeen', () => { + const tx = { + block: { timestamp: 1640000000 }, + timeFirstSeen: '1630000000', + }; + expect(getTransactionTimestamp(tx)).to.equal(1640000000); + }); + + it('should return current time in seconds for transaction without timestamp', () => { + const tx = {}; + const timestamp = getTransactionTimestamp(tx); + 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 new file mode 100644 index 000000000..31641a36f --- /dev/null +++ b/packages/extension/src/providers/ecash/types/ecash-chronik.ts @@ -0,0 +1,47 @@ +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; + sats: bigint; + sequenceNo: number; + token?: any; + }>; + outputs: Array<{ + sats: bigint; + outputScript: string; + token?: any; + spentBy?: { + txid: string; + outIdx: number; + }; + }>; + lockTime: number; + timeFirstSeen: number; + size: number; + isCoinbase: boolean; + isFinal?: boolean; + block?: { + height: number; + hash: 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 new file mode 100644 index 000000000..121539c7e --- /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, isValidECashAddress } 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, + cashAddrPrefix: string = 'ecash', +): string => { + if (isValidECashAddress(pubkey, cashAddrPrefix)) + return getAddressWithoutPrefix(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 address = Address.p2pkh(pubkeyHash.toString('hex'), cashAddrPrefix); + + return getAddressWithoutPrefix(address); + } catch (error) { + console.error('Error converting pubkey to cashaddr:', error); + return ''; + } +}; 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..1c6e912c3 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/libs/fee-calculator.ts @@ -0,0 +1,170 @@ +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; + }); + 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, + 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(); + + const entry = { + nativeValue: feeInXEC, + fiatValue: feeUSD, + nativeSymbol: currencyName, + fiatSymbol: 'USD', + }; + + return { + [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 new file mode 100644 index 000000000..9ea9117b9 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/libs/send-utils.ts @@ -0,0 +1,54 @@ +import { toBN } from 'web3-utils'; +import { toBase, fromBase } from '@enkryptcom/utils'; + +interface UTXO { + sats?: number | bigint; + value?: number | bigint; + token?: any; +} + +export const calculateUTXOBalance = ( + accountUTXOs: UTXO[], +): ReturnType => { + const nonTokenUTXOs = accountUTXOs.filter((utxo: UTXO) => !utxo.token); + return nonTokenUTXOs.reduce((acc, utxo) => { + const sats = utxo.sats ?? utxo.value ?? 0; + return acc.add(toBN(sats.toString())); + }, toBN(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 amountInSats = toBN(toBase(sendAmount, assetDecimals)); + return amountInSats.lt(toBN(dustLimit)) && amountInSats.gt(toBN(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..2592f677e --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/components/send-address-input.vue @@ -0,0 +1,188 @@ + + + + + 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..947a1fe6e --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/index.vue @@ -0,0 +1,479 @@ + + + + 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..00c858bc8 --- /dev/null +++ b/packages/extension/src/providers/ecash/ui/send-transaction/verify-transaction/index.vue @@ -0,0 +1,460 @@ + + + + + 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', diff --git a/packages/extension/src/ui/action/utils/filters.ts b/packages/extension/src/ui/action/utils/filters.ts index b4307a356..62a629989 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,30 @@ 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)}`; + + const decimalStr = new BigNumber(finalValue).toFixed(); + const decimalPlaces = Math.min( + (decimalStr.split('.')[1] ?? '').replace(/0+$/, '').length, + 8, + ); + + const minimumFractionDigits = 2; + const maximumFractionDigits = Math.max(2, decimalPlaces); + + const formatter = new Intl.NumberFormat(locale, { + style: 'currency', + currency: currency, + notation, + minimumFractionDigits, + maximumFractionDigits, + }); + + const formatted = formatter.format(finalValue); + const zeroFormatted = formatter.format(0); + + return `${finalValue > 0 && formatted === zeroFormatted ? '< ' : ''}${formatted}`; }; export const truncate = (value: string, length: number): string => { @@ -60,4 +83,5 @@ export const formatDuration = ( return `${m.padStart(2, '0')}:${s.padStart(2, '0')} `; }; + export { formatFiatValue, formatFloatingPointValue }; diff --git a/packages/extension/src/ui/action/views/deposit/index.vue b/packages/extension/src/ui/action/views/deposit/index.vue index cd1f67ea2..4a6ffe24b 100644 --- a/packages/extension/src/ui/action/views/deposit/index.vue +++ b/packages/extension/src/ui/action/views/deposit/index.vue @@ -18,7 +18,13 @@ :value=" $props.network?.provider == ProviderName.kadena ? network.displayAddress(account.address) - : network.provider + ':' + network.displayAddress(account.address) + : $props.network?.provider == ProviderName.ecash + ? ((network as any).cashAddrPrefix || 'ecash') + + ':' + + network.displayAddress(account.address) + : network.provider + + ':' + + network.displayAddress(account.address) " :size="150" level="H" diff --git a/packages/extension/src/ui/action/views/network-activity/index.vue b/packages/extension/src/ui/action/views/network-activity/index.vue index 3b096eb6c..dadd64c20 100644 --- a/packages/extension/src/ui/action/views/network-activity/index.vue +++ b/packages/extension/src/ui/action/views/network-activity/index.vue @@ -248,6 +248,13 @@ const handleActivityUpdate = (activity: Activity, info: any, timer: any) => { 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, diff --git a/packages/keyring/src/index.ts b/packages/keyring/src/index.ts index 9200005a9..f12c11069 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,54 @@ 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), + ); + } + + const privKeyHex = keypair.privateKey; + assert( + /^(0x)?[0-9a-fA-F]{64}$/.test(privKeyHex), + "Invalid private key format: expected 32-byte hex string", + ); + + return hexToBuffer(privKeyHex); + } } 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(); + }, + ); }); 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..7d143730b 100755 --- a/packages/types/src/networks.ts +++ b/packages/types/src/networks.ts @@ -99,6 +99,8 @@ export enum NetworkNames { Massa = "Massa", MassaBuildnet = "MassaBuildnet", TAC = "TAC", + ECash = "XEC", + ECashTest = "XECTest", } export enum CoingeckoPlatform { diff --git a/yarn.lock b/yarn.lock index 81a4bcd7b..d5f836706 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1779,8 +1779,11 @@ __metadata: bip39: "npm:^3.1.0" bitcoinjs-lib: "npm:^6.1.7" bs58: "npm:^6.0.0" + chronik-client: "npm:^4.1.0" concurrently: "npm:^9.2.1" cross-env: "npm:^10.1.0" + ecash-lib: "npm:4.7.0" + ecash-wallet: "npm:5.1.0" echarts: "npm:^6.0.0" eslint: "npm:^9.39.4" eslint-plugin-vue: "npm:^10.8.0" @@ -11830,6 +11833,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.2.1": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10/1ce05e3174dcacf28dae0e9b854ef1c9a12da44c7ed73617ab6897c5cbe4fccbb155a20be5508ae9a7dde2f83bd80f5cf3baa386b934fc4b40889ec963e94f3a + languageName: node + linkType: hard + "@types/ws@npm:^8.2.2, @types/ws@npm:^8.5.5": version: 8.5.12 resolution: "@types/ws@npm:8.5.12" @@ -14295,6 +14307,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.6.3": + version: 1.15.0 + resolution: "axios@npm:1.15.0" + dependencies: + follow-redirects: "npm:^1.15.11" + form-data: "npm:^4.0.5" + proxy-from-env: "npm:^2.1.0" + checksum: 10/d39a2c0ebc7ff4739401b282e726cc2673377949d6c46d60eb619458f8d7a2f7eadbcada7097f4dbc7d5c59abb4d3bf6fac33d474412bc3415d3f5aa7ed45530 + languageName: node + linkType: hard + "b4a@npm:^1.0.1": version: 1.6.7 resolution: "b4a@npm:1.6.7" @@ -14302,6 +14325,13 @@ __metadata: languageName: node linkType: hard +"b58-ts@npm:^0.1.0": + version: 0.1.0 + resolution: "b58-ts@npm:0.1.0" + checksum: 10/3e57f7d95d9a643e61f2376ecee468dc40c145ac99f9fef7bd11af3d0859d28b7fa33c1d9c3ca834475e4c08a08dde2130b30d681a92ffba56ec969f45ec608e + languageName: node + linkType: hard + "babel-loader@npm:^8.2.2": version: 8.4.1 resolution: "babel-loader@npm:8.4.1" @@ -15762,6 +15792,21 @@ __metadata: languageName: node linkType: hard +"chronik-client@npm:^4.1.0": + version: 4.1.0 + resolution: "chronik-client@npm:4.1.0" + dependencies: + "@types/ws": "npm:^8.2.1" + axios: "npm:^1.6.3" + ecashaddrjs: "npm:^2.0.0" + isomorphic-ws: "npm:^4.0.1" + long: "npm:^4.0.0" + protobufjs: "npm:^6.8.8" + ws: "npm:^8.3.0" + checksum: 10/9320caf02e31784c103b6966e6c7a909aeb41b7dd3f2d959f40b55fa41badc49eece49dd6a266b1257c18702d2c6f697c438b626c5921520bd98ef29b717891f + languageName: node + linkType: hard + "cids@npm:^0.7.1": version: 0.7.5 resolution: "cids@npm:0.7.5" @@ -17633,6 +17678,43 @@ __metadata: languageName: node linkType: hard +"ecash-lib@npm:4.7.0": + version: 4.7.0 + resolution: "ecash-lib@npm:4.7.0" + dependencies: + b58-ts: "npm:^0.1.0" + ecashaddrjs: "npm:^2.0.0" + checksum: 10/a638935259d1c0aeb1d224ea78ee76e07a4702078d566aa0f7b9d31a464cbd2de18bff8edcc6e202d83d2bd7c6efc3ba19c0aaeb0be2cc351d8f750abcb49e08 + languageName: node + linkType: hard + +"ecash-lib@npm:^4.8.0": + version: 4.12.0 + resolution: "ecash-lib@npm:4.12.0" + dependencies: + b58-ts: "npm:^0.1.0" + ecashaddrjs: "npm:^2.0.0" + checksum: 10/46cb0df8c3239dfa6d0e655e932d6e57f447db184cc63301d1ad9f7a1b5efa29a79097f8037ff3815fef3ec2d117b94f2cdcd882b039cb0e5ebe3333d842ef84 + languageName: node + linkType: hard + +"ecash-wallet@npm:5.1.0": + version: 5.1.0 + resolution: "ecash-wallet@npm:5.1.0" + dependencies: + chronik-client: "npm:^4.1.0" + ecash-lib: "npm:^4.8.0" + checksum: 10/8692cffa639f8a51646b92dc68ada4e32444d8d2a9e904dc94b26ac17d22e857a88c3dcd21cd7f1aa7b4929d0ee48062ac118e18351b8aece063acc6551081a3 + languageName: node + linkType: hard + +"ecashaddrjs@npm:^2.0.0": + version: 2.0.0 + resolution: "ecashaddrjs@npm:2.0.0" + checksum: 10/b911b730e21a116a2ba26bc7b6a4e87d703cb8c37dacdfb1b31b4af57872fc4f6f938cd8021bd801aae10eeb8449f73a425dbdad901b25a8024edd4e1b9604fb + languageName: node + linkType: hard + "ecc-jsbn@npm:~0.1.1": version: 0.1.2 resolution: "ecc-jsbn@npm:0.1.2" @@ -19491,6 +19573,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.11": + version: 1.15.11 + resolution: "follow-redirects@npm:1.15.11" + peerDependenciesMeta: + debug: + optional: true + checksum: 10/07372fd74b98c78cf4d417d68d41fdaa0be4dcacafffb9e67b1e3cf090bc4771515e65020651528faab238f10f9b9c0d9707d6c1574a6c0387c5de1042cde9ba + languageName: node + linkType: hard + "for-each@npm:^0.3.3, for-each@npm:^0.3.5": version: 0.3.5 resolution: "for-each@npm:0.3.5" @@ -19579,6 +19671,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.5": + version: 4.0.5 + resolution: "form-data@npm:4.0.5" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10/52ecd6e927c8c4e215e68a7ad5e0f7c1031397439672fd9741654b4a94722c4182e74cc815b225dcb5be3f4180f36428f67c6dd39eaa98af0dcfdd26c00c19cd + languageName: node + linkType: hard + "form-data@npm:~2.3.2": version: 2.3.3 resolution: "form-data@npm:2.3.3" @@ -26276,6 +26381,13 @@ __metadata: languageName: node linkType: hard +"proxy-from-env@npm:^2.1.0": + version: 2.1.0 + resolution: "proxy-from-env@npm:2.1.0" + checksum: 10/fbbaf4dab2a6231dc9e394903a5f66f20475e36b734335790b46feb9da07c37d6b32e2c02e3e2ea4d4b23774c53d8562e5b7cc73282cb43f4a597b7eacaee2ee + languageName: node + linkType: hard + "prr@npm:~1.0.1": version: 1.0.1 resolution: "prr@npm:1.0.1" @@ -32012,7 +32124,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.20.0": +"ws@npm:^8.20.0, ws@npm:^8.3.0": version: 8.20.0 resolution: "ws@npm:8.20.0" peerDependencies: