Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 057d49d5b2 fix: an open popup moves to the recovery screen when its profile becomes unreadable (closes #373)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 3s
A popup already open when the stored profile became unreadable stayed on the
last good profile until reopened. Every save already runs the check loadState()
runs at open; a save refused by it now stops the ten-second refresh, runs the
leave cleanup of the current screen, and raises the recovery screen. From then
on showView() shows nothing else in that popup, so a transaction wait or a later
save cannot take the user off it or clear an export or a typed confirmation.
That is held in memory, never as the saved current view, so a popup opened
after the record is erased elsewhere opens normally. Any other failed save
keeps the "NOT SAVED" banner. The popup test harness now honours
clearInterval().

Model: opus-5-5
2026-10-04 21:08:37 +00:00
4 changed files with 18 additions and 211 deletions
+3 -23
View File
@@ -887,9 +887,7 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
shows is a V4 exact-in `amountIn` of zero and shows is a V4 exact-in `amountIn` of zero.
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
exact-in `amountIn` of zero.
The rule and its exception live in `src/shared/amountDisplay.js` as The rule and its exception live in `src/shared/amountDisplay.js` as
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and `truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
@@ -982,8 +980,7 @@ read:
exact-out step, whichever step set the line, including the `WRAP_ETH` of a exact-out step, whichever step set the line, including the `WRAP_ETH` of a
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure, swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
not necessarily all of it; the wait, success and error screens show it with not necessarily all of it; the wait, success and error screens show it with
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited` and `All available (V4 open delta)` keep their
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 exact-out step sets `Min. received`, that line shows its wording. When a V2 exact-out step sets `Min. received`, that line shows its
`amountOut`, the exact amount it buys. `amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
@@ -994,22 +991,11 @@ read:
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
`amountInMax`, and a V4 swap step the `amountIn` of its first readable `amountInMax`, and a V4 swap step the `amountIn` of its first readable
exact-in action. exact-in action.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
step pays nothing itself and swaps whatever an earlier step sent to the pair,
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
such meaning and is shown as a zero.
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the - `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
`BALANCE_CHECK_ERC20` step's `minBalance`. Before `BALANCE_CHECK_ERC20` step's `minBalance`. Before
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance` [#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
read `0.0000` when the token's scale was known. The router passes a balance read `0.0000` when the token's scale was known.
check whenever the balance is at least `minBalance`, so a zero `minBalance`
guarantees nothing: it sets `Token Out` and `Min. received` only when the
output side holds no minimum, not even a zero one, at the point the check is
reached, and otherwise leaves the current token and figure in place. A nonzero
`minBalance` sets both lines, as a swap step does.
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
reads `Unknown (not named in the calldata)` when the decoder found no token for reads `Unknown (not named in the calldata)` when the decoder found no token for
@@ -1022,12 +1008,6 @@ unwraps the WETH it did not spend shows USDC. The token permission warning on
the signature screen has its own amount wording, including `Unknown`; the the signature screen has its own amount wording, including `Unknown`; the
SignApproval section below describes it. SignApproval section below describes it.
The swap's `Deadline` line is the router's deadline as a UTC date and time, e.g.
`2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
UTC, so a later deadline, such as the `uint256` maximum, reads
`After 275760-09-13 00:00:00 (no deadline in practice)` rather than leaving the
whole swap undecoded.
#### Partial USD totals #### Partial USD totals
Prices are fetched for the top 25 tokens only, so an address can hold assets the Prices are fetched for the top 25 tokens only, so an address can hold assets the
-16
View File
@@ -59,22 +59,6 @@ but the review is broader than any of them.
the current view, so a popup opened after the record is erased in another the current view, so a popup opened after the record is erased in another
window opens normally. Any other failed save still gets the banner and leaves window opens normally. Any other failed save still gets the banner and leaves
the screen alone. the screen alone.
- 2026-10-04: A swap whose deadline is later than a JavaScript date can hold is
decoded ([#437](https://git.eeqj.de/sneak/AutistMask/issues/437)). A date
reaches only to 275760-09-13, so a later deadline, such as the `uint256`
maximum, made the `Deadline` line throw, and the approval screen showed the
swap as an undecoded contract call with nothing saying why. That line now
reads `After 275760-09-13 00:00:00 (no deadline in practice)`.
- 2026-10-04: The swap decoder reads two router zeros the way the router does
([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in
`amountIn` of zero means an earlier step already sent the tokens to the pair;
`Amount` showed `0.0000` for it and now reads
`Whatever an earlier step sent to the pair (V2 already paid)`. A
`BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it
replaced the minimum an earlier swap step stated, so `Min. received` read
`None (no minimum guaranteed)`; it now sets the output side only when that
side holds no minimum at the point the check is reached. A nonzero
`minBalance` still sets the output side.
- 2026-10-04: The signature screen shows a personal message as the bytes that - 2026-10-04: The signature screen shows a personal message as the bytes that
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
showed only the decoded text, with bidirectional and zero-width characters showed only the decoded text, with bidirectional and zero-width characters
+15 -47
View File
@@ -84,31 +84,17 @@ function present(value) {
// //
// `amountOutMinimum` gets no such mapping: V4Router compares it directly // `amountOutMinimum` gets no such mapping: V4Router compares it directly
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so // (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
// a zero minimum is a literal zero slippage floor and is stated as one. Nor // a zero minimum is a literal zero slippage floor and is stated as one. Nor do
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput` // the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero — // special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2 // so a zero `amountIn` there is a literal zero and is displayed as one.
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
const OPEN_DELTA = Symbol("v4-open-delta"); const OPEN_DELTA = Symbol("v4-open-delta");
// The Universal Router's V2 exact-in spells "the pair already holds the input // The two amount lines that state a fact instead of a quantity. Same register
// tokens" as an amount of zero: universal-router // as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
// `contracts/libraries/Constants.sol` declares // as a number — and deliberately not a third phrasing of "not named": these
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases // say different things.
// when a v2 pair has already received input tokens"), and
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
// equals it. The swap then spends whatever an earlier step sent to the pair.
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say
// different things.
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const NO_MINIMUM = "None (no minimum guaranteed)"; const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -199,7 +185,6 @@ function decodeBalanceCheck(input) {
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes. // Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
function decodeV2SwapExactIn(input) { function decodeV2SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -207,7 +192,7 @@ function decodeV2SwapExactIn(input) {
input, input,
); );
return { return {
amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountIn: d[1],
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
@@ -517,13 +502,7 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x0e) { if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at if (b) setOutput(b.token, b.minBalance);
// least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
}
} }
if (cmdId === 0x00) { if (cmdId === 0x00) {
@@ -644,20 +623,14 @@ function decode(data, toAddress, sources) {
} }
if (present(inputAmount)) { if (present(inputAmount)) {
// Three amounts need no scale to describe and are named rather // Two amounts need no scale to describe and are named rather than
// than formatted: V4's open delta and V2's already-paid zero, // formatted: V4's open delta, which is not a quantity at all (see
// neither of which is a quantity at all (see OPEN_DELTA and // OPEN_DELTA), and an unbounded permit. The open-delta test comes
// ALREADY_PAID), and an unbounded permit. Those two tests come // first — the sentinel is not a bigint and cannot be compared with
// first — the sentinels are not bigints and cannot be compared // one.
// with one.
let amount; let amount;
if (inputAmount === OPEN_DELTA) { if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount === ALREADY_PAID) {
amount = {
raw: ALREADY_PAID_AMOUNT,
display: ALREADY_PAID_AMOUNT,
};
} else if (inputAmount >= MAX_UINT160) { } else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" }; amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) { } else if (hasV2ExactOut) {
@@ -724,15 +697,10 @@ function decode(data, toAddress, sources) {
details.push({ label: "Steps", value: commandNames.join(" \u2192 ") }); details.push({ label: "Steps", value: commandNames.join(" \u2192 ") });
// A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A
// later deadline, such as the uint256 maximum, makes an invalid date,
// and toISOString() throws on one, so that deadline is said in words.
const deadlineDate = new Date(Number(deadline) * 1000); const deadlineDate = new Date(Number(deadline) * 1000);
details.push({ details.push({
label: "Deadline", label: "Deadline",
value: isNaN(deadlineDate.getTime()) value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
? "After 275760-09-13 00:00:00 (no deadline in practice)"
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
}); });
return { return {
-125
View File
@@ -554,33 +554,6 @@ describe("uniswap decoder", () => {
); );
}); });
test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR);
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
});
// A JavaScript date cannot hold this deadline. It used to make the whole
// swap undecoded.
test("a deadline of the uint256 maximum is stated in words", () => {
const data = buildExecute(
"0x08", // V2_SWAP_EXACT_IN
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
],
2n ** 256n - 1n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH");
expect(detail(result, "Deadline").value).toBe(
"After 275760-09-13 00:00:00 (no deadline in practice)",
);
});
test("formats permit amount when not unlimited", () => { test("formats permit amount when not unlimited", () => {
const data = buildExecute( const data = buildExecute(
"0x0a", "0x0a",
@@ -858,104 +831,6 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.9900 USDC"); expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
// exact-in reads an amountIn of zero as universal-router
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
// the swap uses all of them. Against 375998b this read "0.0000 USDT".
test("a V2 exact-in already-paid amountIn is named, not printed as zero", () => {
const data = buildExecute(
"0x08",
[
encodeV2SwapExactIn(
USER_ADDR,
0n, // Constants.ALREADY_PAID
500000000000000n,
[USDT_ADDR, WETH_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("USDT");
expect(detail(result, "Amount").value).toBe(
"Whatever an earlier step sent to the pair (V2 already paid)",
);
expect(detail(result, "Amount").rawValue).toBe(
"Whatever an earlier step sent to the pair (V2 already paid)",
);
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
});
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router passes a
// BALANCE_CHECK_ERC20 whenever the balance is at least minBalance, so a
// zero one guarantees nothing. Against 375998b it replaced the swap's
// output side: Token Out = USDC, Min. received = "None (no minimum
// guaranteed)".
test("a zero balance check keeps the minimum a swap step stated", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 0n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("WETH");
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
});
// A nonzero balance check still replaces the output side, as before.
test("a nonzero balance check replaces the minimum a swap step stated", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDT_ADDR,
WETH_ADDR,
]),
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 2000000n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC");
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
});
// With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing.
test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
[
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 0n),
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. received").value).toBe(
"None (no minimum guaranteed)",
);
});
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without // Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no // testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
// output currency, so it says so and titles itself "Uniswap Swap" rather // output currency, so it says so and titles itself "Uniswap Swap" rather