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
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
every step between it and the `UNWRAP_WETH` is one the decoder read that cannot
pay out WETH the router holds, add to it, or wrap the router's ETH: a
every step between it and the `UNWRAP_WETH` is one the decoder read of these: a
`PERMIT2_PERMIT`, a `BALANCE_CHECK_ERC20`, or a `SWEEP`, `TRANSFER`,
`PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH.
Otherwise the swap shows ETH with no minimum, as it does after a V4 step that
`TAKE`s some of its WETH to the user and the rest to the router, or that pays
the router WETH beside a payout the decoder cannot read, and with a WETH
interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`: the decoder does
not count how much of that step's minimum reaches the `UNWRAP_WETH`, nor read
the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH` pays the router, which
then holds the ETH, any later step but those listed may wrap the ETH and pay it
out as WETH: after one, the swap shows WETH with no minimum. 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.
`PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH, but
not of ETH to a WETH contract, which pays the router WETH. None of these can
take WETH out of the router or pay anyone WETH; the decoder treats every other
step as one that may. Otherwise the swap shows ETH with no minimum, as it does
after a V4 step that `TAKE`s some of its WETH to the user and the rest to the
router, or that pays the router WETH beside a payout the decoder cannot read,
and with a WETH interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`:
the decoder does not count how much of that step's minimum reaches the
`UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH`
pays the router, or its recipient cannot be read, the router may hold the ETH,
and a later step may pay it out as WETH, as a `WRAP_ETH` of it to the user does:
after any later step but those listed, the swap shows WETH with no minimum. 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.
`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
// 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
// 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 unwrapIntoRouterStep = null;
// The index of the step that set the output side.
let outputStep = -1;
// The index of the last step that may change the router's WETH: pay
// out WETH the router holds, add to it, or wrap the router's ETH.
// Every step may, but one this 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 (see
// keepsRouterWeth in the loop).
// The index of the last step this decoder treats as one that may take
// WETH out of the router or pay anyone WETH: any step but one it 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, which pays the
// router WETH (see keepsRouterWeth in the loop). None of those can.
let lastWethStep = -1;
// 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
@@ -1434,8 +1435,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
? COMMAND_NAMES[command]
: "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).
// Set by a branch below that reads a step that cannot take WETH
// out of the router or pay anyone WETH (see lastWethStep).
let keepsRouterWeth = false;
try {
@@ -1623,8 +1624,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase());
}
keepsRouterWeth = !WETH_ADDRESSES.includes(
s.token.toLowerCase(),
// ETH paid to a WETH contract pays the router WETH.
keepsRouterWeth = !(
WETH_ADDRESSES.includes(s.token.toLowerCase()) ||
(s.token ===
"0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(
s.recipient.toLowerCase(),
))
);
} else {
payeeUnread = true;
@@ -1663,8 +1670,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) {
heldByRouter.delete(t.token.toLowerCase());
}
keepsRouterWeth = !WETH_ADDRESSES.includes(
t.token.toLowerCase(),
// ETH paid to a WETH contract pays the router WETH.
keepsRouterWeth = !(
WETH_ADDRESSES.includes(t.token.toLowerCase()) ||
(t.token ===
"0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(
t.recipient.toLowerCase(),
))
);
} else {
payeeUnread = true;
@@ -1832,9 +1845,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// that step paid to the user, or to anyone else, stays
// WETH. When that step also paid some of its output
// elsewhere or made a payout this decoder did not read,
// or a step between the two may have changed the
// router's WETH (see lastWethStep), the WETH unwrapped
// may be only part of the output side's, or none of it.
// or this decoder treats a step between the two as one
// that may take WETH out of the router or pay anyone WETH
// (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
// minimum then reaches the UNWRAP_WETH, nor read the
// UNWRAP_WETH's own minimum, so the output side keeps no
@@ -1849,7 +1863,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) {
outputUnwrapped = true;
unwrapIntoRouterStep =
u !== null && isRouter(u.recipient) ? i : null;
u === null || isRouter(u.recipient) ? i : null;
if (
!outputRecipients.every(toRouter) ||
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
// 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.
// After an UNWRAP_WETH that may have paid the output side's WETH to
// the router as ETH, a later step may pay that ETH out as WETH, as a
// WRAP_ETH of it to the user does. This decoder treats every later
// step but those lastWethStep names as one that may, and does not
// count how much of that ETH each pays out: after one, the output
// side reads as WETH with no figure.
if (
outputUnwrapped &&
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
// the UNWRAP_WETH may pay out the router's WETH or add to it, and a step after
// an UNWRAP_WETH into the router may wrap its ETH and pay it out as WETH. Each
// read as ETH with the swap's minimum. A step the decoder read that is a
// the UNWRAP_WETH may take WETH out of the router or pay it WETH, and a step
// after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each
// 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
// 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", () => {
const NO_MINIMUM_TO_USER = PAID_TO_USER.filter(
(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)],
},
{
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)],
},
{
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",
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);
});
// 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.
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(