fix: a WRAP_ETH says where its ETH goes (closes #747)
check / check (push) Successful in 5m26s
e2e / e2e-chrome (push) Successful in 14m4s
e2e / e2e-firefox (push) Successful in 2m37s

A WRAP_ETH into the router now always reads `Moved to: The router`. That is
true whichever step then takes the WETH. Other takes keep
`The swap step that spends it`.

A later WRAP_ETH of CONTRACT_BALANCE to anyone but the router now gets a
`Moved from the sender` line reading `All the ETH the transaction carries`,
and `Moved to` names the recipient. It gets none after an UNWRAP_WETH into
the router, or while the router holds ETH from a V4 step.

README states both rules.

Model: opus-5-5
This commit is contained in:
2026-11-03 14:07:59 +00:00
parent 39c58b8775
commit c56179abff
3 changed files with 430 additions and 56 deletions
+61 -26
View File
@@ -404,6 +404,11 @@ const OPEN_DELTA = Symbol("v4-open-delta");
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// A later WRAP_ETH of CONTRACT_BALANCE wraps all the ETH the router holds, and
// so all the ETH the transaction carries. The calldata states no quantity for
// it. The approval screen's Value line gives that ETH.
const TRANSACTION_ETH = Symbol("transaction-eth");
// The router's whole-balance amount, universal-router
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
// pays the pair all the router holds of the input token.
@@ -416,6 +421,7 @@ const CONTRACT_BALANCE = 2n ** 255n;
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
const ALREADY_PAID_AMOUNT =
"Whatever an earlier step sent to the pair (V2 already paid)";
const TRANSACTION_ETH_AMOUNT = "All the ETH the transaction carries";
const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
@@ -1267,9 +1273,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false;
// The recipient of a WRAP_ETH that set the input side, as written,
// when it is not the router; otherwise null. That WRAP_ETH pays its
// WETH there, not to a swap step (see the Moved to lines).
// The recipient of a WRAP_ETH that set the input side, as written;
// otherwise null. That WRAP_ETH pays its WETH there (see the Moved to
// lines).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
@@ -1289,6 +1295,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let minOutput = null;
let outputEstablished = false;
let hasUnwrapWeth = false;
// Set once an UNWRAP_WETH pays ETH to the router, which a later
// WRAP_ETH of CONTRACT_BALANCE then wraps.
let unwrappedIntoRouter = 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, or a later step may pay that ETH out as WETH (see after the
@@ -1368,9 +1377,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// After the loop each joins the Amount line or gets lines of its own
// (see permitStands). A take with `atMost` set may take less than
// `amount`: it never joins the Amount line, and its line reads
// "Up to" and the amount. A take with a `recipient`, the ETH of a
// later WRAP_ETH that pays anyone but the router, never joins the
// Amount line either: its Moved to line names that recipient.
// "Up to" and the amount. A take with a `recipient` is the ETH of a
// later WRAP_ETH. If that recipient is not the router, the take never
// joins the Amount line either: its Moved to line names that recipient.
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender, or at most that when `atMost` is set. A
@@ -1906,9 +1915,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
wrapEthRecipient = w.recipient;
} else if (
w.amount > 0n &&
w.amount !== CONTRACT_BALANCE
@@ -1916,17 +1923,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// The ETH of a stated amount comes from the
// transaction's value. ETH an earlier step left in
// the router may pay part of it, and the sender
// then pays at most that amount. CONTRACT_BALANCE
// states no amount to take.
// then pays at most that amount.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: w.amount,
atMost: heldByRouter.has(
"0x0000000000000000000000000000000000000000",
),
recipient: isRouter(w.recipient)
? null
: w.recipient,
recipient: w.recipient,
});
} else if (
w.amount === CONTRACT_BALANCE &&
!isRouter(w.recipient) &&
!unwrappedIntoRouter &&
!heldByRouter.has(
"0x0000000000000000000000000000000000000000",
)
) {
// It pays the recipient all the ETH the transaction
// carries. It gets no lines after an UNWRAP_WETH
// into the router, or while the router holds ETH
// from a V4 step: Uniswap's routes pay back what
// their swap did not spend this way, and carry no
// ETH.
userPaid.push({
token: "0x0000000000000000000000000000000000000000",
amount: TRANSACTION_ETH,
recipient: w.recipient,
});
}
addPayee(w.recipient);
@@ -2169,6 +2192,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
heldByRouter.has(w),
);
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient)) {
unwrappedIntoRouter = true;
}
if (isRouter(u.recipient) && heldWeth) {
heldByRouter.set(
"0x0000000000000000000000000000000000000000",
@@ -2266,8 +2292,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What the steps take that the Amount line does not give, as
// { token, amount, atMost, recipient }. A null token is a step this
// decoder did not read (see spentUnread()), or a token it does not
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that
// pays elsewhere has a recipient (see wrapEthRecipient and userPaid).
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH has
// a recipient (see wrapEthRecipient and userPaid).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, in
@@ -2315,7 +2341,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
typeof total === "bigint" &&
typeof t.amount === "bigint" &&
!t.atMost &&
!present(t.recipient)
(!present(t.recipient) || isRouter(t.recipient))
) {
total += t.amount;
inTotal.push(t);
@@ -2413,13 +2439,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
// The text of an amount the swap steps spend, for the Amount line and
// the lines for other tokens below it. Three amounts need no scale to
// describe and are named rather than formatted: V4's open delta and
// V2's already-paid zero, neither of which is a quantity at all (see
// OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be
// compared with one. `atMost` marks an amount that is only the most a
// take may be (see userPaid).
// the lines for other tokens below it. Four amounts need no scale to
// describe and are named rather than formatted: V4's open delta, V2's
// already-paid zero and the ETH the transaction carries, none of which
// is a quantity at all (see OPEN_DELTA, ALREADY_PAID and
// TRANSACTION_ETH), and an unbounded permit. Those three tests come
// first — the sentinels are not bigints and cannot be compared with
// one. `atMost` marks an amount that is only the most a take may be
// (see userPaid).
const spentText = (amount, info, atMost) => {
if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
@@ -2430,6 +2457,12 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
display: ALREADY_PAID_AMOUNT,
};
}
if (amount === TRANSACTION_ETH) {
return {
raw: TRANSACTION_ETH_AMOUNT,
display: TRANSACTION_ETH_AMOUNT,
};
}
if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" };
}
@@ -2489,8 +2522,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this
// decoder does not know is said so, and a step it did not read gets
// that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient;
// `MSG_SENDER` means the sender.
// that spends it. A take with a recipient goes to that recipient:
// the router, the sender as `MSG_SENDER`, or an address.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
@@ -2514,6 +2547,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
label: "Moved to",
value: "The swap step that spends it",
});
} else if (isRouter(s.recipient)) {
details.push({ label: "Moved to", value: "The router" });
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {