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 163 additions and 30 deletions
+20 -11
View File
@@ -1260,17 +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 when that step paid all its WETH to the router. When it minimum reads in ETH when that step paid all its WETH to the router and no step
paid only part of it to the router, as a V4 step does that `TAKE`s some of its between it and the `UNWRAP_WETH` is a `TRANSFER`, `PAY_PORTION` or
WETH to the user and the rest to the router, or paid the router WETH beside a `PAY_PORTION_FULL_PRECISION` of WETH to anyone but the router, or a `WRAP_ETH`
payout the decoder cannot read, the swap shows ETH with no minimum: the decoder into the router. Such a step may pay out WETH the router holds, or put other
does not count how much of that step's minimum reaches the router, nor read the WETH in place of WETH an earlier step paid out. With such a step, or when that
`UNWRAP_WETH`'s own minimum. A later step that sets the output side sets it step paid only part of its WETH to the router, as a V4 step does that `TAKE`s
anew. Otherwise `Token Out` and `Min. received` keep the output side's own token some of its WETH to the user and the rest to the router, or paid the router WETH
and figure, whether the swap was paid in ETH or in a token: a V2 exact-out swap beside a payout the decoder cannot read, the swap shows ETH with no minimum: the
that buys USDC and then unwraps the WETH it did not spend shows USDC. The token decoder does not count how much of that step's minimum reaches the
permission warning on the signature screen has its own amount wording, including `UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. After an `UNWRAP_WETH`
`Unknown`; the SignApproval section below describes it. that pays the output side's WETH to the router as ETH, a `WRAP_ETH` of
`CONTRACT_BALANCE` to anyone but the router, while the router still holds ETH,
pays the output out as WETH again: the swap shows WETH with no minimum, as the
decoder does not count what steps between the two pay out of that ETH. A later
step that sets the output side sets it anew. Otherwise `Token Out` and
`Min. received` keep the output side's own token and figure, whether the swap
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
+53 -9
View File
@@ -1221,8 +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. // again, or a WRAP_ETH pays it out as WETH again (see WRAP_ETH).
// `unwrappedIntoRouter` says whether that UNWRAP_WETH paid the ETH to
// the router, which then holds it.
let outputUnwrapped = false; let outputUnwrapped = false;
let unwrappedIntoRouter = false;
// Set when a step after the one that set the output side may pay out
// WETH the router holds, or add to it: a TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION of WETH to anyone but the router, or a
// WRAP_ETH into the router. Cleared whenever the output side is set
// again.
let routerWethChanged = false;
// 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,
@@ -1272,6 +1281,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = recipients; outputRecipients = recipients;
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
@@ -1641,6 +1651,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
heldByRouter.delete(t.token.toLowerCase()); heldByRouter.delete(t.token.toLowerCase());
} }
if (
WETH_ADDRESSES.includes(t.token.toLowerCase()) &&
!isRouter(t.recipient)
) {
routerWethChanged = true;
}
} else { } else {
payeeUnread = true; payeeUnread = true;
stepsUnread.push(i); stepsUnread.push(i);
@@ -1677,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(
@@ -1687,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,
@@ -1766,6 +1801,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = takeRecipients; outputRecipients = takeRecipients;
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:
@@ -1799,12 +1835,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// among it when the step that set the output side paid it // among it when the step that set the output side paid it
// to the router and the router still holds WETH. WETH // to the router and the router still holds WETH. WETH
// that step paid to the user, or to anyone else, stays // that step paid to the user, or to anyone else, stays
// WETH, so 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,
// only part of it may be unwrapped. This decoder does not // or a later step may have paid out WETH the router holds
// count how much of that step's minimum then reaches the // or added to it (see routerWethChanged), the WETH
// router, nor read the UNWRAP_WETH's own minimum, so the // unwrapped may be only part of the output side's, or
// output side keeps no figure. // none of it. This decoder does not count how much of
// that step's minimum then reaches the UNWRAP_WETH, nor
// read the UNWRAP_WETH's own minimum, so the output side
// keeps no figure.
const u = decodeUnwrapWeth(inputs[i]);
const toRouter = (r) => present(r) && isRouter(r); const toRouter = (r) => present(r) && isRouter(r);
if ( if (
present(outputToken) && present(outputToken) &&
@@ -1813,11 +1853,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputRecipients.some(toRouter) outputRecipients.some(toRouter)
) { ) {
outputUnwrapped = true; outputUnwrapped = true;
if (!outputRecipients.every(toRouter)) { unwrappedIntoRouter =
u !== null && isRouter(u.recipient);
if (
!outputRecipients.every(toRouter) ||
routerWethChanged
) {
minOutput = null; minOutput = null;
} }
} }
const u = decodeUnwrapWeth(inputs[i]);
if (u) { if (u) {
addPayee(u.recipient); addPayee(u.recipient);
// It pays out all the router's WETH, as ETH. Into the // It pays out all the router's WETH, as ETH. Into the
+90 -10
View File
@@ -11,7 +11,8 @@
// what each PERMIT2_TRANSFER_FROM moves // what each PERMIT2_TRANSFER_FROM moves
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an // (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an
// UNWRAP_WETH makes the output ETH // UNWRAP_WETH makes the output ETH
// (https://git.eeqj.de/sneak/AutistMask/issues/607). // (https://git.eeqj.de/sneak/AutistMask/issues/607 and
// https://git.eeqj.de/sneak/AutistMask/issues/642).
// //
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION, // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -745,6 +746,94 @@ 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
// the UNWRAP_WETH that pays out part of the router's WETH, or a WRAP_ETH into
// the router after the swap's WETH was paid out, may leave less of the swap's
// WETH to unwrap than its minimum; an UNWRAP_WETH into the router, then a
// WRAP_ETH of all its ETH to the user, pays the user WETH. Each read as ETH
// with the swap's minimum.
describe("what reaches the UNWRAP_WETH of the swap's WETH", () => {
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
(l) => l !== "Min. received",
);
const NO_MINIMUM_ELSEWHERE = PAID_ELSEWHERE.filter(
(l) => l !== "Min. paid out",
);
test.each([
{
kind: "a TRANSFER of 0.0025 WETH",
step: transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips",
step: transfer(0x06, WETH, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15",
step: transfer(0x07, WETH, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with no minimum",
({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
// The SWEEP pays the user the swap's WETH, so the UNWRAP_WETH unwraps the
// 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", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
sweep(WETH, MSG_SENDER),
wrapEth(ADDRESS_THIS, ONE_WETH),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual(NO_MINIMUM_TO_USER);
});
test("an UNWRAP_WETH into the router, then a WRAP_ETH of all its ETH to the user, reads as WETH with no minimum", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
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 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", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
swapToWeth(ADDRESS_THIS),
unwrapWeth(MSG_SENDER),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
),
);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the // 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 // 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 // only the swap's output. A V4 SETTLE can take more of the user's tokens than
@@ -1711,15 +1800,6 @@ describe("a payout of part of the router's output", () => {
), ),
min: "1.0000 WETH", min: "1.0000 WETH",
}, },
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{ {
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL", kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute( data: execute(