fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum #423

Merged
clawbot merged 1 commits from issue-283-v2-exact-out into next 2026-10-04 15:09:10 +02:00
4 changed files with 315 additions and 18 deletions
+22 -8
View File
@@ -962,16 +962,25 @@ 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.
- `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
@@ -980,9 +989,14 @@ read:
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
that side. The token permission warning on the signature screen has its own
amount wording, including `Unknown`; the SignApproval section below describes
it.
that side. An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; a WETH
`Min. received` figure then reads in ETH. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
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
SignApproval section below describes it.
#### Partial USD totals
+15
View File
@@ -45,6 +45,21 @@ 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,
except where it reads `Unlimited` (an unbounded `PERMIT2_PERMIT`, or any
amount at or above the `uint160` maximum) or `All available (V4 open delta)`.
In every swap, `UNWRAP_WETH` makes `Token Out` ETH only when the output side
is WETH, on mainnet or Sepolia, or when no step set the output side; otherwise
`Token Out` and `Min. received` keep the output side's own token and figure.
V3 exact-out (`0x01`) is still not decoded.
- 2026-10-04: `make test` runs jest in three worker processes
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
+41 -10
View File
@@ -100,6 +100,13 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// decode() is not told the network, so it takes either.
const WETH_ADDRESSES = [
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -198,11 +205,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 +449,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 +524,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) {
@@ -559,12 +572,19 @@ function decode(data, toAddress, sources) {
// Resolve token info. A null token on either side means the calldata
// 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.
// ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH
// when the output side is WETH, or when no step set the output side.
// Any other output keeps its own token and figure: a swap that buys
// USDC and then unwraps the WETH it did not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const outputUnset = !present(outputToken) && !present(minOutput);
const inInfo = tokenInfo(inputToken, sources);
const outInfo = hasUnwrapWeth
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || outputUnset)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
const inSymbol = inInfo.symbol;
const outSymbol = outInfo.symbol;
@@ -613,6 +633,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);
}
+237
View File
@@ -5,6 +5,8 @@ const ROUTER_ADDR = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
const USDT_ADDR = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const WETH_ADDR = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const USDC_ADDR = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27.
@@ -75,6 +77,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 +233,233 @@ 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");
});
// Paid in a token, an UNWRAP_WETH of 0 after a swap that buys something
// other than WETH leaves the output side as it is: Token Out and Min.
// received are the token bought and its figure, not ETH.
test.each([
{
paidIn: "WETH",
tokenIn: WETH_ADDR,
amountInMax: 500000000000000000n, // 0.5 WETH
tokenOut: USDC_ADDR,
amountOut: 1300000000n, // 1,300 USDC
minReceived: "1300.0000 USDC",
},
{
paidIn: "USDC",
tokenIn: USDC_ADDR,
amountInMax: 8000000n, // 8 USDC
tokenOut: DAI_ADDR,
amountOut: 7000000000000000000n, // 7 DAI
minReceived: "7.0000 DAI",
},
])(
"a V2 exact-out swap paid in $paidIn, then UNWRAP_WETH, shows the token it buys",
({ tokenIn, amountInMax, tokenOut, amountOut, minReceived }) => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x09, 0x0c]),
[
encodePermit2(tokenIn, amountInMax, ROUTER_ADDR),
encodeV2SwapExactOut(USER_ADDR, amountOut, amountInMax, [
tokenIn,
tokenOut,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").address).toBe(tokenOut);
expect(detail(result, "Min. received").value).toBe(minReceived);
},
);
// An exact-in swap is held to the same rule: buying USDC, then UNWRAP_WETH,
// receives USDC.
test("an exact-in swap to a token other than WETH, then UNWRAP_WETH, shows that token", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[
encodeV2SwapExactIn(USER_ADDR, 2000000n, 1900000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH.
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
["uint8", "uint8", "uint8", "uint8"],
[0x0b, 0x09, 0x08, 0x0c],
),
[
encodeWrapEth(ROUTER_ADDR, 500000000000000000n),
encodeV2SwapExactOut(
ROUTER_ADDR,
1500000000n, // amountOut: 1,500 USDC
500000000000000000n, // amountInMax: 0.5 WETH
[WETH_ADDR, USDC_ADDR],
),
encodeV2SwapExactIn(
ROUTER_ADDR,
1500000000n, // amountIn: 1,500 USDC
400000000000000000n, // amountOutMin: 0.4 WETH
[USDC_ADDR, WETH_ADDR],
),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
test("a swap to Sepolia WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x08, 0x0c]),
[
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
USDC_ADDR,
SEPOLIA_WETH_ADDR,
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
});
// A step that states a Min. received figure but names no output token has
// set the output side, so UNWRAP_WETH does not make it ETH: nothing says
// the figure is counted in WETH.
test("a step with a minimum but no output token, then UNWRAP_WETH, names no token", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
encodeV4ExactIn(
USDC_ADDR,
[], // no path: this step names no output currency
1000000000n, // 1,000 USDC
400000000000000000n, // amountOutMin
),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received").value).toBe(
"400000000000000000 base units (decimals unknown)",
);
});
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute(
"0x00", // V3_SWAP_EXACT_IN