From 669757401e0b5c7c23e4b5f0b572451de22506f9 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 19 Oct 2026 22:58:47 +0000 Subject: [PATCH] fix: TransactionDetail reads a transaction from the Blockscout of the network it was sent on (closes #623) TransactionDetail read the block, network details and calldata from the active network's Blockscout and decoded the calldata for the active network, so after a network switch a transaction sent on the other network showed none of them. It now reads them from the Blockscout Settings has for the network the transaction was sent on, and decodes the calldata for that network. endpointsByChainId() in the shared view helpers gives a network's RPC and Blockscout URLs, picked as reviewedNetworkProvider() picked its node; reviewedNetworkProvider() now uses it. Restore refuses a stored transaction whose chain id names no network, as it does for WaitTx. Model: opus-5-5 --- README.md | 6 ++-- src/popup/viewRouter.js | 6 ++-- src/popup/views/helpers.js | 34 ++++++++++++++------ src/popup/views/transactionDetail.js | 19 +++++++----- tests/backNavigation.test.js | 9 +++++- tests/persistedFieldContract.test.js | 17 ++++++++-- tests/popupBoot.test.js | 2 +- tests/tokenTransferDetail.test.js | 46 ++++++++++++++++++++++------ tests/uniswapRouterAddress.test.js | 22 ++++++++----- 9 files changed, 118 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 1fd1ff2..ee25c49 100644 --- a/README.md +++ b/README.md @@ -1972,8 +1972,10 @@ view would leave a wallet one click from deletion. #### TransactionDetail (`transaction`) - **When**: User tapped a transaction row on Home, AddressDetail, or - AddressToken. Its etherscan links open the explorer of the network the - transaction was sent on, whichever network is active. + AddressToken. The screen describes the transaction on the network it was sent + on, whichever network is active: its block, network details, decoded details + and raw data are read from that network's Blockscout, the one Settings has for + it, and its etherscan links open that network's explorer. - **Elements** (grouped into logical blocks using light well containers; field labels are self-explanatory so groups have no headings): - "Transaction" heading, "Back" button diff --git a/src/popup/viewRouter.js b/src/popup/viewRouter.js index caefb26..3e59202 100644 --- a/src/popup/viewRouter.js +++ b/src/popup/viewRouter.js @@ -145,15 +145,15 @@ function isRenderablePendingTx(value) { // The stored transaction transactionDetail.render() shows. contractAddress is // optional on an ETH transfer, and reaches addressDotHtml() when it is there. -// `chainId` picks the network whose explorer the links open, so when it is -// there it has to name one. +// `chainId` picks the network whose explorer the links open and whose +// Blockscout the transaction is read from, so it has to name one. function isRenderableTx(value) { return ( isRecord(value) && isAddressText(value.from) && isAddressText(value.to) && (!value.contractAddress || isAddressText(value.contractAddress)) && - (!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId)) + SUPPORTED_CHAIN_IDS.has(value.chainId) ); } diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js index 9912b7b..b14e461 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -1,4 +1,7 @@ -// Shared DOM helpers used by all views. +// Helpers the views in this directory share: showing a view and going back, +// error and flash lines and the warning banners, formatting of amounts, fees, +// balances, addresses and dates, the RPC and Blockscout URLs and node of the +// network a chain id names, explorer links, and click-to-copy. // // Escaping rule for every view in this directory, since they all build // markup by concatenation: any VALUE interpolated into an innerHTML string @@ -608,18 +611,28 @@ const EXT_ICON = `` + ``; -// The node of the network a transaction was reviewed for, the one its chain id -// names, which need not be the active one: the popup can reopen onto ConfirmTx -// or WaitTx after Settings switched the network. Its RPC URL is the one -// Settings has for that network: the active network's is state.rpcUrl, any -// other's the one remembered for it (see applyChainSwitchFields()). -function reviewedNetworkProvider(txInfo) { - const net = networkByChainId(txInfo.chainId); +// The RPC and Blockscout URLs Settings has for the network with this chain id, +// which need not be the active one: ConfirmTx, WaitTx and TransactionDetail +// use the network their transaction's chain id names, and the popup can reopen +// onto them after Settings switched the network. The active network's are +// state.rpcUrl and state.blockscoutUrl, any other's the ones remembered for it, +// or its defaults (see applyChainSwitchFields()). +function endpointsByChainId(chainId) { + const net = networkByChainId(chainId); if (net.id === state.networkId) { - return getProvider(state.rpcUrl, net.id); + return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl }; } const remembered = state.networkEndpoints[net.id] || {}; - return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id); + return { + rpcUrl: remembered.rpcUrl || net.defaultRpcUrl, + blockscoutUrl: remembered.blockscoutUrl || net.defaultBlockscoutUrl, + }; +} + +// The node of the network a transaction was reviewed for. +function reviewedNetworkProvider(txInfo) { + const net = networkByChainId(txInfo.chainId); + return getProvider(endpointsByChainId(txInfo.chainId).rpcUrl, net.id); } // Block-explorer URLs. The origin is a per-network constant from @@ -777,6 +790,7 @@ module.exports = { attachCopyHandlers, etherscanLinkHtml, explorerUrl, + endpointsByChainId, reviewedNetworkProvider, EXT_ICON, truncateMiddle, diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 15bf77d..832211f 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -18,10 +18,11 @@ const { copyableHtml, etherscanLinkHtml, explorerUrl, + endpointsByChainId, displaySymbol, goBack, } = require("./helpers"); -const { state, currentNetwork } = require("../../shared/state"); +const { state } = require("../../shared/state"); const { nativeCurrencyByChainId } = require("../../shared/networks"); const { formatEther, formatUnits } = require("ethers"); const { log, debugFetch } = require("../../shared/log"); @@ -84,9 +85,10 @@ function show(tx) { contractAddress: tx.contractAddress || null, // The network the history entry was read from. The type line and // the fee are in its native currency, not the active network's, - // and the links open its explorer: the active network can change, - // in Settings or when the user approves a site's request, before - // a later popup shows this screen again. + // the links open its explorer, and the block, network details and + // calldata are read from its Blockscout: the active network can + // change, in Settings or when the user approves a site's request, + // before a later popup shows this screen again. chainId: tx.chainId, }, }; @@ -311,8 +313,9 @@ async function loadFullTxDetails(txHash, chainId) { if (!section || !actionEl || !detailsEl) return; try { + const { blockscoutUrl } = endpointsByChainId(chainId); const resp = await debugFetch( - state.blockscoutUrl + "/transactions/" + txHash, + blockscoutUrl + "/transactions/" + txHash, ); if (!resp.ok) return; const txData = await resp.json(); @@ -326,12 +329,12 @@ async function loadFullTxDetails(txHash, chainId) { // Decoded against the address the transaction was sent to, as the // explorer reports it, not the history entry's `to`: for a token // transfer that is the transfer's recipient, not the token contract. - // On the active network, the one whose explorer was just asked, and - // from the sender it reports. + // On the network the transaction was sent on, whose Blockscout was + // just asked, and from the sender it reports. const decoded = decodeCalldata( inputData, txData.to?.hash || "", - currentNetwork().chainId, + chainId, txData.from?.hash || "", ); if (decoded) { diff --git a/tests/backNavigation.test.js b/tests/backNavigation.test.js index 1ed246a..a387aea 100644 --- a/tests/backNavigation.test.js +++ b/tests/backNavigation.test.js @@ -154,7 +154,14 @@ describe("Back onto a view the reopened popup never rendered", () => { test("Back onto the transaction detail renders it", () => { reopenedOn("settings", ["main", "transaction"], { - viewData: { tx: { hash: "0xdead", from: ADDRESS, to: ADDRESS } }, + viewData: { + tx: { + hash: "0xdead", + from: ADDRESS, + to: ADDRESS, + chainId: "0x1", + }, + }, }); goBack(); expect(calls).toEqual(["transactionDetail"]); diff --git a/tests/persistedFieldContract.test.js b/tests/persistedFieldContract.test.js index b2f0c06..47e50c3 100644 --- a/tests/persistedFieldContract.test.js +++ b/tests/persistedFieldContract.test.js @@ -465,12 +465,19 @@ const CONTRACT = [ from: ADDRESS, to: ADDRESS, contractAddress: 42, + chainId: "0x1", }, }, views: ["transaction"], restored: false, }, - // A chain id naming no network has no explorer to link to. + // A transaction whose chain id is missing or names no network has + // no explorer to link to and no Blockscout to be read from. + { + value: { tx: { hash: "0x1", from: ADDRESS, to: ADDRESS } }, + views: ["transaction"], + restored: false, + }, { value: { tx: { @@ -836,7 +843,13 @@ const WELL_FORMED_DATA = { message: "boom", to: ADDRESS, decoded: { details: [{ address: TOKEN_ADDRESS }] }, - tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, contractAddress: null }, + tx: { + hash: "0x1", + from: ADDRESS, + to: ADDRESS, + contractAddress: null, + chainId: "0x1", + }, pendingTx: { token: "ETH", from: ADDRESS, diff --git a/tests/popupBoot.test.js b/tests/popupBoot.test.js index 5c12d5c..b658aeb 100644 --- a/tests/popupBoot.test.js +++ b/tests/popupBoot.test.js @@ -58,7 +58,7 @@ function restoringOnto(view, viewData) { test("the transaction screen's Blockscout request reaches the stub", async () => { const page = await bootPopup( restoringOnto("transaction", { - tx: { hash: "0x1", from: ADDRESS, to: ADDRESS }, + tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, chainId: "0x1" }, }), ); diff --git a/tests/tokenTransferDetail.test.js b/tests/tokenTransferDetail.test.js index 56979db..e7b3b54 100644 --- a/tests/tokenTransferDetail.test.js +++ b/tests/tokenTransferDetail.test.js @@ -10,7 +10,9 @@ // // Its links open the explorer of the network the transaction was sent on, // whichever network is active -// (https://git.eeqj.de/sneak/AutistMask/issues/584). +// (https://git.eeqj.de/sneak/AutistMask/issues/584), and the transaction is +// read from that network's Blockscout, the one Settings has for it +// (https://git.eeqj.de/sneak/AutistMask/issues/623). // // The row is the one the real history parser makes from the explorer's answer, // opened through transactionDetail.show(), which is what a click on the row @@ -26,10 +28,10 @@ jest.mock("../src/shared/log", () => ({ warnf: () => {}, errorf: () => {}, }, - debugFetch: async (url) => ({ - ok: true, - json: async () => mockExplorerAnswer(url), - }), + debugFetch: async (url) => { + mockAsked.push(url); + return { ok: true, json: async () => mockExplorerAnswer(url) }; + }, setRuntimeDebug: () => {}, isDebug: () => false, })); @@ -44,6 +46,8 @@ const { fetchRecentTransactions } = require("../src/shared/transactions"); const transactionDetail = require("../src/popup/views/transactionDetail"); const EXPLORER = "https://explorer.example/api/v2"; +// A Blockscout that knows nothing of the transaction. +const OTHER_EXPLORER = "https://other-explorer.example/api/v2"; const HOLDER = "0x0000000000000000000000000000000000000a11"; const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; // A token the wallet tracks and the bundled token list does not name. @@ -56,6 +60,9 @@ const TRANSFER_DATA = new Interface([ "function transfer(address to, uint256 amount)", ]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); +// Every URL asked of a Blockscout, in order. +const mockAsked = []; + // The explorer's answers: the transfer in HOLDER's token transfers, and the // transaction itself, sent to the token contract. function mockExplorerAnswer(url) { @@ -149,6 +156,7 @@ beforeEach(() => { globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; state.blockscoutUrl = EXPLORER; + state.networkEndpoints = {}; state.wallets = [ { name: "Main", @@ -188,11 +196,13 @@ test("a token transfer's decoded details name the token contract and the amount ); }); -// Mainnet is active and the row was read from Sepolia's explorer. The stub -// explorer answers whichever network asks, so every link on the screen is -// drawn. -test("with mainnet active, a Sepolia transaction's links open the Sepolia explorer", async () => { +// Mainnet is active, and its Blockscout knows nothing of the transaction. The +// row was read from the Blockscout Settings remembers for Sepolia, and the +// screen reads the transaction from there too, so every link on it is drawn. +test("with mainnet active, a Sepolia transaction is read from Sepolia's Blockscout and its links open the Sepolia explorer", async () => { state.networkId = "mainnet"; + state.blockscoutUrl = OTHER_EXPLORER; + state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } }; const [row] = await fetchRecentTransactions( HOLDER, EXPLORER, @@ -217,3 +227,21 @@ test("with mainnet active, a Sepolia transaction's links open the Sepolia explor `${sepolia}/address/${RECIPIENT}`, ]); }); + +test("with mainnet active and no Blockscout remembered for Sepolia, a Sepolia transaction is asked of Sepolia's default Blockscout", async () => { + state.networkId = "mainnet"; + state.blockscoutUrl = OTHER_EXPLORER; + const [row] = await fetchRecentTransactions( + HOLDER, + EXPLORER, + NETWORKS.sepolia.chainId, + ); + mockAsked.length = 0; + + transactionDetail.show(row); + for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0)); + + expect(mockAsked).toEqual([ + NETWORKS.sepolia.defaultBlockscoutUrl + "/transactions/" + TX_HASH, + ]); +}); diff --git a/tests/uniswapRouterAddress.test.js b/tests/uniswapRouterAddress.test.js index 653bfc4..54cccf1 100644 --- a/tests/uniswapRouterAddress.test.js +++ b/tests/uniswapRouterAddress.test.js @@ -186,14 +186,14 @@ function node(id) { return globalThis.document.getElementById(id); } -// Open the transaction detail screen with Sepolia active, for the -// transaction the explorer reports sent to `sentTo`, and wait for its +// Open the transaction detail screen with `activeNetworkId` active, for the +// Sepolia transaction the explorer reports sent to `sentTo`, and wait for its // decoded details. -async function showDetailSentTo(sentTo) { +async function showDetailSentTo(sentTo, activeNetworkId) { mockSentTo = sentTo; globalThis.document = makeDocument(); globalThis.window = { location: { search: "" } }; - state.networkId = "sepolia"; + state.networkId = activeNetworkId; state.blockscoutUrl = "https://explorer.example/api/v2"; state.viewData = {}; state.viewStack = []; @@ -216,21 +216,29 @@ async function showDetailSentTo(sentTo) { describe("execute() calldata on the transaction detail screen", () => { test("sent to a router on the active network, it is a swap", async () => { - await showDetailSentTo(SEPOLIA_ROUTER); + await showDetailSentTo(SEPOLIA_ROUTER, "sepolia"); + expect(node("tx-detail-calldata-details").innerHTML).toContain( + "Swap via Uniswap Universal Router", + ); + }); + + // https://git.eeqj.de/sneak/AutistMask/issues/623 + test("sent to a router on its network, with the other network active, it is a swap", async () => { + await showDetailSentTo(SEPOLIA_ROUTER, "mainnet"); expect(node("tx-detail-calldata-details").innerHTML).toContain( "Swap via Uniswap Universal Router", ); }); test("sent to an ordinary address, it is not decoded", async () => { - await showDetailSentTo(ORDINARY); + await showDetailSentTo(ORDINARY, "sepolia"); expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); }); test("sent to a router of the other network only, it is not decoded", async () => { - await showDetailSentTo(MAINNET_ROUTER); + await showDetailSentTo(MAINNET_ROUTER, "sepolia"); expect(node("tx-detail-calldata-action").textContent).toBe("execute"); expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);