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 44 additions and 118 deletions
+17 -19
View File
@@ -1261,26 +1261,24 @@ 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 of these: a 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
`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, but `PAY_PORTION` or `PAY_PORTION_FULL_PRECISION` of a token other than WETH.
not of ETH to a WETH contract, which pays the router WETH. None of these can Otherwise the swap shows ETH with no minimum, as it does after a V4 step that
take WETH out of the router or pay anyone WETH; the decoder treats every other `TAKE`s some of its WETH to the user and the rest to the router, or that pays
step as one that may. Otherwise the swap shows ETH with no minimum, as it does the router WETH beside a payout the decoder cannot read, and with a WETH
after a V4 step that `TAKE`s some of its WETH to the user and the rest to the interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`: the decoder does
router, or that pays the router WETH beside a payout the decoder cannot read, not count how much of that step's minimum reaches the `UNWRAP_WETH`, nor read
and with a WETH interface fee paid by `PAY_PORTION` before the `UNWRAP_WETH`: the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH` pays the router, which
the decoder does not count how much of that step's minimum reaches the then holds the ETH, any later step but those listed may wrap the ETH and pay it
`UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. When that `UNWRAP_WETH` out as WETH: after one, the swap shows WETH with no minimum. A later step that
pays the router, or its recipient cannot be read, the router may hold the ETH, sets the output side sets it anew. Otherwise `Token Out` and `Min. received`
and a later step may pay it out as WETH, as a `WRAP_ETH` of it to the user does: keep the output side's own token and figure, whether the swap was paid in ETH or
after any later step but those listed, the swap shows WETH with no minimum. A in a token: a V2 exact-out swap that buys USDC and then unwraps the WETH it did
later step that sets the output side sets it anew. Otherwise `Token Out` and not spend shows USDC. The token permission warning on the signature screen has
`Min. received` keep the output side's own token and figure, whether the swap its own amount wording, including `Unknown`; the SignApproval section below
was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then describes it.
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
+22 -37
View File
@@ -1223,18 +1223,17 @@ 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 may have paid the ETH to the router, which then holds it: its // it paid the ETH to the router, which then holds it, otherwise null.
// 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 this decoder treats as one that may take // The index of the last step that may change the router's WETH: pay
// WETH out of the router or pay anyone WETH: any step but one it read // out WETH the router holds, add to it, or wrap the router's ETH.
// that is a PERMIT2_PERMIT, a BALANCE_CHECK_ERC20, or a SWEEP, // Every step may, but one this decoder read that is a PERMIT2_PERMIT,
// TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION of a token // a BALANCE_CHECK_ERC20, or a SWEEP, TRANSFER, PAY_PORTION or
// other than WETH, and not of ETH to a WETH contract, which pays the // PAY_PORTION_FULL_PRECISION of a token other than WETH (see
// router WETH (see keepsRouterWeth in the loop). None of those can. // keepsRouterWeth in the loop).
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
@@ -1435,8 +1434,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 take WETH // Set by a branch below that reads a step that cannot change the
// out of the router or pay anyone WETH (see lastWethStep). // router's WETH (see lastWethStep).
let keepsRouterWeth = false; let keepsRouterWeth = false;
try { try {
@@ -1624,14 +1623,8 @@ 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());
} }
// ETH paid to a WETH contract pays the router WETH. keepsRouterWeth = !WETH_ADDRESSES.includes(
keepsRouterWeth = !( s.token.toLowerCase(),
WETH_ADDRESSES.includes(s.token.toLowerCase()) ||
(s.token ===
"0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(
s.recipient.toLowerCase(),
))
); );
} else { } else {
payeeUnread = true; payeeUnread = true;
@@ -1670,14 +1663,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
heldByRouter.delete(t.token.toLowerCase()); heldByRouter.delete(t.token.toLowerCase());
} }
// ETH paid to a WETH contract pays the router WETH. keepsRouterWeth = !WETH_ADDRESSES.includes(
keepsRouterWeth = !( t.token.toLowerCase(),
WETH_ADDRESSES.includes(t.token.toLowerCase()) ||
(t.token ===
"0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(
t.recipient.toLowerCase(),
))
); );
} else { } else {
payeeUnread = true; payeeUnread = true;
@@ -1845,10 +1832,9 @@ 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 this decoder treats a step between the two as one // or a step between the two may have changed the
// that may take WETH out of the router or pay anyone WETH // router's WETH (see lastWethStep), the WETH unwrapped
// (see lastWethStep), the WETH unwrapped may be only part // may be only part of the output side's, or none of it.
// 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
@@ -1863,7 +1849,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
@@ -1912,12 +1898,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} }
// After an UNWRAP_WETH that may have paid the output side's WETH to // After an UNWRAP_WETH that paid the output side's WETH to the router
// the router as ETH, a later step may pay that ETH out as WETH, as a // as ETH, a later step that may change the router's WETH (see
// WRAP_ETH of it to the user does. This decoder treats every later // lastWethStep) may wrap that ETH and pay it out as WETH. The output
// step but those lastWethStep names as one that may, and does not // side then reads as WETH with no figure, as this decoder does not
// count how much of that ETH each pays out: after one, the output // count how much of that ETH each later step pays out.
// side reads as WETH with no figure.
if ( if (
outputUnwrapped && outputUnwrapped &&
unwrapIntoRouterStep !== null && unwrapIntoRouterStep !== null &&
+5 -62
View File
@@ -747,13 +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 take WETH out of the router or pay it WETH, and a step // the UNWRAP_WETH may pay out the router's WETH or add to it, and a step after
// after an UNWRAP_WETH into the router may pay its ETH out as WETH, yet each // an UNWRAP_WETH into the router may wrap its ETH and pay it out as WETH. Each
// route read as ETH with the swap's minimum. A step the decoder read that is a // 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, and not of ETH to a // PAY_PORTION_FULL_PRECISION of a token other than WETH does neither.
// 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",
@@ -827,13 +825,9 @@ 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 of another address between them", kind: "a BALANCE_CHECK_ERC20 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)],
@@ -875,57 +869,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 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(