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