From 4309e695abbc79b901a3a795b1522759d3748e74 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 09:13:58 +0200 Subject: [PATCH 1/9] fix(frontend): remember where the ETH transaction history left off The initial load reads the newest page of stored transactions and is handed a cursor to the page below it, then drops it. Keep it, so paging back can continue through the backend instead of asking Etherscan for history the backend already holds. Co-Authored-By: Claude Opus 5 --- .../eth/services/eth-transactions.services.ts | 8 ++- .../eth-user-transactions.services.ts | 33 +++++++++ .../eth-transactions.services.spec.ts | 56 ++++++++++++++- .../eth-user-transactions.services.spec.ts | 70 ++++++++++++++++++- 4 files changed, 163 insertions(+), 4 deletions(-) diff --git a/src/frontend/src/eth/services/eth-transactions.services.ts b/src/frontend/src/eth/services/eth-transactions.services.ts index 0db769ae413..8fc7d27e21c 100644 --- a/src/frontend/src/eth/services/eth-transactions.services.ts +++ b/src/frontend/src/eth/services/eth-transactions.services.ts @@ -10,7 +10,8 @@ import { etherscanProviders } from '$eth/providers/etherscan.providers'; import { infuraProviders } from '$eth/providers/infura.providers'; import { loadEthUserTransactions, - saveEthFinalizedTransactions + saveEthFinalizedTransactions, + setEthBackendPaginationCursor } from '$eth/services/eth-user-transactions.services'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; import type { EthAddress } from '$eth/types/address'; @@ -182,6 +183,11 @@ const loadEthTransactions = async ({ ? await loadEthUserTransactions({ identity, tokenId: transactionTokenId }) : undefined; + // Left alone on a reload: the timer must not rewind pages the user has already scrolled past. + if (!updateOnly) { + setEthBackendPaginationCursor({ tokenId, nextStart: stored?.nextStart }); + } + const startBlock = resolveEthIncrementalStartBlock({ newestStoredBlockIndex: stored?.newestBlockIndex, maxBlockFromTransactionsStore: nonNullish(stored?.newestBlockIndex) 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 e28b828e7df..8bcea1a4dcd 100644 --- a/src/frontend/src/eth/services/eth-user-transactions.services.ts +++ b/src/frontend/src/eth/services/eth-user-transactions.services.ts @@ -21,6 +21,34 @@ import type { LoadUserTransactionsResult } from '$lib/types/user-transactions'; import type { ResultSuccess } from '$lib/types/utils'; import { isNullish, nonNullish } from '@dfinity/utils'; +/** + * Where paging through the backend's stored history has got to, per token. + * + * The initial load reads the newest page and is handed the cursor to the one below it. Without + * keeping that cursor, scrolling back would skip the backend and ask Etherscan for history the + * backend already holds. + */ +const ethBackendPaginationCursors = new Map(); + +export const setEthBackendPaginationCursor = ({ + tokenId, + nextStart +}: { + tokenId: TokenId; + nextStart: bigint | undefined; +}) => { + if (nonNullish(nextStart)) { + ethBackendPaginationCursors.set(tokenId, nextStart); + + return; + } + + ethBackendPaginationCursors.delete(tokenId); +}; + +export const getEthBackendPaginationCursor = (tokenId: TokenId): bigint | undefined => + ethBackendPaginationCursors.get(tokenId); + /** * Loads a page of stored ETH transactions from the backend, mapping each * `UserTransaction` into a frontend `Transaction`. @@ -135,12 +163,17 @@ export const loadNextEthUserTransactions = async ({ ethTransactionsStore.append({ tokenId, transactions: certifiedTransactions }); + setEthBackendPaginationCursor({ tokenId, nextStart: result.nextStart }); + return { hasMore: nonNullish(result.nextStart) || nonNullish(result.oldestBlockIndex) }; } } + // The backend has nothing more to give, so the next intersection must not ask it again. + setEthBackendPaginationCursor({ tokenId, nextStart: undefined }); + return loadOlderFromEtherscan({ identity, address, 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 657bf8a8157..fdc96a2c05b 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 @@ -12,7 +12,8 @@ import { } from '$eth/services/eth-transactions.services'; import { loadEthUserTransactions, - saveEthFinalizedTransactions + saveEthFinalizedTransactions, + setEthBackendPaginationCursor } from '$eth/services/eth-user-transactions.services'; import { erc1155CustomTokensStore } from '$eth/stores/erc1155-custom-tokens.store'; import { erc20CustomTokensStore } from '$eth/stores/erc20-custom-tokens.store'; @@ -45,7 +46,8 @@ vi.mock('$lib/services/analytics.services', () => ({ vi.mock('$eth/services/eth-user-transactions.services', () => ({ loadEthUserTransactions: vi.fn(), - saveEthFinalizedTransactions: vi.fn() + saveEthFinalizedTransactions: vi.fn(), + setEthBackendPaginationCursor: vi.fn() })); vi.mock('$lib/utils/console.utils', () => ({ @@ -529,6 +531,56 @@ describe('eth-transactions.services', () => { ); }); + it('should keep the backend cursor of the page below the one it loaded', async () => { + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: createMockEthTransactions(2), + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: 60n, + totalStored: 30n + }); + + infuraMocks.mockInfuraGetBlockNumber.mockResolvedValueOnce(150); + mockEthTransactionsProvider.mockResolvedValueOnce([]); + + await loadEthereumTransactions({ + identity: mockIdentity, + networkId: mockNetworkId, + tokenId: mockTokenId, + chainId: mockChainId, + standard: mockStandard + }); + + expect(setEthBackendPaginationCursor).toHaveBeenCalledWith({ + tokenId: mockTokenId, + nextStart: 60n + }); + }); + + it('should leave the backend cursor alone on a reload', async () => { + vi.mocked(loadEthUserTransactions).mockResolvedValue({ + transactions: createMockEthTransactions(2), + newestBlockIndex: 100n, + oldestBlockIndex: 50n, + nextStart: 60n, + totalStored: 30n + }); + + infuraMocks.mockInfuraGetBlockNumber.mockResolvedValueOnce(150); + mockEthTransactionsProvider.mockResolvedValueOnce([]); + + await loadEthereumTransactions({ + identity: mockIdentity, + networkId: mockNetworkId, + tokenId: mockTokenId, + chainId: mockChainId, + standard: mockStandard, + updateOnly: true + }); + + expect(setEthBackendPaginationCursor).not.toHaveBeenCalled(); + }); + it('should use update method when updateOnly is true', async () => { vi.mocked(loadEthUserTransactions).mockResolvedValue(undefined); 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 8d4bbfa95e7..f8828291465 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 @@ -6,9 +6,11 @@ import * as etherscanProvidersModule from '$eth/providers/etherscan.providers'; import type { InfuraProvider } from '$eth/providers/infura.providers'; import * as infuraProvidersModule from '$eth/providers/infura.providers'; import { + getEthBackendPaginationCursor, loadEthUserTransactions, loadNextEthUserTransactions, - saveEthFinalizedTransactions + saveEthFinalizedTransactions, + setEthBackendPaginationCursor } from '$eth/services/eth-user-transactions.services'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; import { ZERO } from '$lib/constants/app.constants'; @@ -485,6 +487,72 @@ describe('eth-user-transactions.services', () => { }); }); + describe('backend pagination cursor', () => { + beforeEach(() => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: undefined }); + }); + + it('should keep and clear the cursor of a token', () => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: 42n }); + + expect(getEthBackendPaginationCursor(mockTokenId)).toBe(42n); + + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: undefined }); + + expect(getEthBackendPaginationCursor(mockTokenId)).toBeUndefined(); + }); + + it('should advance the cursor to the next page after loading one', async () => { + mockGetUserTransactions.mockResolvedValue( + makeBackendResponse({ + overrides: { + transactions: [ + createMockBackendUserTransaction({ + hash: '0xhash1', + blockIndex: 100n, + timestamp: 1000n + }) + ], + newestBlockIndex: 500n, + oldestBlockIndex: 50n, + totalStored: 300n, + nextStart: 100n + } + }) + ); + + await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + transactionTokenId: mockBackendTokenId, + tokenId: mockTokenId, + networkId: mockNetworkId, + cursor: 200n, + oldestLoadedBlockNumber: 300 + }); + + expect(getEthBackendPaginationCursor(mockTokenId)).toBe(100n); + }); + + it('should clear the cursor once the backend has nothing left', async () => { + setEthBackendPaginationCursor({ tokenId: mockTokenId, nextStart: 200n }); + + mockGetUserTransactions.mockResolvedValue(makeBackendResponse({})); + + await loadNextEthUserTransactions({ + identity: mockIdentity, + address: mockEthAddress, + transactionTokenId: mockBackendTokenId, + tokenId: mockTokenId, + networkId: mockNetworkId, + cursor: 200n, + oldestLoadedBlockNumber: 300 + }); + + expect(getEthBackendPaginationCursor(mockTokenId)).toBeUndefined(); + }); + }); + describe('saveEthFinalizedTransactions', () => { it('returns success false when identity is missing', async () => { const result = await saveEthFinalizedTransactions({ From a19881e3d7a493d4c11280bfd8bf389a8cd7d698 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 09:14:06 +0200 Subject: [PATCH 2/9] fix(frontend): page back through older ETH transactions The ETH and EVM token views showed the newest page of stored transactions and nothing before it: the service that pages further back existed but was never wired to anything, so older history was unreachable. Wrap the date groups in an infinite scroll, as the Solana and ICP views already do. Native entries only - older ERC transfers come from a different Etherscan endpoint, which this path does not query. Co-Authored-By: Claude Opus 5 --- .../transactions/EthTransactions.svelte | 25 ++-- .../transactions/EthTransactionsScroll.svelte | 68 +++++++++++ .../EthTransactionsScroll.spec.ts | 113 ++++++++++++++++++ 3 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte create mode 100644 src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts diff --git a/src/frontend/src/eth/components/transactions/EthTransactions.svelte b/src/frontend/src/eth/components/transactions/EthTransactions.svelte index 19962191781..934e86dc570 100644 --- a/src/frontend/src/eth/components/transactions/EthTransactions.svelte +++ b/src/frontend/src/eth/components/transactions/EthTransactions.svelte @@ -2,6 +2,7 @@ import { isNullish, nonNullish } from '@dfinity/utils'; import EthTokenModal from '$eth/components/tokens/EthTokenModal.svelte'; import EthTransactionModal from '$eth/components/transactions/EthTransactionModal.svelte'; + import EthTransactionsScroll from '$eth/components/transactions/EthTransactionsScroll.svelte'; import EthTransactionsSkeletons from '$eth/components/transactions/EthTransactionsSkeletons.svelte'; import { sortedEthTransactions } from '$eth/derived/eth-transactions.derived'; import { nativeEthereumTokenId } from '$eth/derived/token.derived'; @@ -75,17 +76,19 @@ - {#if nonNullish(groupedTransactions) && Object.values(groupedTransactions).length > 0} - {#each Object.entries(groupedTransactions) as [formattedDate, transactions], index (formattedDate)} - - {/each} - {/if} - - {#if isNullish(groupedTransactions) || Object.values(groupedTransactions).length === 0} + {#if filteredTransactions.length > 0} + + {#if nonNullish(groupedTransactions) && Object.values(groupedTransactions).length > 0} + {#each Object.entries(groupedTransactions) as [formattedDate, transactions], index (formattedDate)} + + {/each} + {/if} + + {:else if isNullish(groupedTransactions) || Object.values(groupedTransactions).length === 0} {/if} diff --git a/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte b/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte new file mode 100644 index 00000000000..1ff2b8f65bc --- /dev/null +++ b/src/frontend/src/eth/components/transactions/EthTransactionsScroll.svelte @@ -0,0 +1,68 @@ + + + + {@render children()} + diff --git a/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts b/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts new file mode 100644 index 00000000000..da690cafaa1 --- /dev/null +++ b/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts @@ -0,0 +1,113 @@ +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 EthTransactionsScroll from '$eth/components/transactions/EthTransactionsScroll.svelte'; +import * as ethUserTransactionsServices from '$eth/services/eth-user-transactions.services'; +import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; +import { token } from '$lib/stores/token.store'; +import type { Transaction } from '$lib/types/transaction'; +import { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; +import { + IntersectionObserverActive, + IntersectionObserverPassive +} from '$tests/mocks/infinite-scroll.mock'; +import { mockSnippet } from '$tests/mocks/snippet.mock'; +import { render } from '@testing-library/svelte'; +import type { MockInstance } from 'vitest'; + +describe('EthTransactionsScroll', () => { + const mockToken = BASE_ETH_TOKEN; + + // Descending block numbers: the oldest entry is the one the next page continues from. + const mockTransactions: Transaction[] = createMockEthTransactions(3).map( + (transaction, index) => ({ + ...transaction, + blockNumber: 300 - index * 100 + }) + ); + + const oldestLoadedBlockNumber = 100; + + let loadNextSpy: MockInstance; + + const setTransactions = ({ tokenId }: { tokenId: symbol }) => { + ethTransactionsStore.set({ + tokenId, + transactions: mockTransactions.map((transaction) => ({ + data: transaction, + certified: false + })) + }); + }; + + beforeAll(() => { + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: IntersectionObserverActive + }); + }); + + beforeEach(() => { + vi.clearAllMocks(); + + ethTransactionsStore.reinitialize(); + + token.set(mockToken); + + loadNextSpy = vi + .spyOn(ethUserTransactionsServices, 'loadNextEthUserTransactions') + .mockResolvedValue({ hasMore: true }); + + setTransactions({ tokenId: mockToken.id }); + }); + + afterAll(() => (global.IntersectionObserver = IntersectionObserverPassive)); + + it('should ask for the page below the oldest transaction on screen', () => { + render(EthTransactionsScroll, { token: mockToken, children: mockSnippet }); + + expect(loadNextSpy).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + transactionTokenId: { EvmNative: mockToken.network.chainId }, + tokenId: mockToken.id, + networkId: mockToken.network.id, + oldestLoadedBlockNumber + }) + ); + }); + + it('should pass the stored backend cursor', () => { + ethUserTransactionsServices.setEthBackendPaginationCursor({ + tokenId: mockToken.id, + nextStart: 77n + }); + + render(EthTransactionsScroll, { token: mockToken, children: mockSnippet }); + + expect(loadNextSpy).toHaveBeenCalledWith(expect.objectContaining({ cursor: 77n })); + + ethUserTransactionsServices.setEthBackendPaginationCursor({ + tokenId: mockToken.id, + nextStart: undefined + }); + }); + + it('should not load anything while no transaction is on screen', () => { + ethTransactionsStore.reinitialize(); + + render(EthTransactionsScroll, { token: mockToken, children: mockSnippet }); + + expect(loadNextSpy).not.toHaveBeenCalled(); + }); + + it('should not load anything for a token whose history is not the chain history', () => { + // Older ERC20 transfers come from `tokentx`, which this path does not query. + token.set(USDC_TOKEN); + + setTransactions({ tokenId: USDC_TOKEN.id }); + + render(EthTransactionsScroll, { token: USDC_TOKEN, children: mockSnippet }); + + expect(loadNextSpy).not.toHaveBeenCalled(); + }); +}); From aa8c3f1a65eb5920e3de3e8b00936426a6876681 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 09:16:37 +0200 Subject: [PATCH 3/9] test(frontend): type the ETH transactions scroll spec against the branded ids `check:tests` type-checks the spec files that `check` skips: the store key is a branded `TokenId`, and the chain id belongs to the network env rather than the token's `Network` type. Co-Authored-By: Claude Opus 5 --- .../components/transactions/EthTransactionsScroll.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts b/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts index da690cafaa1..9d3c9ca61ea 100644 --- a/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts +++ b/src/frontend/src/tests/eth/components/transactions/EthTransactionsScroll.spec.ts @@ -1,9 +1,11 @@ +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 EthTransactionsScroll from '$eth/components/transactions/EthTransactionsScroll.svelte'; import * as ethUserTransactionsServices from '$eth/services/eth-user-transactions.services'; import { ethTransactionsStore } from '$eth/stores/eth-transactions.store'; import { token } from '$lib/stores/token.store'; +import type { TokenId } from '$lib/types/token'; import type { Transaction } from '$lib/types/transaction'; import { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; import { @@ -29,7 +31,7 @@ describe('EthTransactionsScroll', () => { let loadNextSpy: MockInstance; - const setTransactions = ({ tokenId }: { tokenId: symbol }) => { + const setTransactions = ({ tokenId }: { tokenId: TokenId }) => { ethTransactionsStore.set({ tokenId, transactions: mockTransactions.map((transaction) => ({ @@ -68,7 +70,7 @@ describe('EthTransactionsScroll', () => { expect(loadNextSpy).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ - transactionTokenId: { EvmNative: mockToken.network.chainId }, + transactionTokenId: { EvmNative: BASE_NETWORK.chainId }, tokenId: mockToken.id, networkId: mockToken.network.id, oldestLoadedBlockNumber From 374230e59eabe44bb7bdc29557b2141e15d35a2e Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 10:14:23 +0200 Subject: [PATCH 4/9] docs(ai): spec extending the backend transaction cache to ERC20 tokens Grounded against the current code: the cache reaches only the native and Solana paths, the backend already carries an `Erc20` token-id variant, and the ERC Etherscan actions hardcode their block range. Records the constraint that shapes the change: caching ERC20 history without an ERC20 paging path would reproduce the ten-row cap #13728 removes. Co-Authored-By: Claude Opus 5 --- ...14-impr-cache-erc20-transaction-history.md | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md diff --git a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md new file mode 100644 index 00000000000..25e8d6775b6 --- /dev/null +++ b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md @@ -0,0 +1,129 @@ +# Spec: Extend the backend transaction cache to ERC20 tokens + +This spec follows the workflow defined in `docs/ai/spec-driven-development/workflow.md`. + +## Goal + +Store ERC20 transfer history in the backend the way native EVM history is already stored, so an ERC20 token view loads from the user's own canister instead of re-fetching its entire history from Etherscan on every load — and can page back through history that Etherscan will no longer return. + +The cache must arrive **together with** an ERC20 paging path. Caching alone reproduces, on every ERC20 view, exactly the truncation that PR #13728 fixes for native coins. + +--- + +## Background + +### Two loading paths, only one cached + +`loadEthereumTransactions` (`src/frontend/src/eth/services/eth-transactions.services.ts:46`) splits on the token: + +| | native (ETH, BNB, POL, Base/Arbitrum ETH) | ERC20 / ERC4626 / ERC721 / ERC1155 | +| ----------- | ----------------------------------------------------------- | ------------------------------------------------- | +| entry point | `loadEthTransactions` (:157) | `loadErcTransactions` (:267) | +| identity | required | **never passed** | +| backend | one page of stored history + cursor | **not used at all** | +| Etherscan | `txlist` + `txlistinternal`, only _after_ the stored cursor | `tokentx`, whole history from block 0, every load | + +The backend cache was wired into exactly two places — `eth-user-transactions.services.ts` and `sol-user-transactions.services.ts`. There is no ERC equivalent. + +### What that costs today + +Every load of an ERC20 token view re-fetches the token's complete transfer history: + +- One `tokentx` request per token per load, against a budget of `ETHERSCAN_MAX_CALLS_PER_SECOND` = 5 (beta/prod) or 2 elsewhere, batched across the whole token list by `eth-transactions-batch.services.ts`. A user with many ERC20 tokens pays this repeatedly. +- Plus the spam filter's per-hash RPC calls (see below), also repeated every load. +- History is bounded by what `tokentx` will return in one response — no `offset`/`page` is passed, so the request takes Etherscan's default page size. Beyond that ceiling, older transfers are simply unreachable, and always will be. + +### What the backend already supports + +No `backend.did` change is needed: + +- `TokenId` (`src/declarations/backend/backend.did.d.ts:1638`) already has `Erc20: [string, bigint]` (contract address, chain id) and `Erc721: [string, bigint]`, alongside `EvmNative: bigint`. +- `UserTransaction` is chain-agnostic — `id`, `from`, `to`, `block_index`, `value`, `timestamp`, `network_data`. +- `mapTransactionToUserTransaction` / `mapUserTransactionToTransaction` (`src/frontend/src/eth/utils/user-transactions.utils.ts`) round-trip through `network_data.Evm` and already carry `nft_token_id`. They are reusable unchanged. + +So this is entirely frontend work. + +### The constraint that shapes the whole change + +The native path's ten-row cap is not a bug in the cache — it is what a cache without paging looks like. `loadEthTransactions` asks the backend for one page (`WALLET_PAGINATION` = 10, `src/lib/constants/app.constants.ts:175`) and then asks Etherscan only for blocks _newer_ than the newest stored one, so the page it starts on is the page it ends on. That is the defect PR #13728 addresses, by wiring `loadNextEthUserTransactions` to an infinite scroll. + +Applying the same cache to `loadErcTransactions` without an ERC paging path would hand ERC20 views the same ten-row window they have never had. **The paging path is not a follow-up; it is part of this change.** + +--- + +## Scope + +**In:** ERC20, and ERC4626 (which loads through the same `loadErc20Transactions` helper). + +**Out:** ERC721 and ERC1155 — see Pending decisions. Native paths are untouched; #13728 already covers them. + +--- + +## Changes + +### 1. Block bounds on the ERC Etherscan actions + +`erc20Transactions` (`src/frontend/src/eth/providers/etherscan.providers.ts:152`) accepts only `{ address, contract }` and hardcodes `startblock: 0`, `sort: 'desc'`. The same hardcoding is in `erc721Transactions` (:218), `erc1155Transactions` (:272) and `erc721TokenInventory` (:327). + +Give the ERC actions the `startBlock` / `endBlock` / `sort` parameters `getHistory` already has (`TransactionsParams`, :33). Paging back needs `endBlock`; incremental loading forward needs `startBlock`. + +### 2. An ERC branch for older history + +`loadOlderFromEtherscan` (`src/frontend/src/eth/services/eth-user-transactions.services.ts:171`) calls `transactions()`, i.e. `txlist` — native only. It needs to dispatch on the token standard so an ERC20 token fetches `tokentx` bounded by `endBlock: oldestLoadedBlockNumber - 1`, and saves what it finds under `{ Erc20: [contract, chainId] }`. + +### 3. `loadErcTransactions` reads the cache + +Mirror `loadEthTransactions`: take `identity`, read one stored page, derive the incremental `startBlock` from `newestBlockIndex + 1`, fetch only newer transfers from Etherscan, combine newest-first, and save newly finalized rows in the background. `resolveEthIncrementalStartBlock` (:100) and `isTransactionFinalized` / `ETH_FINALITY_BLOCKS` = 64 apply unchanged. + +### 4. Widen the scroll gate + +`EthTransactionsScroll.svelte` (added by #13728) is gated on `isTokenEthereumNative` and builds `{ EvmNative: chainId }`. It must also accept ERC20 tokens and build `{ Erc20: [token.address, chainId] }`. The per-token cursor store from #13728 is already keyed by `TokenId`, so it needs no change. + +### Interactions to respect + +**Spam filtering must not be re-run on cached rows.** `loadErc20Transactions` (:344) passes every candidate through `filterSpamErc20Transfers`, whose `getTransactionSender` resolves the _outer_ transaction sender via one Alchemy `getTransaction(hash)` call per hash — the defence against address-poisoning `Transfer(victim, attacker, 0)` events, where `transaction.from` is the victim. Consequences: + +- Save **filtered** rows, so cached history carries no spam and the filter's RPC calls are paid once per transfer rather than on every load. Skipping this work on the cached page is a large part of the win. +- Do **not** re-filter rows loaded from the backend: it would restore the per-hash RPC cost the cache is meant to remove. +- A transfer already saved before a future filter improvement stays saved. Accepted; note it as a known limitation rather than designing a re-scan. + +**ERC4626 mint/burn normalisation stays a display convention.** `loadErc4626Transactions` rewrites `from`/`to` from the zero address to the vault address after loading. Re-applying it to already-normalised cached rows is idempotent (`from` is the vault, so the `isMint` test is false), so cached rows may pass through it safely. Prefer saving raw rows and normalising on read, so the cache holds chain truth rather than presentation shape. + +**Storage.** This multiplies stored transactions by the number of ERC20 tokens a user holds. `loadNextEthUserTransactions` already takes `beAtCapacity` to skip persisting when storage is full, but nothing in the codebase produces that flag — it is always the `false` default. See Open questions. + +--- + +## Acceptance criteria + +1. An ERC20 token view loads its first page from the backend when stored history exists, and asks Etherscan only for transfers newer than the newest stored block. +2. Scrolling an ERC20 token view pages back through stored history, then continues into Etherscan via `tokentx` bounded by the oldest row on screen, and persists what it fetches. +3. An ERC20 view with more history than one page shows more than one page — the negative guarantee that this change does not import the native ten-row cap. +4. Cached ERC20 rows are spam-filtered, and displaying them triggers no `getTransaction` RPC call per row. +5. A fresh user with no stored history sees exactly what they see today. +6. ERC721 and ERC1155 views behave exactly as they do today. +7. Native views behave exactly as #13728 leaves them. +8. `docs/ai/PRODUCT.md` gains a transaction-history description under `## Ethereum` covering both native and ERC20, since neither is described there today. + +--- + +## Non-goals + +- No `backend.did` or stable-state change. +- No change to how spam is detected — only to how often the detection runs. +- No re-scan or migration of transfers saved before a future filter change. +- No pagination for ERC721 / ERC1155 in this change. + +--- + +## Open questions (facts to confirm) + +1. **What is `tokentx`'s actual default response ceiling** without `offset`/`page`? The 10,000-record figure is the commonly cited Etherscan default but is not verified in our code or in `docs/ai/integrations/etherscan.md`. It sets how much history is reachable on a first load, and therefore how much a user can ever cache. +2. **Does the backend cap stored transactions per token, or per user?** This decides whether many ERC20 tokens can exhaust a user's storage and whether `beAtCapacity` needs a real producer before this ships. +3. **Is `{ Erc20: [address, chainId] }` keyed on a checksummed or lowercased address on the backend?** A mismatch would silently split one token's history across two keys. +4. **Does `saveUserTransactions` deduplicate by `id`?** The error type includes `DuplicateTransaction: { id: string }`, which suggests it rejects rather than ignores; the ERC path will re-offer rows it has already saved. + +## Pending decisions + +1. **ERC721 / ERC1155 in or out.** The mappers already carry `nft_token_id` and `TokenId` has an `Erc721` variant, so extending is cheap — but NFT views load differently and are out of the reported problem. Recommend: out, as a fast-follow. +2. **Whether to raise `WALLET_PAGINATION` (10) for token views.** Ten rows is a small first page for a history view; it is shared across chains, so changing it affects Solana too. +3. **Whether ERC4626 caches raw or normalised rows.** Raw is cleaner (above); normalised avoids a transform on every read. Recommend raw. From b7438d26e01d506320bcd9281d063d88deaa3f2e Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 10:18:54 +0200 Subject: [PATCH 5/9] docs(ai): record the resolved decisions in the ERC20 cache spec ERC721/ERC1155 stay out, `WALLET_PAGINATION` stays at 10, and the cache stores raw ERC4626 rows. Storing raw makes the vault mint/burn normalisation cross-cutting: with a cache and a paging path, vault rows enter the store at three places and only one applies it today, so the mapping has to be hoisted into a shared helper. The transform is idempotent, so applying it more than once is harmless. Co-Authored-By: Claude Opus 5 --- ...14-impr-cache-erc20-transaction-history.md | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md index 25e8d6775b6..b6f28202ce5 100644 --- a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md +++ b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md @@ -55,7 +55,7 @@ Applying the same cache to `loadErcTransactions` without an ERC paging path woul **In:** ERC20, and ERC4626 (which loads through the same `loadErc20Transactions` helper). -**Out:** ERC721 and ERC1155 — see Pending decisions. Native paths are untouched; #13728 already covers them. +**Out:** ERC721 and ERC1155 — see Decisions. Native paths are untouched; #13728 already covers them. --- @@ -87,7 +87,17 @@ Mirror `loadEthTransactions`: take `identity`, read one stored page, derive the - Do **not** re-filter rows loaded from the backend: it would restore the per-hash RPC cost the cache is meant to remove. - A transfer already saved before a future filter improvement stays saved. Accepted; note it as a known limitation rather than designing a re-scan. -**ERC4626 mint/burn normalisation stays a display convention.** `loadErc4626Transactions` rewrites `from`/`to` from the zero address to the vault address after loading. Re-applying it to already-normalised cached rows is idempotent (`from` is the vault, so the `isMint` test is false), so cached rows may pass through it safely. Prefer saving raw rows and normalising on read, so the cache holds chain truth rather than presentation shape. +**ERC4626 mint/burn normalisation becomes a cross-cutting concern.** `loadErc4626Transactions` rewrites `from`/`to` from `ZERO_ETH_ADDRESS` to the vault address, as a display convention — and it does so at the one place vault rows currently enter the store. + +The cache stores **raw** rows, so the backend holds chain truth rather than presentation shape. That obliges the transform to move: with a cache and a paging path, vault rows reach `ethTransactionsStore` at three places, and only the first applies it today — + +| insertion point | source | +| --------------------------------------- | ----------------------------------------------- | +| `eth-transactions.services.ts:321` | `loadErcTransactions` — Etherscan, initial load | +| `eth-user-transactions.services.ts:164` | backend page, while paging back | +| `eth-user-transactions.services.ts:247` | Etherscan, older than the oldest row on screen | + +Leaving it where it is would put raw `0x0` counterparties on the paged rows and vault addresses on the first page of the same list. So hoist the mapping into a shared helper and apply it at every insertion point for an ERC4626 token. The transform is pure and idempotent — after normalisation `from` is the vault, so the `isMint` test is false — hence applying it to rows that have already been through it is harmless, and the helper needs no "already normalised" flag. **Storage.** This multiplies stored transactions by the number of ERC20 tokens a user holds. `loadNextEthUserTransactions` already takes `beAtCapacity` to skip persisting when storage is full, but nothing in the codebase produces that flag — it is always the `false` default. See Open questions. @@ -122,8 +132,11 @@ Mirror `loadEthTransactions`: take `identity`, read one stored page, derive the 3. **Is `{ Erc20: [address, chainId] }` keyed on a checksummed or lowercased address on the backend?** A mismatch would silently split one token's history across two keys. 4. **Does `saveUserTransactions` deduplicate by `id`?** The error type includes `DuplicateTransaction: { id: string }`, which suggests it rejects rather than ignores; the ERC path will re-offer rows it has already saved. -## Pending decisions +## Decisions -1. **ERC721 / ERC1155 in or out.** The mappers already carry `nft_token_id` and `TokenId` has an `Erc721` variant, so extending is cheap — but NFT views load differently and are out of the reported problem. Recommend: out, as a fast-follow. +1. **ERC721 / ERC1155 in or out.** The mappers already carry `nft_token_id` and `TokenId` has an `Erc721` variant, so extending is cheap — but NFT views load differently and are out of the reported problem. + _Resolved: out._ A fast-follow if wanted; this change leaves both untouched. 2. **Whether to raise `WALLET_PAGINATION` (10) for token views.** Ten rows is a small first page for a history view; it is shared across chains, so changing it affects Solana too. -3. **Whether ERC4626 caches raw or normalised rows.** Raw is cleaner (above); normalised avoids a transform on every read. Recommend raw. + _Resolved: leave at 10._ Paging is what makes the page size tolerable; changing a cross-chain constant is a separate call. +3. **Whether ERC4626 caches raw or normalised rows.** + _Resolved: raw, with the normalisation hoisted to a shared helper._ See the ERC4626 interaction above for what this obliges. From c3f9717e8190e9014dc3aff62525dca48daa7352 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 10:27:21 +0200 Subject: [PATCH 6/9] docs(ai): confirm the backend contract in the ERC20 cache spec Read out of the backend rather than assumed: - storage is capped per (principal, token) at 10 000, trimming the oldest - a save above MAX_SAVE_USER_TRANSACTIONS_BATCH (500) is rejected outright, so an ERC20 token's first save has to be chunked - duplicates are silently skipped; DuplicateTransaction is reserved and never constructed, so rows may be re-offered freely - the Erc20 token key is a bare String with no normalisation, so the contract address has to be lowercased consistently or one token gets two keys Etherscan's own docs state no response ceiling for tokentx, so the spec now passes offset/page explicitly instead of relying on an undocumented default. Co-Authored-By: Claude Opus 5 --- ...14-impr-cache-erc20-transaction-history.md | 41 +++++++++++++------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md index b6f28202ce5..b1d98bf500a 100644 --- a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md +++ b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md @@ -31,7 +31,7 @@ Every load of an ERC20 token view re-fetches the token's complete transfer histo - One `tokentx` request per token per load, against a budget of `ETHERSCAN_MAX_CALLS_PER_SECOND` = 5 (beta/prod) or 2 elsewhere, batched across the whole token list by `eth-transactions-batch.services.ts`. A user with many ERC20 tokens pays this repeatedly. - Plus the spam filter's per-hash RPC calls (see below), also repeated every load. -- History is bounded by what `tokentx` will return in one response — no `offset`/`page` is passed, so the request takes Etherscan's default page size. Beyond that ceiling, older transfers are simply unreachable, and always will be. +- History is bounded by what `tokentx` returns in one response — no `offset`/`page` is passed, so the request takes whatever Etherscan's undocumented default is (see Open questions). Beyond that ceiling, older transfers are unreachable today and always will be, because nothing stores them. ### What the backend already supports @@ -69,7 +69,7 @@ Give the ERC actions the `startBlock` / `endBlock` / `sort` parameters `getHisto ### 2. An ERC branch for older history -`loadOlderFromEtherscan` (`src/frontend/src/eth/services/eth-user-transactions.services.ts:171`) calls `transactions()`, i.e. `txlist` — native only. It needs to dispatch on the token standard so an ERC20 token fetches `tokentx` bounded by `endBlock: oldestLoadedBlockNumber - 1`, and saves what it finds under `{ Erc20: [contract, chainId] }`. +`loadOlderFromEtherscan` (`src/frontend/src/eth/services/eth-user-transactions.services.ts:171`) calls `transactions()`, i.e. `txlist` — native only. It needs to dispatch on the token standard so an ERC20 token fetches `tokentx` bounded by `endBlock: oldestLoadedBlockNumber - 1`, and saves what it finds under `{ Erc20: [contract, chainId] }` — lowercased, and chunked to 500 rows per call (see Backend contract). ### 3. `loadErcTransactions` reads the cache @@ -77,7 +77,7 @@ Mirror `loadEthTransactions`: take `identity`, read one stored page, derive the ### 4. Widen the scroll gate -`EthTransactionsScroll.svelte` (added by #13728) is gated on `isTokenEthereumNative` and builds `{ EvmNative: chainId }`. It must also accept ERC20 tokens and build `{ Erc20: [token.address, chainId] }`. The per-token cursor store from #13728 is already keyed by `TokenId`, so it needs no change. +`EthTransactionsScroll.svelte` (added by #13728) is gated on `isTokenEthereumNative` and builds `{ EvmNative: chainId }`. It must also accept ERC20 tokens and build `{ Erc20: [token.address.toLowerCase(), chainId] }`. The per-token cursor store from #13728 is already keyed by the frontend `TokenId`, so it needs no change. ### Interactions to respect @@ -99,7 +99,7 @@ The cache stores **raw** rows, so the backend holds chain truth rather than pres Leaving it where it is would put raw `0x0` counterparties on the paged rows and vault addresses on the first page of the same list. So hoist the mapping into a shared helper and apply it at every insertion point for an ERC4626 token. The transform is pure and idempotent — after normalisation `from` is the vault, so the `isMint` test is false — hence applying it to rows that have already been through it is harmless, and the helper needs no "already normalised" flag. -**Storage.** This multiplies stored transactions by the number of ERC20 tokens a user holds. `loadNextEthUserTransactions` already takes `beAtCapacity` to skip persisting when storage is full, but nothing in the codebase produces that flag — it is always the `false` default. See Open questions. +**Storage.** This multiplies stored transactions by the number of ERC20 tokens a user holds — bounded per token at 10 000 (see Backend contract), so tokens cannot starve one another, but total per-user growth is real. `loadNextEthUserTransactions` already takes `beAtCapacity` to skip persisting when storage is full, and nothing in the codebase produces that flag — it is always the `false` default. Wiring a producer is out of scope here, but the flag is the hook if it becomes necessary. --- @@ -109,10 +109,12 @@ Leaving it where it is would put raw `0x0` counterparties on the paged rows and 2. Scrolling an ERC20 token view pages back through stored history, then continues into Etherscan via `tokentx` bounded by the oldest row on screen, and persists what it fetches. 3. An ERC20 view with more history than one page shows more than one page — the negative guarantee that this change does not import the native ten-row cap. 4. Cached ERC20 rows are spam-filtered, and displaying them triggers no `getTransaction` RPC call per row. -5. A fresh user with no stored history sees exactly what they see today. -6. ERC721 and ERC1155 views behave exactly as they do today. -7. Native views behave exactly as #13728 leaves them. -8. `docs/ai/PRODUCT.md` gains a transaction-history description under `## Ethereum` covering both native and ERC20, since neither is described there today. +5. Saving a token whose fetched history exceeds 500 rows succeeds — the batch is chunked, not rejected with `TooManyTransactions`. +6. The backend `TokenId` is built from a lowercased contract address at both save and load, so one token has one key. +7. A fresh user with no stored history sees exactly what they see today. +8. ERC721 and ERC1155 views behave exactly as they do today. +9. Native views behave exactly as #13728 leaves them. +10. `docs/ai/PRODUCT.md` gains a transaction-history description under `## Ethereum` covering both native and ERC20, since neither is described there today. --- @@ -125,12 +127,27 @@ Leaving it where it is would put raw `0x0` counterparties on the paged rows and --- +## Backend contract (confirmed) + +Read from `src/backend/src/transactions/model.rs` and `src/shared/src/types/user_transaction.rs`. + +| limit | value | consequence | +| ----------------------------------- | ------ | -------------------------------------------------------------------------- | +| `MAX_USER_TRANSACTIONS_PER_TOKEN` | 10 000 | per `(principal, token_id)`, so many ERC20 tokens cannot starve each other | +| `MAX_SAVE_USER_TRANSACTIONS_BATCH` | 500 | **a save of more than 500 rows fails with `TooManyTransactions`** | +| `MAX_GET_USER_TRANSACTIONS_RESULTS` | 100 | caps `maxResults`; `WALLET_PAGINATION` (10) is well under | + +**Saves must be chunked.** `save_transactions` rejects the whole batch above 500 rows. The native path never noticed because it only ever offers an incremental slice, but an ERC20 token's first save is its _entire_ fetched history, which routinely exceeds 500. Chunk into batches of ≤500, or the first save of an active token fails outright. + +**Duplicates are safe to re-offer.** `save_transactions` builds a `HashSet` of known ids and `continue`s past anything already stored. `DuplicateTransaction` is documented in `user_transaction.rs:142` as _"Reserved — duplicates are currently silently skipped during save"_ and is never constructed anywhere in the backend. So the ERC path may re-offer rows it has already saved without special-casing. + +**The token key is a raw string.** `TokenId::Erc20(ErcTokenId, ChainId)` where `ErcTokenId(pub String)` (`src/shared/src/types/custom_token.rs:69`) — no validation, no normalisation, compared byte-for-byte as part of the stable-structures key. Our env tokens carry checksummed addresses while Etherscan returns lowercase, so **lowercase the address when building the backend `TokenId`**, at both save and load. Getting this wrong splits one token's history across two keys, silently. + +**Trimming discards the oldest.** Over 10 000 rows for a token, `save_transactions` keeps the newest and trims the oldest at a whole-block boundary. So rows fetched while paging deep into a token already at the cap are trimmed away again, and will be re-fetched from Etherscan next time. This applies equally to the native path from #13728; it bounds the cache, it does not break paging. + ## Open questions (facts to confirm) -1. **What is `tokentx`'s actual default response ceiling** without `offset`/`page`? The 10,000-record figure is the commonly cited Etherscan default but is not verified in our code or in `docs/ai/integrations/etherscan.md`. It sets how much history is reachable on a first load, and therefore how much a user can ever cache. -2. **Does the backend cap stored transactions per token, or per user?** This decides whether many ERC20 tokens can exhaust a user's storage and whether `beAtCapacity` needs a real producer before this ships. -3. **Is `{ Erc20: [address, chainId] }` keyed on a checksummed or lowercased address on the backend?** A mismatch would silently split one token's history across two keys. -4. **Does `saveUserTransactions` deduplicate by `id`?** The error type includes `DuplicateTransaction: { id: string }`, which suggests it rejects rather than ignores; the ERC path will re-offer rows it has already saved. +1. **`tokentx`'s response ceiling without `offset`/`page` is undocumented.** Etherscan's current v2 endpoint docs (`api-reference/endpoint/tokentx`) show `page: 1` / `offset: 100` only as examples and state no maximum; the commonly cited 10 000-record figure appears nowhere we can verify. Rather than depend on an undocumented default, **pass `offset`/`page` explicitly** so the window is ours to choose, and treat "how much history one request can return" as a parameter rather than a discovered constant. Settling the real ceiling would need a live call against a high-volume address. ## Decisions From e5ea13e8f6023f3c0caad6d32e62d9349f467b53 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:16:05 +0200 Subject: [PATCH 7/9] refactor(frontend): let the ERC transfer actions take a block window `tokentx`, `tokennfttx` and `token1155tx` each hardcoded the whole history newest-first. Paging back through a token's transfers needs to ask for a window that ends where the loaded list does. Defaults reproduce the current query exactly, so no caller moves. The inventory action keeps its parameters untouched: it lists owned token ids, not a block range. Co-Authored-By: Claude Opus 5 --- .../src/eth/providers/etherscan.providers.ts | 43 ++++++++++----- .../eth/providers/etherscan.providers.spec.ts | 53 +++++++++++++++++++ 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/frontend/src/eth/providers/etherscan.providers.ts b/src/frontend/src/eth/providers/etherscan.providers.ts index 17c1fe2df81..58ed8b976a2 100644 --- a/src/frontend/src/eth/providers/etherscan.providers.ts +++ b/src/frontend/src/eth/providers/etherscan.providers.ts @@ -30,13 +30,28 @@ import { } from 'ethers/providers'; import { get } from 'svelte/store'; -interface TransactionsParams { - address: EthAddress; +interface TransactionsWindow { startBlock?: BlockTag; endBlock?: BlockTag; sort?: 'asc' | 'desc'; } +interface TransactionsParams extends TransactionsWindow { + address: EthAddress; +} + +/** + * Maps a block window onto the Etherscan query parameters shared by the token-transfer actions. + * + * Newest-first by default, unlike the native history actions: a token transfer list is read from the + * most recent transfer, and paging back asks for a window that ends where the list currently does. + */ +const ercWindowParams = ({ startBlock, endBlock, sort }: TransactionsWindow) => ({ + startblock: startBlock ?? 0, + ...(nonNullish(endBlock) ? { endblock: endBlock } : {}), + sort: sort ?? 'desc' +}); + export class EtherscanProvider { private readonly provider: EtherscanProviderLib; @@ -151,17 +166,17 @@ export class EtherscanProvider { // Docs: https://docs.etherscan.io/etherscan-v2/api-endpoints/accounts#get-a-list-of-erc20-token-transfer-events-by-address erc20Transactions = async ({ address, - contract: { address: contractAddress } + contract: { address: contractAddress }, + ...window }: { address: EthAddress; contract: Erc20Token | Erc4626Token; - }): Promise => { + } & TransactionsWindow): Promise => { const params = { action: 'tokentx', contractAddress, address, - startblock: 0, - sort: 'desc' + ...ercWindowParams(window) }; const result: EtherscanProviderTokenTransferTransaction[] | string = await this.provider.fetch( @@ -206,17 +221,17 @@ export class EtherscanProvider { // Docs: https://docs.etherscan.io/etherscan-v2/api-endpoints/accounts#get-a-list-of-erc721-token-transfer-events-by-address erc721Transactions = async ({ address, - contract: { address: contractAddress } + contract: { address: contractAddress }, + ...window }: { address: EthAddress; contract: Erc721Token; - }): Promise => { + } & TransactionsWindow): Promise => { const params = { action: 'tokennfttx', contractAddress, address, - startblock: 0, - sort: 'desc' + ...ercWindowParams(window) }; const result: EtherscanProviderErc721TokenTransferTransaction[] | string = @@ -260,17 +275,17 @@ export class EtherscanProvider { // Docs: https://docs.etherscan.io/etherscan-v2/api-endpoints/accounts#get-a-list-of-erc1155-token-transfer-events-by-address erc1155Transactions = async ({ address, - contract: { address: contractAddress } + contract: { address: contractAddress }, + ...window }: { address: EthAddress; contract: Erc1155Token; - }): Promise => { + } & TransactionsWindow): Promise => { const params = { action: 'token1155tx', contractAddress, address, - startblock: 0, - sort: 'desc' + ...ercWindowParams(window) }; const result: EtherscanProviderErc1155TokenTransferTransaction[] | string = diff --git a/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts b/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts index 61fdbfa5607..962b68319de 100644 --- a/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts +++ b/src/frontend/src/tests/eth/providers/etherscan.providers.spec.ts @@ -259,6 +259,59 @@ describe('etherscan.providers', () => { provider.erc20Transactions({ address: mockEthAddress, contract: mockValidErc20Token }) ).rejects.toThrow('Network error'); }); + + it('should query the whole history newest-first when given no window', async () => { + const provider = new EtherscanProvider(network, chainId); + + await provider.erc20Transactions({ + address: mockEthAddress, + contract: mockValidErc20Token + }); + + expect(mockFetch).toHaveBeenCalledWith('account', { + action: 'tokentx', + contractAddress: mockValidErc20Token.address, + address: mockEthAddress, + startblock: 0, + sort: 'desc' + }); + }); + + it('should pass a block window through to the query', async () => { + const provider = new EtherscanProvider(network, chainId); + + await provider.erc20Transactions({ + address: mockEthAddress, + contract: mockValidErc20Token, + startBlock: 100, + endBlock: 200, + sort: 'asc' + }); + + expect(mockFetch).toHaveBeenCalledWith('account', { + action: 'tokentx', + contractAddress: mockValidErc20Token.address, + address: mockEthAddress, + startblock: 100, + endblock: 200, + sort: 'asc' + }); + }); + + it('should omit the end of the window when it is not given', async () => { + const provider = new EtherscanProvider(network, chainId); + + await provider.erc20Transactions({ + address: mockEthAddress, + contract: mockValidErc20Token, + startBlock: 100 + }); + + expect(mockFetch).toHaveBeenCalledWith( + 'account', + expect.not.objectContaining({ endblock: expect.anything() }) + ); + }); }); describe('erc721Transactions', () => { From 611a8ad7083b1fcbd43ef9e8fe03f000112fcacb Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:16:05 +0200 Subject: [PATCH 8/9] refactor(frontend): make the ERC4626 mint/burn normalisation reusable The zero-address-to-vault rewrite lived inside the load service, at the one place vault transfers entered the store. Once stored history and a paging path also feed that store, the same rewrite has to apply there, so it moves into `erc4626.utils` as a helper. Behaviour is unchanged; the helper is idempotent, so applying it to rows that have already been through it is a no-op. Co-Authored-By: Claude Opus 5 --- .../eth/services/eth-transactions.services.ts | 32 ++----------- src/frontend/src/eth/utils/erc4626.utils.ts | 32 +++++++++++++ .../src/tests/eth/utils/erc4626.utils.spec.ts | 48 ++++++++++++++++++- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/src/frontend/src/eth/services/eth-transactions.services.ts b/src/frontend/src/eth/services/eth-transactions.services.ts index 8fc7d27e21c..fe4d7618564 100644 --- a/src/frontend/src/eth/services/eth-transactions.services.ts +++ b/src/frontend/src/eth/services/eth-transactions.services.ts @@ -22,13 +22,12 @@ 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 } from '$eth/utils/erc4626.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 { isSupportedEvmNativeTokenId } from '$evm/utils/native-token.utils'; import { TRACK_COUNT_ETH_LOADING_TRANSACTIONS_ERROR } from '$lib/constants/analytics.constants'; -import { ZERO_ETH_ADDRESS } from '$lib/constants/app.constants'; import { ethAddress as addressStore } from '$lib/derived/address.derived'; import { trackEvent } from '$lib/services/analytics.services'; import { retryWithDelay } from '$lib/services/rest.services'; @@ -379,22 +378,8 @@ const loadErc20Transactions = async ({ }; /** - * Loads ERC4626 vault token transactions and normalizes mint/burn addresses for UI/analytics. - * - * ERC4626 vaults emit standard ERC20 Transfer events for share minting/burning: - * - Deposit (mint shares): Transfer(from=0x0, to=user, amount) - * - Redeem (burn shares): Transfer(from=user, to=0x0, amount) - * - * On-chain, these represent supply changes between the user and the zero address, not transfers - * to or from the vault's own balance. - * - * Since Etherscan's `tokentx` API returns the event's from/to (not the tx signer), we normalize - * the zero address to the vault contract address in our transaction list so that: - * - Mint: from=0x0 → from=vault (treated as vault-issued shares for display) - * - Burn: to=0x0 → to=vault (treated as vault-received/burned shares for display) - * - * This is a presentation/analytics convention only; the underlying on-chain events still use - * the zero address as the mint/burn counterparty. + * Loads ERC4626 vault token transactions, presenting share mints and burns as transfers with the + * vault - see `normalizeErc4626MintBurnTransfers` for why. */ const loadErc4626Transactions = async ({ networkId, @@ -407,16 +392,7 @@ const loadErc4626Transactions = async ({ }): Promise => { const transactions = await loadErc20Transactions({ networkId, token, address }); - return transactions.map((tx) => { - const isMint = tx.from.toLowerCase() === ZERO_ETH_ADDRESS; - const isBurn = tx.to?.toLowerCase() === ZERO_ETH_ADDRESS; - - return { - ...tx, - ...(isMint ? { from: token.address } : {}), - ...(isBurn ? { to: token.address } : {}) - }; - }); + return normalizeErc4626MintBurnTransfers({ transactions, vaultAddress: token.address }); }; const loadErc721Transactions = async ({ diff --git a/src/frontend/src/eth/utils/erc4626.utils.ts b/src/frontend/src/eth/utils/erc4626.utils.ts index 4564dc40751..8d182eadd26 100644 --- a/src/frontend/src/eth/utils/erc4626.utils.ts +++ b/src/frontend/src/eth/utils/erc4626.utils.ts @@ -1,6 +1,8 @@ import type { Erc4626Token } from '$eth/types/erc4626'; import type { Erc4626CustomToken } from '$eth/types/erc4626-custom-token'; +import { ZERO_ETH_ADDRESS } from '$lib/constants/app.constants'; import type { Token } from '$lib/types/token'; +import type { Transaction } from '$lib/types/transaction'; import { isTokenToggleable } from '$lib/utils/token-toggleable.utils'; export const isTokenErc4626 = (token: Token): token is Erc4626Token => @@ -8,3 +10,33 @@ export const isTokenErc4626 = (token: Token): token is Erc4626Token => export const isTokenErc4626CustomToken = (token: Token): token is Erc4626CustomToken => isTokenErc4626(token) && isTokenToggleable(token); + +/** + * Presents an ERC4626 vault's share mints and burns as transfers with the vault itself. + * + * Vaults emit standard ERC20 `Transfer` events against the zero address for supply changes — + * `Transfer(0x0, user)` on deposit, `Transfer(user, 0x0)` on redeem — and Etherscan's `tokentx` + * reports the event's from/to, not the signer. Showing `0x0` as the counterparty would describe a + * deposit as coming from nowhere, so the zero address reads as the vault instead. + * + * A presentation convention only: on-chain, the counterparty is still the zero address. Applied + * wherever vault transfers enter the transaction store, since rows arrive from Etherscan and from + * stored history alike. Idempotent, so a row that has already been through it is unaffected. + */ +export const normalizeErc4626MintBurnTransfers = ({ + transactions, + vaultAddress +}: { + transactions: Transaction[]; + vaultAddress: string; +}): Transaction[] => + transactions.map((transaction) => { + const isMint = transaction.from.toLowerCase() === ZERO_ETH_ADDRESS; + const isBurn = transaction.to?.toLowerCase() === ZERO_ETH_ADDRESS; + + return { + ...transaction, + ...(isMint ? { from: vaultAddress } : {}), + ...(isBurn ? { to: vaultAddress } : {}) + }; + }); diff --git a/src/frontend/src/tests/eth/utils/erc4626.utils.spec.ts b/src/frontend/src/tests/eth/utils/erc4626.utils.spec.ts index 330d536d387..9cd129a759d 100644 --- a/src/frontend/src/tests/eth/utils/erc4626.utils.spec.ts +++ b/src/frontend/src/tests/eth/utils/erc4626.utils.spec.ts @@ -7,9 +7,16 @@ import { SUPPORTED_ETHEREUM_TOKENS } from '$env/tokens/tokens.eth.env'; import { ICP_TOKEN } from '$env/tokens/tokens.icp.env'; import { SUPPORTED_SOLANA_TOKENS } from '$env/tokens/tokens.sol.env'; import { SPL_TOKENS } from '$env/tokens/tokens.spl.env'; -import { isTokenErc4626, isTokenErc4626CustomToken } from '$eth/utils/erc4626.utils'; +import { + isTokenErc4626, + isTokenErc4626CustomToken, + normalizeErc4626MintBurnTransfers +} from '$eth/utils/erc4626.utils'; +import { ZERO_ETH_ADDRESS } 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 { createMockEthTransactions } from '$tests/mocks/eth-transactions.mock'; describe('erc4626.utils', () => { describe('isTokenErc4626', () => { @@ -65,4 +72,43 @@ describe('erc4626.utils', () => { expect(isTokenErc4626CustomToken(token)).toBeFalsy(); }); }); + + describe('normalizeErc4626MintBurnTransfers', () => { + const vaultAddress = '0xVault'; + + const [transaction] = createMockEthTransactions(1); + + const normalize = (transactions: Transaction[]) => + normalizeErc4626MintBurnTransfers({ transactions, vaultAddress }); + + it('should read a share mint as coming from the vault', () => { + const [result] = normalize([{ ...transaction, from: ZERO_ETH_ADDRESS }]); + + expect(result.from).toBe(vaultAddress); + expect(result.to).toBe(transaction.to); + }); + + it('should read a share burn as going to the vault', () => { + const [result] = normalize([{ ...transaction, to: ZERO_ETH_ADDRESS }]); + + expect(result.to).toBe(vaultAddress); + expect(result.from).toBe(transaction.from); + }); + + it('should match the zero address whatever its case', () => { + const [result] = normalize([{ ...transaction, from: ZERO_ETH_ADDRESS.toUpperCase() }]); + + expect(result.from).toBe(vaultAddress); + }); + + it('should leave a transfer between two addresses alone', () => { + expect(normalize([transaction])).toStrictEqual([transaction]); + }); + + it('should be idempotent, so rows that already went through it are unaffected', () => { + const once = normalize([{ ...transaction, from: ZERO_ETH_ADDRESS }]); + + expect(normalize(once)).toStrictEqual(once); + }); + }); }); From 37ea7616f3e1e241e20f77ca508c30eae2c05505 Mon Sep 17 00:00:00 2001 From: Stefan Berger Date: Fri, 14 Aug 2026 11:16:05 +0200 Subject: [PATCH 9/9] docs(ai): record the two-PR split in the ERC20 cache spec Enabling refactors first, then the cache itself, split where behaviour starts changing. Co-Authored-By: Claude Opus 5 --- ...8-14-impr-cache-erc20-transaction-history.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md index b1d98bf500a..a7255180fba 100644 --- a/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md +++ b/docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md @@ -59,13 +59,26 @@ Applying the same cache to `loadErcTransactions` without an ERC paging path woul --- +## Delivery + +Two PRs, split at the point where behaviour starts changing: + +1. **Enabling, behaviour-neutral** — block-window parameters on the ERC transfer actions (change 1) and the ERC4626 mint/burn normalisation hoisted out of the load service into a reusable helper (see the ERC4626 interaction). Defaults preserve every current call, so this is provably a no-op. +2. **The change itself** — the cache read, chunked saves, the ERC paging branch, the widened scroll gate and `PRODUCT.md` (changes 2–4). + +The estimate that motivated the split: ~280 lines of source across 10–12 files, and ~500 of tests at the 1.8× ratio #13728 came in at. Keeping the risky half small was the lesson of the ERC20 fee-entry stack. + +--- + ## Changes ### 1. Block bounds on the ERC Etherscan actions -`erc20Transactions` (`src/frontend/src/eth/providers/etherscan.providers.ts:152`) accepts only `{ address, contract }` and hardcodes `startblock: 0`, `sort: 'desc'`. The same hardcoding is in `erc721Transactions` (:218), `erc1155Transactions` (:272) and `erc721TokenInventory` (:327). +`erc20Transactions` (`src/frontend/src/eth/providers/etherscan.providers.ts:152`) accepts only `{ address, contract }` and hardcodes `startblock: 0`, `sort: 'desc'`. The same hardcoding is in `erc721Transactions` (:218) and `erc1155Transactions` (:272). + +Give the three transfer actions the `startBlock` / `endBlock` / `sort` parameters `getHistory` already has (`TransactionsParams`, :33), defaulting to today's values so nothing moves until a caller asks for a window. Paging back needs `endBlock`; incremental loading forward needs `startBlock`. -Give the ERC actions the `startBlock` / `endBlock` / `sort` parameters `getHistory` already has (`TransactionsParams`, :33). Paging back needs `endBlock`; incremental loading forward needs `startBlock`. +`erc721TokenInventory` (:327) carries the same two parameters but is left alone: it lists owned token ids rather than a block range, has no production callers, and a window means nothing to it. ### 2. An ERC branch for older history