Compare commits

..
3 Commits
Author SHA1 Message Date
sneak d732f1aafe 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:43:53 +00:00
clawbot 6127fd9432 fix: a swap deadline later than a date can hold is stated in words (closes #437)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
decode() rendered the Deadline line with toISOString(), which throws on a
date past 275760-09-13, the last a JavaScript date can hold. A later
deadline, such as the uint256 maximum, therefore left the whole swap
undecoded, with nothing saying why. That line now reads
"After 275760-09-13 00:00:00 (no deadline in practice)".

Model: opus-5-5
2026-10-04 23:43:06 +02:00
clawbot f4a51e1679 fix: the swap decoder reads a V2 already-paid zero and a zero balance check as the router does (closes #415)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 1s
e2e / e2e-firefox (push) Failing after 1s
A V2 exact-in amountIn of zero is the router's ALREADY_PAID marker: an
earlier step sent the tokens to the pair and the swap spends all of them.
Amount showed 0.0000 for it; it now reads "Whatever an earlier step sent
to the pair (V2 already paid)", in the style of the V4 open delta line.

A BALANCE_CHECK_ERC20 passes whenever the balance is at least minBalance,
so a zero one guarantees nothing. It now sets the output side only when
that side holds no minimum at the point the check is reached; a nonzero
one sets the output side as before.

README's Display Consistency text and TODO.md are updated to match.

Model: opus-5-5
2026-10-04 23:09:05 +02:00
4 changed files with 211 additions and 18 deletions
+23 -3
View File
@@ -887,7 +887,9 @@ 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. shows is a V4 exact-in `amountIn` of zero and
`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
@@ -980,7 +982,8 @@ 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` and `All available (V4 open delta)` keep their the same words. `Unlimited`, `All available (V4 open delta)` and
`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
@@ -991,11 +994,22 @@ 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. read `0.0000` when the token's scale was known. The router passes a balance
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
@@ -1008,6 +1022,12 @@ 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,6 +59,22 @@ 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
+47 -15
View File
@@ -84,17 +84,31 @@ 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 do // a zero minimum is a literal zero slippage floor and is stated as one. Nor
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput` // does the V3 path 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 `amountIn` there is a literal zero and is displayed as one. // so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
// 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 two amount lines that state a fact instead of a quantity. Same register // The Universal Router's V2 exact-in spells "the pair already holds the input
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread // tokens" as an amount of zero: universal-router
// as a number — and deliberately not a third phrasing of "not named": these // `contracts/libraries/Constants.sol` declares
// say different things. // `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
// 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".
@@ -185,6 +199,7 @@ 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(
@@ -192,7 +207,7 @@ function decodeV2SwapExactIn(input) {
input, input,
); );
return { return {
amountIn: d[1], amountIn: d[1] === 0n ? ALREADY_PAID : 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],
@@ -502,7 +517,13 @@ function decode(data, toAddress, sources) {
if (cmdId === 0x0e) { if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
if (b) setOutput(b.token, b.minBalance); // The router passes this check whenever the owner holds at
// 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) {
@@ -623,14 +644,20 @@ function decode(data, toAddress, sources) {
} }
if (present(inputAmount)) { if (present(inputAmount)) {
// Two amounts need no scale to describe and are named rather than // Three amounts need no scale to describe and are named rather
// formatted: V4's open delta, which is not a quantity at all (see // than formatted: V4's open delta and V2's already-paid zero,
// OPEN_DELTA), and an unbounded permit. The open-delta test comes // neither of which is a quantity at all (see OPEN_DELTA and
// first — the sentinel is not a bigint and cannot be compared with // ALREADY_PAID), and an unbounded permit. Those two tests come
// one. // first — the sentinels are not bigints and cannot be compared
// 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) {
@@ -697,10 +724,15 @@ 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: deadlineDate.toISOString().replace("T", " ").slice(0, 19), value: isNaN(deadlineDate.getTime())
? "After 275760-09-13 00:00:00 (no deadline in practice)"
: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
}); });
return { return {
+125
View File
@@ -554,6 +554,33 @@ 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",
@@ -831,6 +858,104 @@ 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