From 8c5bd73f30707a5406e6f504997332bf59f3d784 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 first, the first answer filled the second screen with its block, network details, decoded details and raw data. The answer is now dropped unless TransactionDetail is still on screen for the same transaction, beside the check of "Use Blockscout". A request for another transaction that fails no longer logs its error. Model: opus-5-5 --- src/popup/views/transactionDetail.js | 20 ++- tests/transactionDetailLateAnswer.test.js | 172 ++++++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 tests/transactionDetailLateAnswer.test.js diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 84ca5fe..438c0b1 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -311,10 +311,25 @@ function populateOnChainDetails(txData, chainId) { } } +// Whether TransactionDetail is on screen for the transaction with `txHash` on +// `chainId`. +function isOnScreen(txHash, chainId) { + const tx = state.viewData.tx; + return ( + state.currentView === "transaction" && + 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 left this screen or opened another +// transaction. That answer is dropped. If that request fails, its error is not +// logged. async function loadFullTxDetails(txHash, chainId) { if (!(await storedSettingIsOn("useBlockscout"))) return; const section = $("tx-detail-calldata-section"); @@ -333,6 +348,7 @@ async function loadFullTxDetails(txHash, chainId) { if (!resp.ok) return; const txData = await resp.json(); if (!(await storedSettingIsOn("useBlockscout"))) return; + if (!isOnScreen(txHash, chainId)) return; // Populate on-chain detail fields (block, nonce, gas, fee) populateOnChainDetails(txData, chainId); @@ -399,7 +415,9 @@ async function loadFullTxDetails(txHash, chainId) { attachCopyHandlers(container); } } catch (e) { - log.errorf("loadCalldata failed:", e.message); + if (isOnScreen(txHash, chainId)) { + log.errorf("loadCalldata failed:", e.message); + } } } diff --git a/tests/transactionDetailLateAnswer.test.js b/tests/transactionDetailLateAnswer.test.js new file mode 100644 index 0000000..812f0f4 --- /dev/null +++ b/tests/transactionDetailLateAnswer.test.js @@ -0,0 +1,172 @@ +// 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 a second transaction before +// Blockscout answers for the first. The first answer must then fill nothing on +// the second transaction's screen. If the first request fails instead, its +// error must not be logged. +// +// 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 BLOCKSCOUT = "https://eth.blockscout.com/api/v2"; +const RECIPIENT = "0x2222222222222222222222222222222222222222"; + +// A history entry for a send of 1 ETH from ADDRESS to RECIPIENT on mainnet. +function historyEntry(hash) { + return { + hash, + from: ADDRESS, + to: RECIPIENT, + value: "1.0000", + exactValue: "1.0", + symbol: "ETH", + timestamp: 1790000000, + isError: false, + direction: "sent", + directionLabel: "Sent", + contractAddress: null, + chainId: "0x1", + }; +} + +const FIRST = historyEntry("0x" + "aa".repeat(32)); +const SECOND = historyEntry("0x" + "bb".repeat(32)); + +// What Blockscout reports for each transaction. +const ANSWERS = { + [FIRST.hash]: { + block_number: 21000001, + nonce: 4441, + fee: { value: "21000000000000" }, + raw_input: "0x11111111", + method: "firstMethod", + }, + [SECOND.hash]: { + block_number: 21000002, + nonce: 4442, + fee: { value: "42000000000000" }, + raw_input: "0x22222222", + method: "secondMethod", + }, +}; + +// The text each answer puts on screen: its block, nonce, fee, method name and +// raw data. No text from one answer appears in the other. +const FIRST_SHOWN = [ + "21000001", + "4441", + "0.000021 ETH", + "firstMethod", + "0x11111111", +]; +const SECOND_SHOWN = [ + "21000002", + "4442", + "0.000042 ETH", + "secondMethod", + "0x22222222", +]; + +// The error the first request fails with in the second test. +const FIRST_FAILURE = "Blockscout did not answer the first request"; + +// 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); +} + +// Boots the popup on Home with "Use Blockscout" on. The user opens FIRST, +// whose request to Blockscout is held. Then the user presses Back and opens +// SECOND, whose request is answered at once. Returns the page and the held +// request's two outcomes: answer() lets FIRST's answer land, and fail() makes +// the request fail. +async function secondOpenedWhileFirstIsOut() { + const page = await bootPopup( + unversionedValidProfile({ useBlockscout: true }), + ); + let answer; + let fail; + const firstHeld = new Promise((resolve, reject) => { + answer = resolve; + fail = reject; + }); + globalThis.fetch = jest.fn(async (url) => { + const hash = String(url).slice((BLOCKSCOUT + "/transactions/").length); + if (hash === FIRST.hash) await firstHeld; + return { ok: true, status: 200, json: async () => ANSWERS[hash] }; + }); + + open(FIRST); + await page.settle(); + await page.click("btn-tx-back"); + expect(page.visibleViews()).toEqual(["main"]); + open(SECOND); + await page.settle(); + expect(globalThis.fetch.mock.calls.map(([url]) => url)).toEqual([ + BLOCKSCOUT + "/transactions/" + FIRST.hash, + BLOCKSCOUT + "/transactions/" + SECOND.hash, + ]); + return { + page, + answer: () => answer(), + fail: () => fail(new Error(FIRST_FAILURE)), + }; +} + +beforeEach(() => { + jest.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + cleanupPopup(); + jest.restoreAllMocks(); +}); + +test("an answer for the first transaction that lands on the second one's screen fills nothing", async () => { + const { page, answer } = await secondOpenedWhileFirstIsOut(); + answer(); + await page.settle(); + + expect(page.visibleViews()).toEqual(["transaction"]); + 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("a request for the first transaction that fails on the second one's screen logs nothing", async () => { + const { page, fail } = await secondOpenedWhileFirstIsOut(); + fail(); + await page.settle(); + + expect(page.visibleViews()).toEqual(["transaction"]); + const drawn = drawnDetails(page); + for (const text of SECOND_SHOWN) expect(drawn).toContain(text); + expect(console.error.mock.calls.flat()).not.toContain(FIRST_FAILURE); +});