Skip to content

feat(frontend): cache and page ERC20 transaction history - #13730

Draft
sbpublic wants to merge 13 commits into
feat/frontend/erc20-transactions-cachefrom
feat/frontend/erc20-cache-and-paging
Draft

feat(frontend): cache and page ERC20 transaction history#13730
sbpublic wants to merge 13 commits into
feat/frontend/erc20-transactions-cachefrom
feat/frontend/erc20-cache-and-paging

Conversation

@sbpublic

@sbpublic sbpublic commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Motivation

An ERC20 token view re-fetched the token's entire transfer history from Etherscan on every single load — loadErcTransactions never touched the backend cache, which reached only the native EVM and Solana paths. That is one tokentx request per token per load against a 5-call/second budget, plus the spam filter's RPC calls, and history was capped at whatever one response returns: older transfers were unreachable forever, because nothing stored them.

Implements docs/ai/spec-driven-development/specs/2026-08-14-impr-cache-erc20-transaction-history.md (added in #13729). Second of the two PRs the spec's Delivery section describes: #13729 carried the behaviour-neutral prep, this one is the change.

Changes

  • Stored history for ERC20 and vault tokens. The load reads one stored page, asks Etherscan only for transfers newer than the newest stored block, shows both, and saves what it fetched. Collectibles have no backend key, so they neither read the cache nor take an incremental start block — structurally unchanged, not just unchanged by accident.
  • Paging dispatches on the token. Older history came from txlist, which answers for the chain's own coin; running it for a token would have appended another asset's transactions under that token. A fungible token now pages tokentx bounded by the oldest row on screen. loadNextEthUserTransactions takes the token instead of three ids and a cursor, since all four follow from it.
  • One place derives the backend key, lowercasing the contract address. The backend holds it as a bare 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.
  • Saves are chunked. save_user_transactions rejects a batch over 500 outright instead of storing part of it. The native path never hit this because it only ever offers an incremental slice; a token's first save is its whole history. Chunked in the shared service, since the limit is the backend's rather than any one chain's.
  • The scroll gate now admits anything with a backend storage key.
  • Vault mint/burn normalisation is applied for display only. The backend keeps rows as the chain reported them, so stored rows pass through the convention on the way out, not just fetched ones.
  • PRODUCT.md gains a transaction-history section — it had none for either path.
  • A refresh keeps pages already scrolled in. Once a token's load is incremental, the batch is the newest stored page plus anything newer, so replacing the store slot with it would discard every older page the user scrolled in — and the refresh timer runs this path every 30 seconds. Cached tokens prepend; collectibles keep replacing, since their history is still fetched whole and there the replacement is a real refresh. This is the same defect fix(frontend): keep scrolled-in transaction history across the periodic refresh #13732 fixes on the native path, which this PR would otherwise reintroduce for ERC20.

Divergence from the spec: none in substance. The spec's loadOlderFromEtherscan branch became a shared erc-transfers.services.ts so the initial load and the paging path share one spam-filtered fetch, rather than each holding its own copy.

#13732 has since merged to main and been merged in here. It conflicted only on the store-max helper's name, kept as maxBlockNumberInStore because it now serves the ERC path as well as the native one; hasStoredEthTransactions collapsed into a single declaration as intended.

Tests

  • array.utils.spec.tschunk splits in order, handles an exact fit, an empty input, and a degenerate size without losing data.
  • user-transactions.services.spec.ts (lib) — an oversized save is split into batches the backend accepts; a failing batch reports failure.
  • user-transactions.utils.spec.ts (eth) — the key per token kind, the contract lowercased so one contract is never two histories, nothing for non-EVM or collectible tokens.
  • eth-transactions.services.spec.ts — reads the stored page under the contract key, asks Etherscan only for newer transfers, shows stored and fetched together, keeps the cursor of the page below, saves under the contract key, and never reaches the backend for a collectible. Plus, for the refresh: pages already scrolled in survive it, the cursor is left alone once the list has been built, and a token that is not cached still has its list replaced. The refresh test's two sets of rows carry fixed, disjoint hashes rather than the mock's random ones, since the store deduplicates by hash.
  • eth-user-transactions.services.spec.ts — pages tokentx rather than txlist, with the right endBlock; appends to the token's slot; reads and writes under the contract key; pages nothing for a collectible.
  • EthTransactionsScroll.spec.ts — an ERC20 token pages like the chain coin; a collectible does not.
  • npm run format, npm run lint -- --max-warnings 0, npm run check (0 errors), npm run check:tests (0 errors), and 3874 tests pass across tests/eth (plus the touched lib specs).

Not visually verified — it needs an account with more than one page of transfers on one token.


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

sbpublic and others added 7 commits August 14, 2026 11:38
`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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
PRODUCT.md had nothing on transaction history for either the native or the
ERC20 path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sbpublic and others added 6 commits August 14, 2026 15:42
…resh

Same defect #13732 fixes on the native path, which this PR would otherwise
reintroduce for ERC20: once a token's load is incremental, the batch is the
newest stored page plus anything newer, so replacing the store slot with it
discards every older page the user scrolled in - and the refresh timer runs
this path every 30 seconds.

Prepend for a cached token; keep replacing for collectibles, whose history is
still fetched whole, so there the replacement is a real refresh. The cursor
reset is keyed on the list being empty rather than on `updateOnly`, since the
timer path is not `updateOnly`.

The `hasStoredEthTransactions` helper is deliberately written to match #13732
byte for byte: when main is merged in, check it did not land twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The store deduplicates by hash, so the exact-length assertion was relying on
`createMockEthTransactions` never drawing the same random hash for both sets.

Same change as on #13732, which Copilot raised it on; this PR carries the ERC20
twin of that test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…end/erc20-cache-and-paging

Brings in #13732 via main. One conflict: the store-max helper, renamed here to
`maxBlockNumberInStore` because it now serves the ERC path as well as the
native one. Kept this branch's name.

`hasStoredEthTransactions` was written to match #13732 byte for byte and git
collapsed the two into one, as intended - verified there is exactly one
declaration and no stale references to the old helper name.
johandelforge pushed a commit to yogabuild/oisy-wallet that referenced this pull request Aug 20, 2026
…ic refresh (dfinity#13732)

# Motivation

Scroll a native EVM token view back through its history, then wait:
after about 30 seconds the list snaps back to the newest page, and
scrolling down re-fetches everything again. Reproducible on `main`.

A regression from dfinity#13728, which wired up paging without noticing that
the refresh timer takes the same code path.

`batchLoadTransactions` calls `loadEthereumTransactions` without
`updateOnly`, so the 30-second `WALLET_TIMER_INTERVAL_MILLIS` tick runs
the full load and ends in `ethTransactionsStore.set(...)`. Since dfinity#12193
made the native load incremental, that batch is **not** the whole
history — it is the newest stored page (ten rows) plus anything newer
than it. Replacing the token's slot with it discards every older page
the user scrolled in.

ERC20 views were unaffected only because their history is still fetched
whole, so setting the slot there is a genuine refresh.

# Changes

- **Prepend the batch instead of setting it.** A refresh updates the
head and leaves the tail alone. `prepend` already deduplicates by hash,
and on a first load the slot is empty, so it behaves exactly like `set`
did.
- **Key the pagination-cursor reset on the list being empty**, not on
`updateOnly`. The timer path is not `updateOnly`, so the cursor was also
being rewound to the second page every 30 seconds — the next scroll then
re-walked pages already in the store. The comment in dfinity#13728 claiming the
timer was `updateOnly` was simply wrong.
- **The refresh test uses fixed, disjoint hashes** rather than the
mock's random ones, since the store deduplicates by hash and the
exact-length assertion would otherwise depend on them never colliding
(raised by Copilot).

Only the native path changes. ERC token history is still fetched in
full, so its `set` stays correct — until dfinity#13730 makes that path
incremental too, at which point it needs the same treatment.

# Tests

- `eth-transactions.services.spec.ts` — a refresh keeps pages already
scrolled in (the regression itself), and the cursor is left alone once
the list has been built. The pre-existing cursor test for a fresh load
still holds.
- The `updateOnly` reload test that asserted the cursor was untouched
has been rewritten: it passed for the wrong reason — an empty store, not
the reload flag.
- `npm run format`, `npm run lint -- --max-warnings 0`, `npm run check`
(0 errors), `npm run check:tests` (0 errors), 3848 tests pass across
`tests/eth`.

Diagnosed statically from the symptom (30s, ten surviving rows,
native-only) and confirmed by the reporter on `main`; not re-verified in
a browser here.

---

🤖 Generated with [Claude Code](https://claude.com/claude-code) — Claude
Opus 5 (claude-opus-5)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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