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
This commit is contained in:
2026-10-20 15:34:11 +00:00
parent 4472746b89
commit 6a878d6836
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
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 when that step paid all its WETH to the router. When it
paid only part of it to the router, as a V4 step does that `TAKE`s some of its
WETH to the user and the rest to the router, or paid the router WETH beside a
payout the decoder cannot read, the swap shows ETH with no minimum: the decoder
does not count how much of that step's minimum reaches the router, nor read the
`UNWRAP_WETH`'s own 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.
minimum reads in ETH when that step paid all its WETH to the router and no step
between it and the `UNWRAP_WETH` is a `TRANSFER`, `PAY_PORTION` or
`PAY_PORTION_FULL_PRECISION` of WETH to anyone but the router, or a `WRAP_ETH`
into the router. Such a step may pay out WETH the router holds, or put other
WETH in place of WETH an earlier step paid out. With such a step, or when that
step paid only part of its WETH to the router, as a V4 step does that `TAKE`s
some of its WETH to the user and the rest to the router, or paid the router WETH
beside a payout the decoder cannot read, the swap shows ETH with no minimum: the
decoder does not count how much of that step's minimum reaches the
`UNWRAP_WETH`, nor read the `UNWRAP_WETH`'s own minimum. After an `UNWRAP_WETH`
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.
`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;
// 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
// 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 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
// 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,
@@ -1272,6 +1281,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true;
outputUnwrapped = false;
outputRecipients = recipients;
routerWethChanged = false;
};
// 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());
}
if (
WETH_ADDRESSES.includes(t.token.toLowerCase()) &&
!isRouter(t.recipient)
) {
routerWethChanged = true;
}
} else {
payeeUnread = true;
stepsUnread.push(i);
@@ -1677,6 +1693,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
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.
if (w.amount === CONTRACT_BALANCE) {
heldByRouter.delete(
@@ -1687,6 +1721,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// amount is known when the step states it and the
// router held no WETH before.
if (isRouter(w.recipient) && w.amount > 0n) {
routerWethChanged = true;
const weth = WETH.get(chainId);
heldByRouter.set(
weth,
@@ -1766,6 +1801,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true;
outputUnwrapped = false;
outputRecipients = takeRecipients;
routerWethChanged = false;
} else if (v4) {
// A step with no swap action sets only the sides its
// 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
// to the router and the router still holds WETH. WETH
// that step paid to the user, or to anyone else, stays
// WETH, so when that step also paid some of its output
// elsewhere, or made a payout this decoder did not read,
// only part of it may be unwrapped. This decoder does not
// count how much of that step's minimum then reaches the
// router, nor read the UNWRAP_WETH's own minimum, so the
// output side keeps no figure.
// WETH. When that step also paid some of its output
// elsewhere or made a payout this decoder did not read,
// or a later step may have paid out WETH the router holds
// or added to it (see routerWethChanged), 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 figure.
const u = decodeUnwrapWeth(inputs[i]);
const toRouter = (r) => present(r) && isRouter(r);
if (
present(outputToken) &&
@@ -1813,11 +1853,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputRecipients.some(toRouter)
) {
outputUnwrapped = true;
if (!outputRecipients.every(toRouter)) {
unwrappedIntoRouter =
u !== null && isRouter(u.recipient);
if (
!outputRecipients.every(toRouter) ||
routerWethChanged
) {
minOutput = null;
}
}
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
// 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
// (https://git.eeqj.de/sneak/AutistMask/issues/611), and when an
// 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,
// 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
// 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
@@ -1711,15 +1800,6 @@ describe("a payout of part of the router's output", () => {
),
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",
data: execute(