2 Commits
Author SHA1 Message Date
clawbot 3b97571a41 fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The swap's Amount line took only the first swap step's amount. It now
gives the total the steps take of Token In: the amount of the step that
sets the input side plus what each later step the user pays takes of it.
A take of another token, one after an amount that is not a quantity, or
one the decoder cannot tell, gets lines of its own; an unread take leaves
no figure. A V4 step counts every SETTLE paid by the user and every
SETTLE_ALL. A PERMIT2_PERMIT names Token In, and its amount stands only in
the plain shape of Uniswap's own routes, checked in one place after the
loop.

Model: opus-5-5
2026-10-20 01:14:56 +00:00
clawbot 3aed2bb5f0 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.
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
2026-10-20 03:10:30 +02:00
9 changed files with 125 additions and 48 deletions
+11 -7
View File
@@ -1524,9 +1524,11 @@ first screen that cannot be rendered from saved state, such as Send or AddToken,
or at AddressToken for any token but the native one, because that token was
picked on the previous network. The cut stack is saved with the switch, so a
popup closed straight after it and reopened onto Settings lands in the same
place. "Back" then renders the screen it lands on for the new network, except
ConfirmTx, which keeps the network its transaction was reviewed on, and anything
typed there and not saved, which belonged to the previous network, is dropped.
place. "Back" then renders the screen it lands on for the new network, and
anything typed there and not saved, which belonged to the previous network, is
dropped. ConfirmTx, WaitTx, SuccessTx, ErrorTx and TransactionDetail are
rendered instead for the network of the transaction they show: the network it
was reviewed on for ConfirmTx, and the network it was sent on for the others.
Without a wallet the stack is not cut, because it starts at Welcome, which shows
nothing from a network.
@@ -1966,8 +1968,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
@@ -2993,8 +2997,8 @@ Currently supported:
"Swap USDT → ETH"), token addresses, amounts, execution steps, and deadline.
The swap summary is shown only for a transaction sent to one of the Universal
Router addresses Uniswap publishes for the network: on TxApproval the network
the transaction is for, on TransactionDetail the active network. The same
calldata sent to any other address is shown as raw data. Decodes Permit2,
the transaction is for, on TransactionDetail the network it was sent on. The
same calldata sent to any other address is shown as raw data. Decodes Permit2,
V2/V3/V4 swaps, wrap/unwrap, and balance checks. A V4 swap action is read only
in the parameter shape deployed on mainnet and Sepolia, not in the newer one
on `v4-periphery` `main`, which adds a `minHopPriceX36` field. An action in
+3 -3
View File
@@ -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)
);
}
+24 -10
View File
@@ -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 =
`<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) {
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,
+11 -8
View File
@@ -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) {
+8 -1
View File
@@ -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"]);
+15 -2
View File
@@ -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,
+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);