fix: TransactionDetail reads a transaction from the Blockscout of the network it was sent on (closes #623)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

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
This commit is contained in:
2026-10-19 22:58:47 +00:00
parent 5376723c41
commit f8101b559e
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`)
- **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
+19 -8
View File
@@ -608,18 +608,28 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`;
// 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,
+14 -10
View File
@@ -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) {
+1 -1
View File
@@ -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" },
}),
);
+37 -9
View File
@@ -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,
]);
});
+15 -7
View File
@@ -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);