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) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
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 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
input side takes of any other token counts as a later step's take. A V4
`SETTLE` of ETH paid from the router still takes from the user's wallet, since
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
input side takes of any other token counts as a later step's take. 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
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
the router spends what the router holds and takes nothing, and so does a V2
exact-in whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is
added to the total while both are quantities: a V3 exact-in of 600 USDT and a
V2 exact-in of 400 USDT, both paid by the user, read `1000.0000 USDT`. When
the decoder does not know a take's amount, the total is not known and there is
no `Amount` line. A `PERMIT2_PERMIT` names `Token In` only when its amount,
`Unlimited` included, stands as the `Amount` figure in place of the total,
which it does only in the plain shape of Uniswap's own routes: it comes before
the step that sets the input side, it is the only `PERMIT2_PERMIT` in the
transaction and there is no `PERMIT2_PERMIT_BATCH`, its spender is the router,
the router may not let it fail, no step before it takes its token, a
`PERMIT2_TRANSFER_FROM` of it included, or a token the decoder does not know,
and the decoder reads every step after it in full. Permit2 then lets the
router take no more of the token than that amount, so the permit covers every
take of it. A step before the permit takes under whatever allowance the router
already had, so it may take more than the permit's amount, which would then
read lower than what leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of
2,000 USDT into the router, then a permit of 600 USDT to the router and a V3
exact-in of 2,000 USDT paid from the router, read `Amount 2000.0000 USDT`. A
later permit or batch may raise what Permit2 lets the router take, and so may
a step the decoder does not read in full: a command the decoder does not read
(listed at the end of this section), a step whose input it cannot read, in
whole or in part, and a step it fails on. Every other `PERMIT2_PERMIT`, before
the step that sets the input side or after it, leaves `Token In` and the
`Amount` line as they read with no permit, unless it lets a spender other than
the router take `Token In` (see below): a permit of 5,000 DAI to another
address and then a V3 exact-in of 600 USDT read `Swap USDT → USDC` with
`Amount 600.0000 USDT`, and so do that exact-in and then a permit of 5,000 DAI
to the router. A permit on the zero address never stands as the `Amount`
figure, because Permit2 has no allowance of ETH. When a permit's amount stands
and the step that sets the input side spends a token other than the permit's,
or one the decoder does not know, that step's amount gets lines of its own,
unless it states no amount and the user pays it none of its input token. Each
take the total does not count gives three lines after the `Amount` line (after
`Token In` when there is none): one of a token other than `Token In`; one of
`Token In` after an amount that is not a quantity (a V4 open delta,
`ALREADY_PAID`, which keep their wording on the `Amount` line, or no stated
amount, as of a V4 exact-out step); and one whose amount the decoder does not
know. When there is no `Amount` line, each take the total counts gets them
too, so that every amount the decoder reads is shown: the amount the step that
sets the input side states, when it is a quantity and the user pays that step
(a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's
value), and each later take of `Token In` added to it. A step that sets the
input side and is paid from the router takes nothing from the user's wallet
and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins
of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When
the step that sets the input side states no amount, the user pays it some of
its input token, and there is no `Amount` line, that step gets them too
whenever another take's lines give a figure, or a `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, as when the router pays it, that
step takes none of that token from the user's wallet and 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
rest taken to another address, reads `5000.0000 USDT`. A later step that pays
an ERC-20 token from the router takes none of that token from the user's
wallet. A V2 exact-in whose `amountIn` is `ALREADY_PAID` takes nothing either.
A V4 `SETTLE` of ETH paid from the router still takes from the user's wallet,
since the router holds the transaction's value. When the decoder does not know
the amount of such a `SETTLE`, as for `CONTRACT_BALANCE`, that `SETTLE` counts
as a later step's take. When an earlier step left ETH in the router, that ETH
may pay part of a `SETTLE` of ETH paid from the router. That `SETTLE` then
gets lines of its own, with `Up to` before its amount. A later take of
`Token In` that only the user's wallet pays is added to the total while both
are quantities: a V3 exact-in of 600 USDT and a V2 exact-in of 400 USDT, both
paid by the user, read `1000.0000 USDT`. When the decoder does not know a
take's amount, the total is not known and there is no `Amount` line. A
`PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` included,
stands as the `Amount` figure in place of the total, which it does only in the
plain shape of Uniswap's own routes: it comes before the step that sets the
input side, it is the only `PERMIT2_PERMIT` in the transaction and there is no
`PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it
fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it
included, or a token the decoder does not know, and the decoder reads every
step after it in full. Permit2 then lets the router take no more of the token
than that amount, so the permit covers every take of it. A step before the
permit takes under whatever allowance the router already had, so it may take
more than the permit's amount, which would then read lower than what leaves
the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router,
then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid
from the router, read `Amount 2000.0000 USDT`. A later permit or batch may
raise what Permit2 lets the router take, and so may a step the decoder does
not read in full: a command the decoder does not read (listed at the end of
this section), a step whose input it cannot read, in whole or in part, and a
step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the
input side or after it, leaves `Token In` and the `Amount` line as they read
with no permit, unless it lets a spender other than the router take `Token In`
(see below): a permit of 5,000 DAI to another address and then a V3 exact-in
of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do
that exact-in and then a permit of 5,000 DAI to the router. A permit on the
zero address never stands as the `Amount` figure, because Permit2 has no
allowance of ETH. When a permit's amount stands and the step that sets the
input side spends a token other than the permit's, or one the decoder does not
know, that step's amount gets lines of its own, unless it states no amount and
the user pays it none of its input token. Each take the total does not count
gives three lines after the `Amount` line (after `Token In` when there is
none): one of a token other than `Token In`; one of `Token In` after an amount
that is not a quantity (a V4 open delta, `ALREADY_PAID`, which keep their
wording on the `Amount` line, or no stated amount, as of a V4 exact-out step);
one of `Token In` that ETH an earlier step left in the router may pay part of;
and one whose amount the decoder does not know. When there is no `Amount`
line, each take the total counts gets them too, so that every amount the
decoder reads is shown: the amount the step that sets the input side states,
when it is a quantity and the user pays that step (a V2 or V3 step whose
`payerIsUser` is true, a V4 step the user pays some of its input token, or a
`WRAP_ETH`, whose ETH comes from the transaction's value), and each later take
of `Token In` added to it. A step that sets the input side gets none when the
user pays it none of its input token. A `PERMIT2_PERMIT` the decoder cannot
read, then V3 exact-ins of 600 USDT and 400 DAI paid by the user, give lines
for both amounts. When the step that sets the input side states no amount, the
user pays it some of its input token, and there is no `Amount` line, that step
gets them too whenever another take's lines give a figure, or a
`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;
`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
+24 -12
View File
@@ -1240,9 +1240,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputEstablished = false;
// 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
// its input currency (see decodeV4Swap()), or a WRAP_ETH, whose ETH
// comes from the transaction's value. A step paid from the router
// takes nothing from the sender.
// its input currency (see paidByUser in the loop), or a WRAP_ETH,
// whose ETH comes from the transaction's value. A step that pays an
// ERC-20 token from the router takes none of that token from the
// sender.
let inputPaidByUser = false;
// The last PERMIT2_PERMIT this decoder read before a step set the
// 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,
}));
// What the user pays the step: what decodeV4Swap() says,
// and the ETH of each SETTLE from the router when the
// router holds no other ETH.
// and the ETH of each SETTLE from the router of a known
// amount when the router holds no other ETH.
const paidByUser = v4
? v4.paidByUser.concat(
routerHeldEth ? [] : ethFromRouter,
routerHeldEth
? []
: ethFromRouter.filter((s) =>
present(s.amount),
),
)
: [];
if (v4) {
@@ -1939,8 +1944,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (setInputHere && p === paidIn) continue;
addUserPaid(p.currency, p.amount);
}
if (routerHeldEth) {
for (const p of ethFromRouter) {
// A SETTLE of ETH from the router that `paidByUser` leaves
// 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({
token: p.currency,
amount: p.amount,
@@ -2100,14 +2112,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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.
// its own (see below). The amount of the step that set the input side
// counts in `total` whoever pays that step. That amount is among them
// only when the user pays the step (see inputPaidByUser).
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
// 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
// from the sender.
// some of its input token.
const hasFirstTake =
inputEstablished && (present(inputAmount) || inputPaidByUser);
// 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"],
]);
});
// 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"],
]);
});
});
});