Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot e3745a2acf fix: WETH paid to the user before an UNWRAP_WETH reads as WETH (closes #607)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
An UNWRAP_WETH made Token Out ETH whenever the output side was WETH, so
a swap that paid its WETH to the user before it read as ETH, though the
user receives WETH. decode() now turns the output into ETH only when the
router holds the output side's WETH when the step runs, which it reads
from what it already follows the router to hold; a later step that sets
the output side sets it anew. So an UNWRAP_WETH that runs before the
swap no longer makes the WETH the swap leaves in the router read as ETH.
README Display Consistency says the same.

Model: opus-5-5
2026-10-20 10:52:09 +00:00
3 changed files with 30 additions and 132 deletions
+14 -17
View File
@@ -1250,23 +1250,20 @@ Decoding). So a step whose USDT-for-USDC swap action could not be read, with a
A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`, A V4 step with no swap action sets only the sides its `SETTLE`, `SETTLE_ALL`,
`TAKE` or `TAKE_ALL` names. `TAKE` or `TAKE_ALL` names.
An `UNWRAP_WETH` step makes `Token Out` ETH only when no step set the output An `UNWRAP_WETH` step makes `Token Out` ETH only when the output side is WETH,
side, or when the output side is WETH, on mainnet or Sepolia, that the step that on mainnet or Sepolia, that the router holds when the step runs (Transaction
set it paid to the router and that the router still holds when the `UNWRAP_WETH` Decoding below gives what the router holds), or when no step set the output
runs (Transaction Decoding below gives what the router holds). The router side; a WETH `Min. received` figure then reads in ETH. A later step that sets
unwraps only the WETH it holds, so WETH that step paid to the user, or to anyone the output side sets it anew. The router unwraps only the WETH it holds, so a
else, stays WETH: a swap that pays the router WETH, then a swap that pays the swap that pays its WETH to the user and then unwraps shows WETH. The decoder
user WETH, then an unwrap, shows WETH with the second swap's minimum. The follows what the router holds by token, not by step: a swap that pays part of
minimum reads in ETH when that step paid all its WETH to the router. When it its WETH to the user and part to the router, and then unwraps, shows ETH with
paid only part of it to the router, as a V4 step does that `TAKE`s some of its the minimum of the step that set the output side. Otherwise `Token Out` and
WETH to the user and the rest to the router, or paid the router WETH beside a `Min. received` keep the output side's own token and figure, whether the swap
payout the decoder cannot read, the swap shows ETH with no minimum, since the was paid in ETH or in a token: a V2 exact-out swap that buys USDC and then
calldata states none for the ETH. A later step that sets the output side sets it unwraps the WETH it did not spend shows USDC. The token permission warning on
anew. Otherwise `Token Out` and `Min. received` keep the output side's own token the signature screen has its own amount wording, including `Unknown`; the
and figure, whether the swap was paid in ETH or in a token: a V2 exact-out swap SignApproval section below describes it.
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. 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
+11 -37
View File
@@ -1223,11 +1223,6 @@ 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. // again.
let outputUnwrapped = false; let outputUnwrapped = false;
// Where the step that set the output side paid its output: the
// recipient of a swap step, as written, or of each TAKE and
// TAKE_PORTION of it in a V4 step, with null for a payout of that
// step this decoder did not read. A BALANCE_CHECK_ERC20 pays nobody.
let outputRecipients = [];
let hasV2OrV3ExactOut = false; let hasV2OrV3ExactOut = false;
let swapUndecoded = false; let swapUndecoded = false;
let hasSubPlan = false; let hasSubPlan = false;
@@ -1264,13 +1259,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!present(token) && !present(amount)) return; if (!present(token) && !present(amount)) return;
setInput(token, amount); setInput(token, amount);
}; };
const setOutput = (token, amount, recipients) => { const setOutput = (token, amount) => {
if (!present(token) && !present(amount)) return; if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null; outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null; minOutput = present(amount) ? amount : null;
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = recipients;
}; };
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
@@ -1516,7 +1510,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// does. It pays nobody. // does. It pays nobody.
const minBalance = allowedToFail ? 0n : b.minBalance; const minBalance = allowedToFail ? 0n : b.minBalance;
if (!(minBalance === 0n && present(minOutput))) { if (!(minBalance === 0n && present(minOutput))) {
setOutput(b.token, minBalance, []); setOutput(b.token, minBalance);
} }
} }
} else if (command === "V3_SWAP_EXACT_IN") { } else if (command === "V3_SWAP_EXACT_IN") {
@@ -1530,7 +1524,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Always update output: in multi-step swaps (V3 → V4), // Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]); setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1550,7 +1544,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn); addUserPaid(s.tokenIn, s.amountIn);
} }
setOutput(s.tokenOut, s.amountOutMin, [s.recipient]); setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) { if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn); spendFromRouter(s.tokenIn, s.amountIn);
} }
@@ -1581,7 +1575,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) { } else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax); addUserPaid(s.tokenIn, s.amountInMax);
} }
setOutput(s.tokenOut, s.amountOut, [s.recipient]); setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
@@ -1739,14 +1733,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
(p) => p.currency === v4.tokenIn, (p) => p.currency === v4.tokenIn,
); );
const amountIn = paidIn ? paidIn.amount : v4?.amountIn; const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
// Where the step pays its output, if it sets the output
// side (see outputRecipients).
const takeRecipients = v4
? v4.takes
.filter((t) => t.currency === v4.tokenOut)
.map((t) => t.recipient)
.concat(v4.recipientUnread ? [null] : [])
: [];
if (v4 && v4.hasSwapAction) { if (v4 && v4.hasSwapAction) {
// A step with a swap action always sets the output // A step with a swap action always sets the output
// side, as the last swap step wins, and sets the // side, as the last swap step wins, and sets the
@@ -1764,14 +1750,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
minOutput = v4.amountOutMin; minOutput = v4.amountOutMin;
outputEstablished = true; outputEstablished = true;
outputUnwrapped = false; outputUnwrapped = false;
outputRecipients = takeRecipients;
} else if (v4) { } else if (v4) {
// A step with no swap action sets only the sides its // A step with no swap action sets only the sides its
// SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names: // SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names:
// setInputOnce() and setOutput() leave alone a side // setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated. // whose two halves are both unstated.
setInputOnce(v4.tokenIn, amountIn); setInputOnce(v4.tokenIn, amountIn);
setOutput(v4.tokenOut, v4.amountOutMin, takeRecipients); setOutput(v4.tokenOut, v4.amountOutMin);
} }
// Every V4 step, with a swap action or without, takes // Every V4 step, with a swap action or without, takes
// from the sender what the user pays it (an amount an // from the sender what the user pays it (an amount an
@@ -1793,27 +1778,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else if (command === "UNWRAP_WETH") { } else if (command === "UNWRAP_WETH") {
hasUnwrapWeth = true; hasUnwrapWeth = true;
// The router unwraps all the WETH it holds, even when the // The router unwraps all the WETH it holds, the output
// recipient cannot be read. The output side's WETH is // side's included when it is WETH the router holds, even
// among it when the step that set the output side paid it // when the recipient cannot be read. WETH a step paid to
// to the router and the router still holds WETH. WETH // anyone else is not unwrapped.
// 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. The calldata then
// states no minimum for the ETH, and the output side keeps
// no figure.
const toRouter = (r) => present(r) && isRouter(r);
if ( if (
present(outputToken) && present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase()) && WETH_ADDRESSES.includes(outputToken.toLowerCase()) &&
heldByRouter.has(outputToken.toLowerCase()) && heldByRouter.has(outputToken.toLowerCase())
outputRecipients.some(toRouter)
) { ) {
outputUnwrapped = true; outputUnwrapped = true;
if (!outputRecipients.every(toRouter)) {
minOutput = null;
}
} }
const u = decodeUnwrapWeth(inputs[i]); const u = decodeUnwrapWeth(inputs[i]);
if (u) { if (u) {
+5 -78
View File
@@ -270,28 +270,18 @@ const SWAP_USDT_FOR_ETH = [
[[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]], [[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]],
), ),
]; ];
const SWAP_USDT_FOR_WETH = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, WETH, 500, 10, ZERO], true, USDT_2000, ONE_WETH, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"]; const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [ const SETTLE_USDT = [
0x0b, 0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]), coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
]; ];
// A TAKE of `amount`, by default zero: the open delta, all the step owes. function take(currency, recipient) {
function take(currency, recipient, amount = 0n) {
return [ return [
0x0e, 0x0e,
coder.encode( coder.encode(
["address", "address", "uint256"], ["address", "address", "uint256"],
[currency, recipient, amount], [currency, recipient, 0n],
), ),
]; ];
} }
@@ -604,9 +594,9 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
}); });
// The user receives the first step's WETH as WETH and the second's as // The user receives the first step's WETH as WETH and the second's as
// ETH. The second step sets the output side and pays its WETH to the // ETH. The second step sets the output side, and the router holds its
// router, so the output reads as ETH with the second step's minimum. // WETH, so the output reads as ETH with the second step's minimum.
test("two swaps, the second paying its WETH to the router, then an UNWRAP_WETH, reads as ETH", () => { test("a swap paying part of its WETH to the user and the rest to the router, then an UNWRAP_WETH, reads as ETH", () => {
const result = decode( const result = decode(
execute( execute(
swapToWeth(MSG_SENDER), swapToWeth(MSG_SENDER),
@@ -621,69 +611,6 @@ describe("an UNWRAP_WETH makes the output ETH only when the router holds it", ()
expect(detail(result, "Min. received").value).toBe("0.5000 ETH"); expect(detail(result, "Min. received").value).toBe("0.5000 ETH");
}); });
// The same two swaps the other way round. The user receives the first
// step's WETH as ETH and the second's as WETH. The second step sets the
// output side and pays its WETH to the user, so the output reads as WETH
// with the second step's minimum, though the router holds the first's.
test("two swaps, the second paying its WETH to the user, then an UNWRAP_WETH, reads as WETH", () => {
const result = decode(
execute(
v2Swap(0x08, ADDRESS_THIS, USDC_1990, ONE_WETH / 2n, [
USDC,
WETH,
]),
swapToWeth(MSG_SENDER),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
});
// One V4 step buys at least 1 WETH and TAKEs 0.6 WETH to the user and the
// rest to the router. The user receives 0.6 WETH and the rest as ETH, so
// neither minimum is shown: the calldata states none for the ETH.
test("one V4 step paying part of its WETH to the user and the rest to the router, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
take(WETH, MSG_SENDER, (ONE_WETH * 6n) / 10n),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Steps",
"Deadline",
]);
});
// A TAKE the decoder cannot read may pay part of the WETH to the user.
test("one V4 step paying WETH to the router beside a TAKE that cannot be read, then an UNWRAP_WETH, reads as ETH with no minimum", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_WETH,
SETTLE_USDT,
withDirtyWord(take(WETH, MSG_SENDER), 1),
take(WETH, ADDRESS_THIS),
),
unwrapWeth(MSG_SENDER),
),
);
expect(result.name).toBe("Swap USDT → ETH");
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeUndefined();
});
// A step after the UNWRAP_WETH that sets the output side sets it anew. // A step after the UNWRAP_WETH that sets the output side sets it anew.
test("a swap paying WETH to the user after an UNWRAP_WETH of an earlier swap's WETH reads as WETH", () => { test("a swap paying WETH to the user after an UNWRAP_WETH of an earlier swap's WETH reads as WETH", () => {
const result = decode( const result = decode(