1 Commits
Author SHA1 Message Date
sneak 628bc07c27 fix: a V4 SETTLE of ETH paid from the router reads as moved from the sender (closes #682)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
V4Router pays a SETTLE of ETH from the router's own ETH, whichever payer
it names. The router holds the transaction's value. So a SETTLE of ETH
that names the router as payer now counts as paid by the user.

When an earlier step left ETH in the router, such a SETTLE gets lines of
its own. Its line reads "Up to" and its amount, and that amount never
joins the Amount total. When the decoder does not know the SETTLE's
amount, as for CONTRACT_BALANCE, its line reads "Unknown (could not be
decoded)", also on the step that sets the input side.

One existing row in tests/uniswapRecipient.test.js now expects the ETH
lines. Its SETTLE spends all the router's ETH, the transaction's value
included.

Model: opus-5-5
2026-10-27 05:18:43 +00:00
3 changed files with 143 additions and 76 deletions
+67 -64
View File
@@ -3473,72 +3473,75 @@ Currently supported:
the user's wallet. Every V4 step, with a swap action or without, takes from the user's wallet. Every V4 step, with a swap action or without, takes from
the user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and the user's wallet what its `SETTLE`s paid by the user (`payerIsUser` true) and
its `SETTLE_ALL`s pay, for each token on its own; what the step that sets the its `SETTLE_ALL`s pay, for each token on its own; what the step that sets the
input side takes of any other token counts as a later step's take. A V4 input side takes of any other token counts as a later step's take. A `SETTLE`
`SETTLE` of ETH paid from the router still takes from the user's wallet, since of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
the router holds the transaction's value. When an earlier step left ETH in the
router, such a `SETTLE` gets lines of its own, with `Up to` before its amount.
A `SETTLE` of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of
`OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step `OPEN_DELTA` or a `SETTLE_ALL` pays what the step owes, as above. So a V4 step
that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the that swaps 400 USDT, with a `SETTLE` of 5,000 USDT paid by the user and the
rest taken to another address, reads `5000.0000 USDT`. A later step paid from rest taken to another address, reads `5000.0000 USDT`. A later step that pays
the router spends what the router holds and takes nothing, and so does a V2 an ERC-20 token from the router takes none of that token from the user's
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is wallet. A V2 exact-in whose `amountIn` is `ALREADY_PAID` takes nothing either.
added to the total while both are quantities: a V3 exact-in of 600 USDT and a A V4 `SETTLE` of ETH paid from the router still takes from the user's wallet,
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When since the router holds the transaction's value. When the decoder does not know
the decoder does not know a take's amount, the total is not known and there is the amount of such a `SETTLE`, as for `CONTRACT_BALANCE`, that `SETTLE` counts
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount, as a later step's take. When an earlier step left ETH in the router, that ETH
`Unlimited` included, stands as the `Amount` figure in place of the total, may pay part of a `SETTLE` of ETH paid from the router. That `SETTLE` then
which it does only in the plain shape of Uniswap's own routes: it comes before gets lines of its own, with `Up to` before its amount. A later take of
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the `Token In` that only the user's wallet pays is added to the total while both
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both
the router may not let it fail, no step before it takes its token, a paid by the user, read `1000.0000 USDT`. When the decoder does not know a
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know, take's amount, the total is not known and there is no `Amount` line. A
and the decoder reads every step after it in full. Permit2 then lets the `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` included,
router take no more of the token than that amount, so the permit covers every stands as the `Amount` figure in place of the total, which it does only in the
take of it. A step before the permit takes under whatever allowance the router plain shape of Uniswap's own routes: it comes before the step that sets the
already had, so it may take more than the permit's amount, which would then input side, it is the only `PERMIT2_PERMIT` in the transaction and there is no
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3 fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A included, or a token the decoder does not know, and the decoder reads every
later permit or batch may raise what Permit2 lets the router take, and so may step after it in full. Permit2 then lets the router take no more of the token
a step the decoder does not read in full: a command the decoder does not read than that amount, so the permit covers every take of it. A step before the
(listed at the end of this section), a step whose input it cannot read, in permit takes under whatever allowance the router already had, so it may take
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before more than the permit's amount, which would then read lower than what leaves
the step that sets the input side or after it, leaves `Token In` and the the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router,
`Amount` line as they read with no permit, unless it lets a spender other than then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid
the router take `Token In` (see below): a permit of 5,000 DAI to another from the router, read `Amount 2000.0000 USDT`. A later permit or batch may
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with raise what Permit2 lets the router take, and so may a step the decoder does
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI not read in full: a command the decoder does not read (listed at the end of
to the router. A permit on the zero address never stands as the `Amount` this section), a step whose input it cannot read, in whole or in part, and a
figure, because Permit2 has no allowance of ETH. When a permit's amount stands step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the
and the step that sets the input side spends a token other than the permit's, input side or after it, leaves `Token In` and the `Amount` line as they read
or one the decoder does not know, that step's amount gets lines of its own, with no permit, unless it lets a spender other than the router take `Token In`
unless it states no amount and the user pays it none of its input token. Each (see below): a permit of 5,000 DAI to another address and then a V3 exact-in
take the total does not count gives three lines after the `Amount` line (after of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do
`Token In` when there is none): one of a token other than `Token In`; one of that exact-in and then a permit of 5,000 DAI to the router. A permit on the
`Token In` after an amount that is not a quantity (a V4 open delta, zero address never stands as the `Amount` figure, because Permit2 has no
`ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated allowance of ETH. When a permit's amount stands and the step that sets the
amount, as of a V4 exact-out step); and one whose amount the decoder does not input side spends a token other than the permit's, or one the decoder does not
know. When there is no `Amount` line, each take the total counts gets them know, that step's amount gets lines of its own, unless it states no amount and
too, so that every amount the decoder reads is shown: the amount the step that the user pays it none of its input token. Each take the total does not count
sets the input side states, when it is a quantity and the user pays that step gives three lines after the `Amount` line (after `Token In` when there is
(a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of none): one of a token other than `Token In`; one of `Token In` after an amount
its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their
value), and each later take of `Token In` added to it. A step that sets the wording on the `Amount` line, or no stated amount, as of a V4 exact-out step);
input side and is paid from the router takes nothing from the user's wallet one of `Token In` that ETH an earlier step left in the router may pay part of;
and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins and one whose amount the decoder does not know. When there is no `Amount`
of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When line, each take the total counts gets them too, so that every amount the
the step that sets the input side states no amount, the user pays it some of decoder reads is shown: the amount the step that sets the input side states,
its input token, and there is no `Amount` line, that step gets them too when it is a quantity and the user pays that step (a V2 or V3 step whose
whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of `payerIsUser` is true, a V4 step the user pays some of its input token, or a
its token gives one, on the lines below or, into the router, on its `Paid to` `WRAP_ETH`, whose ETH comes from the transaction's value), and each later take
line, so that figure cannot read as all that leaves. When it states no amount of `Token In` added to it. A step that sets the input side gets none when the
and the user pays it none of its input token, as when the router pays it, that user pays it none of its input token. A `PERMIT2_PERMIT` the decoder cannot
step takes none of that token from the user's wallet and never gets them, with read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give lines
a permit or without. They come in step order, except that the step that sets for both amounts. When the step that sets the input side states no amount, the
the input side comes first when it has them, followed, when there is no user pays it some of its input token, and there is no `Amount` line, that step
`Amount` line, by the other takes the total counts: `Moved from the sender`, gets them too whenever another take's lines give a figure, or a
its amount, worded as the `Amount` line words one, or `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
the router, on its `Paid to` line, so that figure cannot read as all that
leaves. When it states no amount and the user pays it none of its input token,
that step never gets them, with a permit or without. They come in step order,
except that the step that sets the input side comes first when it has them,
followed, when there is no `Amount` line, by the other takes the total counts:
`Moved from the sender`, its amount, worded as the `Amount` line words one, or
`Unknown (could not be decoded)` when the decoder does not know it; `Unknown (could not be decoded)` when the decoder does not know it;
`Token moved`, giving the token's address as the `Token In` line does; and `Token moved`, giving the token's address as the `Token In` line does; and
`Moved to`, which reads `The swap step that spends it`. A token the decoder `Moved to`, which reads `The swap step that spends it`. A token the decoder
+24 -12
View File
@@ -1240,9 +1240,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputEstablished = false; let inputEstablished = false;
// Whether the user pays the step that set the input side: a V2 or V3 // Whether the user pays the step that set the input side: a V2 or V3
// swap step with `payerIsUser` true, a V4 step the user pays some of // swap step with `payerIsUser` true, a V4 step the user pays some of
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH // its input currency (see paidByUser in the loop), or a WRAP_ETH,
// comes from the transaction's value. A step paid from the router // whose ETH comes from the transaction's value. A step that pays an
// takes nothing from the sender. // ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false; let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the // The last PERMIT2_PERMIT this decoder read before a step set the
// input side, or null (see permitStands after the loop). // input side, or null (see permitStands after the loop).
@@ -1841,11 +1842,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
s.amount === CONTRACT_BALANCE ? null : s.amount, s.amount === CONTRACT_BALANCE ? null : s.amount,
})); }));
// What the user pays the step: what decodeV4Swap() says, // What the user pays the step: what decodeV4Swap() says,
// and the ETH of each SETTLE from the router when the // and the ETH of each SETTLE from the router of a known
// router holds no other ETH. // amount when the router holds no other ETH.
const paidByUser = v4 const paidByUser = v4
? v4.paidByUser.concat( ? v4.paidByUser.concat(
routerHeldEth ? [] : ethFromRouter, routerHeldEth
? []
: ethFromRouter.filter((s) =>
present(s.amount),
),
) )
: []; : [];
if (v4) { if (v4) {
@@ -1939,8 +1944,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (setInputHere && p === paidIn) continue; if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount); addUserPaid(p.currency, p.amount);
} }
if (routerHeldEth) { // A SETTLE of ETH from the router that `paidByUser` leaves
for (const p of ethFromRouter) { // out is a take of its own, even on the step that sets
// the input side. Either this decoder does not know its
// amount, or ETH an earlier step left in the router may
// pay part of that amount (see userPaid).
for (const p of ethFromRouter) {
if (!present(p.amount)) {
addUserPaid(p.currency, null);
} else if (routerHeldEth) {
userPaid.push({ userPaid.push({
token: p.currency, token: p.currency,
amount: p.amount, amount: p.amount,
@@ -2100,14 +2112,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, as // 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 // { 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 // its own (see below). The amount of the step that set the input side
// and is paid from the router counts in `total` but is not among them. // counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
const inTotal = []; const inTotal = [];
// Whether the step that set the input side counts as a take from the // 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 // sender, which may get lines of its own. The amount it stated counts
// whoever pays it. Stating none, it counts only when the user pays it // whoever pays it. Stating none, it counts only when the user pays it
// some of its input token: paid from the router, it takes nothing // some of its input token.
// from the sender.
const hasFirstTake = const hasFirstTake =
inputEstablished && (present(inputAmount) || inputPaidByUser); inputEstablished && (present(inputAmount) || inputPaidByUser);
// The take of the step that set the input side, when the total // The take of the step that set the input side, when the total
+52
View File
@@ -3509,6 +3509,58 @@ describe("uniswap decoder", () => {
["Amount", "1.0000 ETH"], ["Amount", "1.0000 ETH"],
]); ]);
}); });
// The WRAP_ETH sets Token In to ETH, and the UNWRAP_WETH leaves
// ETH in the router. The SETTLE may spend that ETH before the
// transaction's value, so its 1 ETH never joins the Amount line.
test("after a WRAP_ETH and an UNWRAP_WETH into the router, keeps the Amount line and shows at most 1 ETH moved from the sender", () => {
const result = decodeSteps(
[0x0b, 0x0c, 0x10],
[
encodeWrapEth(ROUTER_ADDR, ONE_ETH / 2n),
encodeWrapEth(ROUTER_ADDR, 0n), // UNWRAP_WETH, same encoding
v4EthFromRouter(ONE_ETH),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "0.5000 ETH"],
["Moved from the sender", "Up to 1.0000 ETH"],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
// A SETTLE of CONTRACT_BALANCE pays all the router holds, an
// amount the calldata does not state.
test("of CONTRACT_BALANCE, on the step that sets the input side, shows an unknown amount moved from the sender", () => {
const result = decodeSteps(
[0x10],
[
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(
ZERO_ADDR,
USDC_ADDR,
ONE_ETH,
1n,
),
settle(ZERO_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
encodeV4TakeAll(USDC_ADDR, 1n),
],
),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
]);
});
}); });
}); });