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