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/views/helpers.js b/src/popup/views/helpers.js index 9912b7b..fc03187 100644 --- a/src/popup/views/helpers.js +++ b/src/popup/views/helpers.js @@ -608,18 +608,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) { +// The RPC and Blockscout URLs Settings has for the network a transaction was +// reviewed for or sent on, the one its chain id names, which need not be the +// active one: the popup can reopen onto ConfirmTx, WaitTx or TransactionDetail +// 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 reviewedNetworkEndpoints(txInfo) { const net = networkByChainId(txInfo.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(reviewedNetworkEndpoints(txInfo).rpcUrl, net.id); } // Block-explorer URLs. The origin is a per-network constant from @@ -777,6 +787,7 @@ module.exports = { attachCopyHandlers, etherscanLinkHtml, explorerUrl, + reviewedNetworkEndpoints, reviewedNetworkProvider, EXT_ICON, truncateMiddle, diff --git a/src/popup/views/transactionDetail.js b/src/popup/views/transactionDetail.js index 15bf77d..d193a31 100644 --- a/src/popup/views/transactionDetail.js +++ b/src/popup/views/transactionDetail.js @@ -18,10 +18,11 @@ const { copyableHtml, etherscanLinkHtml, explorerUrl, + reviewedNetworkEndpoints, 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, }, }; @@ -189,7 +191,7 @@ function render() { if (el) el.classList.add("hidden"); } - loadFullTxDetails(tx.hash, tx.chainId); + loadFullTxDetails(tx); const isoStr = isoDate(tx.timestamp); $("tx-detail-time").innerHTML = @@ -301,7 +303,8 @@ function populateOnChainDetails(txData, chainId) { } } -async function loadFullTxDetails(txHash, chainId) { +async function loadFullTxDetails(tx) { + const chainId = tx.chainId; const section = $("tx-detail-calldata-section"); const actionEl = $("tx-detail-calldata-action"); const detailsEl = $("tx-detail-calldata-details"); @@ -311,8 +314,9 @@ async function loadFullTxDetails(txHash, chainId) { if (!section || !actionEl || !detailsEl) return; try { + const { blockscoutUrl } = reviewedNetworkEndpoints(tx); const resp = await debugFetch( - state.blockscoutUrl + "/transactions/" + txHash, + blockscoutUrl + "/transactions/" + tx.hash, ); if (!resp.ok) return; const txData = await resp.json(); @@ -326,12 +330,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/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);