Compare commits

...
1 Commits
Author SHA1 Message Date
sneak a93eba3f46 fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum (closes #283)
check / check (push) Successful in 2m32s
e2e / e2e-chrome (push) Successful in 3m28s
e2e / e2e-firefox (push) Successful in 31s
decode() had no arm for Universal Router command 0x09, so the approval
screen named the step and showed no token or amount. It now takes the
path's first token with amountInMax as the input side and the last token
with amountOut as the output side, like 0x08. When the transaction has
such a step, the Amount figure reads "Up to <amount>" whichever step set
it, a WRAP_ETH or a permit included, and so does the value the wait,
success and error screens show. In such a swap paid in ETH, the
UNWRAP_WETH step that returns the ETH not spent does not make the output
ETH. The eslint-disable comment on decodeV2SwapExactOut() is gone.

Model: opus-5-5
2026-10-04 09:10:00 +00:00
4 changed files with 147 additions and 13 deletions
+16 -5
View File
@@ -962,16 +962,27 @@ read:
- `Unlimited`: on the ERC-20 `Amount` line, an `approve` of the `uint256`
maximum, an unbounded allowance. On the swap's `Amount` line, any amount at or
above the `uint160` maximum, whichever step set the line: a `PERMIT2_PERMIT`
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in
or `WRAP_ETH` amount that large, which is not an allowance. The router's
whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such amount.
amount at that maximum, which is an unbounded permit, or a V2 or V3 exact-in,
V2 exact-out or `WRAP_ETH` amount that large, which is not an allowance. The
router's whole-balance value, `CONTRACT_BALANCE` (`2^255`), is one such
amount.
- `Up to <amount>`: the swap's `Amount` line, when the transaction has a V2
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,
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
wording. When a V2 exact-out step sets `Min. received`, that line shows its
`amountOut`, the exact amount it buys. In a V2 exact-out swap paid in ETH, the
`UNWRAP_WETH` step returns the ETH not spent, so `Token Out` is the token the
swap buys, not ETH.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of one step that names an input token or amount: the last
`PERMIT2_PERMIT` step if there is one, otherwise the first V2 or V3 exact-in,
`WRAP_ETH` or V4 swap step, a V4 swap step giving the `amountIn` of its first
readable exact-in action.
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
exact-in action.
- `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
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
+11
View File
@@ -45,6 +45,17 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
decoded on the approval screen
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
`src/shared/uniswap.js` had no arm for it, so the screen named the step and
showed no token or amount. The input side is the path's first token with
`amountInMax`, the output side the last token with `amountOut`. When the
transaction has such a step, the `Amount` figure reads `Up to <amount>`,
whichever step set it, there and on the wait, success and error screens. In
such a swap paid in ETH, the `UNWRAP_WETH` step that returns the ETH not spent
does not make `Token Out` ETH. V3 exact-out (`0x01`) is still not decoded.
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
token balance or a network fee below 0.000001 as zero
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
+28 -8
View File
@@ -198,11 +198,6 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
//
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
// its command name and no token or amount detail. Kept for the fix, which is
// https://git.eeqj.de/sneak/AutistMask/issues/283.
// eslint-disable-next-line no-unused-vars
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
@@ -447,6 +442,7 @@ function decode(data, toAddress, sources) {
let outputToken = null;
let minOutput = null;
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
@@ -521,6 +517,16 @@ function decode(data, toAddress, sources) {
}
}
if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
}
}
if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w) {
@@ -561,10 +567,13 @@ function decode(data, toAddress, sources) {
// named no currency for it; tokenInfo() refuses rather than calling it
// ETH. UNWRAP_WETH is the one output that is ETH without a currency to
// decode, and it is answered here rather than left to that rule.
// Except in a V2 exact-out swap paid in ETH: there UNWRAP_WETH returns
// the ETH the swap did not spend, and the output is the token it buys.
const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const outInfo =
hasUnwrapWeth && !(hasV2ExactOut && inInfo.symbol === "ETH")
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -613,6 +622,17 @@ function decode(data, toAddress, sources) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) {
// A V2 exact-out swap spends at most this figure, whichever
// step set the line (its amountInMax, the WRAP_ETH of a swap
// paid in ETH, a permit), so it is said to be a maximum, in
// `raw` too: the wait, success and error screens show `raw` as
// the transaction's amount.
const most = amountText(inputAmount, inInfo);
amount = {
raw: "Up to " + most.raw,
display: "Up to " + most.display,
};
} else {
amount = amountText(inputAmount, inInfo);
}
+92
View File
@@ -75,6 +75,14 @@ function encodeV2SwapExactIn(recipient, amountIn, amountOutMin, pathAddrs) {
);
}
// Helper: encode a V2_SWAP_EXACT_OUT input (command 0x09)
function encodeV2SwapExactOut(recipient, amountOut, amountInMax, pathAddrs) {
return coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amountOut, amountInMax, pathAddrs, true],
);
}
// Helper: encode a V3_SWAP_EXACT_IN input (command 0x00)
function encodeV3SwapExactIn(recipient, amountIn, amountOutMin, pathTokens) {
// V3 path: token(20) + fee(3) + token(20) ...
@@ -223,6 +231,90 @@ describe("uniswap decoder", () => {
expect(minOut.value).toContain("WETH");
});
// Buy exactly 0.5 WETH for at most 1,500 USDC, paid by the user, sent to
// the caller (the router's MSG_SENDER recipient, address(1)).
test("decodes V2_SWAP_EXACT_OUT, stating the input amount as a maximum", () => {
const data = buildExecute(
"0x09", // V2_SWAP_EXACT_OUT
[
encodeV2SwapExactOut(
"0x0000000000000000000000000000000000000001",
500000000000000000n, // amountOut: 0.5 WETH
1500000000n, // amountInMax: 1,500 USDC (6 decimals)
[USDC_ADDR, WETH_ADDR],
),
],
1767225600n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Token In").address).toBe(USDC_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
// The wait, success and error screens show rawValue as the amount, so
// it says "Up to" as well.
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 1500.0000 USDC");
expect(amount.rawValue).toBe("Up to 1500.0000");
expect(detail(result, "Min. received").value).toBe("0.5000 WETH");
});
// Buy exactly 1,500 USDC for at most 0.5 ETH: WRAP_ETH of the maximum, the
// swap, then UNWRAP_WETH of 0, which returns the ETH the swap did not spend.
test("a V2 exact-out swap paid in ETH shows the token it buys and a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0b, 0x09, 0x0c]),
[
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
encodeV2SwapExactOut(
USER_ADDR,
1500000000n, // amountOut: 1,500 USDC
500000000000000000n, // amountInMax: 0.5 WETH
[WETH_ADDR, USDC_ADDR],
),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH same encoding
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap ETH → USDC");
const amount = detail(result, "Amount");
expect(amount.value).toBe("Up to 0.5000 ETH");
expect(amount.rawValue).toBe("Up to 0.5000");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1500.0000 USDC");
});
// Buy exactly 0.5 ETH for at most 1,500 USDC under a permit: the swap buys
// WETH and UNWRAP_WETH turns it into ETH.
test("a V2 exact-out swap that buys ETH shows ETH and the permit as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
[
encodePermit2(USDC_ADDR, 1500000000n, ROUTER_ADDR),
encodeV2SwapExactOut(
ROUTER_ADDR,
500000000000000000n, // amountOut: 0.5 WETH
1500000000n, // amountInMax: 1,500 USDC
[USDC_ADDR, WETH_ADDR],
),
encodeWrapEth(USER_ADDR, 500000000000000000n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDC → ETH");
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
});
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute(
"0x00", // V3_SWAP_EXACT_IN