fix: a swap's WETH minimum reads as ETH only when all its WETH reaches the UNWRAP_WETH (closes #642)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

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.
Only a step that sets the output side again brings back ETH or a
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
This commit was merged in pull request #645.
This commit is contained in:
2026-10-20 21:40:50 +02:00
parent 93c34e1a2a
commit a3da448f0d
3 changed files with 472 additions and 36 deletions
+361 -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,365 @@ 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 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, 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",
);
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]);
},
);
test.each([
{
kind: "a SWEEP whose recipient cannot be read",
step: withDirtyWord(sweep(WETH, OTHER), 1),
},
{
kind: "a V4 SETTLE of 0.5 WETH from the router",
step: v4Swap([
0x0b,
coder.encode(
["address", "uint256", "bool"],
[WETH, ONE_WETH / 2n, false],
),
]),
},
{
kind: "a bridge deposit of 0.5 WETH",
step: acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
},
{
kind: "a PERMIT2_TRANSFER_FROM of 0.5 WETH into the router",
step: permit2TransferFrom(WETH, ADDRESS_THIS, ONE_WETH / 2n),
},
])("$kind between them reads as ETH with no minimum", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step, unwrapWeth(MSG_SENDER)),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
test.each([
{
kind: "a PERMIT2_PERMIT between them",
steps: [permit2Permit(USDT, USDT_2000), unwrapWeth(MSG_SENDER)],
},
{
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)],
},
{
kind: "an UNWRAP_WETH into the router, then a SWEEP of its ETH,",
steps: [unwrapWeth(ADDRESS_THIS), sweep(ZERO, MSG_SENDER)],
},
])("$kind reads as ETH with the swap's minimum", ({ steps }) => {
const result = decode(execute(swapToWeth(ADDRESS_THIS), ...steps));
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test("a V4 step that TAKEs its WETH to the router, then an UNWRAP_WETH to the user, reads as ETH with the swap's minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received").value).toBe("1.0000 ETH");
});
test.each([
{
kind: "a TRANSFER of 100 USDC",
step: transfer(0x05, USDC, OTHER, 100000000n),
},
{
kind: "a PAY_PORTION of 25 bips of USDC",
step: transfer(0x06, USDC, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of USDC",
step: transfer(0x07, USDC, OTHER, 10n ** 15n),
},
])(
"$kind to another address between them reads as ETH with the swap's 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(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
test.each([
{
kind: "a TRANSFER of 0.0025 ETH",
step: transfer(0x05, ZERO, OTHER, ONE_WETH / 400n),
},
{
kind: "a PAY_PORTION of 25 bips of its ETH",
step: transfer(0x06, ZERO, OTHER, 25n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e15 of its ETH",
step: transfer(0x07, ZERO, OTHER, 10n ** 15n),
},
])(
"an UNWRAP_WETH into the router, then $kind to another address, then a SWEEP of its ETH to the user, reads as ETH with the swap's minimum",
({ step }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
step,
sweep(ZERO, MSG_SENDER),
),
USER,
ROUTER_V2_1_2,
);
expect(result.name).toBe("Swap USDT → ETH");
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 ETH");
},
);
// 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 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();
});
// The first UNWRAP_WETH may pay the router the swap's output as ETH, and
// the WRAP_ETH to the user pays all of it out as WETH, so the last
// UNWRAP_WETH finds none of it.
test.each([
{
kind: "an UNWRAP_WETH whose recipient cannot be read, then a WRAP_ETH of all the router's ETH to the user",
steps: [
withDirtyWord(unwrapWeth(ADDRESS_THIS), 0),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
],
},
{
kind: "an UNWRAP_WETH into the router, a WRAP_ETH of all its ETH to the user, then a WRAP_ETH of all its ETH into the router",
steps: [
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, CONTRACT_BALANCE),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
],
},
])(
"$kind, then an UNWRAP_WETH to the user, reads as WETH with no minimum",
({ steps }) => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
...steps,
unwrapWeth(MSG_SENDER),
),
);
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(
execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
wrapEth(MSG_SENDER, ONE_WETH / 2n),
sweep(ZERO, MSG_SENDER),
),
);
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");
});
// The first swap is for at least 0.5 WETH and the second for at least
// 1 WETH. The second sets the output side again, so the first
// UNWRAP_WETH into the router no longer bears on it.
test.each([
{ kind: "a V3 swap step", step: swapToWeth(ADDRESS_THIS) },
{
kind: "a V4 step that TAKEs its WETH to the router",
step: v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, ADDRESS_THIS),
),
},
])(
"a swap paying WETH to the router, an UNWRAP_WETH into the router, a SWEEP of its ETH to the user, then $kind and an UNWRAP_WETH to the user, reads as ETH with the second swap's minimum",
({ step }) => {
const result = decode(
execute(
v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
ONE_WETH / 2n,
USDT,
WETH,
),
unwrapWeth(ADDRESS_THIS),
sweep(ZERO, MSG_SENDER),
step,
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → 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 +2071,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(