1 Commits
Author SHA1 Message Date
clawbot 6a878d6836 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
An UNWRAP_WETH unwraps whatever WETH the router holds when it runs. A
TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of WETH to anyone but
the router between the swap and the UNWRAP_WETH may pay out part of the
swap's WETH, and a WRAP_ETH into the router may put other WETH in place of
WETH already paid out; after either, the swap now reads as ETH with no
minimum. An UNWRAP_WETH into the router followed by a WRAP_ETH of all the
router's ETH to anyone else pays the output out as WETH again, so the swap
reads as WETH, with no minimum. README Display Consistency says the same.

Model: opus-5-5
2026-10-20 15:34:11 +00:00
3 changed files with 78 additions and 158 deletions

No files matched your search

+20 -19
View File
@@ -1260,25 +1260,26 @@ 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 only when that step paid all its WETH to the router and minimum reads in ETH when that step paid all its WETH to the router and no step
every step between it and the `UNWRAP_WETH` is one the decoder read that cannot between it and the `UNWRAP_WETH` is a `TRANSFER`, `PAY_PORTION` or
pay out WETH the router holds, add to it, or wrap the router's ETH: a `PAY_PORTION_FULL_PRECISION` of WETH to anyone but the router, or a `WRAP_ETH`
`PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`, into the router. Such a step may pay out WETH the router holds, or put other
`PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH. WETH in place of WETH an earlier step paid out. With such a step, or when that
Otherwise the swap shows ETH with no minimum, as it does after a V4 step that step paid only part of its WETH to the router, as a V4 step does that `TAKE`s
`TAKE`s some of its WETH to the user and the rest to the router, or that pays some of its WETH to the user and the rest to the router, or paid the router WETH
the router WETH beside a payout the decoder cannot read, and with a WETH beside a payout the decoder cannot read, the swap shows ETH with no minimum: the
interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`: the decoder does decoder does not count how much of that step's minimum reaches the
not count how much of that step's minimum reaches the `UNWRAP_WETH`, nor read `UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. After an `UNWRAP_WETH`
the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH` pays the router, which that pays the output side's WETH to the router as ETH, a `WRAP_ETH` of
then holds the ETH, any later step but those listed may wrap the ETH and pay it `CONTRACT_BALANCE` to anyone but the router, while the router still holds ETH,
out as WETH: after one, the swap shows WETH with no minimum. A later step that pays the output out as WETH again: the swap shows WETH with no minimum, as the
sets the output side sets it anew. Otherwise `Token Out` and `Min. received` decoder does not count what steps between the two pay out of that ETH. A later
keep the output side's own token and figure, whether the swap was paid in ETH or step that sets the output side sets it anew. Otherwise `Token Out` and
in a token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did `Min. received` keep the output side's own token and figure, whether the swap
not spend shows USDC. The token permission warning on the signature screen has was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
its own amount wording, including `Unknown`; the SignApproval section below unwraps the WETH it did not spend shows USDC. The token permission warning on
describes it. the signature screen has its own amount wording, including `Unknown`; the
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
+53 -65
View File
@@ -1221,20 +1221,17 @@ 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 later step may pay that ETH out as WETH (see after the // again, or a WRAP_ETH pays it out as WETH again (see WRAP_ETH).
// loop). `unwrapIntoRouterStep` is the index of that UNWRAP_WETH when // `unwrappedIntoRouter` says whether that UNWRAP_WETH paid the ETH to
// it paid the ETH to the router, which then holds it, otherwise null. // the router, which then holds it.
let outputUnwrapped = false; let outputUnwrapped = false;
let unwrapIntoRouterStep = null; let unwrappedIntoRouter = false;
// The index of the step that set the output side. // Set when a step after the one that set the output side may pay out
let outputStep = -1; // WETH the router holds, or add to it: a TRANSFER, PAY_PORTION or
// The index of the last step that may change the router's WETH: pay // PAY_PORTION_FULL_PRECISION of WETH to anyone but the router, or a
// out WETH the router holds, add to it, or wrap the router's ETH. // WRAP_ETH into the router. Cleared whenever the output side is set
// Every step may, but one this decoder read that is a PERMIT2_PERMIT, // again.
// a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or let routerWethChanged = false;
// 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,
@@ -1277,14 +1274,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 = (step, token, amount, recipients) => { const setOutput = (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;
outputStep = step; routerWethChanged = false;
}; };
// 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
@@ -1434,9 +1431,6 @@ 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") {
@@ -1468,7 +1462,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
allowedToFail, allowedToFail,
beforeEveryTake: !tookBefore, beforeEveryTake: !tookBefore,
}; };
keepsRouterWeth = true;
} else { } else {
stepsUnread.push(i); stepsUnread.push(i);
} }
@@ -1524,7 +1517,6 @@ 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
@@ -1535,9 +1527,8 @@ 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(i, b.token, minBalance, []); setOutput(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]);
@@ -1550,7 +1541,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(i, s.tokenOut, s.amountOutMin, [s.recipient]); setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1570,7 +1561,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(i, s.tokenOut, s.amountOutMin, [s.recipient]); setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1601,7 +1592,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(i, s.tokenOut, s.amountOut, [s.recipient]); setOutput(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);
} }
@@ -1623,9 +1614,6 @@ 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);
@@ -1663,9 +1651,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
heldByRouter.delete(t.token.toLowerCase()); heldByRouter.delete(t.token.toLowerCase());
} }
keepsRouterWeth = !WETH_ADDRESSES.includes( if (
t.token.toLowerCase(), WETH_ADDRESSES.includes(t.token.toLowerCase()) &&
); !isRouter(t.recipient)
) {
routerWethChanged = true;
}
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i); stepsUnread.push(i);
@@ -1702,6 +1693,24 @@ 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(
@@ -1712,6 +1721,7 @@ 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,
@@ -1791,19 +1801,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = takeRecipients; outputRecipients = takeRecipients;
outputStep = i; routerWethChanged = false;
} 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( setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients);
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
@@ -1832,13 +1837,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 step between the two may have changed the // or a later step may have paid out WETH the router holds
// router's WETH (see lastWethStep), the WETH unwrapped // or added to it (see routerWethChanged), the WETH
// may be only part of the output side's, or none of it. // unwrapped may be only part of the output side's, or
// This decoder does not count how much of that step's // none of it. This decoder does not count how much of
// minimum then reaches the UNWRAP_WETH, nor read the // that step's minimum then reaches the UNWRAP_WETH, nor
// UNWRAP_WETH's own minimum, so the output side keeps no // read the UNWRAP_WETH's own minimum, so the output side
// figure. // keeps no 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 (
@@ -1848,11 +1853,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputRecipients.some(toRouter) outputRecipients.some(toRouter)
) { ) {
outputUnwrapped = true; outputUnwrapped = true;
unwrapIntoRouterStep = unwrappedIntoRouter =
u !== null && isRouter(u.recipient) ? i : null; u !== null && isRouter(u.recipient);
if ( if (
!outputRecipients.every(toRouter) || !outputRecipients.every(toRouter) ||
lastWethStep > outputStep routerWethChanged
) { ) {
minOutput = null; minOutput = null;
} }
@@ -1893,23 +1898,6 @@ 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.
+5 -74
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 may pay out the router's WETH or add to it, and a step after // the UNWRAP_WETH that pays out part of the router's WETH, or a WRAP_ETH into
// an UNWRAP_WETH into the router may wrap its ETH and pay it out as WETH. Each // the router after the swap's WETH was paid out, may leave less of the swap's
// read as ETH with the swap's minimum. A step the decoder read that is a // WETH to unwrap than its minimum; an UNWRAP_WETH into the router, then a
// PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or // WRAP_ETH of all its ETH to the user, pays the user WETH. Each read as ETH
// PAY_PORTION_FULL_PRECISION of a token other than WETH does neither. // with the swap's minimum.
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,61 +787,6 @@ 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", () => {
@@ -869,20 +814,6 @@ 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", () => {