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
+88 -15
View File
@@ -1221,8 +1221,23 @@ 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 later step may pay that ETH out as WETH (see after the
// loop). `unwrapIntoRouterStep` is the index of the first such
// UNWRAP_WETH that may have paid the ETH to the router, which then
// holds it: its recipient is the router or cannot be read. A later
// UNWRAP_WETH does not replace it. It is null when there is none, and
// is cleared with `outputUnwrapped` when the output side is set again.
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 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
// TAKE_PORTION of it in a V4 step, a TAKE_ALL as paid to the sender,
@@ -1265,13 +1280,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount, recipients) => {
const setOutput = (step, token, amount, recipients) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputEstablished = true;
outputUnwrapped = false;
unwrapIntoRouterStep = null;
outputRecipients = recipients;
outputStep = step;
};
// What the steps take from the sender beyond the amount of the step
@@ -1405,6 +1422,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
);
};
// Whether a SWEEP, TRANSFER, PAY_PORTION or PAY_PORTION_FULL_PRECISION
// of `token` to `recipient` pays anyone WETH (see lastWethStep): it
// does when `token` is WETH, and when it pays ETH to a WETH contract,
// which pays the router WETH for it.
const paysWeth = (token, recipient) =>
WETH_ADDRESSES.includes(token.toLowerCase()) ||
(token === "0x0000000000000000000000000000000000000000" &&
WETH_ADDRESSES.includes(recipient.toLowerCase()));
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
@@ -1421,6 +1446,9 @@ 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 take WETH
// out of the router or pay anyone WETH (see lastWethStep).
let keepsRouterWeth = false;
try {
if (command === "EXECUTE_SUB_PLAN") {
@@ -1452,6 +1480,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
allowedToFail,
beforeEveryTake: !tookBefore,
};
keepsRouterWeth = true;
} else {
stepsUnread.push(i);
}
@@ -1507,6 +1536,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// what the user receives: it sets no side, and the
// step says so.
commandNames[i] += " (of another address)";
keepsRouterWeth = true;
} else {
// The router passes this check whenever the owner
// holds at least minBalance, so a zero one guarantees
@@ -1517,8 +1547,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance, []);
setOutput(i, b.token, minBalance, []);
}
keepsRouterWeth = true;
}
} else if (command === "V3_SWAP_EXACT_IN") {
const s = decodeV3SwapExactIn(inputs[i]);
@@ -1531,7 +1562,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1551,7 +1582,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]);
setOutput(i, s.tokenOut, s.amountOutMin, [s.recipient]);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
@@ -1582,7 +1613,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
setOutput(s.tokenOut, s.amountOut, [s.recipient]);
setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1604,6 +1635,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase());
}
keepsRouterWeth = !paysWeth(s.token, s.recipient);
} else {
payeeUnread = true;
stepsUnread.push(i);
@@ -1641,6 +1673,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) {
heldByRouter.delete(t.token.toLowerCase());
}
keepsRouterWeth = !paysWeth(t.token, t.recipient);
} else {
payeeUnread = true;
stepsUnread.push(i);
@@ -1765,14 +1798,21 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
minOutput = v4.amountOutMin;
outputEstablished = true;
outputUnwrapped = false;
unwrapIntoRouterStep = null;
outputRecipients = takeRecipients;
outputStep = i;
} else if (v4) {
// A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, amountIn);
setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients);
setOutput(
i,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
);
}
// Every V4 step, with a swap action or without, takes
// from the sender what the user pays it (an amount an
@@ -1799,12 +1839,17 @@ 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 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
// figure.
const u = decodeUnwrapWeth(inputs[i]);
const toRouter = (r) => present(r) && isRouter(r);
if (
present(outputToken) &&
@@ -1813,11 +1858,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputRecipients.some(toRouter)
) {
outputUnwrapped = true;
if (!outputRecipients.every(toRouter)) {
if (
unwrapIntoRouterStep === null &&
(u === null || isRouter(u.recipient))
) {
unwrapIntoRouterStep = i;
}
if (
!outputRecipients.every(toRouter) ||
lastWethStep > outputStep
) {
minOutput = null;
}
}
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
// It pays out all the router's WETH, as ETH. Into the
@@ -1854,6 +1907,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
if (!keepsRouterWeth) {
lastWethStep = i;
}
}
// 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. Neither this nor the loss of the
// figure in the UNWRAP_WETH branch is undone by any later step, a
// later UNWRAP_WETH included, but one that sets the output side again.
if (
outputUnwrapped &&
unwrapIntoRouterStep !== null &&
lastWethStep > unwrapIntoRouterStep
) {
outputUnwrapped = false;
minOutput = null;
}
// The Amount line, and the lines for what the steps take beyond it.