2 Commits
Author SHA1 Message Date
sneak 172f2c8a87 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:25:52 +00:00
clawbot 96446f4661 fix: a network switch in Settings updates the testnet banner at once (closes #511)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
The Settings network handler now redraws the red banner once the switch
is saved, so it appears on a switch to Sepolia and goes on a switch back
to mainnet while Settings is still on screen. Before, the banner changed
only when the next screen came up. The Chrome browser suite switches
both ways on Settings and reads the banner each time.

Model: opus-5-5
2026-10-19 22:53:11 +02:00
6 changed files with 306 additions and 33 deletions
+21 -6
View File
@@ -418,6 +418,10 @@ Each group of these assertions records a coverage key and a final case demands
the exact set, so a section that silently stopped running reddens the suite the exact set, so a section that silently stopped running reddens the suite
instead of quietly shrinking it. instead of quietly shrinking it.
It also covers the red banner on the Settings screen: switching the network
there to Sepolia must put up the `[TESTNET]` banner, and switching back to
mainnet must take it down, both while Settings is still on screen.
It also covers the confirmation screen, for both a native ETH send and an ERC-20 It also covers the confirmation screen, for both a native ETH send and an ERC-20
send: Send disabled while the fee estimate is in flight, enabled once it lands, send: Send disabled while the fee estimate is in flight, enabled once it lands,
the fee block quoting the expected cost and the reserve separately, the distinct the fee block quoting the expected cost and the reserve separately, the distinct
@@ -2360,9 +2364,10 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a - Site origin (bold, scheme and port included) + "wants to send a
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps,
`Paid to` line for each other address it pays, or saying that a token deadline, and unless a swap pays only the From address, a `Paid to` line
stays in the router or who it pays is unknown (see Transaction Decoding) 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 - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -3112,9 +3117,19 @@ Currently supported:
name); and one more reads `Unknown (could not be decoded)` when a step that 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 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 `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 minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when
decode as an address, or when it is a command the decoder does not read: every there is none), each `PERMIT2_TRANSFER_FROM` but one into the router, in step
command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, 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`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20` `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
+2 -1
View File
@@ -128,7 +128,8 @@ function showView(name) {
} }
// Create or update the debug/insecure warning banner. // Create or update the debug/insecure warning banner.
// Called on every view switch and after the settings debug toggle changes. // Called on every view switch, after the settings debug toggle changes, and
// after a network switch in Settings.
// The banner is shown when the compile-time DEBUG constant is true OR when // The banner is shown when the compile-time DEBUG constant is true OR when
// the user has enabled runtime debug mode via the settings easter egg, OR // the user has enabled runtime debug mode via the settings easter egg, OR
// when the active network is a testnet. // when the active network is a testnet.
+3 -1
View File
@@ -343,11 +343,13 @@ function init(ctx) {
// switch request. // switch request.
notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId }); notify({ type: "AUTISTMASK_NETWORK_CHANGED", chainId: net.chainId });
resetRenderedViews(); resetRenderedViews();
// Settings itself is brought up to date for the new network here. // Settings itself, and the testnet banner above it, are brought up
// to date for the new network here.
markViewRendered("settings"); markViewRendered("settings");
$("settings-rpc").value = state.rpcUrl; $("settings-rpc").value = state.rpcUrl;
$("settings-blockscout").value = state.blockscoutUrl; $("settings-blockscout").value = state.blockscoutUrl;
renderTrackedTokens(); renderTrackedTokens();
updateDebugBanner(state.currentView);
showFlash("Switched to " + net.name + "."); showFlash("Switched to " + net.name + ".");
}); });
+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. // Decode PERMIT2_PERMIT (command 0x0a) input bytes.
// ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce), // ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce),
// address spender, uint256 sigDeadline), bytes signature // 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 // router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it. // there: nothing here checks that they spend all of it.
const movedIntoRouter = []; 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 // 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 // or spent in full, by its address, lowercased, ETH as the zero
// address. The router is paid what a swap step, V4 TAKE or // 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 // pays address(1) and address(2) as written (see
// decodePermit2TransferFrom()). // decodePermit2TransferFrom()).
addPaidElsewhere(p.recipient); addPaidElsewhere(p.recipient);
movedFromSender.push({ ...p, to: null });
} else if (isRouter(p.recipient)) { } else if (isRouter(p.recipient)) {
movedIntoRouter.push(p); 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); addPayee(p.recipient);
movedFromSender.push({
...p,
to:
p.recipient.toLowerCase() === MSG_SENDER
? "The sender"
: null,
});
} }
} else if (command === "BALANCE_CHECK_ERC20") { } else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]); 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 // 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 // bundled list has no symbol, and gating the line on one dropped it
// entirely, leaving a Min. received figure with nothing saying what is // entirely, leaving a Min. received figure with nothing saying what is
@@ -1723,24 +1778,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) { if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); 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) { for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources); const info = tokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
@@ -1750,7 +1787,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display + amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)", " 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) { if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
@@ -1764,7 +1803,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
continue; continue;
} }
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); 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) { if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
+59
View File
@@ -1824,6 +1824,65 @@ test("the Settings assertions above all ran (#229)", async (env) => {
); );
}); });
// ------------------------------------------------ testnet banner (#511)
// This suite runs a release build, so the red banner at the top reads
// "[TESTNET]" on Sepolia and is absent on mainnet. A network switch in
// Settings must change it while Settings is still on screen; it used to
// change only when the next screen came up
// (https://git.eeqj.de/sneak/AutistMask/issues/511).
// The banner's text, or null when there is none, once the switch to `net`
// has finished. The switch's flash is the last thing its handler does, and
// showView() clears the flash, so the banner read while that flash is up is
// the one the switch left on the screen it was made from.
async function bannerAfterSwitch(page, net) {
await page.waitForFunction(
(text) => document.getElementById("flash-msg").textContent === text,
"Switched to " + net.name + ".",
);
return page.evaluate(() => {
const banner = document.getElementById("debug-banner");
return {
text: banner === null ? null : banner.textContent,
onSettings: !document
.getElementById("view-settings")
.classList.contains("hidden"),
};
});
}
test("a network switch in Settings shows and removes the testnet banner at once (#511)", async (env) => {
const { mainnet, sepolia } = NETWORKS;
await openSettings(env.page);
assert(
(await env.page.locator("#debug-banner").count()) === 0,
"a banner is up on mainnet before the switch",
);
await env.page.selectOption("#settings-network", "sepolia");
let after = await bannerAfterSwitch(env.page, sepolia);
assert(after.onSettings, "Settings left the screen on the switch");
assert(
after.text === "[TESTNET]",
"after the switch to Sepolia, Settings shows the banner " +
JSON.stringify(after.text) +
', expected "[TESTNET]"',
);
await env.page.selectOption("#settings-network", "mainnet");
after = await bannerAfterSwitch(env.page, mainnet);
assert(after.onSettings, "Settings left the screen on the switch back");
assert(
after.text === null,
"after the switch back to mainnet, Settings still shows the banner " +
JSON.stringify(after.text),
);
await env.page.click("#btn-settings-back");
await visible(env.page, "#view-main");
});
// ------------------------------------------------ dust threshold (#233) // ------------------------------------------------ dust threshold (#233)
// The popup size README documents the UI as designed for. Pages in this // The popup size README documents the UI as designed for. Pages in this
+155 -4
View File
@@ -6,8 +6,10 @@
// release runs for a command type // release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose // (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks // balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap // (https://git.eeqj.de/sneak/AutistMask/issues/600), any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606). // 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, // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -349,6 +351,13 @@ const PAID_ELSEWHERE = [
"Steps", "Steps",
"Deadline", "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`. // A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (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", () => { 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]) { for (const r of [MSG_SENDER, USER]) {
const result = decode(movedThenSwapped(r)); 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"); 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)); const toOther = decode(movedThenSwapped(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE); expect(labels(toOther)).toEqual(withMove(PAID_ELSEWHERE));
expect(paidTo(toOther)).toEqual([OTHER]); expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH"); 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]) { for (const r of [MSG_SENDER, ADDRESS_THIS]) {
const result = decode(movedThenSwapped(r), USER, ROUTER_V1); const result = decode(movedThenSwapped(r), USER, ROUTER_V1);
expect(paidTo(result)).toEqual([r]); expect(paidTo(result)).toEqual([r]);
expect(detail(result, "Moved to")).toEqual({
label: "Moved to",
value: r,
address: r,
});
expect(detail(result, "Min. received")).toBeUndefined(); 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. // A payout of only part of what the router holds leaves the rest there.
describe("a payout of part of the router's output", () => { describe("a payout of part of the router's output", () => {
test.each([ 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 + '"');
});
});