2 Commits
Author SHA1 Message Date
sneak 8f940b6481 fix: each PERMIT2_TRANSFER_FROM shows the amount it moves and where to (closes #611)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The swap decoder now gives three lines for every PERMIT2_TRANSFER_FROM
it reads but a move into the router, after the swap's Amount line: the
amount moved from the sender, the token with its address, and where it
goes (the V2 pair the next swap spends, the sender, or the address
itself on a line of its own). A move into the router keeps its Paid to
and Token moved into the router lines, which already give its amount.
Before, a move to another address or to the next V2 pair showed no
amount at all. Who counts as paid, and so Min. received against Min.
paid out, is unchanged. tokenMovedLine() moves to module level and
takes its label as an argument.

Model: opus-5-5
2026-10-19 21:42:22 +00:00
clawbot e46d894527 fix: WaitTx and TransactionDetail use the network the transaction was sent on (closes #584)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
WaitTx asked the active network's node for the receipt, so after a network
switch a transaction sent on the other network was never found and was
reported as timed out. It now asks the node of the network its chain id
names, through reviewedNetworkProvider(), moved from ConfirmTx into the
shared view helpers so both screens use one function. WaitTx and
TransactionDetail build their explorer links for that network too. A stored
wait or transaction whose chain id names no network is refused on restore,
as a stored ConfirmTx transaction already is.

Model: opus-5-5
2026-10-19 23:38:10 +02:00
13 changed files with 458 additions and 97 deletions
+30 -15
View File
@@ -1891,14 +1891,17 @@ view would leave a wallet one click from deletion.
no recipient." instead
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
persisted: closing and reopening the popup resumes the poll, with the elapsed
counter and the timeout deadline still measured from the original broadcast. A
lookup that fails is retried on the next tick rather than counted as a missing
receipt, because a failed lookup says nothing about the transaction; but six
failures in a row (60 seconds at the poll cadence) end the wait, so an RPC
that never answers cannot leave it running indefinitely. Any lookup that
answers resets that count.
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The screen
describes the transaction on the network it was sent on, whichever network is
active: the receipt is asked of that network's RPC endpoint, the one Settings
has for it, and the To and hash links open that network's explorer. The wait
is persisted: closing and reopening the popup resumes the poll, with the
elapsed counter and the timeout deadline still measured from the original
broadcast. A lookup that fails is retried on the next tick rather than counted
as a missing receipt, because a failed lookup says nothing about the
transaction; but six failures in a row (60 seconds at the poll cadence) end
the wait, so an RPC that never answers cannot leave it running indefinitely.
Any lookup that answers resets that count.
- **Transitions**:
- Receipt found → **SuccessTx**
- A lookup that answers "no receipt" 60 seconds or more after broadcast →
@@ -1963,7 +1966,8 @@ view would leave a wallet one click from deletion.
#### TransactionDetail (`transaction`)
- **When**: User tapped a transaction row on Home, AddressDetail, or
AddressToken.
AddressToken. Its etherscan links open the explorer of the network the
transaction was sent on, whichever network is active.
- **Elements** (grouped into logical blocks using light well containers; field
labels are self-explanatory so groups have no headings):
- "Transaction" heading, "Back" button
@@ -2364,9 +2368,10 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that a token
stays in the router or who it pays is unknown (see Transaction Decoding)
amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps,
deadline, and unless a swap pays only the From address, a `Paid to` line
for each other address it pays, or saying that a token stays in the router
or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
@@ -3116,9 +3121,19 @@ Currently supported:
name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. A step goes unread when its recipient does not
decode as an address, or when it is a command the decoder does not read: every
command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when
there is none), each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and
`Token moved into the router` line already give the amount, the token and
where it goes, and so its amount is shown once. These lines change neither the
`Paid to` lines nor the minimum's label. A step goes unread when its recipient
does not decode as an address, or when it is a command the decoder does not
read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
+4 -1
View File
@@ -145,12 +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.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress))
(!value.contractAddress || isAddressText(value.contractAddress)) &&
(!value.chainId || SUPPORTED_CHAIN_IDS.has(value.chainId))
);
}
+2 -18
View File
@@ -23,12 +23,10 @@ const {
goBack,
onViewLeave,
formatFee,
reviewedNetworkProvider,
} = require("./helpers");
const { state, currentNetwork } = require("../../shared/state");
const {
networkByChainId,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getSignerForAddress } = require("../../shared/wallet");
const { walletDefect } = require("../../shared/walletDefects");
const { decryptWithPassword } = require("../../shared/vault");
@@ -458,20 +456,6 @@ function setVisible(id, visible) {
$(id).style.visibility = visible ? "visible" : "hidden";
}
// The node of the network this transaction was reviewed for, which need not be
// the active one: the popup can reopen onto this screen 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);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
async function estimateGas(txInfo) {
try {
const provider = reviewedNetworkProvider(txInfo);
+16
View File
@@ -19,6 +19,7 @@ const {
isBelowOneMillionth,
} = require("../../shared/amountDisplay");
const { resolveTokenSymbol } = require("../../shared/approvalAmount");
const { getProvider } = require("../../shared/balances");
const { DEBUG } = require("../../shared/constants");
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log");
@@ -607,6 +608,20 @@ 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);
if (net.id === state.networkId) {
return getProvider(state.rpcUrl, net.id);
}
const remembered = state.networkEndpoints[net.id] || {};
return getProvider(remembered.rpcUrl || net.defaultRpcUrl, net.id);
}
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js, the explorer of the network with `chainId`, or of
// the active network when no chain id is given; only the path segment is
@@ -762,6 +777,7 @@ module.exports = {
attachCopyHandlers,
etherscanLinkHtml,
explorerUrl,
reviewedNetworkProvider,
EXT_ICON,
truncateMiddle,
isoDate,
+24 -14
View File
@@ -48,16 +48,16 @@ function getTransactionType(tx) {
return "Native " + nativeCurrencyByChainId(tx.chainId) + " Transfer";
}
function txAddressHtml(address, ensName, title) {
function txAddressHtml(address, ensName, title, chainId) {
const blockie = blockieHtml(address);
return (
`<div class="mb-1">${blockie}</div>` +
renderAddressHtml(address, { title, ensName })
renderAddressHtml(address, { title, ensName, chainId })
);
}
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink;
}
@@ -83,10 +83,10 @@ function show(tx) {
method: tx.method || null,
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:
// 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 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.
chainId: tx.chainId,
},
};
@@ -96,18 +96,24 @@ function show(tx) {
function render() {
const tx = state.viewData.tx;
if (!tx) return;
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash, tx.chainId);
const fromTitle = addressTitle(tx.from, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
tx.chainId,
);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
$("tx-detail-to").innerHTML = txAddressHtml(
tx.to,
tx.toEns,
toTitle,
tx.chainId,
);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
@@ -146,7 +152,7 @@ function render() {
if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress, tx.chainId);
// Hand-rolled rather than renderAddressHtml() because the
// link goes to the explorer's /token/ page, not /address/.
// Same two-row shape though: dot and link on the strip, the
@@ -204,7 +210,11 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData, chainId) {
// Block number
if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number));
const blockLink = explorerUrl(
"block",
String(txData.block_number),
chainId,
);
const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block");
if (blockSection && blockEl) {
@@ -340,9 +350,9 @@ async function loadFullTxDetails(txHash, chainId) {
if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`;
}
detailsHtml += renderAddressHtml(d.address);
detailsHtml += renderAddressHtml(d.address, { chainId });
} else if (d.address) {
detailsHtml += renderAddressHtml(d.address);
detailsHtml += renderAddressHtml(d.address, { chainId });
} else {
detailsHtml += `<div class="font-bold">${escapeHtml(d.value)}</div>`;
}
+25 -17
View File
@@ -14,10 +14,13 @@ const {
displaySymbol,
clearViewStack,
tokenLabel,
reviewedNetworkProvider,
} = require("./helpers");
const { state } = require("../../shared/state");
const { nativeCurrencyByChainId } = require("../../shared/networks");
const { getProvider } = require("../../shared/balances");
const {
SUPPORTED_CHAIN_IDS,
nativeCurrencyByChainId,
} = require("../../shared/networks");
const { log } = require("../../shared/log");
// Receipt poll cadence and the deadline after which the wait is reported as
@@ -61,15 +64,16 @@ function endWait() {
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
// writes `to: toAddr || ""`). The link opens the explorer of the network with
// `chainId`, or of the active network when none is given.
function toAddressHtml(address, chainId) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
return renderAddressHtml(address, { title, chainId });
}
function txHashHtml(hash) {
const link = explorerUrl("tx", hash);
function txHashHtml(hash, chainId) {
const link = explorerUrl("tx", hash, chainId);
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
}
@@ -91,9 +95,11 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// native currency of txInfo.chainId, the network the transaction was sent
// on, not the active network's: the active network can change, in
// Settings or when the user approves a site's request, while this screen
// is open or before a later popup resumes it. A null token is a swap whose
// amount was not decoded (see showTxApproval() in approval.js): the amount
// says so, and no symbol follows it.
// is open or before a later popup resumes it. This screen's links open
// that network's explorer, and its receipt is asked of that network's
// node, for the same reason. A null token is a swap whose amount was not
// decoded (see showTxApproval() in approval.js): the amount says so, and
// no symbol follows it.
let symbol = "";
if (txInfo.token === "ETH") {
symbol = nativeCurrencyByChainId(txInfo.chainId);
@@ -102,8 +108,8 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
}
$("wait-tx-summary").textContent =
txInfo.amount + (symbol ? " " + symbol : "");
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash);
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to, txInfo.chainId);
$("wait-tx-hash").innerHTML = txHashHtml(txHash, txInfo.chainId);
attachCopyHandlers("view-wait-tx");
// Persisted so closing and reopening the popup resumes this wait
@@ -128,7 +134,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
renderElapsed();
}, 1000);
const provider = getProvider(state.rpcUrl, state.networkId);
const provider = reviewedNetworkProvider(txInfo);
let consecutiveFailures = 0;
async function poll() {
@@ -207,10 +213,11 @@ function showWait(txInfo, txHash) {
// an object merely missing one of them throws a TypeError out of
// restoreView() — which init() does not guard, skipping the rest of popup
// init and leaving wait-tx on screen with no back control. A non-numeric
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.token,
// txInfo.tokenSymbol and txInfo.chainId are deliberately unchecked: they are
// compared and coalesced rather than dereferenced, and tokenSymbol is null for
// ETH.
// broadcastTime leaves an unexitable wait counting "NaNs". txInfo.chainId picks
// the node the receipt is asked of and the explorer the links open, so it has
// to name a network. txInfo.token and txInfo.tokenSymbol are deliberately
// unchecked: they are compared and coalesced rather than dereferenced, and
// tokenSymbol is null for ETH.
function restoreWait() {
const d = state.viewData;
if (!d || !d.pendingWait) return false;
@@ -226,6 +233,7 @@ function restoreWait() {
// path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (!SUPPORTED_CHAIN_IDS.has(info.chainId)) return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
return false;
}
+66 -21
View File
@@ -488,6 +488,21 @@ function amountText(raw, info) {
};
}
// The line giving a moved token under `label`, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no address.
function tokenMovedLine(label, token, sources) {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return { label, value: info.symbol };
}
return {
label,
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
}
// Decode PERMIT2_PERMIT (command 0x0a) input bytes.
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
// address spender, uint256 sigDeadline), bytes signature
@@ -1171,6 +1186,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it.
const movedIntoRouter = [];
// What each PERMIT2_TRANSFER_FROM not into the router moves from the
// sender, in step order, whoever counts as paid, as
// { token, recipient, amount, to }: `to` says where it goes, the
// sender or the V2 pair the next swap spends, and is null when it
// goes to `recipient` as written. A move into the router is in
// `movedIntoRouter`, whose lines give its amount already.
const movedFromSender = [];
// Each token a step paid to the router that no later step paid out
// or spent in full, by its address, lowercased, ETH as the zero
// address. The router is paid what a swap step, V4 TAKE or
@@ -1302,10 +1324,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// pays address(1) and address(2) as written (see
// decodePermit2TransferFrom()).
addPaidElsewhere(p.recipient);
movedFromSender.push({ ...p, to: null });
} else if (isRouter(p.recipient)) {
movedIntoRouter.push(p);
} else if (!isHop(i, p.token, p.recipient)) {
} else if (isHop(i, p.token, p.recipient)) {
movedFromSender.push({
...p,
to: "The V2 pair the next swap spends",
});
} else {
addPayee(p.recipient);
movedFromSender.push({
...p,
to:
p.recipient.toLowerCase() === MSG_SENDER
? "The sender"
: null,
});
}
} else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]);
@@ -1675,6 +1710,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
details.push({
label: "Moved from the sender",
value: amountText(m.amount, tokenInfo(m.token, sources))
.display,
});
details.push(tokenMovedLine("Token moved", m.token, sources));
details.push(
present(m.to)
? { label: "Moved to", value: m.to }
: {
label: "Moved to",
value: m.recipient,
address: m.recipient,
},
);
}
// Keyed on the address, not the symbol: a token absent from the
// bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is
@@ -1723,24 +1778,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
// The line giving a token in the router, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no
// address.
const tokenMovedLine = (token) => {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return {
label: "Token moved into the router",
value: info.symbol,
};
}
return {
label: "Token moved into the router",
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
};
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
paidTo.push({
@@ -1750,7 +1787,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push(tokenMovedLine(m.token));
paidTo.push(
tokenMovedLine("Token moved into the router", m.token, sources),
);
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -1764,7 +1803,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
continue;
}
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push(tokenMovedLine(getAddress(token)));
paidTo.push(
tokenMovedLine(
"Token moved into the router",
getAddress(token),
sources,
),
);
}
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
+1
View File
@@ -192,6 +192,7 @@ describe("the wait, success and error screens", () => {
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
chainId: "0x1",
};
const transfer = { ...creation, to: RECIPIENT };
+14 -1
View File
@@ -459,6 +459,19 @@ const CONTRACT = [
views: ["transaction"],
restored: false,
},
// A chain id naming no network has no explorer to link to.
{
value: {
tx: {
hash: "0x1",
from: ADDRESS,
to: ADDRESS,
chainId: "0x5",
},
},
views: ["transaction"],
restored: false,
},
// confirm-tx passes on `data.pendingTx`.
{
value: { pendingTx: { amount: "1" } },
@@ -821,7 +834,7 @@ const WELL_FORMED_DATA = {
},
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1" },
txInfo: { to: ADDRESS, amount: "1", chainId: "0x1" },
broadcastTime: BROADCAST_TIME,
},
};
+6 -1
View File
@@ -225,7 +225,12 @@ describe("a popup already open when the stored profile becomes unreadable", () =
viewData: {
pendingWait: {
hash: "0x1",
txInfo: { to: ADDRESS, amount: "1", token: "ETH" },
txInfo: {
to: ADDRESS,
amount: "1",
token: "ETH",
chainId: "0x1",
},
broadcastTime: Date.now(),
},
},
+45 -2
View File
@@ -8,6 +8,10 @@
// tracks. It decodes against the address the explorer reports the transaction
// was sent to.
//
// 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).
//
// 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
// calls. Driven against a minimal DOM stub in the shape
@@ -35,6 +39,7 @@ globalThis.chrome = {
};
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { fetchRecentTransactions } = require("../src/shared/transactions");
const transactionDetail = require("../src/popup/views/transactionDetail");
@@ -77,7 +82,11 @@ function mockExplorerAnswer(url) {
};
}
if (url === EXPLORER + "/transactions/" + TX_HASH) {
return { to: { hash: TOKEN }, raw_input: TRANSFER_DATA };
return {
to: { hash: TOKEN },
raw_input: TRANSFER_DATA,
block_number: 100,
};
}
return { items: [] };
}
@@ -154,7 +163,11 @@ beforeEach(() => {
});
test("a token transfer's decoded details name the token contract and the amount at its scale", async () => {
const [row] = await fetchRecentTransactions(HOLDER, EXPLORER, 1);
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.mainnet.chainId,
);
expect(row.to).toBe(RECIPIENT);
expect(row.contractAddress).toBe(TOKEN.toLowerCase());
@@ -174,3 +187,33 @@ test("a token transfer's decoded details name the token contract and the amount
'<div class="font-bold">1234.5000 TDOL</div>',
);
});
// 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 () => {
state.networkId = "mainnet";
const [row] = await fetchRecentTransactions(
HOLDER,
EXPLORER,
NETWORKS.sepolia.chainId,
);
transactionDetail.show(row);
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
const sepolia = "https://sepolia.etherscan.io";
const links = (id) =>
[...node(id).innerHTML.matchAll(/href="([^"]*)"/g)].map((m) => m[1]);
expect(links("tx-detail-hash")).toEqual([`${sepolia}/tx/${TX_HASH}`]);
expect(links("tx-detail-from")).toEqual([`${sepolia}/address/${HOLDER}`]);
expect(links("tx-detail-to")).toEqual([`${sepolia}/address/${RECIPIENT}`]);
expect(links("tx-detail-token-contract")).toEqual([
`${sepolia}/token/${TOKEN.toLowerCase()}`,
]);
expect(links("tx-detail-block")).toEqual([`${sepolia}/block/100`]);
expect(links("tx-detail-calldata-details")).toEqual([
`${sepolia}/address/${TOKEN}`,
`${sepolia}/address/${RECIPIENT}`,
]);
});
+70 -3
View File
@@ -29,8 +29,13 @@ jest.mock("../src/shared/log", () => ({
}));
const mockReceiptLookup = jest.fn();
// The RPC URL and network of every node a wait was given.
const mockNodes = [];
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: mockReceiptLookup }),
getProvider: (rpcUrl, networkId) => {
mockNodes.push({ rpcUrl, networkId });
return { getTransactionReceipt: mockReceiptLookup };
},
refreshBalances: jest.fn(),
}));
@@ -107,6 +112,7 @@ global.chrome = { storage };
const txStatus = require("../src/popup/views/txStatus");
const { state } = require("../src/shared/state");
const { NETWORKS } = require("../src/shared/networks");
const { RESTORABLE_VIEWS } = require("../src/shared/restorableViews");
const { log } = require("../src/shared/log");
@@ -118,6 +124,7 @@ const TX_INFO = {
amount: "0.0050",
token: "ETH",
tokenSymbol: null,
chainId: NETWORKS.mainnet.chainId,
};
// True when a view element is not hidden.
@@ -137,7 +144,11 @@ beforeEach(() => {
jest.setSystemTime(new Date("2026-08-11T12:00:00Z"));
elements.clear();
mockReceiptLookup.mockReset();
mockNodes.length = 0;
log.errorf.mockClear();
state.networkId = "mainnet";
state.rpcUrl = NETWORKS.mainnet.defaultRpcUrl;
state.networkEndpoints = {};
state.wallets = [];
state.viewData = {};
state.viewStack = [];
@@ -349,14 +360,14 @@ describe("WaitTx persistence across popup close", () => {
// out of restoreView().
{
hash: TX_HASH,
txInfo: { to: 42, amount: "0.0050" },
txInfo: { ...TX_INFO, to: 42 },
broadcastTime: Date.now(),
},
// Otherwise complete but an array: only Array.isArray() rejects
// it, since typeof [] is "object" and the fields are present.
{
hash: TX_HASH,
txInfo: Object.assign([], { to: RECIPIENT, amount: "0.0050" }),
txInfo: Object.assign([], TX_INFO),
broadcastTime: Date.now(),
},
{
@@ -364,6 +375,18 @@ describe("WaitTx persistence across popup close", () => {
txInfo: { to: RECIPIENT },
broadcastTime: Date.now(),
},
// Otherwise complete but for a chain id naming no network: there
// is no node to ask for the receipt, and no explorer to link to.
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: undefined },
broadcastTime: Date.now(),
},
{
hash: TX_HASH,
txInfo: { ...TX_INFO, chainId: "0x5" },
broadcastTime: Date.now(),
},
]) {
state.viewData = { pendingWait: bad };
expect(txStatus.restoreWait()).toBe(false);
@@ -499,6 +522,50 @@ describe("WaitTx when the popup closes", () => {
});
});
// The popup can reopen onto WaitTx after Settings switched the network. The
// wait asked the active network's node for the receipt, never found it, and
// reported a timeout, and its links went to the active network's explorer
// (https://git.eeqj.de/sneak/AutistMask/issues/584).
describe("WaitTx for a Sepolia transaction with mainnet active", () => {
const SEPOLIA_NODE = "https://sepolia.node.example.invalid";
const sepoliaTx = { ...TX_INFO, chainId: NETWORKS.sepolia.chainId };
beforeEach(() => {
state.rpcUrl = "https://mainnet.node.example.invalid";
state.networkEndpoints = { sepolia: { rpcUrl: SEPOLIA_NODE } };
});
test("the receipt is asked of the Sepolia node Settings has", async () => {
mockReceiptLookup.mockResolvedValue({ blockNumber: 21000000 });
state.viewData = {
pendingWait: {
txInfo: sepoliaTx,
hash: TX_HASH,
broadcastTime: Date.now(),
},
};
expect(txStatus.restoreWait()).toBe(true);
await jest.advanceTimersByTimeAsync(0);
expect(mockNodes).toEqual([
{ rpcUrl: SEPOLIA_NODE, networkId: "sepolia" },
]);
expect(state.currentView).toBe("success-tx");
});
test("the recipient and hash link to the Sepolia explorer", () => {
mockReceiptLookup.mockResolvedValue(null);
txStatus.showWait(sepoliaTx, TX_HASH);
expect(getElement("wait-tx-to").innerHTML).toContain(
`href="https://sepolia.etherscan.io/address/${RECIPIENT}"`,
);
expect(getElement("wait-tx-hash").innerHTML).toContain(
`href="https://sepolia.etherscan.io/tx/${TX_HASH}"`,
);
});
});
describe("wait-tx is a view the popup may reopen onto", () => {
// The resume feature is wired through RESTORABLE_VIEWS: restoreView()
// refuses any view not in the set, so dropping "wait-tx" from it kills
+155 -4
View File
@@ -6,8 +6,10 @@
// release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606).
// (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606), and
// what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -349,6 +351,13 @@ const PAID_ELSEWHERE = [
"Steps",
"Deadline",
];
// `lines` with the lines of one PERMIT2_TRANSFER_FROM after Amount.
const withMove = (lines) =>
lines.flatMap((l) =>
l === "Amount"
? [l, "Moved from the sender", "Token moved", "Moved to"]
: [l],
);
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (recipient) =>
@@ -1154,12 +1163,20 @@ describe("each transfer command's recipient", () => {
test("PERMIT2_TRANSFER_FROM: to the user it pays no one else, to another address that address is named", () => {
for (const r of [MSG_SENDER, USER]) {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual(PAID_TO_USER);
expect(labels(result)).toEqual(withMove(PAID_TO_USER));
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
}
// A move to the From address as written shows that address, not The
// sender.
expect(detail(decode(movedThenSwapped(USER)), "Moved to")).toEqual({
label: "Moved to",
value: USER,
address: USER,
});
const toOther = decode(movedThenSwapped(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(labels(toOther)).toEqual(withMove(PAID_ELSEWHERE));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
@@ -1249,6 +1266,11 @@ describe("each transfer command's recipient", () => {
for (const r of [MSG_SENDER, ADDRESS_THIS]) {
const result = decode(movedThenSwapped(r), USER, ROUTER_V1);
expect(paidTo(result)).toEqual([r]);
expect(detail(result, "Moved to")).toEqual({
label: "Moved to",
value: r,
address: r,
});
expect(detail(result, "Min. received")).toBeUndefined();
}
@@ -1294,6 +1316,112 @@ describe("each transfer command's recipient", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/611: after the swap's own
// Amount, each PERMIT2_TRANSFER_FROM but one into the router gives the amount
// of the sender's tokens it moves, the token, and where it goes, whoever
// counts as paid.
describe("what each PERMIT2_TRANSFER_FROM moves", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const USDT_1 = 1000000n;
const USDT_1000000 = 1000000000000n;
// The lines between Amount and Token Out.
function afterAmount(result) {
const all = labels(result);
return result.details.slice(
all.indexOf("Amount") + 1,
all.indexOf("Token Out"),
);
}
// The lines of a move of `amount` of `token`, then `to`.
const moved = (amount, token, symbol, to) => [
{ label: "Moved from the sender", value: amount },
{
label: "Token moved",
value: symbol + " (" + token + ")",
address: token,
isToken: true,
},
to,
];
const movedTo = (value) => ({ label: "Moved to", value });
const movedToAddress = (address) => ({
label: "Moved to",
value: address,
address,
});
test("1,000,000 USDT moved to another address, then a swap of 1 USDT", () => {
const result = decode(
execute(
permit2TransferFrom(USDT, OTHER, USDT_1000000),
v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(afterAmount(result)).toEqual(
moved("1000000.0000 USDT", USDT, "USDT", movedToAddress(OTHER)),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("1 WETH moved to the pair the next V2 swap spends", () => {
const result = decode(
execute(
permit2TransferFrom(WETH, PAIR, ONE_WETH),
v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false),
),
);
expect(detail(result, "Amount").value).toBe(
"Whatever an earlier step sent to the pair (V2 already paid)",
);
expect(afterAmount(result)).toEqual(
moved(
"1.0000 WETH",
WETH,
"WETH",
movedTo("The V2 pair the next swap spends"),
),
);
expect(paidTo(result)).toEqual([]);
expect(detail(result, "Min. received").value).toBe("1990.0000 USDC");
});
// Its Paid to and Token moved into the router lines already say what a
// move into the router moves, so it gets no lines after Amount.
test("2,000 USDT moved into the router, then a swap of 1 USDT from the router's balance, shows the 2,000 USDT once", () => {
const result = decode(
execute(
permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000),
v3SwapExactIn(MSG_SENDER, USDT_1, ONE_WETH, USDT, WETH, false),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(afterAmount(result)).toEqual([]);
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(
result.details.filter((d) => d.value.includes("2000.0000 USDT")),
).toHaveLength(1);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a move to address(1) goes to the sender, and each move is shown in step order", () => {
const result = decode(
execute(
permit2TransferFrom(USDT, MSG_SENDER, USDT_2000),
permit2TransferFrom(USDC, OTHER, USDC_1990),
swapToWeth(MSG_SENDER),
),
);
expect(afterAmount(result)).toEqual([
...moved("2000.0000 USDT", USDT, "USDT", movedTo("The sender")),
...moved("1990.0000 USDC", USDC, "USDC", movedToAddress(OTHER)),
]);
});
});
// A payout of only part of what the router holds leaves the rest there.
describe("a payout of part of the router's output", () => {
test.each([
@@ -2787,3 +2915,26 @@ describe("the screens show each token moved into the router as an address", () =
}
});
});
// The issue's example: 1,000,000 USDT moved to another address, then a swap
// of 1 USDT. Both screens show the amount moved and where it goes.
describe("the screens show what a PERMIT2_TRANSFER_FROM moves", () => {
const millionToOther = execute(
permit2TransferFrom(USDT, OTHER, 1000000000000n),
v3SwapExactIn(MSG_SENDER, 1000000n, ONE_WETH, USDT, WETH),
);
test.each([
{ screen: "approval", show: showApproval },
{ screen: "transaction detail", show: showDetail },
])("the $screen screen", async ({ show }) => {
const html = await show(millionToOther);
expect(html).toContain("1000000.0000 USDT");
// Each line is drawn in a div of its own; the Paid to line names
// OTHER too, so the Moved to line's own div is checked.
const movedToLine = html
.split('<div class="mb-2">')
.find((line) => line.includes(">Moved to<"));
expect(movedToLine).toContain('data-copy="' + OTHER + '"');
});
});