feat(frontend): cache and page ERC20 transaction history - #13730
Draft
sbpublic wants to merge 13 commits into
Draft
feat(frontend): cache and page ERC20 transaction history#13730sbpublic wants to merge 13 commits into
sbpublic wants to merge 13 commits into
Conversation
`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>
…end/erc20-cache-and-paging
…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>
….com/dfinity/oisy-wallet into feat/frontend/erc20-cache-and-paging
…end/erc20-cache-and-paging
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>
….com/dfinity/oisy-wallet into feat/frontend/erc20-cache-and-paging
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
An ERC20 token view re-fetched the token's entire transfer history from Etherscan on every single load —
loadErcTransactionsnever touched the backend cache, which reached only the native EVM and Solana paths. That is onetokentxrequest 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
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 pagestokentxbounded by the oldest row on screen.loadNextEthUserTransactionstakes the token instead of three ids and a cursor, since all four follow from it.Stringand compares it byte-for-byte, while our token list is checksummed and Etherscan is lowercase — two casings would be two histories of one token.save_user_transactionsrejects 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.PRODUCT.mdgains a transaction-history section — it had none for either path.Divergence from the spec: none in substance. The spec's
loadOlderFromEtherscanbranch became a sharederc-transfers.services.tsso 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
maxBlockNumberInStorebecause it now serves the ERC path as well as the native one;hasStoredEthTransactionscollapsed into a single declaration as intended.Tests
array.utils.spec.ts—chunksplits 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— pagestokentxrather thantxlist, with the rightendBlock; 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 acrosstests/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)