From 7b7cded6ad9edcbb70175256fdafb42c181a5835 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:38:51 +0200 Subject: [PATCH 1/8] fix(frontend): split a transaction save the backend would reject `save_user_transactions` rejects a batch above MAX_SAVE_USER_TRANSACTIONS_BATCH (500) outright rather than storing part of it. The native path never noticed, because it only ever offers the slice fetched since the last load - but a token's first save is its whole history, which routinely exceeds that. Chunked in the shared service, since the limit is the backend's rather than any one chain's. Co-Authored-By: Claude Opus 5 --- .../src/lib/constants/app.constants.ts | 4 ++ .../services/user-transactions.services.ts | 23 ++++++-- src/frontend/src/lib/utils/array.utils.ts | 15 +++++ .../user-transactions.services.spec.ts | 56 ++++++++++++++++++- .../src/tests/lib/utils/array.utils.spec.ts | 22 +++++++- 5 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/frontend/src/lib/constants/app.constants.ts b/src/frontend/src/lib/constants/app.constants.ts index dca60ac7655..626e93d3dad 100644 --- a/src/frontend/src/lib/constants/app.constants.ts +++ b/src/frontend/src/lib/constants/app.constants.ts @@ -173,6 +173,10 @@ export const NFT_TIMER_INTERVAL_MILLIS = (SECONDS_IN_MINUTE / 3) * 1_000; // 20 // Wallets export const WALLET_TIMER_INTERVAL_MILLIS = (SECONDS_IN_MINUTE / 2) * 1_000; // 30 seconds in milliseconds export const WALLET_PAGINATION = 10n; +// The backend rejects a whole `save_user_transactions` batch above its own limit +// (`MAX_SAVE_USER_TRANSACTIONS_BATCH` in src/shared/src/types/user_transaction.rs), so saves are +// split into batches of this size. Keep the two in step. +export const USER_TRANSACTIONS_SAVE_BATCH_SIZE = 500; // How many consecutive jobs must fail to fetch the transactions of an IC token before we tell the user about it. // At the interval above, that is about 90 seconds of silence - long enough to skip transient hiccups. export const IC_TRANSACTIONS_UNAVAILABLE_THRESHOLD = 3; diff --git a/src/frontend/src/lib/services/user-transactions.services.ts b/src/frontend/src/lib/services/user-transactions.services.ts index 05c2531fa35..edefe26228c 100644 --- a/src/frontend/src/lib/services/user-transactions.services.ts +++ b/src/frontend/src/lib/services/user-transactions.services.ts @@ -1,10 +1,11 @@ import type { TokenId as BackendTokenId, UserTransaction } from '$declarations/backend/backend.did'; import { getUserTransactions, saveUserTransactions } from '$lib/api/backend.api'; -import { WALLET_PAGINATION } from '$lib/constants/app.constants'; +import { USER_TRANSACTIONS_SAVE_BATCH_SIZE, WALLET_PAGINATION } from '$lib/constants/app.constants'; import type { NullishIdentity } from '$lib/types/identity'; import type { Transaction as EthTransaction } from '$lib/types/transaction'; import type { LoadUserTransactionsResult } from '$lib/types/user-transactions'; import type { ResultSuccess } from '$lib/types/utils'; +import { chunk } from '$lib/utils/array.utils'; import type { SolTransactionUi } from '$sol/types/sol-transaction'; import { isNullish } from '@dfinity/utils'; @@ -83,12 +84,22 @@ export const saveFinalizedTransactions = async ({ return { success: true }; } + // The backend rejects an oversized batch outright rather than storing part of it, so a token whose + // whole history is offered at once - as an ERC20 token's first save is - has to be split up. + const batches = chunk({ + elements: finalized.map(mapToBackend), + size: USER_TRANSACTIONS_SAVE_BATCH_SIZE + }); + try { - await saveUserTransactions({ - identity, - tokenId, - transactions: finalized.map(mapToBackend) - }); + for (const batch of batches) { + await saveUserTransactions({ + identity, + tokenId, + transactions: batch + }); + } + return { success: true }; } catch (_: unknown) { return { success: false }; diff --git a/src/frontend/src/lib/utils/array.utils.ts b/src/frontend/src/lib/utils/array.utils.ts index a7f6b00bdf2..1fa27b36db9 100644 --- a/src/frontend/src/lib/utils/array.utils.ts +++ b/src/frontend/src/lib/utils/array.utils.ts @@ -1,3 +1,18 @@ +/** + * Splits an array into consecutive groups of at most `size`, preserving order. + * + * A `size` below one would never consume the input, so it yields a single group. + */ +export const chunk = ({ elements, size }: { elements: T[]; size: number }): T[][] => { + if (size < 1) { + return elements.length === 0 ? [] : [elements]; + } + + return Array.from({ length: Math.ceil(elements.length / size) }, (_, index) => + elements.slice(index * size, index * size + size) + ); +}; + export const last = (elements: T[]): T | undefined => { const { length, [length - 1]: last } = elements; return last; diff --git a/src/frontend/src/tests/lib/services/user-transactions.services.spec.ts b/src/frontend/src/tests/lib/services/user-transactions.services.spec.ts index cc7e7b8de27..41ab22199c8 100644 --- a/src/frontend/src/tests/lib/services/user-transactions.services.spec.ts +++ b/src/frontend/src/tests/lib/services/user-transactions.services.spec.ts @@ -1,5 +1,5 @@ import * as backendApi from '$lib/api/backend.api'; -import { WALLET_PAGINATION } from '$lib/constants/app.constants'; +import { USER_TRANSACTIONS_SAVE_BATCH_SIZE, WALLET_PAGINATION } from '$lib/constants/app.constants'; import { loadUserTransactions, saveFinalizedTransactions @@ -189,6 +189,60 @@ describe('user-transactions.services', () => { expect(backendApi.saveUserTransactions).not.toHaveBeenCalled(); }); + it('should split a save the backend would reject into batches it accepts', async () => { + vi.spyOn(backendApi, 'saveUserTransactions').mockResolvedValue(); + + const transactions = Array.from( + { length: USER_TRANSACTIONS_SAVE_BATCH_SIZE + 1 }, + (_, i) => ({ + ...mockTx, + hash: `tx-${i}` + }) + ); + + const result = await saveFinalizedTransactions({ + identity: mockIdentity, + tokenId: mockUserTransactionTokenId, + transactions, + isFinalizedFn: alwaysFinalized, + mapToBackend: mockMapToBackendUserTransaction, + canSave: alwaysSaveable + }); + + expect(result).toEqual({ success: true }); + expect(backendApi.saveUserTransactions).toHaveBeenCalledTimes(2); + + const [[first], [second]] = vi.mocked(backendApi.saveUserTransactions).mock.calls; + + expect(first.transactions).toHaveLength(USER_TRANSACTIONS_SAVE_BATCH_SIZE); + expect(second.transactions).toHaveLength(1); + }); + + it('should report failure when one batch fails', async () => { + vi.spyOn(backendApi, 'saveUserTransactions') + .mockResolvedValueOnce() + .mockRejectedValueOnce(new Error('too many')); + + const transactions = Array.from( + { length: USER_TRANSACTIONS_SAVE_BATCH_SIZE + 1 }, + (_, i) => ({ + ...mockTx, + hash: `tx-${i}` + }) + ); + + const result = await saveFinalizedTransactions({ + identity: mockIdentity, + tokenId: mockUserTransactionTokenId, + transactions, + isFinalizedFn: alwaysFinalized, + mapToBackend: mockMapToBackendUserTransaction, + canSave: alwaysSaveable + }); + + expect(result).toEqual({ success: false }); + }); + it('should save finalized transactions that pass both filters', async () => { vi.spyOn(backendApi, 'saveUserTransactions').mockResolvedValue(); diff --git a/src/frontend/src/tests/lib/utils/array.utils.spec.ts b/src/frontend/src/tests/lib/utils/array.utils.spec.ts index c86aa9123a8..b93725b8379 100644 --- a/src/frontend/src/tests/lib/utils/array.utils.spec.ts +++ b/src/frontend/src/tests/lib/utils/array.utils.spec.ts @@ -1,4 +1,4 @@ -import { last, primitiveArrayEqual } from '$lib/utils/array.utils'; +import { chunk, last, primitiveArrayEqual } from '$lib/utils/array.utils'; describe('array.utils', () => { describe('last', () => { @@ -72,4 +72,24 @@ describe('array.utils', () => { expect(primitiveArrayEqual([1], [])).toBeFalsy(); }); }); + + describe('chunk', () => { + it('should split into groups of the given size, in order', () => { + expect(chunk({ elements: [1, 2, 3, 4, 5], size: 2 })).toStrictEqual([[1, 2], [3, 4], [5]]); + }); + + it('should return one group when the size covers everything', () => { + expect(chunk({ elements: [1, 2], size: 5 })).toStrictEqual([[1, 2]]); + }); + + it('should return nothing for an empty array', () => { + expect(chunk({ elements: [], size: 3 })).toStrictEqual([]); + }); + + it('should not lose the input for a size below one', () => { + expect(chunk({ elements: [1, 2], size: 0 })).toStrictEqual([[1, 2]]); + + expect(chunk({ elements: [], size: 0 })).toStrictEqual([]); + }); + }); }); From f728f8342d3a8e8f0c005351a09117bc672273c7 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:38:51 +0200 Subject: [PATCH 2/8] feat(frontend): derive the backend storage key from a token One place decides which key a token's stored history lives under, and lowercases the contract address on the way: the backend holds it as a plain string and compares it byte-for-byte, while our token list is checksummed and Etherscan is lowercase. Two casings would be two histories of one token. Returns nothing for tokens this path cannot store, which is what keeps collectibles out of it. Co-Authored-By: Claude Opus 5 --- .../src/eth/utils/user-transactions.utils.ts | 39 +++++++++++++++++- .../eth/utils/user-transactions.utils.spec.ts | 40 ++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/eth/utils/user-transactions.utils.ts b/src/frontend/src/eth/utils/user-transactions.utils.ts index 1ba38123e7a..b21b95c0c04 100644 --- a/src/frontend/src/eth/utils/user-transactions.utils.ts +++ b/src/frontend/src/eth/utils/user-transactions.utils.ts @@ -1,8 +1,45 @@ -import type { UserTransaction } from '$declarations/backend/backend.did'; +import type { TokenId as BackendTokenId, UserTransaction } from '$declarations/backend/backend.did'; +import { isTokenErc20 } from '$eth/utils/erc20.utils'; +import { isTokenErc4626 } from '$eth/utils/erc4626.utils'; +import { isTokenEthereumNative } from '$eth/utils/native-token.utils'; import { ZERO } from '$lib/constants/app.constants'; +import type { Token } from '$lib/types/token'; import type { Transaction } from '$lib/types/transaction'; +import { isNetworkEthereum } from '$lib/utils/network.utils'; import { fromNullable, isNullish, nonNullish, toNullable } from '@dfinity/utils'; +/** + * The backend key under which a token's stored transaction history lives. + * + * The contract address is lowercased: the backend holds it as a plain string and compares it + * byte-for-byte as part of the storage key, while our token list carries checksummed addresses and + * Etherscan returns lowercase ones. Two casings of one contract would be two separate histories. + * + * `undefined` for tokens whose history this path cannot store - non-fungible transfers, and anything + * off an EVM chain. + */ +export const toBackendTokenId = (token: Token): BackendTokenId | undefined => { + const { network } = token; + + if (!isNetworkEthereum(network)) { + return; + } + + const { chainId } = network; + + if (isTokenEthereumNative(token)) { + return { EvmNative: chainId }; + } + + if (isTokenErc4626(token)) { + return { Erc4626: [token.address.toLowerCase(), chainId] }; + } + + if (isTokenErc20(token)) { + return { Erc20: [token.address.toLowerCase(), chainId] }; + } +}; + export const mapTransactionToUserTransaction = (transaction: Transaction): UserTransaction => { if (isNullish(transaction.hash)) { throw new Error('Cannot store a transaction without a hash'); diff --git a/src/frontend/src/tests/eth/utils/user-transactions.utils.spec.ts b/src/frontend/src/tests/eth/utils/user-transactions.utils.spec.ts index c9b7bb6a67c..8b275d837e0 100644 --- a/src/frontend/src/tests/eth/utils/user-transactions.utils.spec.ts +++ b/src/frontend/src/tests/eth/utils/user-transactions.utils.spec.ts @@ -1,12 +1,19 @@ import type { UserTransaction } from '$declarations/backend/backend.did'; +import { BASE_NETWORK } from '$env/networks/networks-evm/networks.evm.base.env'; +import { USDC_TOKEN } from '$env/tokens/tokens-erc20/tokens.usdc.env'; +import { BASE_ETH_TOKEN } from '$env/tokens/tokens-evm/tokens-base/tokens.eth.env'; +import { ICP_TOKEN } from '$env/tokens/tokens.icp.env'; import { ETH_FINALITY_BLOCKS, isTransactionFinalized, mapTransactionToUserTransaction, - mapUserTransactionToTransaction + mapUserTransactionToTransaction, + toBackendTokenId } from '$eth/utils/user-transactions.utils'; import { ZERO } from '$lib/constants/app.constants'; import type { Transaction } from '$lib/types/transaction'; +import { MOCK_ERC1155_TOKENS } from '$tests/mocks/erc1155-tokens.mock'; +import { MOCK_ERC721_TOKENS } from '$tests/mocks/erc721-tokens.mock'; import { mockEthAddress } from '$tests/mocks/eth.mock'; import { extractMockEvmTransactionData, @@ -317,4 +324,35 @@ describe('user-transactions.utils', () => { ).toBeFalsy(); }); }); + + describe('toBackendTokenId', () => { + it('should key a chain native coin by its chain id', () => { + expect(toBackendTokenId(BASE_ETH_TOKEN)).toStrictEqual({ EvmNative: BASE_NETWORK.chainId }); + }); + + it('should key an ERC20 token by contract and chain id', () => { + expect(toBackendTokenId(USDC_TOKEN)).toStrictEqual({ + Erc20: [USDC_TOKEN.address.toLowerCase(), USDC_TOKEN.network.chainId] + }); + }); + + it('should lowercase the contract, so one contract is never two histories', () => { + const checksummed = { + ...USDC_TOKEN, + address: `0x${USDC_TOKEN.address.slice(2).toUpperCase()}` + }; + + expect(toBackendTokenId(checksummed)).toStrictEqual(toBackendTokenId(USDC_TOKEN)); + }); + + it('should return undefined for a token off an EVM chain', () => { + expect(toBackendTokenId(ICP_TOKEN)).toBeUndefined(); + }); + + it('should return undefined for non-fungible tokens', () => { + expect(toBackendTokenId(MOCK_ERC721_TOKENS[0])).toBeUndefined(); + + expect(toBackendTokenId(MOCK_ERC1155_TOKENS[0])).toBeUndefined(); + }); + }); }); From 8f1315b2a7a73a9dff76cdb5ad9dc740e6ed9265 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:39:09 +0200 Subject: [PATCH 3/8] feat(frontend): store ERC20 transfer history in the backend An ERC20 token view re-fetched the token's whole history from Etherscan on every load: the backend cache reached only the native and Solana paths. It now reads the stored page first and asks Etherscan only for transfers newer than the newest stored one, saving what it fetches. The Etherscan fetch moves into its own service so the paging path can reuse it and both filter spam the same way. Rows are saved as the chain reported them, so the vault mint/burn convention is applied for display only - which is why stored rows pass through it too, not just fetched ones. Collectibles are deliberately untouched: they have no backend key, so they neither read the cache nor take an incremental start block. Co-Authored-By: Claude Opus 5 --- .../eth/services/erc-transfers.services.ts | 53 +++++++ .../eth/services/eth-transactions.services.ts | 123 ++++++++--------- .../eth-transactions.services.spec.ts | 130 +++++++++++++++++- 3 files changed, 243 insertions(+), 63 deletions(-) create mode 100644 src/frontend/src/eth/services/erc-transfers.services.ts diff --git a/src/frontend/src/eth/services/erc-transfers.services.ts b/src/frontend/src/eth/services/erc-transfers.services.ts new file mode 100644 index 00000000000..69852ad8c6f --- /dev/null +++ b/src/frontend/src/eth/services/erc-transfers.services.ts @@ -0,0 +1,53 @@ +import { alchemyProviders } from '$eth/providers/alchemy.providers'; +import { etherscanProviders } from '$eth/providers/etherscan.providers'; +import type { EthAddress } from '$eth/types/address'; +import type { Erc20Token } from '$eth/types/erc20'; +import type { Erc4626Token } from '$eth/types/erc4626'; +import { filterSpamErc20Transfers } from '$eth/utils/eth-transactions-spam.utils'; +import { retryWithDelay } from '$lib/services/rest.services'; +import type { Address } from '$lib/types/address'; +import type { NetworkId } from '$lib/types/network'; +import type { Transaction } from '$lib/types/transaction'; + +/** + * Fetches a window of a token's ERC20 `Transfer` events, without the ones that only look like the + * user's activity. + * + * Shared by the two paths that pull transfers from Etherscan - the initial load and the fetch for + * history older than the list - so that both filter spam the same way and neither has to know how. + */ +export const fetchErc20Transfers = async ({ + networkId, + token, + address, + startBlock, + endBlock +}: { + networkId: NetworkId; + token: Erc20Token | Erc4626Token; + address: Address; + startBlock?: number; + endBlock?: number; +}): Promise => { + const { erc20Transactions } = etherscanProviders(networkId); + + const transactions = await retryWithDelay({ + request: async () => + await erc20Transactions({ contract: token, address, startBlock: startBlock ?? 0, endBlock }) + }); + + const { getTransaction } = alchemyProviders(networkId); + + return filterSpamErc20Transfers({ + transactions, + userAddress: address, + // The `transaction.from` is the `Transfer` event's _from (who tokens move from), not + // the EOA that signed the tx. In address-poisoning scams the attacker emits + // `Transfer(victim, attacker, 0)`, so `transaction.from == victim`. We need the + // outer tx sender via RPC to tell whether the user actually initiated it. + getTransactionSender: async (hash: string): Promise => { + const tx = await getTransaction(hash); + return tx?.from; + } + }); +}; diff --git a/src/frontend/src/eth/services/eth-transactions.services.ts b/src/frontend/src/eth/services/eth-transactions.services.ts index fe4d7618564..4a5e6259a57 100644 --- a/src/frontend/src/eth/services/eth-transactions.services.ts +++ b/src/frontend/src/eth/services/eth-transactions.services.ts @@ -5,27 +5,24 @@ import { enabledErc1155Tokens } from '$eth/derived/erc1155.derived'; import { enabledErc20Tokens } from '$eth/derived/erc20.derived'; import { erc4626Tokens } from '$eth/derived/erc4626.derived'; import { enabledErc721Tokens } from '$eth/derived/erc721.derived'; -import { alchemyProviders } from '$eth/providers/alchemy.providers'; import { etherscanProviders } from '$eth/providers/etherscan.providers'; import { infuraProviders } from '$eth/providers/infura.providers'; +import { fetchErc20Transfers } from '$eth/services/erc-transfers.services'; import { loadEthUserTransactions, saveEthFinalizedTransactions, setEthBackendPaginationCursor } from '$eth/services/eth-user-transactions.services'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; -import type { EthAddress } from '$eth/types/address'; import type { Erc1155CustomToken } from '$eth/types/erc1155-custom-token'; -import type { Erc20CustomToken } from '$eth/types/erc20-custom-token'; -import type { Erc4626CustomToken } from '$eth/types/erc4626-custom-token'; import type { Erc721CustomToken } from '$eth/types/erc721-custom-token'; import type { EthereumChainId } from '$eth/types/network'; import { isTokenErc1155 } from '$eth/utils/erc1155.utils'; import { isTokenErc20 } from '$eth/utils/erc20.utils'; import { isTokenErc4626, normalizeErc4626MintBurnTransfers } from '$eth/utils/erc4626.utils'; import { isTokenErc721 } from '$eth/utils/erc721.utils'; -import { filterSpamErc20Transfers } from '$eth/utils/eth-transactions-spam.utils'; import { isSupportedEthTokenId } from '$eth/utils/eth.utils'; +import { toBackendTokenId } from '$eth/utils/user-transactions.utils'; import { isSupportedEvmNativeTokenId } from '$evm/utils/native-token.utils'; import { TRACK_COUNT_ETH_LOADING_TRANSACTIONS_ERROR } from '$lib/constants/analytics.constants'; import { ethAddress as addressStore } from '$lib/derived/address.derived'; @@ -64,7 +61,7 @@ export const loadEthereumTransactions = ({ return loadEthTransactions({ identity, networkId, tokenId, chainId, updateOnly, silent }); } - return loadErcTransactions({ networkId, tokenId, standard, updateOnly }); + return loadErcTransactions({ identity, networkId, tokenId, standard, updateOnly }); }; // If we use the update method instead of the set method, we can keep the existing transactions and just update their data. @@ -78,7 +75,7 @@ export const reloadEthereumTransactions = (params: { silent?: boolean; }): Promise => loadEthereumTransactions({ ...params, updateOnly: true }); -const maxEthNativeBlockNumberInStore = (tokenId: TokenId): number | undefined => { +const maxBlockNumberInStore = (tokenId: TokenId): number | undefined => { const rows = get(ethTransactionsStore)?.[tokenId]; if (isNullish(rows) || rows.length === 0) { @@ -191,7 +188,7 @@ const loadEthTransactions = async ({ newestStoredBlockIndex: stored?.newestBlockIndex, maxBlockFromTransactionsStore: nonNullish(stored?.newestBlockIndex) ? undefined - : maxEthNativeBlockNumberInStore(tokenId) + : maxBlockNumberInStore(tokenId) }); const newTransactions = await loadNewEthNativeTransactionsAfterStartBlock({ @@ -264,11 +261,13 @@ const loadEthTransactions = async ({ }; const loadErcTransactions = async ({ + identity, networkId, tokenId, standard, updateOnly = false }: { + identity: NullishIdentity; networkId: NetworkId; tokenId: TokenId; standard: TokenStandard; @@ -295,18 +294,52 @@ const loadErcTransactions = async ({ return { success: false }; } + // Non-fungible transfers come from endpoints this path does not store, so they keep fetching their + // whole history every time - and must not pick up an incremental start block from the store. + const transactionTokenId = toBackendTokenId(token); + const cached = USER_TRANSACTIONS_LOAD_FROM_BACKEND_ENABLED && nonNullish(transactionTokenId); + try { - const transactions = isTokenErc4626(token) - ? await loadErc4626Transactions({ networkId, token, address }) - : isTokenErc20(token) - ? await loadErc20Transactions({ networkId, token, address }) + const stored = cached + ? await loadEthUserTransactions({ identity, tokenId: transactionTokenId }) + : undefined; + + // Left alone on a reload: the timer must not rewind pages the user has already scrolled past. + if (cached && !updateOnly) { + setEthBackendPaginationCursor({ tokenId, nextStart: stored?.nextStart }); + } + + const startBlock = cached + ? resolveEthIncrementalStartBlock({ + newestStoredBlockIndex: stored?.newestBlockIndex, + maxBlockFromTransactionsStore: nonNullish(stored?.newestBlockIndex) + ? undefined + : maxBlockNumberInStore(tokenId) + }) + : 0; + + const fetched = + isTokenErc4626(token) || isTokenErc20(token) + ? await fetchErc20Transfers({ networkId, token, address, startBlock }) : isTokenErc721(token) ? await loadErc721Transactions({ networkId, token, address }) : isTokenErc1155(token) ? await loadErc1155Transactions({ networkId, token, address }) : []; - const certifiedTransactions = transactions.map((transaction) => ({ + // Combine newest-first: new transactions (desc) then stored (desc from backend) + const allTransactions = [...fetched, ...(stored?.transactions ?? [])]; + + // Applied here rather than on the fetched rows alone, because stored rows are held as the chain + // reported them - see `normalizeErc4626MintBurnTransfers`. + const displayedTransactions = isTokenErc4626(token) + ? normalizeErc4626MintBurnTransfers({ + transactions: allTransactions, + vaultAddress: token.address + }) + : allTransactions; + + const certifiedTransactions = displayedTransactions.map((transaction) => ({ data: transaction, // We set the certified property to false because we don't have a way to certify ERC transactions for now. certified: false @@ -319,6 +352,21 @@ const loadErcTransactions = async ({ } else { ethTransactionsStore.set({ tokenId, transactions: certifiedTransactions }); } + + // Saved as fetched, before the vault normalisation, so the backend holds what the chain reported. + if (cached && fetched.length > 0) { + const blockNumbers = fetched.map(({ blockNumber }) => blockNumber).filter(nonNullish); + const maxBlockNumber = blockNumbers.length > 0 ? Math.max(...blockNumbers) : 0; + + if (maxBlockNumber > 0) { + saveEthFinalizedTransactions({ + identity, + tokenId: transactionTokenId, + transactions: fetched, + currentBlockNumber: maxBlockNumber + }).catch((err) => consoleError('Background save of finalized transactions failed:', err)); + } + } } catch (err: unknown) { ethTransactionsStore.nullify(tokenId); @@ -346,55 +394,6 @@ const loadErcTransactions = async ({ return { success: true }; }; -const loadErc20Transactions = async ({ - networkId, - token, - address -}: { - networkId: NetworkId; - token: Erc20CustomToken | Erc4626CustomToken; - address: Address; -}): Promise => { - const { erc20Transactions } = etherscanProviders(networkId); - - const transactions = await retryWithDelay({ - request: async () => await erc20Transactions({ contract: token, address }) - }); - - const { getTransaction } = alchemyProviders(networkId); - - return filterSpamErc20Transfers({ - transactions, - userAddress: address, - // The `transaction.from` is the `Transfer` event's _from (who tokens move from), not - // the EOA that signed the tx. In address-poisoning scams the attacker emits - // `Transfer(victim, attacker, 0)`, so `transaction.from == victim`. We need the - // outer tx sender via RPC to tell whether the user actually initiated it. - getTransactionSender: async (hash: string): Promise => { - const tx = await getTransaction(hash); - return tx?.from; - } - }); -}; - -/** - * Loads ERC4626 vault token transactions, presenting share mints and burns as transfers with the - * vault - see `normalizeErc4626MintBurnTransfers` for why. - */ -const loadErc4626Transactions = async ({ - networkId, - token, - address -}: { - networkId: NetworkId; - token: Erc4626CustomToken; - address: Address; -}): Promise => { - const transactions = await loadErc20Transactions({ networkId, token, address }); - - return normalizeErc4626MintBurnTransfers({ transactions, vaultAddress: token.address }); -}; - const loadErc721Transactions = async ({ networkId, token, diff --git a/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts b/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts index fdc96a2c05b..973dc5a9ace 100644 --- a/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts +++ b/src/frontend/src/tests/eth/services/eth-transactions.services.spec.ts @@ -20,6 +20,7 @@ import { erc20CustomTokensStore } from '$eth/stores/erc20-custom-tokens.store'; import { erc4626DefaultTokensStore } from '$eth/stores/erc4626-default-tokens.store'; import { erc721CustomTokensStore } from '$eth/stores/erc721-custom-tokens.store'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; +import { isTokenErc20 } from '$eth/utils/erc20.utils'; import { TRACK_COUNT_ETH_LOADING_TRANSACTIONS_ERROR } from '$lib/constants/analytics.constants'; import { ZERO_ETH_ADDRESS } from '$lib/constants/app.constants'; import { trackEvent } from '$lib/services/analytics.services'; @@ -98,6 +99,9 @@ describe('eth-transactions.services', () => { erc1155Transactions: mockErcTransactions } as unknown as EtherscanProvider); + // Fungible transfers are now saved in the background, so the mock has to be awaitable. + vi.mocked(saveEthFinalizedTransactions).mockResolvedValue({ success: true }); + erc721CustomTokensStore.resetAll(); erc721CustomTokensStore.setAll([ { data: { ...mockValidErc721Token, enabled: true }, certified: false } @@ -164,7 +168,9 @@ describe('eth-transactions.services', () => { expect(mockErcTransactions).toHaveBeenCalledWith({ contract: { ...token, enabled: true }, - address: mockEthAddress + address: mockEthAddress, + // Only the fungible transfer endpoint takes a window; the rest still fetch everything. + ...(isTokenErc20(token) ? { startBlock: 0 } : {}) }); } ); @@ -294,6 +300,128 @@ describe('eth-transactions.services', () => { }); } ); + + describe('stored history', () => { + const mockToken = USDC_TOKEN; + const mockTokenId = USDC_TOKEN.id; + + const backendTokenId = { + Erc20: [USDC_TOKEN.address.toLowerCase(), USDC_TOKEN.network.chainId] + }; + + const loadUsdc = (updateOnly = false) => + loadEthereumTransactions({ + identity: mockIdentity, + networkId: mockToken.network.id, + tokenId: mockTokenId, + chainId: ETHEREUM_NETWORK.chainId, + standard: mockToken.standard, + updateOnly + }); + + beforeEach(() => { + mockErcTransactions.mockResolvedValue([]); + vi.mocked(loadEthUserTransactions).mockResolvedValue(undefined); + }); + + it('should read the stored page under the token contract key', async () => { + await loadUsdc(); + + expect(loadEthUserTransactions).toHaveBeenCalledWith( + expect.objectContaining({ tokenId: backendTokenId }) + ); + }); + + it('should ask Etherscan only for transfers newer than the newest stored one', async () => { + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: [], + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: undefined, + totalStored: 2n + }); + + await loadUsdc(); + + expect(mockErcTransactions).toHaveBeenCalledWith( + expect.objectContaining({ startBlock: 101 }) + ); + }); + + it('should show stored history together with what it fetched', async () => { + const storedTransactions = createMockEthTransactions(2); + const fetchedTransactions = createMockEthTransactions(1); + + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: storedTransactions, + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: 40n, + totalStored: 2n + }); + mockErcTransactions.mockResolvedValue(fetchedTransactions); + + await loadUsdc(); + + expect(get(ethTransactionsStore)?.[mockTokenId]).toHaveLength(3); + }); + + it('should keep the cursor of the page below the stored one', async () => { + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: [], + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: 40n, + totalStored: 2n + }); + + await loadUsdc(); + + expect(setEthBackendPaginationCursor).toHaveBeenCalledWith({ + tokenId: mockTokenId, + nextStart: 40n + }); + }); + + it('should leave the cursor alone on a reload', async () => { + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: [], + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: 40n, + totalStored: 2n + }); + + await loadUsdc(true); + + expect(setEthBackendPaginationCursor).not.toHaveBeenCalled(); + }); + + it('should store what it fetched under the token contract key', async () => { + mockErcTransactions.mockResolvedValue(createMockEthTransactions(1)); + + await loadUsdc(); + + expect(saveEthFinalizedTransactions).toHaveBeenCalledWith( + expect.objectContaining({ tokenId: backendTokenId }) + ); + }); + + it('should not reach the backend for a token whose history it cannot store', async () => { + const { id: tokenId, network, standard } = mockValidErc721Token; + + await loadEthereumTransactions({ + identity: mockIdentity, + networkId: network.id, + tokenId, + chainId: ETHEREUM_NETWORK.chainId, + standard + }); + + expect(loadEthUserTransactions).not.toHaveBeenCalled(); + expect(saveEthFinalizedTransactions).not.toHaveBeenCalled(); + }); + }); }, 60000); describe('when token is native ETH', () => { From 162b62abf1a0cef2b45d3d6efa8fb25747a748c5 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:39:09 +0200 Subject: [PATCH 4/8] feat(frontend): page ERC20 history back through the token's own endpoint Paging older history asked `txlist`, which answers for the chain's coin - running it for a token would have appended another asset's transactions under that token. It now dispatches on the token: `tokentx` bounded by the oldest row on screen for a fungible token, `txlist` for the chain's own. `loadNextEthUserTransactions` takes the token rather than three ids and a cursor, since all four follow from it, and the cursor was already module state. Co-Authored-By: Claude Opus 5 --- .../eth-user-transactions.services.ts | 107 +++++---- .../eth-user-transactions.services.spec.ts | 209 +++++++++++++----- 2 files changed, 219 insertions(+), 97 deletions(-) diff --git a/src/frontend/src/eth/services/eth-user-transactions.services.ts b/src/frontend/src/eth/services/eth-user-transactions.services.ts index 8bcea1a4dcd..4efd892ffb3 100644 --- a/src/frontend/src/eth/services/eth-user-transactions.services.ts +++ b/src/frontend/src/eth/services/eth-user-transactions.services.ts @@ -1,12 +1,16 @@ import type { TokenId as BackendTokenId } from '$declarations/backend/backend.did'; import { etherscanProviders } from '$eth/providers/etherscan.providers'; import { infuraProviders } from '$eth/providers/infura.providers'; +import { fetchErc20Transfers } from '$eth/services/erc-transfers.services'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; import type { OptionEthAddress } from '$eth/types/address'; +import { isTokenErc20 } from '$eth/utils/erc20.utils'; +import { isTokenErc4626, normalizeErc4626MintBurnTransfers } from '$eth/utils/erc4626.utils'; import { isTransactionFinalized, mapTransactionToUserTransaction, - mapUserTransactionToTransaction + mapUserTransactionToTransaction, + toBackendTokenId } from '$eth/utils/user-transactions.utils'; import { WALLET_PAGINATION } from '$lib/constants/app.constants'; import { @@ -14,8 +18,7 @@ import { saveFinalizedTransactions } from '$lib/services/user-transactions.services'; import type { NullishIdentity } from '$lib/types/identity'; -import type { NetworkId } from '$lib/types/network'; -import type { TokenId } from '$lib/types/token'; +import type { Token, TokenId } from '$lib/types/token'; import type { Transaction } from '$lib/types/transaction'; import type { LoadUserTransactionsResult } from '$lib/types/user-transactions'; import type { ResultSuccess } from '$lib/types/utils'; @@ -110,6 +113,24 @@ export const saveEthFinalizedTransactions = ({ canSave: (tx) => nonNullish(tx.blockNumber) && nonNullish(tx.hash) }); +/** + * Presents stored rows the way the token's own load path would. + * + * Stored history is held as the chain reported it, so a vault's share mints and burns still name the + * zero address and have to be read as transfers with the vault - the same convention the initial load + * applies. Every other token passes through untouched. + */ +const forDisplay = ({ + transactions, + token +}: { + transactions: Transaction[]; + token: Token; +}): Transaction[] => + isTokenErc4626(token) + ? normalizeErc4626MintBurnTransfers({ transactions, vaultAddress: token.address }) + : transactions; + /** * Loads the next page of stored transactions from the backend and appends to the store. * When the backend has no more pages, falls back to Etherscan to fetch older transactions @@ -118,10 +139,8 @@ export const saveEthFinalizedTransactions = ({ * @param identity - The caller's identity; if nullish the backend call is skipped. * @param address - The user's ETH address used to query Etherscan for older history; * if nullish the Etherscan fallback is skipped. - * @param transactionTokenId - The backend-typed token identifier used for API calls. - * @param tokenId - The frontend token identifier used to key the transactions store. - * @param networkId - The network to query when falling back to Etherscan. - * @param cursor - The `nextStart` value from a previous page; `undefined` when the backend is exhausted. + * @param token - The token whose history is being paged; determines both the backend key and which + * Etherscan action answers for older history. A token this path cannot store pages nothing. * @param oldestLoadedBlockNumber - The lowest block number among transactions already * displayed in the UI. Used as the upper bound when querying Etherscan for older history. * @param beAtCapacity - When `true`, skip persisting Etherscan results to the backend @@ -131,22 +150,26 @@ export const saveEthFinalizedTransactions = ({ export const loadNextEthUserTransactions = async ({ identity, address, - transactionTokenId, - tokenId, - networkId, - cursor, + token, oldestLoadedBlockNumber, beAtCapacity = false }: { identity: NullishIdentity; address: OptionEthAddress; - transactionTokenId: BackendTokenId; - tokenId: TokenId; - networkId: NetworkId; - cursor: bigint | undefined; + token: Token; oldestLoadedBlockNumber: number | undefined; beAtCapacity?: boolean; }): Promise<{ hasMore: boolean }> => { + const transactionTokenId = toBackendTokenId(token); + + if (isNullish(transactionTokenId)) { + return { hasMore: false }; + } + + const { id: tokenId } = token; + + const cursor = getEthBackendPaginationCursor(tokenId); + if (nonNullish(cursor)) { const result = await loadEthUserTransactions({ identity, @@ -156,10 +179,12 @@ export const loadNextEthUserTransactions = async ({ }); if (nonNullish(result) && result.transactions.length > 0) { - const certifiedTransactions = result.transactions.map((transaction) => ({ - data: transaction, - certified: false - })); + const certifiedTransactions = forDisplay({ transactions: result.transactions, token }).map( + (transaction) => ({ + data: transaction, + certified: false + }) + ); ethTransactionsStore.append({ tokenId, transactions: certifiedTransactions }); @@ -178,8 +203,7 @@ export const loadNextEthUserTransactions = async ({ identity, address, transactionTokenId, - tokenId, - networkId, + token, oldestLoadedBlockNumber, skipSave: beAtCapacity }); @@ -193,8 +217,7 @@ export const loadNextEthUserTransactions = async ({ * @param address - The user's ETH address to query Etherscan with; * returns `{ hasMore: false }` when nullish. * @param transactionTokenId - The backend-typed token identifier used for saving. - * @param tokenId - The frontend token identifier used to key the transactions store. - * @param networkId - The network whose Etherscan provider will be used. + * @param token - The token being paged; decides which Etherscan action answers for its history. * @param oldestLoadedBlockNumber - The lowest block number currently displayed; * Etherscan is queried for blocks strictly below this value. Returns early when * `undefined` or `<= 0`. @@ -205,16 +228,14 @@ const loadOlderFromEtherscan = async ({ identity, address, transactionTokenId, - tokenId, - networkId, + token, oldestLoadedBlockNumber, skipSave }: { identity: NullishIdentity; address: OptionEthAddress; transactionTokenId: BackendTokenId; - tokenId: TokenId; - networkId: NetworkId; + token: Token; oldestLoadedBlockNumber: number | undefined; skipSave: boolean; }): Promise<{ hasMore: boolean }> => { @@ -226,23 +247,35 @@ const loadOlderFromEtherscan = async ({ return { hasMore: false }; } + const { + id: tokenId, + network: { id: networkId } + } = token; + try { - const { transactions: transactionsProvider } = etherscanProviders(networkId); + const endBlock = oldestLoadedBlockNumber - 1; - const olderTransactions = await transactionsProvider({ - address, - endBlock: oldestLoadedBlockNumber - 1, - sort: 'desc' - }); + // A token transfer's history is in `tokentx`, keyed by contract; the chain's own is in `txlist`. + // Asking the wrong one would append another asset's transactions under this token. + const olderTransactions = + isTokenErc20(token) || isTokenErc4626(token) + ? await fetchErc20Transfers({ networkId, token, address, endBlock }) + : await etherscanProviders(networkId).transactions({ + address, + endBlock, + sort: 'desc' + }); if (olderTransactions.length === 0) { return { hasMore: false }; } - const certifiedTransactions = olderTransactions.map((transaction) => ({ - data: transaction, - certified: false - })); + const certifiedTransactions = forDisplay({ transactions: olderTransactions, token }).map( + (transaction) => ({ + data: transaction, + certified: false + }) + ); ethTransactionsStore.append({ tokenId, transactions: certifiedTransactions }); diff --git a/src/frontend/src/tests/eth/services/eth-user-transactions.services.spec.ts b/src/frontend/src/tests/eth/services/eth-user-transactions.services.spec.ts index f8828291465..372cd30dec4 100644 --- a/src/frontend/src/tests/eth/services/eth-user-transactions.services.spec.ts +++ b/src/frontend/src/tests/eth/services/eth-user-transactions.services.spec.ts @@ -1,6 +1,8 @@ import type { UserTransaction } from '$declarations/backend/backend.did'; -import { ETHEREUM_NETWORK_ID } from '$env/networks/networks.eth.env'; -import { ETHEREUM_TOKEN_ID } from '$env/tokens/tokens.eth.env'; +import { USDC_TOKEN } from '$env/tokens/tokens-erc20/tokens.usdc.env'; +import { ETHEREUM_TOKEN, ETHEREUM_TOKEN_ID } from '$env/tokens/tokens.eth.env'; +import type { AlchemyProvider } from '$eth/providers/alchemy.providers'; +import * as alchemyProvidersModule from '$eth/providers/alchemy.providers'; import type { EtherscanProvider } from '$eth/providers/etherscan.providers'; import * as etherscanProvidersModule from '$eth/providers/etherscan.providers'; import type { InfuraProvider } from '$eth/providers/infura.providers'; @@ -16,6 +18,7 @@ import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; import { ZERO } from '$lib/constants/app.constants'; import type { GetUserTransactionsResponse } from '$lib/types/api'; import type { Transaction } from '$lib/types/transaction'; +import { MOCK_ERC721_TOKENS } from '$tests/mocks/erc721-tokens.mock'; import { mockEthAddress } from '$tests/mocks/eth.mock'; import { mockIdentity } from '$tests/mocks/identity.mock'; import { createMockBackendUserTransaction } from '$tests/mocks/user-transactions.mock'; @@ -39,8 +42,8 @@ let mockGetUserTransactions: MockInstance; let mockSaveUserTransactions: MockInstance; const mockBackendTokenId = { EvmNative: 1n }; -const mockNetworkId = ETHEREUM_NETWORK_ID; const mockTokenId = ETHEREUM_TOKEN_ID; +const mockNativeToken = ETHEREUM_TOKEN; const makeTx = ({ hash, @@ -80,7 +83,9 @@ const MOCK_LATEST_BLOCK_NUMBER = 1000; describe('eth-user-transactions.services', () => { let etherscanProvidersSpy: MockInstance; let infuraProvidersSpy: MockInstance; + let alchemyProvidersSpy: MockInstance; let mockTransactionsProvider: MockInstance; + let mockErc20TransactionsProvider: MockInstance; let mockGetBlockNumber: MockInstance; beforeEach(async () => { @@ -88,16 +93,27 @@ describe('eth-user-transactions.services', () => { ethTransactionsStore.reinitialize(); + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: undefined }); + const backendApi = await import('$lib/api/backend.api'); mockGetUserTransactions = vi.mocked(backendApi.getUserTransactions); mockSaveUserTransactions = vi.mocked(backendApi.saveUserTransactions); mockTransactionsProvider = vi.fn().mockResolvedValue([]); + mockErc20TransactionsProvider = vi.fn().mockResolvedValue([]); etherscanProvidersSpy = vi.spyOn(etherscanProvidersModule, 'etherscanProviders'); etherscanProvidersSpy.mockReturnValue({ - transactions: mockTransactionsProvider + transactions: mockTransactionsProvider, + erc20Transactions: mockErc20TransactionsProvider } as unknown as EtherscanProvider); + // Only reached for zero-value transfers, which the fixtures avoid; stubbed so the spam filter + // does not build a real provider. + alchemyProvidersSpy = vi.spyOn(alchemyProvidersModule, 'alchemyProviders'); + alchemyProvidersSpy.mockReturnValue({ + getTransaction: vi.fn().mockResolvedValue(undefined) + } as unknown as AlchemyProvider); + mockGetBlockNumber = vi.fn().mockResolvedValue(MOCK_LATEST_BLOCK_NUMBER); infuraProvidersSpy = vi.spyOn(infuraProvidersModule, 'infuraProviders'); infuraProvidersSpy.mockReturnValue({ @@ -197,10 +213,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: undefined }); @@ -210,6 +223,8 @@ describe('eth-user-transactions.services', () => { // Case 2: Paginating through backend — more pages available it('paginates through backend when cursor is defined', async () => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: 200n }); + mockGetUserTransactions.mockResolvedValue( makeBackendResponse({ overrides: { @@ -236,10 +251,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: 200n, + token: mockNativeToken, oldestLoadedBlockNumber: 300 }); @@ -254,6 +266,8 @@ describe('eth-user-transactions.services', () => { // Case 2b: Last backend page — nextStart is None but oldestBlockIndex exists it('signals hasMore when backend exhausted but Etherscan may have older', async () => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: 1n }); + mockGetUserTransactions.mockResolvedValue( makeBackendResponse({ overrides: { @@ -274,10 +288,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: 1n, + token: mockNativeToken, oldestLoadedBlockNumber: 200 }); @@ -297,10 +308,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 100, beAtCapacity: false }); @@ -324,10 +332,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 50 }); @@ -347,10 +352,7 @@ describe('eth-user-transactions.services', () => { await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 100, beAtCapacity: true }); @@ -367,10 +369,7 @@ describe('eth-user-transactions.services', () => { await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 100, beAtCapacity: false }); @@ -385,10 +384,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 0 }); @@ -401,10 +397,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: undefined, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 100 }); @@ -419,10 +412,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 100 }); @@ -431,6 +421,8 @@ describe('eth-user-transactions.services', () => { // Case 9: Backend returns empty on cursor — falls through to Etherscan it('falls to Etherscan when backend returns empty for a cursor', async () => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: 5n }); + mockGetUserTransactions.mockResolvedValue(makeBackendResponse()); const olderTxs = [makeTx({ hash: '0xold1', blockNumber: 80 })]; @@ -439,10 +431,7 @@ describe('eth-user-transactions.services', () => { const { hasMore } = await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: 5n, + token: mockNativeToken, oldestLoadedBlockNumber: 100 }); @@ -472,10 +461,7 @@ describe('eth-user-transactions.services', () => { await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: undefined, + token: mockNativeToken, oldestLoadedBlockNumber: 110, beAtCapacity: true }); @@ -487,6 +473,113 @@ describe('eth-user-transactions.services', () => { }); }); + describe('paging a token rather than the chain', () => { + const mockErc20Token = { ...USDC_TOKEN, enabled: true }; + + const erc20BackendTokenId = { + Erc20: [USDC_TOKEN.address.toLowerCase(), USDC_TOKEN.network.chainId] + }; + + beforeEach(() => { + setEthBackendPaginationCursor({ tokenId: mockErc20Token.id, nextStart: undefined }); + + mockErc20TransactionsProvider.mockResolvedValue([]); + }); + + it('should ask the token transfer endpoint, not the chain history', async () => { + mockErc20TransactionsProvider.mockResolvedValue([makeTx({ hash: '0xold', blockNumber: 90 })]); + + const { hasMore } = await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + token: mockErc20Token, + oldestLoadedBlockNumber: 100 + }); + + expect(hasMore).toBeTruthy(); + expect(mockErc20TransactionsProvider).toHaveBeenCalledWith( + expect.objectContaining({ contract: mockErc20Token, address: mockEthAddress, endBlock: 99 }) + ); + + // `txlist` would answer with the chain's own transactions, not this token's transfers. + expect(mockTransactionsProvider).not.toHaveBeenCalled(); + }); + + it('should append what it fetched to the token slot', async () => { + mockErc20TransactionsProvider.mockResolvedValue([makeTx({ hash: '0xold', blockNumber: 90 })]); + + await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + token: mockErc20Token, + oldestLoadedBlockNumber: 100 + }); + + expect(get(ethTransactionsStore)?.[mockErc20Token.id]).toHaveLength(1); + }); + + it('should store what it fetched under the token contract key', async () => { + mockErc20TransactionsProvider.mockResolvedValue([makeTx({ hash: '0xold', blockNumber: 90 })]); + + await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + token: mockErc20Token, + oldestLoadedBlockNumber: 100 + }); + + expect(mockSaveUserTransactions).toHaveBeenCalledWith( + expect.objectContaining({ tokenId: erc20BackendTokenId }) + ); + }); + + it('should read the stored page from the token contract key', async () => { + setEthBackendPaginationCursor({ tokenId: mockErc20Token.id, nextStart: 200n }); + + mockGetUserTransactions.mockResolvedValue( + makeBackendResponse({ + overrides: { + transactions: [ + createMockBackendUserTransaction({ + hash: '0xstored', + blockIndex: 100n, + timestamp: 1000n + }) + ], + nextStart: 50n + } + }) + ); + + await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + token: mockErc20Token, + oldestLoadedBlockNumber: 300 + }); + + expect(mockGetUserTransactions).toHaveBeenCalledWith( + expect.objectContaining({ tokenId: erc20BackendTokenId }) + ); + + expect(mockErc20TransactionsProvider).not.toHaveBeenCalled(); + }); + + it('should page nothing for a token whose history it cannot store', async () => { + const { hasMore } = await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + token: MOCK_ERC721_TOKENS[0], + oldestLoadedBlockNumber: 100 + }); + + expect(hasMore).toBeFalsy(); + expect(mockGetUserTransactions).not.toHaveBeenCalled(); + expect(mockErc20TransactionsProvider).not.toHaveBeenCalled(); + expect(mockTransactionsProvider).not.toHaveBeenCalled(); + }); + }); + describe('backend pagination cursor', () => { beforeEach(() => { setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: undefined }); @@ -503,6 +596,8 @@ describe('eth-user-transactions.services', () => { }); it('should advance the cursor to the next page after loading one', async () => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: 200n }); + mockGetUserTransactions.mockResolvedValue( makeBackendResponse({ overrides: { @@ -524,10 +619,7 @@ describe('eth-user-transactions.services', () => { await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: 200n, + token: mockNativeToken, oldestLoadedBlockNumber: 300 }); @@ -542,10 +634,7 @@ describe('eth-user-transactions.services', () => { await loadNextEthUserTransactions({ identity: mockIdentity, address: mockEthAddress, - transactionTokenId: mockBackendTokenId, - tokenId: mockTokenId, - networkId: mockNetworkId, - cursor: 200n, + token: mockNativeToken, oldestLoadedBlockNumber: 300 }); From 2776f74e19757d7a14796358edeaf4f338bade81 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:39:09 +0200 Subject: [PATCH 5/8] feat(frontend): let ERC20 token views scroll into older history The scroll was gated on the chain's own coin. Now anything with a backend storage key pages, which covers ERC20 and vault tokens and still excludes collectibles. Co-Authored-By: Claude Opus 5 --- .../transactions/EthTransactionsScroll.svelte | 28 ++++--------- .../EthTransactionsScroll.spec.ts | 40 +++++++++---------- 2 files changed, 26 insertions(+), 42 deletions(-) diff --git a/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte b/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte index 1ff2b8f65bc..dc235d0cdd6 100644 --- a/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte +++ b/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte @@ -1,19 +1,14 @@