Author SHA1 Message Date
sneak f8101b559e fix: TransactionDetail reads a transaction from the Blockscout of the network it was sent on (closes #623)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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.
reviewedNetworkEndpoints() in the shared view helpers gives that network's RPC
and Blockscout URLs, picked as reviewedNetworkProvider() picked its node;
reviewedNetworkProvider() now uses it.

Model: opus-5-5
2026-10-19 22:58:47 +00:00
6 changed files with 90 additions and 37 deletions
+4 -2
View File
@@ -1972,8 +1972,10 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`) #### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row on Home, AddressDetail, or - **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken. Its etherscan links open the explorer of the network the AddressToken. The screen describes the transaction on the network it was sent
transaction was sent on, whichever network is active. 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 - **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings): labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button - "Transaction" heading, "Back" button
+19 -8
View File
@@ -608,18 +608,28 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// The node of the network a transaction was reviewed for, the one its chain id // The RPC and Blockscout URLs Settings has for the network a transaction was
// names, which need not be the active one: the popup can reopen onto ConfirmTx // reviewed for or sent on, the one its chain id names, which need not be the
// or WaitTx after Settings switched the network. Its RPC URL is the one // active one: the popup can reopen onto ConfirmTx, WaitTx or TransactionDetail
// Settings has for that network: the active network's is state.rpcUrl, any // after Settings switched the network. The active network's are state.rpcUrl
// other's the one remembered for it (see applyChainSwitchFields()). // and state.blockscoutUrl, any other's the ones remembered for it, or its
function reviewedNetworkProvider(txInfo) { // defaults (see applyChainSwitchFields()).
function reviewedNetworkEndpoints(txInfo) {
const net = networkByChainId(txInfo.chainId); const net = networkByChainId(txInfo.chainId);
if (net.id === state.networkId) { if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id); return { rpcUrl: state.rpcUrl, blockscoutUrl: state.blockscoutUrl };
} }
const remembered = state.networkEndpoints[net.id] || {}; 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 // Block-explorer URLs. The origin is a per-network constant from
@@ -777,6 +787,7 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
reviewedNetworkEndpoints,
reviewedNetworkProvider, reviewedNetworkProvider,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
+14 -10
View File
@@ -18,10 +18,11 @@ const {
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl, explorerUrl,
reviewedNetworkEndpoints,
displaySymbol, displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state, currentNetwork } = require("../../shared/state"); const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks"); const { nativeCurrencyByChainId } = require("../../shared/networks");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -84,9 +85,10 @@ function show(tx) {
contractAddress: tx.contractAddress || null, contractAddress: tx.contractAddress || null,
// The network the history entry was read from. The type line and // The network the history entry was read from. The type line and
// the fee are in its native currency, not the active network's, // the fee are in its native currency, not the active network's,
// and the links open its explorer: the active network can change, // the links open its explorer, and the block, network details and
// in Settings or when the user approves a site's request, before // calldata are read from its Blockscout: the active network can
// a later popup shows this screen again. // change, in Settings or when the user approves a site's request,
// before a later popup shows this screen again.
chainId: tx.chainId, chainId: tx.chainId,
}, },
}; };
@@ -189,7 +191,7 @@ function render() {
if (el) el.classList.add("hidden"); if (el) el.classList.add("hidden");
} }
loadFullTxDetails(tx.hash, tx.chainId); loadFullTxDetails(tx);
const isoStr = isoDate(tx.timestamp); const isoStr = isoDate(tx.timestamp);
$("tx-detail-time").innerHTML = $("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 section = $("tx-detail-calldata-section");
const actionEl = $("tx-detail-calldata-action"); const actionEl = $("tx-detail-calldata-action");
const detailsEl = $("tx-detail-calldata-details"); const detailsEl = $("tx-detail-calldata-details");
@@ -311,8 +314,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (!section || !actionEl || !detailsEl) return; if (!section || !actionEl || !detailsEl) return;
try { try {
const { blockscoutUrl } = reviewedNetworkEndpoints(tx);
const resp = await debugFetch( const resp = await debugFetch(
state.blockscoutUrl + "/transactions/" + txHash, blockscoutUrl + "/transactions/" + tx.hash,
); );
if (!resp.ok) return; if (!resp.ok) return;
const txData = await resp.json(); 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 // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token // explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract. // transfer that is the transfer's recipient, not the token contract.
// On the active network, the one whose explorer was just asked, and // On the network the transaction was sent on, whose Blockscout was
// from the sender it reports. // just asked, and from the sender it reports.
const decoded = decodeCalldata( const decoded = decodeCalldata(
inputData, inputData,
txData.to?.hash || "", txData.to?.hash || "",
currentNetwork().chainId, chainId,
txData.from?.hash || "", txData.from?.hash || "",
); );
if (decoded) { if (decoded) {
+1 -1
View File
@@ -58,7 +58,7 @@ function restoringOnto(view, viewData) {
test("the transaction screen's Blockscout request reaches the stub", async () => { test("the transaction screen's Blockscout request reaches the stub", async () => {
const page = await bootPopup( const page = await bootPopup(
restoringOnto("transaction", { restoringOnto("transaction", {
tx: { hash: "0x1", from: ADDRESS, to: ADDRESS }, tx: { hash: "0x1", from: ADDRESS, to: ADDRESS, chainId: "0x1" },
}), }),
); );
+37 -9
View File
@@ -10,7 +10,9 @@
// //
// Its links open the explorer of the network the transaction was sent on, // Its links open the explorer of the network the transaction was sent on,
// whichever network is active // 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, // 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 // opened through transactionDetail.show(), which is what a click on the row
@@ -26,10 +28,10 @@ jest.mock("../src/shared/log", () => ({
warnf: () => {}, warnf: () => {},
errorf: () => {}, errorf: () => {},
}, },
debugFetch: async (url) => ({ debugFetch: async (url) => {
ok: true, mockAsked.push(url);
json: async () => mockExplorerAnswer(url), return { ok: true, json: async () => mockExplorerAnswer(url) };
}), },
setRuntimeDebug: () => {}, setRuntimeDebug: () => {},
isDebug: () => false, isDebug: () => false,
})); }));
@@ -44,6 +46,8 @@ const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail"); const transactionDetail = require("../src/popup/views/transactionDetail");
const EXPLORER = "https://explorer.example/api/v2"; 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 HOLDER = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD"; const RECIPIENT = "0x711e691962E5c054b78071C789A0C8CE657377aD";
// A token the wallet tracks and the bundled token list does not name. // 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)", "function transfer(address to, uint256 amount)",
]).encodeFunctionData("transfer", [RECIPIENT, 1234500000n]); ]).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 // The explorer's answers: the transfer in HOLDER's token transfers, and the
// transaction itself, sent to the token contract. // transaction itself, sent to the token contract.
function mockExplorerAnswer(url) { function mockExplorerAnswer(url) {
@@ -149,6 +156,7 @@ beforeEach(() => {
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.blockscoutUrl = EXPLORER; state.blockscoutUrl = EXPLORER;
state.networkEndpoints = {};
state.wallets = [ state.wallets = [
{ {
name: "Main", 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 // Mainnet is active, and its Blockscout knows nothing of the transaction. The
// explorer answers whichever network asks, so every link on the screen is // row was read from the Blockscout Settings remembers for Sepolia, and the
// drawn. // screen reads the transaction from there too, so every link on it is drawn.
test("with mainnet active, a Sepolia transaction's links open the Sepolia explorer", async () => { 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.networkId = "mainnet";
state.blockscoutUrl = OTHER_EXPLORER;
state.networkEndpoints = { sepolia: { blockscoutUrl: EXPLORER } };
const [row] = await fetchRecentTransactions( const [row] = await fetchRecentTransactions(
HOLDER, HOLDER,
EXPLORER, EXPLORER,
@@ -217,3 +227,21 @@ test("with mainnet active, a Sepolia transaction's links open the Sepolia explor
`${sepolia}/address/${RECIPIENT}`, `${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,
]);
});
+15 -7
View File
@@ -186,14 +186,14 @@ function node(id) {
return globalThis.document.getElementById(id); return globalThis.document.getElementById(id);
} }
// Open the transaction detail screen with Sepolia active, for the // Open the transaction detail screen with `activeNetworkId` active, for the
// transaction the explorer reports sent to `sentTo`, and wait for its // Sepolia transaction the explorer reports sent to `sentTo`, and wait for its
// decoded details. // decoded details.
async function showDetailSentTo(sentTo) { async function showDetailSentTo(sentTo, activeNetworkId) {
mockSentTo = sentTo; mockSentTo = sentTo;
globalThis.document = makeDocument(); globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } }; globalThis.window = { location: { search: "" } };
state.networkId = "sepolia"; state.networkId = activeNetworkId;
state.blockscoutUrl = "https://explorer.example/api/v2"; state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {}; state.viewData = {};
state.viewStack = []; state.viewStack = [];
@@ -216,21 +216,29 @@ async function showDetailSentTo(sentTo) {
describe("execute() calldata on the transaction detail screen", () => { describe("execute() calldata on the transaction detail screen", () => {
test("sent to a router on the active network, it is a swap", async () => { 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( expect(node("tx-detail-calldata-details").innerHTML).toContain(
"Swap via Uniswap Universal Router", "Swap via Uniswap Universal Router",
); );
}); });
test("sent to an ordinary address, it is not decoded", async () => { 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-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);
}); });
test("sent to a router of the other network only, it is not decoded", async () => { 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-action").textContent).toBe("execute");
expect(node("tx-detail-calldata-details").innerHTML).toBe(""); expect(node("tx-detail-calldata-details").innerHTML).toBe("");
expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA); expect(node("tx-detail-rawdata").innerHTML).toContain(SWAP_DATA);