1 Commits
Author SHA1 Message Date
clawbot 3d5c5e0e6b 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, and not of ETH to a WETH contract, keeps the
swap's WETH minimum as an ETH minimum; any other step leaves ETH with no
minimum. After an UNWRAP_WETH whose recipient is the router or cannot be
read, 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 17:13:34 +00:00
3 changed files with 118 additions and 44 deletions
+19 -17
View File
@@ -1261,24 +1261,26 @@ 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 only when that step paid all its WETH to the router and
every step between it and the `UNWRAP_WETH` is one the decoder read that cannot every step between it and the `UNWRAP_WETH` is one the decoder read of these: a
pay out WETH the router holds, add to it, or wrap the router's ETH: a
`PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`, `PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`,
`PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH. `PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH, but
Otherwise the swap shows ETH with no minimum, as it does after a V4 step that not of ETH to a WETH contract, which pays the router WETH. None of these can
`TAKE`s some of its WETH to the user and the rest to the router, or that pays take WETH out of the router or pay anyone WETH; the decoder treats every other
the router WETH beside a payout the decoder cannot read, and with a WETH step as one that may. Otherwise the swap shows ETH with no minimum, as it does
interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`: the decoder does after a V4 step that `TAKE`s some of its WETH to the user and the rest to the
not count how much of that step's minimum reaches the `UNWRAP_WETH`, nor read router, or that pays the router WETH beside a payout the decoder cannot read,
the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH` pays the router, which and with a WETH interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`:
then holds the ETH, any later step but those listed may wrap the ETH and pay it the decoder does not count how much of that step's minimum reaches the
out as WETH: after one, the swap shows WETH with no minimum. A later step that `UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH`
sets the output side sets it anew. Otherwise `Token Out` and `Min. received` pays the router, or its recipient cannot be read, the router may hold the ETH,
keep the output side's own token and figure, whether the swap was paid in ETH or and a later step may pay it out as WETH, as a `WRAP_ETH` of it to the user does:
in a token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did after any later step but those listed, the swap shows WETH with no minimum. A
not spend shows USDC. The token permission warning on the signature screen has later step that sets the output side sets it anew. Otherwise `Token Out` and
its own amount wording, including `Unknown`; the SignApproval section below `Min. received` keep the output side's own token and figure, whether the swap
describes it. 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.
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
+37 -22
View File
@@ -1223,17 +1223,18 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// 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 later step may pay that ETH out as WETH (see after the
// loop). `unwrapIntoRouterStep` is the index of that UNWRAP_WETH when // loop). `unwrapIntoRouterStep` is the index of that UNWRAP_WETH when
// it paid the ETH to the router, which then holds it, otherwise null. // it may have paid the ETH to the router, which then holds it: its
// recipient is the router or cannot be read. Otherwise it is null.
let outputUnwrapped = false; let outputUnwrapped = false;
let unwrapIntoRouterStep = null; let unwrapIntoRouterStep = null;
// The index of the step that set the output side. // The index of the step that set the output side.
let outputStep = -1; let outputStep = -1;
// The index of the last step that may change the router's WETH: pay // The index of the last step this decoder treats as one that may take
// out WETH the router holds, add to it, or wrap the router's ETH. // WETH out of the router or pay anyone WETH: any step but one it read
// Every step may, but one this decoder read that is a PERMIT2_PERMIT, // that is a PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP,
// a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or // TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of a token
// PAY_PORTION_FULL_PRECISION of a token other than WETH (see // other than WETH, and not of ETH to a WETH contract, which pays the
// keepsRouterWeth in the loop). // router WETH (see keepsRouterWeth in the loop). None of those can.
let lastWethStep = -1; 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
@@ -1434,8 +1435,8 @@ 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 // Set by a branch below that reads a step that cannot take WETH
// router's WETH (see lastWethStep). // out of the router or pay anyone WETH (see lastWethStep).
let keepsRouterWeth = false; let keepsRouterWeth = false;
try { try {
@@ -1623,8 +1624,14 @@ 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( // ETH paid to a WETH contract pays the router WETH.
s.token.toLowerCase(), keepsRouterWeth = !(
WETH_ADDRESSES.includes(s.token.toLowerCase()) ||
(s.token ===
"0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(
s.recipient.toLowerCase(),
))
); );
} else { } else {
payeeUnread = true; payeeUnread = true;
@@ -1663,8 +1670,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
heldByRouter.delete(t.token.toLowerCase()); heldByRouter.delete(t.token.toLowerCase());
} }
keepsRouterWeth = !WETH_ADDRESSES.includes( // ETH paid to a WETH contract pays the router WETH.
t.token.toLowerCase(), keepsRouterWeth = !(
WETH_ADDRESSES.includes(t.token.toLowerCase()) ||
(t.token ===
"0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(
t.recipient.toLowerCase(),
))
); );
} else { } else {
payeeUnread = true; payeeUnread = true;
@@ -1832,9 +1845,10 @@ 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 this decoder treats a step between the two as one
// router's WETH (see lastWethStep), the WETH unwrapped // that may take WETH out of the router or pay anyone WETH
// may be only part of the output side's, or none of it. // (see lastWethStep), the WETH unwrapped may be only part
// of the output side's, or none of it.
// This decoder does not count how much of that step's // This decoder does not count how much of that step's
// minimum then reaches the UNWRAP_WETH, nor read the // minimum then reaches the UNWRAP_WETH, nor read the
// UNWRAP_WETH's own minimum, so the output side keeps no // UNWRAP_WETH's own minimum, so the output side keeps no
@@ -1849,7 +1863,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
outputUnwrapped = true; outputUnwrapped = true;
unwrapIntoRouterStep = unwrapIntoRouterStep =
u !== null && isRouter(u.recipient) ? i : null; u === null || isRouter(u.recipient) ? i : null;
if ( if (
!outputRecipients.every(toRouter) || !outputRecipients.every(toRouter) ||
lastWethStep > outputStep lastWethStep > outputStep
@@ -1898,11 +1912,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} }
// After an UNWRAP_WETH that paid the output side's WETH to the router // After an UNWRAP_WETH that may have paid the output side's WETH to
// as ETH, a later step that may change the router's WETH (see // the router as ETH, a later step may pay that ETH out as WETH, as a
// lastWethStep) may wrap that ETH and pay it out as WETH. The output // WRAP_ETH of it to the user does. This decoder treats every later
// side then reads as WETH with no figure, as this decoder does not // step but those lastWethStep names as one that may, and does not
// count how much of that ETH each later step pays out. // count how much of that ETH each pays out: after one, the output
// side reads as WETH with no figure.
if ( if (
outputUnwrapped && outputUnwrapped &&
unwrapIntoRouterStep !== null && unwrapIntoRouterStep !== null &&
+62 -5
View File
@@ -747,11 +747,13 @@ 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 may take WETH out of the router or pay it WETH, and a step
// an UNWRAP_WETH into the router may wrap its ETH and pay it out as WETH. Each // after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each
// read as ETH with the swap's minimum. A step the decoder read that is a // route read as ETH with the swap's minimum. A step the decoder read that is a
// PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or // PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION of a token other than WETH does neither. // PAY_PORTION_FULL_PRECISION of a token other than WETH, and not of ETH to a
// WETH contract, cannot take WETH out of the router or pay anyone WETH; the
// decoder treats every other step as one that may.
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",
@@ -825,9 +827,13 @@ describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)], steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
}, },
{ {
kind: "a BALANCE_CHECK_ERC20 between them", kind: "a BALANCE_CHECK_ERC20 of another address between them",
steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)], steps: [balanceCheck(USDC, 1n, OTHER), unwrapWeth(MSG_SENDER)],
}, },
{
kind: "a BALANCE_CHECK_ERC20 of the sender with a zero minimum between them",
steps: [balanceCheck(USDC, 0n), unwrapWeth(MSG_SENDER)],
},
{ {
kind: "a SWEEP of USDC between them", kind: "a SWEEP of USDC between them",
steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)], steps: [sweep(USDC, MSG_SENDER), unwrapWeth(MSG_SENDER)],
@@ -869,6 +875,57 @@ 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 router reads the recipient from the last 20 bytes of its word,
// here address(2), the router itself.
test("an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// A SWEEP of WETH pays out WETH, and ETH paid to a WETH contract pays the
// router WETH for it. The decoder treats each as a step that may pay out
// as WETH the swap's output, which the router holds as ETH.
test.each([
{
kind: "a SWEEP of WETH to the user",
step: sweep(WETH, MSG_SENDER),
},
{
kind: "a SWEEP of its ETH to the WETH contract",
step: sweep(ZERO, WETH),
},
{
kind: "a TRANSFER of 0.5 ETH to the WETH contract",
step: transfer(0x05, ZERO, WETH, ONE_WETH / 2n),
},
{
kind: "a PAY_PORTION of all its ETH to the WETH contract",
step: transfer(0x06, ZERO, WETH, 10000n),
},
])(
"an UNWRAP_WETH into the router, then $kind, reads as WETH with no minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
),
);
expect(result.name).toBe("Swap USDT → WETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
},
);
// The user receives 0.5 WETH and the rest of the swap's output as ETH. // 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", () => { 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( const result = decode(