fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum (closes #283)
decode() had no arm for Universal Router command 0x09, so the approval screen showed no token or amount for it. It now takes the path's first token and amountInMax as the input side, the last token and amountOut as the output side. With such a step, the Amount figure reads "Up to <amount>" whichever step set it, on the approval, wait, success and error screens, unless it reads "Unlimited", as an unbounded PERMIT2_PERMIT does, 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 no step set it; otherwise the output keeps its own token and figure. decodeV2SwapExactOut() loses its eslint-disable comment. Model: opus-5-5
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user