fix: any token a swap leaves in the router keeps it from reading as received (closes #606)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

The decoder checked only the swap's output for being left in the router.
It now records every token a step pays the router, the WETH of a
WRAP_ETH and the rest of a V4 SETTLE taken to the router among them, and
any token still there after the last step gets a Paid to line naming it,
so the minimum reads Min. paid out. A swap step or V4 SETTLE that spends
all the router holds of a token, a WRAP_ETH of all its ETH, and swap
steps from the router that together spend what a WRAP_ETH stated count
as spending it. A SETTLE of OPEN_DELTA from the router counts only as
its step's one SETTLE of that token, after every swap action, with none
buying it.

Model: opus-5-5
This commit is contained in:
2026-10-19 17:39:44 +00:00
parent d6fb002656
commit b07f250926
4 changed files with 625 additions and 78 deletions
+4 -2
View File
@@ -739,7 +739,9 @@ describe("uniswap decoder", () => {
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH.
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
+389 -2
View File
@@ -4,9 +4,10 @@
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
// release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -546,6 +547,392 @@ describe("an output left in the router is not received", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
// router stays there unless a later step pays out or spends all of it, not
// only the swap's output. A V4 SETTLE can take more of the user's tokens than
// the step's swap spends, a WRAP_ETH wraps the user's ETH into the router, and
// a swap can leave there the token it bought on the way to the output.
describe("any token left in the router is not received", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const USDT_5000 = 5000000000n;
// V4 SETTLE of `amount` of `currency` (zero: what the step's swap
// spends), paid by the user, or by the router when `payerIsUser` is false.
function settle(currency, amount, payerIsUser) {
return [
0x0b,
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
),
];
}
// V4 SWAP_EXACT_IN_SINGLE of `amountIn` of `currencyIn` (zero: the open
// delta) for at least `amountOutMin` of `currencyOut`.
function swapExactInSingle(
currencyIn,
currencyOut,
amountIn,
amountOutMin,
) {
return [
0x06,
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currencyIn, currencyOut, 500, 10, ZERO],
true,
amountIn,
amountOutMin,
"0x",
],
],
),
];
}
// The line giving a token left in the router.
const tokenLine = (token, symbol) => ({
label: "Token moved into the router",
value: symbol + " (" + token + ")",
address: token,
isToken: true,
});
test.each([
{
kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
take(USDT, ADDRESS_THIS),
takeAll(USDC),
),
),
left: tokenLine(USDT, "USDT"),
},
{
kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE_PORTION",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
takeAll(USDC),
takePortion(USDT, ADDRESS_THIS, 10000n),
),
),
left: tokenLine(USDT, "USDT"),
},
{
kind: "a WRAP_ETH into the router that nothing spends",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH / 2n,
USDC_1990,
WETH,
USDC,
false,
),
),
left: tokenLine(WETH, "WETH"),
},
// A SETTLE of OPEN_DELTA from the router pays only what the step
// still owes of its currency when it runs.
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.5 WETH with two SETTLEs of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH / 2n, USDC_1990),
settle(WETH, 0n, false),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.75 WETH with a SETTLE of 0.25 WETH and one of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(
WETH,
USDC,
(ONE_WETH * 3n) / 4n,
USDC_1990,
),
settle(WETH, ONE_WETH / 4n, false),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step that also buys WETH back, with a SETTLE of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
swapExactInSingle(USDC, WETH, 1000000n, 1n),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
settle(WETH, 0n, false),
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, true),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, a TRANSFER of 1 wei of it to the WETH/USDC pair, then a V2 swap of what the pair holds",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, PAIR, 1n),
v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, then a swap of 1 wei of it from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
v2Swap(0x08, MSG_SENDER, 1n, USDC_1990, [WETH, USDC], false),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then another swap",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
"Steps",
"Deadline",
]);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
// Uniswap's own routes: a swap paid in ETH wraps into the router what its
// swap steps then spend from it, and a route through several pools pays
// each token it buys on the way to the router and spends all of it in the
// next step (CONTRACT_BALANCE), turning ETH into WETH or back where the
// pools need it.
test.each([
{
kind: "a V4 SETTLE of what its swap spends",
data: execute(
v4Swap(
settle(USDT, USDT_2000, true),
SWAP_USDT_FOR_USDC,
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a V4 SETTLE of more than its swap spends, the rest taken to the user",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
take(USDT, MSG_SENDER),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a swap of all of it from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then two swaps from the router that together spend all of it",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
(ONE_WETH * 3n) / 5n,
1n,
WETH,
USDC,
false,
),
v2Swap(
0x08,
MSG_SENDER,
(ONE_WETH * 2n) / 5n,
USDC_1990,
[WETH, USDC],
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a V4 swap of all of it settled from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH of all the router's ETH, then a swap of all its WETH",
data: execute(
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, then a V2 swap of all of it",
data: execute(
swapToWeth(ADDRESS_THIS),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
[WETH, USDC],
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, then a V4 swap settling all of it from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
v4Swap(
settle(WETH, CONTRACT_BALANCE, false),
swapExactInSingle(WETH, USDC, 0n, USDC_1990),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a V4 swap taking USDC to the router, then a V3 swap of all of it",
data: execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
USDC,
WETH,
false,
),
),
min: "1.0000 WETH",
},
{
kind: "a V4 swap taking ETH to the router, a WRAP_ETH of all of it, then a V3 swap of all the WETH",
data: execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v4Swap(
settle(ZERO, CONTRACT_BALANCE, false),
swapExactInSingle(ZERO, USDC, 0n, USDC_1990),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address,
// and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.