1 Commits
Author SHA1 Message Date
sneak fde4b9fa4d fix: a split swap's Min. received is the sum of its steps' minimums (closes #647)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
When two or more swap steps pay the output token to the sender, the
minimum is now the sum of their minimums, not the last step's alone.
There is no figure when one of them states none or pays part of that
token elsewhere, or when the decoder did not read one of them, or a
step between them, in full. A swap step that spends the output token
drops earlier steps from the sum, so a multi-hop route reads as before.
A step that pays only the router keeps its own minimum, as before.

Model: opus-5-5
2026-10-27 08:17:49 +00:00
3 changed files with 38 additions and 138 deletions
+7 -12
View File
@@ -1266,8 +1266,7 @@ see Transaction Decoding), do not always read as a number. They can read:
the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows
its `amountOut`, the exact amount it buys. When earlier swap steps also pay
that token to the user, the line shows the sum (see Transaction Decoding).
its `amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -3460,16 +3459,12 @@ Currently supported:
name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. The step that sets the output side gives the
minimum. When that step and earlier swap steps each pay the output token to
the transaction's sender, the minimum is the sum of their minimums. There is
no minimum when the decoder reads none from one of them, or when one of them
pays part of that token elsewhere. There is also no minimum when the decoder
did not read one of them, or a step between them, in full. A swap step that
spends the output token drops earlier steps from the sum. A V4 step spends
each token that its swap actions spend. A V4 `SETTLE` or `SETTLE_ALL` of the
output token also drops earlier steps. A `PERMIT2_TRANSFER_FROM` of the output
token drops earlier steps too. The `Amount` line gives the total the steps
minimum reads `Min. paid out`. When two or more swap steps pay the output
token to the transaction's sender, the minimum is the sum of their minimums.
There is no minimum when one of them states none or pays part of that token
elsewhere. There is also no minimum when the decoder did not read one of them,
or a step between them, in full. A swap step that spends the output token
drops earlier steps from the sum. The `Amount` line gives the total the steps
take of `Token In`, from the step that sets the input side on: the first V2 or
V3 exact-in or exact-out, `WRAP_ETH` or V4 swap step that names an input token
or amount. A V4 step with a swap action sets the input side when no earlier
+31 -47
View File
@@ -988,9 +988,6 @@ function totalAmount(amounts) {
// pays it of Token In as the step's amount in place of the rules' Amount, or
// no amount when this decoder cannot tell what that is.
//
// `spent` lists the currency that each swap action spends, for each swap
// action that this decoder could read.
//
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
@@ -1166,8 +1163,6 @@ function decodeV4Swap(input) {
}
}
const spent = swaps.map((s) => s.tokenIn);
// Rule 3.
if (swapUndecoded) {
return {
@@ -1175,7 +1170,6 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
spent,
takes,
settledFromRouter,
paidByUser,
@@ -1186,6 +1180,7 @@ function decodeV4Swap(input) {
};
}
const spent = swaps.map((s) => s.tokenIn);
const bought = swaps.map((s) => s.tokenOut);
// Rules 1 and 2 for Token In.
@@ -1221,7 +1216,6 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
spent,
takes,
settledFromRouter,
paidByUser,
@@ -1479,32 +1473,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// Each swap step that paid the sender some of the token it bought,
// as { step, token, amount, onlyToSender }: the step's index, that
// token, the step's stated minimum, and whether the step paid all of
// that token to the sender. A later step that spends that token takes
// the step off this list (see dropFromSum()).
// that token to the sender. A later swap step that spends that token
// takes the step off this list.
let swapsPayingSender = [];
// The step being read spends `token`, so it may spend what an earlier
// swap step paid the sender. Each step on swapsPayingSender that
// bought `token` leaves the list. These steps spend a token:
// - a V2 or V3 swap step spends its input token;
// - a V4 step spends each token that one of its swap actions spends,
// and each token that one of its SETTLEs or SETTLE_ALLs pays;
// - a PERMIT2_TRANSFER_FROM spends the token it moves.
const dropFromSum = (token) => {
swapsPayingSender = swapsPayingSender.filter(
(s) => !sameAddress(s.token, token),
);
};
// The minimum the output side shows for the swap step at `step`. The
// step buys `token` with the stated minimum `amount`, and pays
// `token` to `recipients`. The minimum is the step's own, unless this
// step and an earlier one on swapsPayingSender both pay `token` to
// the sender, as a route split across steps does. The minimum is then
// the total of all such steps' minimums. There is none when this
// decoder reads no minimum from one of them, or when one of them pays
// some of `token` elsewhere. There is also none when this decoder did
// not read one of them, or a step between them, in full (see
// stepsUnread).
const swapMinimum = (step, token, amount, recipients) => {
// step spends `tokenIn`, buys `token` with the stated minimum
// `amount`, and pays `token` to `recipients`. The minimum is the
// step's own, unless this step and an earlier one on
// swapsPayingSender both pay `token` to the sender, as a route split
// across steps does. The minimum is then the total of all such steps'
// minimums. There is none when one of them states no minimum or pays
// some of `token` elsewhere, or when this decoder did not read in full
// one of them or a step between them (see stepsUnread).
const swapMinimum = (step, tokenIn, token, amount, recipients) => {
swapsPayingSender = swapsPayingSender.filter(
(s) => !sameAddress(s.token, tokenIn),
);
const toSender = recipients.filter(
(r) => present(r) && isSender(r),
);
@@ -1670,7 +1654,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
: null,
});
}
if (p) dropFromSum(p.token);
} else if (command === "BALANCE_CHECK_ERC20") {
const b = decodeBalanceCheck(inputs[i]);
if (!b) {
@@ -1713,16 +1696,19 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
dropFromSum(s.tokenIn);
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output
// token. swapMinimum() gives the minimum.
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
swapMinimum(
i,
s.tokenIn,
s.tokenOut,
s.amountOutMin,
[s.recipient],
),
[s.recipient],
);
if (!s.payerIsUser) {
@@ -1745,13 +1731,16 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
dropFromSum(s.tokenIn);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOutMin, [
s.recipient,
]),
swapMinimum(
i,
s.tokenIn,
s.tokenOut,
s.amountOutMin,
[s.recipient],
),
[s.recipient],
);
if (!s.payerIsUser) {
@@ -1785,11 +1774,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
dropFromSum(s.tokenIn);
setOutput(
i,
s.tokenOut,
swapMinimum(i, s.tokenOut, s.amountOut, [
swapMinimum(i, s.tokenIn, s.tokenOut, s.amountOut, [
s.recipient,
]),
[s.recipient],
@@ -1965,11 +1953,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addPayee(t.recipient, t.currency);
}
}
for (const token of v4.spent) dropFromSum(token);
for (const s of v4.paidByUser) dropFromSum(s.currency);
for (const s of v4.settledFromRouter) {
dropFromSum(s.currency);
}
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
@@ -2020,6 +2003,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputToken = v4.tokenOut;
minOutput = swapMinimum(
i,
v4.tokenIn,
v4.tokenOut,
v4.amountOutMin,
takeRecipients,
-79
View File
@@ -3763,85 +3763,6 @@ describe("uniswap decoder", () => {
]);
});
// The middle step swaps 0.3 WETH and 400 DAI for USDC, both paid
// by the user. It spends WETH whichever SETTLE comes last.
test.each([
{ order: "WETH first", settled: [WETH_ADDR, DAI_ADDR] },
{ order: "DAI first", settled: [DAI_ADDR, WETH_ADDR] },
])(
"a V4 step that spends WETH and DAI between them leaves the last step's minimum, with the SETTLE of $order",
({ settled }) => {
const wethAndDaiForUsdc = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
WETH_ADDR,
USDC_ADDR,
WETH_03,
1n,
),
encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
1n,
),
...settled.map((token) => encodeV4Settle(token)),
encodeV4TakeAll(USDC_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethAndDaiForUsdc, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. received",
"0.2000 WETH",
]);
},
);
// The middle step has no swap action. Its SETTLE pays 0.3 WETH
// from the user, and its TAKE pays that WETH to another address.
test("a V4 step whose SETTLE pays WETH between them leaves the last step's minimum", () => {
const wethToOther = encodeV4Swap(
new Uint8Array([V4_SETTLE_ID, V4_TAKE_ID]),
[
settle(WETH_ADDR, WETH_03, true),
take(WETH_ADDR, OTHER_ADDR, 0n),
],
);
const result = decodeSteps(
[0x00, 0x10, 0x08],
[V3_TO_WETH, wethToOther, V2_TO_WETH],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
});
test("a PERMIT2_TRANSFER_FROM of WETH between them leaves the last step's minimum", () => {
const result = decodeSteps(
[0x00, 0x02, 0x08],
[
V3_TO_WETH,
permit2TransferFrom(WETH_ADDR, OTHER_ADDR, WETH_03),
V2_TO_WETH,
],
);
expect(minimum(result)).toEqual([
"Min. paid out",
"0.2000 WETH",
]);
});
// The decoder reads no minimum from a V4 exact-out action.
test("a step that states no minimum leaves no figure", () => {
const exactOut = encodeV4Swap(