From 7694affd26756ff9af7ee393955824c16d841a25 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Sun, 4 Jan 2026 23:58:49 +0800 Subject: [PATCH] fix(transaction): fetch EVM tx history via ChainProvider --- src/services/transaction/web.test.ts | 97 ++++++++++++++++++++++++++++ src/services/transaction/web.ts | 58 +++++++++++++++-- 2 files changed, 149 insertions(+), 6 deletions(-) create mode 100644 src/services/transaction/web.test.ts diff --git a/src/services/transaction/web.test.ts b/src/services/transaction/web.test.ts new file mode 100644 index 000000000..2f00e6977 --- /dev/null +++ b/src/services/transaction/web.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + mockWalletStorageInitialize: vi.fn(), + mockGetWalletChainAddresses: vi.fn(), + mockInitializeChainConfigs: vi.fn(), + mockGetEnabledChains: vi.fn(), + mockGetChainById: vi.fn(), + mockCreateBioforestAdapter: vi.fn(), + mockGetTransactionHistory: vi.fn(), + mockGetChainProvider: vi.fn(), +})) + +vi.mock('@/services/wallet-storage', () => ({ + walletStorageService: { + initialize: mocks.mockWalletStorageInitialize, + getWalletChainAddresses: mocks.mockGetWalletChainAddresses, + }, +})) + +vi.mock('@/services/chain-config', () => ({ + initialize: mocks.mockInitializeChainConfigs, + getEnabledChains: mocks.mockGetEnabledChains, + getChainById: mocks.mockGetChainById, +})) + +vi.mock('@/services/chain-adapter', () => ({ + createBioforestAdapter: mocks.mockCreateBioforestAdapter, +})) + +vi.mock('@/services/chain-adapter/providers', () => ({ + getChainProvider: mocks.mockGetChainProvider, +})) + +import { transactionService } from './web' + +describe('transactionService(web)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('loads EVM history via ChainProvider.getTransactionHistory', async () => { + mocks.mockWalletStorageInitialize.mockResolvedValue(undefined) + mocks.mockGetWalletChainAddresses.mockResolvedValue([ + { + chain: 'ethereum', + address: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', + }, + ]) + + mocks.mockInitializeChainConfigs.mockResolvedValue({}) + + const ethereumConfig = { + id: 'ethereum', + enabled: true, + type: 'evm', + symbol: 'ETH', + decimals: 18, + } + mocks.mockGetEnabledChains.mockReturnValue([ethereumConfig]) + mocks.mockGetChainById.mockReturnValue(null) + + mocks.mockGetChainProvider.mockReturnValue({ + supportsTransactionHistory: true, + getTransactionHistory: mocks.mockGetTransactionHistory, + }) + + mocks.mockGetTransactionHistory.mockResolvedValue([ + { + hash: '0xdeadbeef', + from: '0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa', + to: '0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb', + value: '1000000000000000000', + symbol: 'ETH', + timestamp: 1_700_000_000_000, + status: 'confirmed', + blockNumber: 123n, + }, + ]) + + const records = await transactionService.getHistory({ + walletId: 'w1', + filter: { chain: 'ethereum', period: 'all', type: 'all', status: 'all' } as any, + }) + + expect(records).toHaveLength(1) + expect(records[0]?.id).toBe('ethereum--0xdeadbeef') + expect(records[0]?.chain).toBe('ethereum') + expect(records[0]?.type).toBe('send') + expect(records[0]?.status).toBe('confirmed') + expect(records[0]?.amount.toRawString()).toBe('1000000000000000000') + expect(records[0]?.blockNumber).toBe(123) + + const cached = await transactionService.getTransaction({ id: 'ethereum--0xdeadbeef' }) + expect(cached?.id).toBe('ethereum--0xdeadbeef') + }) +}) diff --git a/src/services/transaction/web.ts b/src/services/transaction/web.ts index 7ba83be82..40484d2c1 100644 --- a/src/services/transaction/web.ts +++ b/src/services/transaction/web.ts @@ -6,6 +6,8 @@ import { transactionServiceMeta, type TransactionFilter, type TransactionRecord, import { walletStorageService } from '@/services/wallet-storage' import { initialize as initializeChainConfigs, getEnabledChains, getChainById, type ChainConfig } from '@/services/chain-config' import { createBioforestAdapter, type Transaction as ChainTransaction } from '@/services/chain-adapter' +import { getChainProvider, type Transaction as ProviderTransaction } from '@/services/chain-adapter/providers' +import { Amount } from '@/types/amount' const recordCache = new Map() type TransactionFilterInput = Partial | undefined @@ -24,12 +26,25 @@ async function fetchHistory(walletId: string, filter?: TransactionFilterInput): if (targetChain && addressInfo.chain !== targetChain) return [] const config = enabledMap.get(addressInfo.chain) ?? getChainById(snapshot, addressInfo.chain) - if (!config || !config.enabled || config.type !== 'bioforest') return [] - - const adapter = createBioforestAdapter(config) - return adapter.transaction.getTransactionHistory(addressInfo.address, 50).then((list) => - list.map((tx) => mapChainTransaction(tx, config, addressInfo.address)) - ) + if (!config || !config.enabled) return [] + + if (config.type === 'bioforest') { + const adapter = createBioforestAdapter(config.id) + return adapter.transaction.getTransactionHistory(addressInfo.address, 50).then((list) => + list.map((tx) => mapChainTransaction(tx, config, addressInfo.address)) + ) + } + + try { + const provider = getChainProvider(addressInfo.chain) + if (!provider.supportsTransactionHistory || !provider.getTransactionHistory) return [] + + return provider.getTransactionHistory(addressInfo.address, 50).then((list) => + list.map((tx) => mapProviderTransaction(tx, config, addressInfo.address)) + ) + } catch { + return [] + } }) const results = await Promise.all(tasks) @@ -125,6 +140,37 @@ function mapChainTransaction(tx: ChainTransaction, config: ChainConfig, address: } } +function isSameAddress(left: string, right: string): boolean { + const a = left.trim() + const b = right.trim() + if (a.startsWith('0x') && b.startsWith('0x')) return a.toLowerCase() === b.toLowerCase() + return a === b +} + +function mapProviderTransaction(tx: ProviderTransaction, config: ChainConfig, address: string): TransactionRecord { + const isOutgoing = isSameAddress(tx.from, address) + const type: TransactionType = isOutgoing ? 'send' : 'receive' + + return { + // Use '--' as separator to avoid URL routing conflicts with ':' + id: `${config.id}--${tx.hash}`, + type, + status: tx.status, + amount: Amount.fromRaw(tx.value, config.decimals, config.symbol), + symbol: config.symbol, + decimals: config.decimals, + address: type === 'send' ? tx.to : tx.from, + timestamp: new Date(tx.timestamp), + hash: tx.hash, + chain: config.id, + blockNumber: tx.blockNumber ? Number(tx.blockNumber) : undefined, + confirmations: undefined, + fee: undefined, + feeSymbol: undefined, + feeDecimals: undefined, + } +} + function filterByPeriod(records: TransactionRecord[], period: TransactionFilter['period']): TransactionRecord[] { if (!period || period === 'all') return records const days = period === '7d' ? 7 : period === '30d' ? 30 : 90