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); +});