1 Commits
Author SHA1 Message Date
clawbot 1146ffd12b fix: a swap's WETH minimum reads as ETH only when all its WETH reaches the UNWRAP_WETH (closes #642)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
Between the step that set the output side and the UNWRAP_WETH, only a
step the decoder read that is a PERMIT2_PERMIT, a BALANCE_CHECK_ERC20,
or a SWEEP, TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of a
token other than WETH keeps the swap's WETH minimum as an ETH minimum;
any other step leaves ETH with no minimum. After an UNWRAP_WETH into the
router, any later step but those makes the swap read WETH with no
minimum. A WETH fee paid by PAY_PORTION before the UNWRAP_WETH now shows
no minimum. README Display Consistency states the same rule.

Model: opus-5-5
2026-10-20 16:21:45 +00:00
3 changed files with 158 additions and 78 deletions

No files matched your search

+19 -20
View File
@@ -1260,26 +1260,25 @@ runs (Transaction Decoding below gives what the router holds). The router
unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone
else, stays WETH: a swap that pays the router WETH, then a swap that pays the else, stays WETH: a swap that pays the router WETH, then a swap that pays the
user WETH, then an unwrap, shows WETH with the second swap's minimum. The user WETH, then an unwrap, shows WETH with the second swap's minimum. The
minimum reads in ETH when that step paid all its WETH to the router and no step minimum reads in ETH only when that step paid all its WETH to the router and
between it and the `UNWRAP_WETH` is a `TRANSFER`, `PAY_PORTION` or every step between it and the `UNWRAP_WETH` is one the decoder read that cannot
`PAY_PORTION_FULL_PRECISION` of WETH to anyone but the router, or a `WRAP_ETH` pay out WETH the router holds, add to it, or wrap the router's ETH: a
into the router. Such a step may pay out WETH the router holds, or put other `PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`,
WETH in place of WETH an earlier step paid out. With such a step, or when that `PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH.
step paid only part of its WETH to the router, as a V4 step does that `TAKE`s Otherwise the swap shows ETH with no minimum, as it does after a V4 step that
some of its WETH to the user and the rest to the router, or paid the router WETH `TAKE`s some of its WETH to the user and the rest to the router, or that pays
beside a payout the decoder cannot read, the swap shows ETH with no minimum: the the router WETH beside a payout the decoder cannot read, and with a WETH
decoder does not count how much of that step's minimum reaches the interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`: the decoder does
`UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. After an `UNWRAP_WETH` not count how much of that step's minimum reaches the `UNWRAP_WETH`, nor read
that pays the output side's WETH to the router as ETH, a `WRAP_ETH` of the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH` pays the router, which
`CONTRACT_BALANCE` to anyone but the router, while the router still holds ETH, then holds the ETH, any later step but those listed may wrap the ETH and pay it
pays the output out as WETH again: the swap shows WETH with no minimum, as the out as WETH: after one, the swap shows WETH with no minimum. A later step that
decoder does not count what steps between the two pay out of that ETH. A later sets the output side sets it anew. Otherwise `Token Out` and `Min. received`
step that sets the output side sets it anew. Otherwise `Token Out` and keep the output side's own token and figure, whether the swap was paid in ETH or
`Min. received` keep the output side's own token and figure, whether the swap in a token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then not spend shows USDC. The token permission warning on the signature screen has
unwraps the WETH it did not spend shows USDC. The token permission warning on its own amount wording, including `Unknown`; the SignApproval section below
the signature screen has its own amount wording, including `Unknown`; the 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. 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 `2026-02-27 08:25:51`. A JavaScript date reaches only to 275760-09-13 00:00:00
+65 -53
View File
@@ -1221,17 +1221,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let hasUnwrapWeth = false; let hasUnwrapWeth = false;
// Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH, // Set when an UNWRAP_WETH pays out, as ETH, the output side's WETH,
// which the router holds; cleared whenever the output side is set // which the router holds; cleared whenever the output side is set
// again, or a WRAP_ETH pays it out as WETH again (see WRAP_ETH). // again, or a later step may pay that ETH out as WETH (see after the
// `unwrappedIntoRouter` says whether that UNWRAP_WETH paid the ETH to // loop). `unwrapIntoRouterStep` is the index of that UNWRAP_WETH when
// the router, which then holds it. // it paid the ETH to the router, which then holds it, otherwise null.
let outputUnwrapped = false; let outputUnwrapped = false;
let unwrappedIntoRouter = false; let unwrapIntoRouterStep = null;
// Set when a step after the one that set the output side may pay out // The index of the step that set the output side.
// WETH the router holds, or add to it: a TRANSFER, PAY_PORTION or let outputStep = -1;
// PAY_PORTION_FULL_PRECISION of WETH to anyone but the router, or a // The index of the last step that may change the router's WETH: pay
// WRAP_ETH into the router. Cleared whenever the output side is set // out WETH the router holds, add to it, or wrap the router's ETH.
// again. // Every step may, but one this decoder read that is a PERMIT2_PERMIT,
let routerWethChanged = false; // a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION of a token other than WETH (see
// keepsRouterWeth in the loop).
let lastWethStep = -1;
// Where the step that set the output side paid its output: the // Where the step that set the output side paid its output: the
// recipient of a swap step, as written, or of each TAKE, TAKE_ALL and // recipient of a swap step, as written, or of each TAKE, TAKE_ALL and
// TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender, // TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender,
@@ -1274,14 +1277,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!present(token) && !present(amount)) return; if (!present(token) && !present(amount)) return;
setInput(token, amount); setInput(token, amount);
}; };
const setOutput = (token, amount, recipients) => { const setOutput = (step, token, amount, recipients) => {
if (!present(token) && !present(amount)) return; if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null; outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null; minOutput = present(amount) ? amount : null;
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = recipients; outputRecipients = recipients;
routerWethChanged = false; outputStep = step;
}; };
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
@@ -1431,6 +1434,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
? COMMAND_NAMES[command] ? COMMAND_NAMES[command]
: "Command 0x" + cmdId.toString(16).padStart(2, "0"), : "Command 0x" + cmdId.toString(16).padStart(2, "0"),
); );
// Set by a branch below that reads a step that cannot change the
// router's WETH (see lastWethStep).
let keepsRouterWeth = false;
try { try {
if (command === "EXECUTE_SUB_PLAN") { if (command === "EXECUTE_SUB_PLAN") {
@@ -1462,6 +1468,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
allowedToFail, allowedToFail,
beforeEveryTake: !tookBefore, beforeEveryTake: !tookBefore,
}; };
keepsRouterWeth = true;
} else { } else {
stepsUnread.push(i); stepsUnread.push(i);
} }
@@ -1517,6 +1524,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// what the user receives: it sets no side, and the // what the user receives: it sets no side, and the
// step says so. // step says so.
commandNames[i] += " (of another address)"; commandNames[i] += " (of another address)";
keepsRouterWeth = true;
} else { } else {
// The router passes this check whenever the owner // The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees // holds at least minBalance, so a zero one guarantees
@@ -1527,8 +1535,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// does. It pays nobody. // does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance; const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) { if (!(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance, []); setOutput(i, b.token, minBalance, []);
} }
keepsRouterWeth = true;
} }
} else if (command === "V3_SWAP_EXACT_IN") { } else if (command === "V3_SWAP_EXACT_IN") {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
@@ -1541,7 +1550,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Always update output: in multi-step swaps (V3 → V4), // Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]); setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1561,7 +1570,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn); addUserPaid(s.tokenIn, s.amountIn);
} }
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]); setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1592,7 +1601,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax); addUserPaid(s.tokenIn, s.amountInMax);
} }
setOutput(s.tokenOut, s.amountOut, [s.recipient]); setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
@@ -1614,6 +1623,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!isRouter(s.recipient)) { if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase()); heldByRouter.delete(s.token.toLowerCase());
} }
keepsRouterWeth = !WETH_ADDRESSES.includes(
s.token.toLowerCase(),
);
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i); stepsUnread.push(i);
@@ -1651,12 +1663,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
heldByRouter.delete(t.token.toLowerCase()); heldByRouter.delete(t.token.toLowerCase());
} }
if ( keepsRouterWeth = !WETH_ADDRESSES.includes(
WETH_ADDRESSES.includes(t.token.toLowerCase()) && t.token.toLowerCase(),
!isRouter(t.recipient) );
) {
routerWethChanged = true;
}
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i); stepsUnread.push(i);
@@ -1693,24 +1702,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
addPayee(w.recipient); addPayee(w.recipient);
// After an UNWRAP_WETH paid the output side's WETH to
// the router as ETH, a WRAP_ETH of all the router's
// ETH to anyone else pays it out as WETH again. This
// decoder does not count what steps between the two
// paid out of that ETH, so the output side keeps no
// figure.
if (
outputUnwrapped &&
unwrappedIntoRouter &&
w.amount === CONTRACT_BALANCE &&
!isRouter(w.recipient) &&
heldByRouter.has(
"0x0000000000000000000000000000000000000000",
)
) {
outputUnwrapped = false;
minOutput = null;
}
// CONTRACT_BALANCE wraps all the router's ETH. // CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) { if (w.amount === CONTRACT_BALANCE) {
heldByRouter.delete( heldByRouter.delete(
@@ -1721,7 +1712,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// amount is known when the step states it and the // amount is known when the step states it and the
// router held no WETH before. // router held no WETH before.
if (isRouter(w.recipient) && w.amount > 0n) { if (isRouter(w.recipient) && w.amount > 0n) {
routerWethChanged = true;
const weth = WETH.get(chainId); const weth = WETH.get(chainId);
heldByRouter.set( heldByRouter.set(
weth, weth,
@@ -1801,14 +1791,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = takeRecipients; outputRecipients = takeRecipients;
routerWethChanged = false; outputStep = i;
} else if (v4) { } else if (v4) {
// A step with no swap action sets only the sides its // A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names: // SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side // setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated. // whose two halves are both unstated.
setInputOnce(v4.tokenIn, amountIn); setInputOnce(v4.tokenIn, amountIn);
setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients); setOutput(
i,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
);
} }
// Every V4 step, with a swap action or without, takes // Every V4 step, with a swap action or without, takes
// from the sender what the user pays it (an amount an // from the sender what the user pays it (an amount an
@@ -1837,13 +1832,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// that step paid to the user, or to anyone else, stays // that step paid to the user, or to anyone else, stays
// WETH. When that step also paid some of its output // WETH. When that step also paid some of its output
// elsewhere or made a payout this decoder did not read, // elsewhere or made a payout this decoder did not read,
// or a later step may have paid out WETH the router holds // or a step between the two may have changed the
// or added to it (see routerWethChanged), the WETH // router's WETH (see lastWethStep), the WETH unwrapped
// unwrapped may be only part of the output side's, or // may be only part of the output side's, or none of it.
// none of it. This decoder does not count how much of // This decoder does not count how much of that step's
// that step's minimum then reaches the UNWRAP_WETH, nor // minimum then reaches the UNWRAP_WETH, nor read the
// read the UNWRAP_WETH's own minimum, so the output side // UNWRAP_WETH's own minimum, so the output side keeps no
// keeps no figure. // figure.
const u = decodeUnwrapWeth(inputs[i]); const u = decodeUnwrapWeth(inputs[i]);
const toRouter = (r) => present(r) && isRouter(r); const toRouter = (r) => present(r) && isRouter(r);
if ( if (
@@ -1853,11 +1848,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputRecipients.some(toRouter) outputRecipients.some(toRouter)
) { ) {
outputUnwrapped = true; outputUnwrapped = true;
unwrappedIntoRouter = unwrapIntoRouterStep =
u !== null && isRouter(u.recipient); u !== null && isRouter(u.recipient) ? i : null;
if ( if (
!outputRecipients.every(toRouter) || !outputRecipients.every(toRouter) ||
routerWethChanged lastWethStep > outputStep
) { ) {
minOutput = null; minOutput = null;
} }
@@ -1898,6 +1893,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (allowedToFail) { if (allowedToFail) {
commandNames[i] += " (allowed to fail)"; commandNames[i] += " (allowed to fail)";
} }
if (!keepsRouterWeth) {
lastWethStep = i;
}
}
// After an UNWRAP_WETH that paid the output side's WETH to the router
// as ETH, a later step that may change the router's WETH (see
// lastWethStep) may wrap that ETH and pay it out as WETH. The output
// side then reads as WETH with no figure, as this decoder does not
// count how much of that ETH each later step pays out.
if (
outputUnwrapped &&
unwrapIntoRouterStep !== null &&
lastWethStep > unwrapIntoRouterStep
) {
outputUnwrapped = false;
minOutput = null;
} }
// The Amount line, and the lines for what the steps take beyond it. // The Amount line, and the lines for what the steps take beyond it.
+74 -5
View File
@@ -747,11 +747,11 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/642: a step between the swap and // https://git.eeqj.de/sneak/AutistMask/issues/642: a step between the swap and
// the UNWRAP_WETH that pays out part of the router's WETH, or a WRAP_ETH into // the UNWRAP_WETH may pay out the router's WETH or add to it, and a step after
// the router after the swap's WETH was paid out, may leave less of the swap's // an UNWRAP_WETH into the router may wrap its ETH and pay it out as WETH. Each
// WETH to unwrap than its minimum; an UNWRAP_WETH into the router, then a // read as ETH with the swap's minimum. A step the decoder read that is a
// WRAP_ETH of all its ETH to the user, pays the user WETH. Each read as ETH // PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// with the swap's minimum. // PAY_PORTION_FULL_PRECISION of a token other than WETH does neither.
describe("what reaches the UNWRAP_WETH of the swap's WETH", () => { describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter( const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
(l) => l !== "Min. received", (l) => l !== "Min. received",
@@ -787,6 +787,61 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
}, },
); );
test.each([
{
kind: "a SWEEP whose recipient cannot be read",
step: withDirtyWord(sweep(WETH, OTHER), 1),
},
{
kind: "a V4 SETTLE of 0.5 WETH from the router",
step: v4Swap([
0x0b,
coder.encode(
["address", "uint256", "bool"],
[WETH, ONE_WETH / 2n, false],
),
]),
},
{
kind: "a bridge deposit of 0.5 WETH",
step: acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
},
{
kind: "a PERMIT2_TRANSFER_FROM of 0.5 WETH into the router",
step: permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH / 2n),
},
])("$kind between them reads as ETH with no minimum", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
test.each([
{
kind: "a PERMIT2_PERMIT between them",
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
},
{
kind: "a BALANCE_CHECK_ERC20 between them",
steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)],
},
{
kind: "a SWEEP of USDC between them",
steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)],
},
{
kind: "an UNWRAP_WETH into the router, then a SWEEP of its ETH,",
steps: [unwrapWeth(ADDRESS_THIS), sweep(ZERO, MSG_SENDER)],
},
])("$kind reads as ETH with the swap's minimum", ({ steps }) => {
const result = decode(execute(swapToWeth(ADDRESS_THIS), ...steps));
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the // The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// WRAP_ETH's. // WRAP_ETH's.
test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => { test("a SWEEP of the swap's WETH to the user, then a WRAP_ETH into the router, reads as ETH with no minimum", () => {
@@ -814,6 +869,20 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER); expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
}); });
// The user receives 0.5 WETH and the rest of the swap's output as ETH.
test("an UNWRAP_WETH into the router, then a WRAP_ETH of 0.5 ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, ONE_WETH / 2n),
sweep(ZERO, MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
// The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps // The UNWRAP_WETH pays the user the swap's WETH as ETH; the WRAP_ETH wraps
// the ETH an earlier V4 step paid the router. // the ETH an earlier V4 step paid the router.
test("an UNWRAP_WETH to the user, then a WRAP_ETH of all the router's ETH to the user, reads as ETH", () => { test("an UNWRAP_WETH to the user, then a WRAP_ETH of all the router's ETH to the user, reads as ETH", () => {