fix: the swap decoder names where a WRAP_ETH pays and counts the router's ETH in a V4 settle (closes #665)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A WRAP_ETH that sets the input side and pays its WETH to anyone but
the router now names that recipient on its Moved to line, or reads
The sender for address(1).

V4Router pays ETH from the router's own ETH, whoever a SETTLE names as
payer. ETH the router already holds now pays first for the ETH a V4
step would take from the user, and only the rest is moved from the
sender. An UNWRAP_WETH into the router keeps a known WETH amount as
the ETH the router then holds.

Model: opus-5-5
This commit is contained in:
2026-10-27 00:13:54 +00:00
parent 33ab99b7be
commit 47b186841a
3 changed files with 314 additions and 124 deletions
+103 -32
View File
@@ -1244,6 +1244,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// comes from the transaction's value. A step paid from the router
// takes nothing 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).
let wrapEthRecipient = null;
// The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop).
let permit = null;
@@ -1399,9 +1403,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of
// ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or
// ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid
// from the router (see spendFromRouter()). The value is the amount the
// router holds, in base units, when a WRAP_ETH stated it, otherwise
// null.
// from the router (see spendFromRouter()). A V4 SETTLE of ETH paid by
// the user spends the router's ETH too (see the V4_SWAP branch). The
// value is the amount the router holds, in base units, when a
// WRAP_ETH stated it, otherwise null. An UNWRAP_WETH into the router
// turns that amount of WETH into the same amount of ETH.
const heldByRouter = new Map();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
@@ -1409,8 +1415,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
};
// A swap step or V4 SETTLE pays `amount` of `token` from the router's
// own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all
// of it once their amounts reach that one: a swap paid in ETH wraps
// counts against a known amount the router holds, and the steps pay
// all of it once their amounts reach that one: a swap paid in ETH wraps
// what its swap steps then spend. An amount that is not a quantity,
// such as the null decodeV4Swap() gives a SETTLE whose payment it
// cannot tell, or one of a token whose amount is not known, pays out
@@ -1787,6 +1793,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
inputPaidByUser = true;
if (!isRouter(w.recipient)) {
wrapEthRecipient = w.recipient;
}
}
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
@@ -1815,12 +1824,45 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished;
// What the user pays the step, as decodeV4Swap() gives
// it, less the ETH the router already holds (below).
// Empty when the step cannot be read.
const paidByUser = [];
if (v4) {
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
for (const s of v4.settledFromRouter) {
spendFromRouter(s.currency, s.amount);
}
// V4Router pays ETH from the router's own ETH,
// whoever a SETTLE names as payer. So ETH the router
// holds from an earlier step pays first for the ETH
// the user would pay the step. The user pays only the
// rest. When this decoder does not know how much ETH
// the router holds, or how much the user would pay,
// it counts the router as paying all of that ETH.
for (const p of v4.paidByUser) {
if (
p.currency !==
"0x0000000000000000000000000000000000000000" ||
!heldByRouter.has(p.currency)
) {
paidByUser.push(p);
continue;
}
const held = heldByRouter.get(p.currency);
if (
typeof held === "bigint" &&
typeof p.amount === "bigint" &&
p.amount > held
) {
paidByUser.push({
currency: p.currency,
amount: p.amount - held,
});
}
spendFromRouter(p.currency, p.amount);
}
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency);
@@ -1845,10 +1887,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
commandNames[i] += " (could not be decoded)";
}
// The step's amount, if it sets the input side: what the
// user pays it of its input currency (see
// decodeV4Swap()), or, when the user pays none of it,
// what its swap actions spend of it, whoever pays that.
const paidIn = v4?.paidByUser.find(
// user pays it of its input currency (see paidByUser),
// or, when the user pays none of it, what its swap
// actions spend of it, whoever pays that.
const paidIn = paidByUser.find(
(p) => p.currency === v4.tokenIn,
);
const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
@@ -1902,11 +1944,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined;
if (v4) {
for (const p of v4.paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
}
for (const p of paidByUser) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
}
if (!v4 || v4.settleUnread) {
spentUnread();
@@ -1954,15 +1994,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addPayee(u.recipient);
// It pays out all the router's WETH, as ETH. Into the
// router, the router then holds as ETH the WETH a
// step paid it.
const heldWeth = WETH_ADDRESSES.some((w) =>
// step paid it. That amount is known when the router
// held a known amount of WETH and no ETH before.
const heldWeth = WETH_ADDRESSES.filter((w) =>
heldByRouter.has(w),
);
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient) && heldWeth) {
const ethAmount =
heldWeth.length === 1 &&
!heldByRouter.has(
"0x0000000000000000000000000000000000000000",
)
? heldByRouter.get(heldWeth[0])
: null;
heldWeth.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient) && heldWeth.length > 0) {
heldByRouter.set(
"0x0000000000000000000000000000000000000000",
null,
ethAmount,
);
}
} else {
@@ -2051,14 +2099,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know.
// { token, amount, recipient }. A null token is a step this decoder
// did not read (see spentUnread()), or a token it does not know. Only
// the take of a WRAP_ETH that pays elsewhere has a recipient (see
// wrapEthRecipient).
const spentFromSender = [];
let total = null;
// The takes from the sender that `total` adds up, each a quantity, as
// { token, amount }. When there is no Amount line, each gets lines of
// its own (see below). The amount of a step that set the input side
// and is paid from the router counts in `total` but is not among them.
// The takes from the sender that `total` adds up, each a quantity, in
// the shape of `spentFromSender`. When there is no Amount line, each
// gets lines of its own (see below). The amount of a step that set
// the input side and is paid from the router counts in `total` but is
// not among them.
const inTotal = [];
// Whether the step that set the input side counts as a take from the
// sender, which may get lines of its own. The amount it stated counts
@@ -2073,7 +2124,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!permitStands || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (typeof inputAmount === "bigint" && inputPaidByUser) {
inTotal.push({ token: inputToken, amount: inputAmount });
inTotal.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (hasFirstTake && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
@@ -2081,7 +2136,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (hasFirstTake) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so its amount gets lines of its own.
spentFromSender.push({ token: inputToken, amount: inputAmount });
spentFromSender.push({
token: inputToken,
amount: inputAmount,
recipient: wrapEthRecipient,
});
}
if (permitStands) inputToken = permit.token;
for (const t of userPaid) {
@@ -2260,7 +2319,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// What steps take from the sender beyond the Amount line, in the shape
// 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.
// that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes there, and MSG_SENDER
// there is the sender.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
@@ -2270,10 +2331,20 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
if (!present(s.recipient)) {
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
} else if (s.recipient.toLowerCase() === MSG_SENDER) {
details.push({ label: "Moved to", value: "The sender" });
} else {
details.push({
label: "Moved to",
value: s.recipient,
address: s.recipient,
});
}
}
}