Skip to content

refactor(frontend): prepare the ERC transaction path for a backend cache - #13729

Draft
sbpublic wants to merge 12 commits into
mainfrom
feat/frontend/erc20-transactions-cache
Draft

refactor(frontend): prepare the ERC transaction path for a backend cache#13729
sbpublic wants to merge 12 commits into
mainfrom
feat/frontend/erc20-transactions-cache

Conversation

@sbpublic

@sbpublic sbpublic commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Motivation

ERC20 token views re-fetch a token's entire transfer history from Etherscan on every load: loadErcTransactions never touches the backend cache, which reaches only the native EVM and Solana paths. That costs a tokentx request per token per load against a 5-call/second budget, plus the spam filter's per-hash RPC calls, and it caps reachable history at whatever one tokentx response returns — beyond that, older transfers are unreachable forever, because nothing stores them.

This PR carries the spec plus the enabling refactors. No behaviour changes — the cache itself lands in a follow-up on top of this one, per the two-PR split recorded in the spec.

Stacked on #13728, which fixes the mirror-image problem on the native path.

Changes

Adds docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md, grounded against the current code and the backend's actual contract rather than assumptions.

The constraint the spec is built around: the backend cache is precisely what creates the ten-row window #13728 removes, so ERC20 caching and an ERC20 paging path have to ship together, or every ERC20 view regresses.

Confirmed by reading src/backend/src/transactions/model.rs and src/shared/src/types/:

  • No backend.did change is needed — TokenId already carries Erc20(ErcTokenId, ChainId), and the UserTransaction mappers already round-trip everything they'd need (including nft_token_id). Entirely frontend work.
  • MAX_SAVE_USER_TRANSACTIONS_BATCH is 500 and an oversized batch is rejected whole, so an ERC20 token's first save — its complete history — must be chunked. The native path never hit this because it only ever saves an incremental slice.
  • ErcTokenId is a bare String with no normalisation, compared byte-for-byte in the stable key. Our env tokens are checksummed and Etherscan returns lowercase, so the contract address has to be lowercased at both save and load or one token silently gets two histories.
  • Storage is capped per (principal, token) at 10 000, trimming the oldest at a whole-block boundary. Tokens cannot starve each other.
  • Duplicates are silently skipped on save; DuplicateTransaction is reserved and constructed nowhere, so rows can be re-offered freely.

Decisions recorded in the spec: ERC721/ERC1155 stay out, WALLET_PAGINATION stays at 10, and the cache stores raw ERC4626 rows — which makes the vault mint/burn normalisation cross-cutting, since with a cache and paging those rows enter the store at three places and only one normalises them today.

One open question remains: Etherscan's v2 docs state no response ceiling for tokentx (page/offset appear only as examples), so the spec passes them explicitly rather than relying on an undocumented default.

Enabling refactors

Both are provably behaviour-neutral, which is why they are split out from the cache.

  • A block window on the ERC transfer actions. tokentx, tokennfttx and token1155tx each hardcoded startblock: 0 with sort: 'desc'; they now accept the startBlock / endBlock / sort parameters the native getHistory already had, defaulting to exactly today's query. Paging back needs a window that ends where the loaded list does. erc721TokenInventory is deliberately left alone — it lists owned token ids, not a block range, and has no production callers.
  • The ERC4626 mint/burn normalisation moved to erc4626.utils. The zero-address-to-vault rewrite sat inside the load service, at the one place vault transfers entered the store. Once stored history and a paging path feed the same store, the rewrite has to apply there too, so it becomes a reusable helper. Idempotent, so rows that have already been through it are unaffected.

Tests

  • etherscan.providers.spec.ts — the default query is unchanged; a window is passed through as startblock / endblock / sort; endblock is omitted when not given.
  • erc4626.utils.spec.ts — mints read as coming from the vault, burns as going to it, the zero address matches in any case, a plain transfer is untouched, and the helper is idempotent.
  • npm run format, npm run lint -- --max-warnings 0, npm run check (0 errors), npm run check:tests (0 errors); 676 tests pass across the ETH services, providers and utils specs.

🤖 Generated with Claude Code — Claude Opus 5 (claude-opus-5)

sbpublic and others added 9 commits August 14, 2026 09:13
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…nded 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Enabling refactors first, then the cache itself, split where behaviour
starts changing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sbpublic sbpublic changed the title docs(ai): spec extending the backend transaction cache to ERC20 tokens impr(frontend): prepare the ERC transaction path for a backend cache Aug 14, 2026
@sbpublic sbpublic changed the title impr(frontend): prepare the ERC transaction path for a backend cache refactor(frontend): prepare the ERC transaction path for a backend cache Aug 14, 2026
Base automatically changed from fix/frontend/eth-transactions-pagination to main August 14, 2026 10:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant