fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

The swap's Amount line took only the first swap step's amount. Each later
V2, V3 or V4 swap step the user pays for now adds what it spends of
Token In to that line, and one that spends another token gets Moved from
the sender, Token moved and Moved to lines of its own. Steps paid from
the router add nothing, and a PERMIT2_PERMIT keeps its own amount.

Model: opus-5-5
This commit is contained in:
2026-10-19 22:42:09 +00:00
parent 671b4d3216
commit 4b5d6de218
4 changed files with 365 additions and 64 deletions
+114 -33
View File
@@ -335,7 +335,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// commands this decoder does not read. That action names both of its
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read.
// address this decoder did not read, and so does the amount a swap step the
// user pays for spends of another token, when this decoder read none.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or
@@ -566,6 +567,7 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
@@ -578,6 +580,7 @@ function decodeV2SwapExactOut(input) {
amountInMax: d[2],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
payerIsUser: d[4],
};
} catch {
return null;
@@ -904,7 +907,9 @@ function totalAmount(amounts) {
// read. `settledFromRouter` is every SETTLE that the router pays from its own
// balance (`payerIsUser` false), as its currency and the amount it pays: a
// quantity, CONTRACT_BALANCE (all the router holds), or null when this
// decoder cannot tell (see below).
// decoder cannot tell (see below). `payerIsUser` is true when the user pays
// the step's Token In: a SETTLE paid by the user (`payerIsUser` true) or a
// SETTLE_ALL, which V4Router always has the user pay, names it.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -919,6 +924,8 @@ function decodeV4Swap(input) {
const routerSettles = [];
// The currency of every SETTLE and SETTLE_ALL.
const settledCurrencies = [];
// The currency of every SETTLE and SETTLE_ALL the user pays.
const settledByUser = [];
// The index of the step's last swap action, read or not, or -1.
let lastSwapAction = -1;
// Set when an action of the step could not be read.
@@ -943,7 +950,9 @@ function decodeV4Swap(input) {
);
settleToken = s[0];
settledCurrencies.push(s[0]);
if (!s[2]) {
if (s[2]) {
settledByUser.push(s[0]);
} else {
routerSettles.push({
currency: s[0],
amount: s[1],
@@ -955,6 +964,7 @@ function decodeV4Swap(input) {
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
settledCurrencies.push(s[0]);
settledByUser.push(s[0]);
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -1055,6 +1065,7 @@ function decodeV4Swap(input) {
recipientUnread,
swapUndecoded,
hasSwapAction: true,
payerIsUser: settledByUser.includes(settleToken),
};
}
@@ -1097,6 +1108,7 @@ function decodeV4Swap(input) {
recipientUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
payerIsUser: present(tokenIn) && settledByUser.includes(tokenIn),
};
} catch {
return null;
@@ -1125,6 +1137,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
let inputFromPermit = false;
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
@@ -1135,8 +1148,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop. A figure is never rendered against a token that
// did not supply it.
// from the same hop, or from hops that all spend that token. A figure
// is never rendered against a token that did not supply it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
@@ -1152,7 +1165,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// states neither half: the output side always, and the input side
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop.
// from a different hop. Each later swap step the user pays for adds
// what it spends, in addUserPaid(), the one place the total is formed.
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -1170,6 +1184,33 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true;
};
// What each swap step the user pays for spends of a token other than
// the input side's, as { token, amount }, in step order.
const otherTokensSpent = [];
// A swap step the user pays for (`payerIsUser` true), after the input
// side is set, takes `amount` of `token` from the sender. Of the input
// side's token it adds to the input side's amount, so the Amount line
// is what the steps spend of it together; when either amount is not a
// quantity, the total is not known and the amount is null. Of another
// token it goes in `otherTokensSpent`. A V2 exact-in of ALREADY_PAID
// takes nothing from the sender, and a permit's amount stays as the
// permit states it.
const addUserPaid = (token, amount) => {
if (amount === ALREADY_PAID) return;
const sameToken =
present(inputToken) &&
token.toLowerCase() === inputToken.toLowerCase();
if (!sameToken) {
otherTokensSpent.push({ token, amount });
return;
}
if (inputFromPermit) return;
inputAmount =
typeof inputAmount === "bigint" && typeof amount === "bigint"
? inputAmount + amount
: null;
};
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
// its own address, holds what it is paid until a later step pays it
@@ -1309,7 +1350,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
if (p) {
setInput(p.token, p.amount);
inputFromPermit = true;
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
@@ -1375,7 +1419,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V3_SWAP_EXACT_IN") {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
@@ -1392,7 +1440,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V2_SWAP_EXACT_IN") {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
@@ -1408,7 +1460,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
@@ -1550,6 +1606,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// other half.
if (!inputEstablished) {
setInput(v4.tokenIn, v4.amountIn);
} else if (v4.payerIsUser) {
addUserPaid(v4.tokenIn, v4.amountIn);
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
@@ -1672,37 +1730,43 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
if (present(inputAmount)) {
// 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.
let amount;
if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount === ALREADY_PAID) {
amount = {
// 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.
const spentText = (amount, info) => {
if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
}
if (amount === ALREADY_PAID) {
return {
raw: ALREADY_PAID_AMOUNT,
display: ALREADY_PAID_AMOUNT,
};
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) {
}
if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" };
}
if (hasV2ExactOut) {
// A V2 exact-out swap spends at most this figure, whichever
// step set the line (its amountInMax, the WRAP_ETH of a swap
// paid in ETH, a permit), so it is said to be a maximum, in
// `raw` too: the wait, success and error screens show `raw` as
// the transaction's amount.
const most = amountText(inputAmount, inInfo);
amount = {
// steps gave it (an amountInMax, the WRAP_ETH of a swap paid
// in ETH, a permit, or several steps' total), so it is said to
// be a maximum, in `raw` too: the wait, success and error
// screens show `raw` as the transaction's amount.
const most = amountText(amount, info);
return {
raw: "Up to " + most.raw,
display: "Up to " + most.display,
};
} else {
amount = amountText(inputAmount, inInfo);
}
return amountText(amount, info);
};
if (present(inputAmount)) {
const amount = spentText(inputAmount, inInfo);
details.push({
label: "Amount",
value: amount.display,
@@ -1710,6 +1774,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// What each swap step the user pays for spends of another token, in
// the shape of the lines below for a PERMIT2_TRANSFER_FROM. An amount
// this decoder did not read is said so.
for (const s of otherTokensSpent) {
details.push({
label: "Moved from the sender",
value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources)).display
: UNDECODED_CURRENCY,
});
details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {