fix: one transaction history row per value movement (closes #177) #196
Reference in New Issue
Block a user
Delete Branch "fix/issue-177-duplicate-transfer-rows"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #177.
The merge rule
The merge loop moved out of
fetchRecentTransactionsinto a puremergeTransactions(txs, tokenTransfers)insrc/shared/transactions.js,exported and unit tested directly. It takes parsed entries, returns a new
list sorted newest block first, and mutates neither input.
The key is the transaction hash for the native entry and hash + token
contract for each token transfer, as the issue asked — no widening of the
direction/methodstring comparison, which is what produced the bug:direction === "contract") absorbs everytoken leg of its hash into the single native entry. Unchanged behaviour:
the legs of a swap are hops of one operation, not separate movements.
a transaction that really moved several tokens stays several rows.
token transfer shares its hash — that entry is the ERC-20 call itself,
already represented by the token row.
Rule 3 is what fixes the duplicate. It is deliberately not "suppress
zero-value native rows": the drop needs a token transfer on the same hash,
so a genuine zero-value native transaction still displays. And it is not
"drop the native row whenever a token transfer shares the hash": a native
entry that moved ETH survives beside the token rows, because the ETH and the
tokens are two real movements.
Zero ETH is decided on
rawAmountas a BigInt, not onvalueGwei, whichfloors sub-gwei amounts to 0.
Row counts, before and after
transferexecute, sent + received legs)sentswapExactETHForTokens)approve(no token transfer)Only the two
transfer-shaped rows change. The swap row still takes itsdisplay amount from the received leg and keeps the user's own
from/torather than the router's;
swapExactETHForTokenskeeps itsvalueGwei, sothe ETH leg stays visible as the row's native quantity.
Dust filter
filterTransactions'sisContractCalldust exemption is unchanged, andstill earns its place:
approveand other zero-ETH calls have no token rowto be represented by, so without the exemption they would vanish from
history as dust. The spurious row it was accidentally protecting no longer
exists. Nothing else in
filterTransactionswas touched — the fouranti-poisoning filters and their tests are untouched.
Tests
src/shared/transactions.jsis the only source file changed. Fifteen newunit tests against
mergeTransactionscover every row in the table above,plus sort order and non-mutation of the inputs. The
current behaviour: a plain ERC-20 transfer produces two entriestest was inverted intoa plain ERC-20 transfer produces exactly one entryrather than deleted, and everyother existing test in
tests/transactions.test.jsis unmodified.Demonstrated failing against the unfixed code
Rule 3 disabled locally,
make test:Mutation from the issue comment, now killed
Narrowing
isReceived || needsAmounttoisReceived(the surviving mutantfrom the review of #175)
is now caught by the all-
sentswap fixture:rawAmount-not-valueGweimutant, now killedThe review found that replacing
movedNoEther'sBigInt(tx.rawAmount || "0") === BigInt(0)with(tx.valueGwei || 0) === 0left the whole suite green, so nothing pinned the sub-gwei case the rule
above is written for. New fixture
a sub-gwei ETH movement keeps its row beside the token row: a native entry withrawAmount: "500000000"(0.5gwei, so
valueGweifloors to 0) sharing a hash with a USDC transfer,asserting both rows survive and the surviving ETH row still carries its
rawAmount. With thevalueGweimutation applied,make test:Reverted after capturing;
make testback to165 passed, 165 total. Theproduction logic is unchanged — this was a test-coverage gap, not a
behaviour bug.
make checkRun on the rebased branch (
b9ac3cf, on top ofnextatb9bc226):Exit 0. The count rose from 159 to 165 across the rebase because
nextbrought the
walletDeletesuite with it. Baseline before this change was143 tests passing.
FAIL —
needs-rework.1. Surviving mutant: the
rawAmount-not-valueGweichoice inmovedNoEtheris unpinned.src/shared/transactions.js:118-121. Replacingwith
leaves all 158 tests passing (
make test:Tests: 158 passed, 158 total).The mutant is not equivalent. With a native entry of
rawAmount: "500000000"(0.5 gwei, sovalueGweifloors to0) sharing a hash with a token transfer, the unmutated code returns 2 rows["ETH","USDC"]and the mutant returns 1["USDC"]— the ETH row silently disappears. Verified by adding a throwaway fixture: it passes unmutated and fails under the mutant, so the divergence is real and no test in the suite covers it. No fixture hasrawAmountnonzero whilevalueGwei === 0:erc20CallTxhas both zero and the moved-ETH fixture has both nonzero.This matters because the PR body sells this exact line as a deliberate design point ("Zero ETH is decided on
rawAmountas a BigInt, not onvalueGwei, which floors sub-gwei amounts to 0"), and it is on the disappearing-row side of the merge — the dangerous direction. It is also the same defect class that produced the follow-up requirement on #177: a branch someone could delete with every test still green.Acceptable: one fixture in the
mergeTransactionsdescribe — a native entry with sub-gweirawAmountandvalueGwei: 0plus a token transfer on the same hash — asserting two rows survive and the ETH row is one of them. Then flip the line tovalueGwei, confirm it fails, revert, and state the result as the PR already does for the other two mutants.2. CI is not green on the head commit.
check / check (push)ondea053dispending/ "Waiting to run" (run 452), still unstarted ~7 minutes after push. Localmake checkondea053dis green here (7 suites, 158 tests,prettier --checkclean both passes, 18s wall), but the tracker gate is unmet.Notes, not blocking. Branch
fix/issue-177-duplicate-transfer-rowsdeviates from theTODO.mdWorkflow rule "namedissue-<N>-<slug>".movedNoEther'sif (tx.direction === "contract") return false;is unreachable — the sole call site sits after acontinueon that same condition — but it is load-bearing if ever reached, sinceBigInt("")is0nand contract entries carryrawAmount: "".Verified passing. Both other mutants die: dropping
existing &&fails 5 tests; narrowingisReceived || needsAmounttoisReceivedfails the all-sentswap test, closing the #177 comment's requirement. The dust-filterisContractCallexemption is still needed and now pinned — removing it fails 2 tests. Thecurrent behaviour:inversion is legitimate: the prior test documented the buggy two-row output as current behaviour, it did not assert a correct one. Merge rule reasoned through swap, ETH-plus-token call, self-send, contract creation,approve, zero-value native with no token leg, and multi-token hash — no other disappearing-row case found. Single commit titled(closes #177), basenext, oneTODO.mdentry,src/shared/transactions.jsthe only source file changed, clean merge against currentnext(19cb1ca), inclusive terminology clean, no attribution trailers.Disclosure.
make checkwas run through the repo's ownscript/entrypoints on the host;script/linthere isprettierand is not containerised, so nothing was bypassed. Like the author, I did not runscript/cibuildunder Docker and did not confirm the change visually in the popup — the row counts above are reasoned from the code and pinned by unit tests only.dea053d90fto3f02a699d63f02a699d6tob9ac3cf928PASS — re-review at
b9ac3cf. ThevalueGweimutant is dead, the new fixture is faithful to the real parser, and eight further mutations of the merge rule each killed at least one test.Anomalies and disclosures.
b9ac3cfis a phantom — do not treat it as evidence.check / check (push)reportssuccess("Successful in 25s", run 478), but the only job reachable from it (job 502) fetched+e7a960c6...:refs/remotes/origin/fix/approval-address-title, checked outfix/approval-address-title, and reportedTest Suites: 2 passed, 2 total / Tests: 14 passed, 14 total. This PR's head has 8 suites and 165 tests. The job'shtml_urlpoints at run 204 while itsrun_idis 478, and its timestamps read 2026-02-27. Per instruction I am not failing on CI, but the status is attached to a run of a different commit. Localmake checkonb9ac3cfis the real evidence: 8 suites, 165 tests,prettier --checkclean on both passes, exit 0, andmake checkleft the tree unmodified.src/shared/transactions.js:119— flippingif (tx.direction === "contract") return false;toreturn true;leaves all 165 tests green. The sole call site is guarded by acontinueon that same condition, so the branch is unreachable; there is no observable behaviour to pin. Already noted in the prior review; recorded, not held against the PR.dea053dwas force-pushed away and is unreachable both viagitand via the Gitea API. Corroborated only indirectly: the prior review citedmovedNoEtheratsrc/shared/transactions.js:118-121with the exact text present at those exact lines today. The production logic was instead re-verified from scratch here by mutation.isErroris lost when the native row is absorbed. A native entry withstatus !== "ok"(reverted, and also pending, which Blockscout reports as a null status) that shares a hash with a token transfer is deleted, andparseTokenTransferhardcodesisError: falseon the survivor, so the failure indicator vanishes. Before this change both rows displayed. I judge this unreachable — a reverted or pending transaction emits no logs, so it yields no token transfer to trigger the drop — but I did not verify Blockscout's behaviour for pending token transfers empirically, so the bound is reasoned, not tested. Not blocking.fix/issue-177-duplicate-transfer-rows, notissue-<N>-<slug>per theTODO.mdWorkflow rule. It matches the convention every other live branch on this repo uses. Cosmetic; not blocking.Mutations applied, all killed unless noted.
movedNoEtherrawAmountBigInt to(tx.valueGwei || 0) === 0— 1 fail,a sub-gwei ETH movement keeps its row beside the token row.existing &&guard dropped so an unmatched token transfer takes the contract branch — 5 fail.direction === "contract"inverted — 10 fail. Merge key narrowed from hash+contract to hash alone — 2 fail. Native drop made unconditional — 2 fail.isReceived || needsAmountnarrowed toisReceived— 1 fail. Sort comparator reversed — 2 fail.{ ...tx }defensive copy dropped — 1 fail.movedNoEthercontract early return — 0 fail, equivalent, see above.Fixture faithfulness, verified end to end. Driving a throwaway probe through
fetchRecentTransactionswith a mocked Blockscout response ofvalue: "500000000",to.is_contract: true,method: "transfer", the realparseTxemits exactly the hand-written fixture:rawAmount: "500000000",valueGwei: 0,exactValue: "0.0000000005",direction: "sent",isContractCall: true. The combination is reachable, not invented. The probe file was deleted; the tree is pristine atb9ac3cf.Same-token-twice on one hash: pre-existing, not worsened. Two
USDCtransfers on one hash still collapse to one row, because the second overwrites the first at keyhash + ":" + contract. That line is byte-identical to the pre-change code, and its pinning testtwo transfers of the same token in one transaction collapse to one entryis untouched by this PR. The delta is only that the spurious zero-ETH native row no longer accompanies it — that row was never a stand-in for the lost second transfer.Rebases lost nothing.
TODO.mdat head equalsTODO.mdat the merge-base plus the one#177entry. A real trial merge onto the currentnexttip (9b957ff) applies cleanly, retains all thirteen Completed Steps entries including#195and#213, andmake checkon the merged result is green (9 suites, 173 tests).Also checked and clean: single commit, title ends
(closes #177), basenext, authored and committedclawbot <clawbot@eeqj.de>, no Claude or Anthropic references and no attribution trailers anywhere in the diff or message,src/shared/transactions.jsthe only source file changed, 16 new tests carrying 47 assertions with no vacuous cases, thecurrent behaviour:test inverted rather than deleted, inclusive terminology, and no scope creep.