From 9e3d56ef3f9e7bd68df2d880e52acd638aa4c95a Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 27 Oct 2026 13:05:35 +0000 Subject: [PATCH] fix: TransactionDetail drops a Blockscout answer for another transaction (closes #675) TransactionDetail asks Blockscout for its transaction and does not wait for the answer. If the user pressed Back and opened another transaction before Blockscout answered, the first answer filled the second screen with its block, network details, decoded details and raw data. Now the answer is dropped once TransactionDetail shows another transaction. This check sits beside the "Use Blockscout" check. Model: opus-5-5 --- src/popup/views/transactionDetail.js | 12 ++ tests/transactionDetailLateAnswer.test.js | 202 ++++++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 tests/transactionDetailLateAnswer.test.js diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 84ca5fe..0d40756 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -311,10 +311,21 @@ function populateOnChainDetails(txData, chainId) { } } +// Whether TransactionDetail still holds the transaction with `txHash` on +// `chainId`. Opening another transaction replaces the one TransactionDetail +// holds. A visit to Settings does not. +function holdsTransaction(txHash, chainId) { + const tx = state.viewData.tx; + return tx?.hash === txHash && tx?.chainId === chainId; +} + // Reads the block, network details and calldata from Blockscout, and decodes // the calldata. While the stored "Use Blockscout" is off, Blockscout is not // asked. An answer that lands after the setting went off is dropped // (src/shared/storedSettings.js). Either way none of them is shown. +// +// Blockscout can answer after the user has opened another transaction. That +// answer is dropped. async function loadFullTxDetails(txHash, chainId) { if (!(await storedSettingIsOn("useBlockscout"))) return; const section = $("tx-detail-calldata-section"); @@ -333,6 +344,7 @@ async function loadFullTxDetails(txHash, chainId) { if (!resp.ok) return; const txData = await resp.json(); if (!(await storedSettingIsOn("useBlockscout"))) return; + if (!holdsTransaction(txHash, chainId)) return; // Populate on-chain detail fields (block, nonce, gas, fee) populateOnChainDetails(txData, chainId); diff --git a/tests/transactionDetailLateAnswer.test.js b/tests/transactionDetailLateAnswer.test.js new file mode 100644 index 0000000..5e07d09 --- /dev/null +++ b/tests/transactionDetailLateAnswer.test.js @@ -0,0 +1,202 @@ +// TransactionDetail shows only details that Blockscout gave for the +// transaction on screen (https://git.eeqj.de/sneak/AutistMask/issues/675). +// +// The screen asks Blockscout for its transaction and does not wait for the +// answer. So the user can press Back and open another transaction before +// Blockscout answers for the first. The first answer must then fill nothing on +// the second transaction's screen. An answer that lands while Settings is up +// must still fill TransactionDetail. +// +// Driven through the real popup over a stored profile +// (tests/support/popupBoot.js). Each transaction is opened as a history screen +// opens it: the current screen is pushed, then transactionDetail.show() draws +// the transaction. + +"use strict"; + +const { + bootPopup, + cleanupPopup, + unversionedValidProfile, + ADDRESS, +} = require("./support/popupBoot"); + +const MAINNET_BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; +const SEPOLIA_BLOCKSCOUT = "https://eth-sepolia.blockscout.com/api/v2"; +const RECIPIENT = "0x2222222222222222222222222222222222222222"; + +// A history entry for a send of 1 `symbol` from ADDRESS to RECIPIENT, on the +// network with `chainId`. +function historyEntry(hash, chainId, symbol) { + return { + hash, + from: ADDRESS, + to: RECIPIENT, + value: "1.0000", + exactValue: "1.0", + symbol, + timestamp: 1790000000, + isError: false, + direction: "sent", + directionLabel: "Sent", + contractAddress: null, + chainId, + }; +} + +const FIRST = historyEntry("0x" + "aa".repeat(32), "0x1", "ETH"); +const SECOND = historyEntry("0x" + "bb".repeat(32), "0x1", "ETH"); +// A transaction on Sepolia with the same hash as FIRST. +const FIRST_ON_SEPOLIA = historyEntry(FIRST.hash, "0xaa36a7", "SepoliaETH"); + +// The URL that asks Blockscout for `entry`, on the network it was sent on. +function requestUrl(entry) { + const blockscout = + entry.chainId === "0x1" ? MAINNET_BLOCKSCOUT : SEPOLIA_BLOCKSCOUT; + return blockscout + "/transactions/" + entry.hash; +} + +// What Blockscout reports for each transaction, by the URL that asks for it. +const ANSWERS = { + [requestUrl(FIRST)]: { + block_number: 21000001, + nonce: 4441, + fee: { value: "21000000000000" }, + raw_input: "0x11111111", + method: "firstMethod", + }, + [requestUrl(SECOND)]: { + block_number: 21000002, + nonce: 4442, + fee: { value: "42000000000000" }, + raw_input: "0x22222222", + method: "secondMethod", + }, + [requestUrl(FIRST_ON_SEPOLIA)]: { + block_number: 21000003, + nonce: 4443, + fee: { value: "63000000000000" }, + raw_input: "0x33333333", + method: "sepoliaMethod", + }, +}; + +// The text each answer puts on screen: its block, nonce, fee, method name and +// raw data. No text from one answer appears in another. +const FIRST_SHOWN = [ + "21000001", + "4441", + "0.000021 ETH", + "firstMethod", + "0x11111111", +]; +const SECOND_SHOWN = [ + "21000002", + "4442", + "0.000042 ETH", + "secondMethod", + "0x22222222", +]; +const FIRST_ON_SEPOLIA_SHOWN = [ + "21000003", + "4443", + "0.000063 SepoliaETH", + "sepoliaMethod", + "0x33333333", +]; + +// Everything drawn in the fields that Blockscout's answer fills. +function drawnDetails(page) { + return [ + "tx-detail-block", + "tx-detail-nonce", + "tx-detail-fee", + "tx-detail-calldata-action", + "tx-detail-rawdata", + ] + .map((id) => page.node(id).innerHTML + page.node(id).textContent) + .join("\n"); +} + +// Opens `entry` on TransactionDetail, as a history screen does. +function open(entry) { + require("../src/popup/views/helpers").pushCurrentView(); + require("../src/popup/views/transactionDetail").show(entry); +} + +// The URLs of every request to Blockscout so far, in order. +function requestedUrls() { + return globalThis.fetch.mock.calls.map(([url]) => url); +} + +// Boots the popup on Home with "Use Blockscout" on. Blockscout answers every +// request at once from ANSWERS, except the request for `held`. Returns the +// page and answer(), which lets the answer for `held` land. +async function bootWithHeldRequest(held) { + const page = await bootPopup( + unversionedValidProfile({ useBlockscout: true }), + ); + let answer; + const heldAnswer = new Promise((resolve) => { + answer = resolve; + }); + globalThis.fetch = jest.fn(async (url) => { + if (url === requestUrl(held)) await heldAnswer; + return { ok: true, status: 200, json: async () => ANSWERS[url] }; + }); + return { page, answer: () => answer() }; +} + +// The user opens `first`, whose request to Blockscout is held. Then the user +// presses Back and opens `second`. Then the answer for `first` lands. Returns +// the page. +async function firstAnswerLandsOnSecond(first, second) { + const { page, answer } = await bootWithHeldRequest(first); + open(first); + await page.settle(); + await page.click("btn-tx-back"); + expect(page.visibleViews()).toEqual(["main"]); + open(second); + await page.settle(); + expect(requestedUrls()).toEqual([requestUrl(first), requestUrl(second)]); + answer(); + await page.settle(); + expect(page.visibleViews()).toEqual(["transaction"]); + return page; +} + +afterEach(() => { + cleanupPopup(); +}); + +test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => { + const page = await firstAnswerLandsOnSecond(FIRST, SECOND); + + const drawn = drawnDetails(page); + for (const text of SECOND_SHOWN) expect(drawn).toContain(text); + for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text); +}); + +test("an answer for a hash on mainnet that lands on the same hash's screen on Sepolia fills nothing", async () => { + const page = await firstAnswerLandsOnSecond(FIRST, FIRST_ON_SEPOLIA); + + const drawn = drawnDetails(page); + for (const text of FIRST_ON_SEPOLIA_SHOWN) expect(drawn).toContain(text); + for (const text of FIRST_SHOWN) expect(drawn).not.toContain(text); +}); + +test("an answer that lands while Settings is up still fills TransactionDetail", async () => { + const { page, answer } = await bootWithHeldRequest(FIRST); + open(FIRST); + await page.settle(); + await page.click("btn-settings"); + expect(page.visibleViews()).toEqual(["settings"]); + answer(); + await page.settle(); + await page.click("btn-settings"); + + expect(page.visibleViews()).toEqual(["transaction"]); + expect(requestedUrls()).toEqual([requestUrl(FIRST)]); + const drawn = drawnDetails(page); + for (const text of FIRST_SHOWN) expect(drawn).toContain(text); +});