Author SHA1 Message Date
sneak 47b186841a 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) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s
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
2026-10-27 00:13:54 +00:00
3 changed files with 228 additions and 428 deletions
+123 -121
View File
@@ -1265,9 +1265,7 @@ see Transaction Decoding), do not always read as a number. They can read:
the same words. `Unlimited`, `All available (V4 open delta)` and the same words. `Unlimited`, `All available (V4 open delta)` and
`Whatever an earlier step sent to the pair (V2 already paid)` keep their `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 wording. When a V2 or V3 exact-out step sets `Min. received`, that line shows
its `amountOut`, the exact amount it buys. When the router may hold ETH from its `amountOut`, the exact amount it buys.
an earlier step, the `Moved from the sender` line of ETH that the user pays a
V4 step also reads `Up to <amount>` (see Transaction Decoding).
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it - `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 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 whole open delta", so the calldata states no quantity. Transaction Decoding
@@ -3414,43 +3412,47 @@ Currently supported:
as a swap paid in ETH does, but only when that amount is not as a swap paid in ETH does, but only when that amount is not
`CONTRACT_BALANCE` and the router held no WETH from an earlier step when the `CONTRACT_BALANCE` and the router held no WETH from an earlier step when the
`WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1 `WRAP_ETH` ran: after two `WRAP_ETH`s of 0.5 ETH into the router, a swap of 1
WETH from it reads as leaving the WETH there. A V2 or V3 exact-out step paid WETH from it reads as leaving the WETH there. An `UNWRAP_WETH` into the router
from the router spends an amount the calldata does not state, at most its turns all the router's WETH into as much ETH. The decoder then knows how much
`amountInMax`, so the decoder counts it as spending nothing the router holds. ETH the router holds only when it knew the WETH's amount and the router held
A `SETTLE` of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step no ETH before. A V4 `SETTLE` of ETH spends the ETH the router holds, whoever
still owes of their token when they run. The decoder takes that to be the pays it (see below). A V2 or V3 exact-out step paid from the router spends an
amounts the step's swap actions state they spend of that token only when every amount the calldata does not state, at most its `amountInMax`, so the decoder
action in the step could be read, every swap action names the token it spends counts it as spending nothing the router holds. A `SETTLE` of `OPEN_DELTA`
and the token it buys (a swap action with an empty path swaps nothing), the (zero), and a `SETTLE_ALL`, pay only what the step still owes of their token
`SETTLE` or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that when they run. The decoder takes that to be the amounts the step's swap
token and comes after every swap action, no swap action buys that token, no actions state they spend of that token only when every action in the step
`TAKE`, `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a could be read, every swap action names the token it spends and the token it
stated amount adds to what the step owes), and the swap actions that spend it buys (a swap action with an empty path swaps nothing), the `SETTLE` or
state a quantity together; otherwise it does not know the amount, and such a `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and
`SETTLE` from the router counts as spending nothing. So the rest of a V4 comes after every swap action, no swap action buys that token, no `TAKE`,
`SETTLE` of more than its swap spends, taken to the router, the WETH of a `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount
`WRAP_ETH`, and a token a swap pays the router on the way to the output, each adds to what the step owes), and the swap actions that spend it state a
stays in the router when later steps do not spend all of it, where anyone can quantity together; otherwise it does not know the amount, and such a `SETTLE`
take it in a later transaction. The router sends ETH as written, so a from the router counts as spending nothing. So the rest of a V4 `SETTLE` of
`TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A more than its swap spends, taken to the router, the WETH of a `WRAP_ETH`, and
step paying a token to the V2 pair that the very next step spends, as a V2 a token a swap pays the router on the way to the output, each stays in the
exact-in of that token whose `amountIn` is `ALREADY_PAID` (zero) or router when later steps do not spend all of it, where anyone can take it in a
`CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes pay the later transaction. The router sends ETH as written, so a `TRANSFER` of
next V2 pair directly, and the router enforces that swap's own minimum. The `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
decoder checks that the address paid is that pair, deriving it from the V2 token to the V2 pair that the very next step spends, as a V2 exact-in of that
factory and pair init code hash the network's routers were deployed with and token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
the first two tokens of that step's path; a payment to any other address is hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
named. (Sepolia's `UniversalRouterV1_2_V2Support` was deployed with the router enforces that swap's own minimum. The decoder checks that the
placeholders for both values, so no V2 swap step can succeed on that router.) address paid is that pair, deriving it from the V2 factory and pair init code
Every other payment counts, in whichever step and of whatever token, a share hash the network's routers were deployed with and the first two tokens of that
of the output included: an interface fee paid to another address by step's path; a payment to any other address is named. (Sepolia's
`PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `UniversalRouterV1_2_V2Support` was deployed with placeholders for both
`Min. paid out`, because the swap's minimum includes the fee. The minimum values, so no V2 swap step can succeed on that router.) Every other payment
reads `Min. received` only when some step pays the transaction's sender (the counts, in whichever step and of whatever token, a share of the output
From address on TxApproval, the sender the explorer reports on included: an interface fee paid to another address by `PAY_PORTION`,
TransactionDetail), no `Paid to` line is shown, and no permit or batch grants `TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
an allowance to a spender other than the router (see below). `Paid to` lines because the swap's minimum includes the fee. The minimum reads `Min. received`
after `Token Out` name each other address paid; one reads only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail), no
`Paid to` line is shown, and no permit or batch grants an allowance to a
spender other than the router (see below). `Paid to` lines after `Token Out`
name each other address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a `The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example, bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` `The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
@@ -3479,89 +3481,89 @@ Currently supported:
of any amount but `OPEN_DELTA` pays that amount, and a `SETTLE` of 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`. V4 pays a `SETTLE` of rest taken to another address, reads `5000.0000 USDT`. V4 pays ETH from the
ETH from the router's own ETH, whichever payer it names. So when the router router's own ETH, whoever a `SETTLE` names as payer. So when a V4 step would
may hold ETH from an earlier step (see above), the ETH that the user pays a V4 take ETH from the user's wallet, ETH the router already holds pays first. The
step gets lines of its own (see below). A later step paid from the router wallet pays only the rest. For example, an `UNWRAP_WETH` into the router
spends what the router holds and takes nothing, and so does a V2 exact-in leaves ETH there. When the decoder does not know how much ETH the router
whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the holds, or how much the step would take, it counts the router as paying all of
total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in that ETH. A later step paid from the router spends what the router holds and
of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder takes nothing, and so does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A
does not know a take's amount, the total is not known and there is no `Amount` later take of `Token In` is added to the total while both are quantities: a V3
line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` exact-in of 600 USDT and a V2 exact-in of 400 USDT, both paid by the user,
included, stands as the `Amount` figure in place of the total, which it does read `1000.0000 USDT`. When the decoder does not know a take's amount, the
only in the plain shape of Uniswap's own routes: it comes before the step that total is not known and there is no `Amount` line. A `PERMIT2_PERMIT` names
sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and `Token In` only when its amount, `Unlimited` included, stands as the `Amount`
there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may figure in place of the total, which it does only in the plain shape of
not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` Uniswap's own routes: it comes before the step that sets the input side, it is
of it included, or a token the decoder does not know, and the decoder reads the only `PERMIT2_PERMIT` in the transaction and there is no
every step after it in full. Permit2 then lets the router take no more of the `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may not let it
token than that amount, so the permit covers every take of it. A step before fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` of it
the permit takes under whatever allowance the router already had, so it may included, or a token the decoder does not know, and the decoder reads every
take more than the permit's amount, which would then read lower than what step after it in full. Permit2 then lets the router take no more of the token
leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the than that amount, so the permit covers every take of it. A step before the
router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000 permit takes under whatever allowance the router already had, so it may take
USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or more than the permit's amount, which would then read lower than what leaves
batch may raise what Permit2 lets the router take, and so may a step the the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router,
decoder does not read in full: a command the decoder does not read (listed at then a permit of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid
the end of this section), a step whose input it cannot read, in whole or in from the router, read `Amount 2000.0000 USDT`. A later permit or batch may
part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step raise what Permit2 lets the router take, and so may a step the decoder does
that sets the input side or after it, leaves `Token In` and the `Amount` line not read in full: a command the decoder does not read (listed at the end of
as they read with no permit, unless it lets a spender other than the router this section), a step whose input it cannot read, in whole or in part, and a
take `Token In` (see below): a permit of 5,000 DAI to another address and then step it fails on. Every other `PERMIT2_PERMIT`, before the step that sets the
a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, input side or after it, leaves `Token In` and the `Amount` line as they read
and so do that exact-in and then a permit of 5,000 DAI to the router. When a with no permit, unless it lets a spender other than the router take `Token In`
permit's amount stands and the step that sets the input side spends a token (see below): a permit of 5,000 DAI to another address and then a V3 exact-in
other than the permit's, or one the decoder does not know, that step's amount of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do
gets lines of its own, unless it states no amount and the user pays it none of that exact-in and then a permit of 5,000 DAI to the router. When a permit's
its input token. Each take the total does not count gives three lines after amount stands and the step that sets the input side spends a token other than
the `Amount` line (after `Token In` when there is none): one of a token other the permit's, or one the decoder does not know, that step's amount gets lines
than `Token In`; one of `Token In` after an amount that is not a quantity (a of its own, unless it states no amount and the user pays it none of its input
V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, token. Each take the total does not count gives three lines after the `Amount`
or no stated amount, as of a V4 exact-out step); one whose amount the decoder line (after `Token In` when there is none): one of a token other than
does not know; and one of ETH that the user pays a V4 step when the router may `Token In`; one of `Token In` after an amount that is not a quantity (a V4
hold ETH. When there is no `Amount` line, each take the total counts gets them open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, or
too, so that every amount the decoder reads is shown: the amount the step that no stated amount, as of a V4 exact-out step); and one whose amount the decoder
sets the input side states, when it is a quantity and the user pays that step does not know. When there is no `Amount` line, each take the total counts gets
(a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of them too, so that every amount the decoder reads is shown: the amount the step
its input token, or a `WRAP_ETH`, whose ETH comes from the transaction's that sets the input side states, when it is a quantity and the user pays that
value), and each later take of `Token In` added to it. A step that sets the step (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays
input side and is paid from the router takes nothing from the user's wallet some of its input token, or a `WRAP_ETH`, whose ETH comes from the
and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then V3 exact-ins transaction's value), and each later take of `Token In` added to it. A step
of 600 USDT and 400 DAI paid by the user, give lines for both amounts. When that sets the input side and is paid from the router takes nothing from the
the step that sets the input side states no amount, the user pays it some of user's wallet and gets none. A `PERMIT2_PERMIT` the decoder cannot read, then
its input token, and there is no `Amount` line, that step gets them too V3 exact-ins of 600 USDT and 400 DAI paid by the user, give lines for both
whenever another take's lines give a figure, or a `PERMIT2_TRANSFER_FROM` of amounts. When the step that sets the input side states no amount, the user
its token gives one, on the lines below or, into the router, on its `Paid to` pays it some of its input token, and there is no `Amount` line, that step gets
line, so that figure cannot read as all that leaves. When it states no amount them too whenever another take's lines give a figure, or a
and the user pays it none of its input token, as when the router pays it, that `PERMIT2_TRANSFER_FROM` of its token gives one, on the lines below or, into
step takes none of that token from the user's wallet and never gets them, with the router, on its `Paid to` line, so that figure cannot read as all that
a permit or without. They come in step order, except that the step that sets leaves. When it states no amount and the user pays it none of its input token,
the input side comes first when it has them, followed, when there is no as when the router pays it, that step takes none of that token from the user's
`Amount` line, by the other takes the total counts: `Moved from the sender`, wallet and never gets them, with a permit or without. They come in step order,
its amount, worded as the `Amount` line words one, or 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`. For ETH that the user `Moved to`, which reads `The swap step that spends it`. For a `WRAP_ETH` that
pays a V4 step when the router may hold ETH, `Moved from the sender` reads pays its WETH to anyone but the router, `Moved to` names that recipient
`Up to <amount>` for a known amount. For a `WRAP_ETH` that pays its WETH to instead: `The sender` for `address(1)`, and otherwise the address. A token the
anyone but the router, `Moved to` names that recipient instead: `The sender` decoder does not know gives the `Moved from the sender` line alone. A step may
for `address(1)`, and otherwise the address. A token the decoder does not know also take from the user's wallet what the decoder did not read: a V4 step
gives the `Moved from the sender` line alone. A step may also take from the whose input or one of whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which
user's wallet what the decoder did not read: a V4 step whose input or one of the router still runs; a V2 or V3 exact-in or exact-out step or a
whose `SETTLE`s or `SETTLE_ALL`s it cannot read, which the router still runs; `PERMIT2_TRANSFER_FROM` whose input it cannot read, such as a V3 step whose
a V2 or V3 exact-in or exact-out step or a `PERMIT2_TRANSFER_FROM` whose input path is shorter than one token address or has bytes after its last hop; every
it cannot read, such as a V3 step whose path is shorter than one token address `PERMIT2_TRANSFER_FROM_BATCH`; every `EXECUTE_SUB_PLAN`, wherever it stands;
or has bytes after its last hop; every `PERMIT2_TRANSFER_FROM_BATCH`; every and every step it fails on. Each such step gives one `Moved from the sender`
`EXECUTE_SUB_PLAN`, wherever it stands; and every step it fails on. Each such line among those, reading `Unknown (could not be decoded)`. That line, or any
step gives one `Moved from the sender` line among those, reading line for a token the decoder does not know, leaves no `Amount` line, whose
`Unknown (could not be decoded)`. That line, or any line for a token the figure might be too low, and such a step before a `PERMIT2_PERMIT` keeps the
decoder does not know, leaves no `Amount` line, whose figure might be too low, permit's amount from standing. Then each `PERMIT2_TRANSFER_FROM` but one into
and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from the router, in step order, gives three lines: `Moved from the sender`, the
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step amount it moves, for example `1000000.0000 USDT`; `Token moved`, giving the
order, gives three lines: `Moved from the sender`, the amount it moves, for token's address as the `Token In` line does; and `Moved to`, which reads
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
`The V2 pair the next swap spends` for a hop and `The sender` for `address(1)` `The V2 pair the next swap spends` for a hop and `The sender` for `address(1)`
on every router but `UniversalRouterV1`, and otherwise gives the address. A on every router but `UniversalRouterV1`, and otherwise gives the address. A
move into the router needs none of them, because its `Paid to` line and move into the router needs none of them, because its `Paid to` line and
+78 -58
View File
@@ -1340,18 +1340,15 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}; };
// What the steps take from the sender beyond the amount of the step // What the steps take from the sender beyond the amount of the step
// that sets the input side, as { token, amount, atMost }, in step // that sets the input side, as { token, amount }, in step order.
// order. After the loop each joins the Amount line or gets lines of // After the loop each joins the Amount line or gets lines of its own
// its own (see permitStands). A take with `atMost` set takes at most // (see permitStands).
// its amount, and always gets lines of its own.
const userPaid = []; const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount` // A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender, or at most that when `atMost` is set. A // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
// V2 exact-in of ALREADY_PAID takes nothing. // nothing.
const addUserPaid = (token, amount, atMost = false) => { const addUserPaid = (token, amount) => {
if (amount !== ALREADY_PAID) { if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
userPaid.push({ token, amount, atMost });
}
}; };
// A step may take from the sender what this decoder did not read: a // A step may take from the sender what this decoder did not read: a
// V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it
@@ -1406,9 +1403,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of // token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of
// ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or // 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 // 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 // from the router (see spendFromRouter()). A V4 SETTLE of ETH paid by
// router holds, in base units, when a WRAP_ETH stated it, otherwise // the user spends the router's ETH too (see the V4_SWAP branch). The
// null. // 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 heldByRouter = new Map();
const isRouter = (recipient) => { const isRouter = (recipient) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
@@ -1416,8 +1415,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}; };
// A swap step or V4 SETTLE pays `amount` of `token` from the router's // 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 // own balance. CONTRACT_BALANCE pays all of it. Any other quantity
// counts against the amount a WRAP_ETH stated, and the steps pay all // counts against a known amount the router holds, and the steps pay
// of it once their amounts reach that one: a swap paid in ETH wraps // 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, // what its swap steps then spend. An amount that is not a quantity,
// such as the null decodeV4Swap() gives a SETTLE whose payment it // 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 // cannot tell, or one of a token whose amount is not known, pays out
@@ -1825,20 +1824,45 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished; const inputSetBefore = inputEstablished;
// V4Router pays a SETTLE of ETH from the router's own // What the user pays the step, as decodeV4Swap() gives
// ETH, whichever payer it names. When the router may hold // it, less the ETH the router already holds (below).
// ETH from an earlier step, that ETH may pay some of the // Empty when the step cannot be read.
// ETH that the user pays this step. The sender then pays const paidByUser = [];
// at most the step's amount.
const routerMayHoldEth = heldByRouter.has(
"0x0000000000000000000000000000000000000000",
);
if (v4) { if (v4) {
// Each SETTLE paid from the router spends the amount // Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays. // decodeV4Swap() says it pays.
for (const s of v4.settledFromRouter) { for (const s of v4.settledFromRouter) {
spendFromRouter(s.currency, s.amount); 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) { for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) { if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency); addPayee(t.recipient, t.currency);
@@ -1863,10 +1887,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
commandNames[i] += " (could not be decoded)"; commandNames[i] += " (could not be decoded)";
} }
// The step's amount, if it sets the input side: what the // The step's amount, if it sets the input side: what the
// user pays it of its input currency (see // user pays it of its input currency (see paidByUser),
// decodeV4Swap()), or, when the user pays none of it, // or, when the user pays none of it, what its swap
// what its swap actions spend of it, whoever pays that. // actions spend of it, whoever pays that.
const paidIn = v4?.paidByUser.find( const paidIn = paidByUser.find(
(p) => p.currency === v4.tokenIn, (p) => p.currency === v4.tokenIn,
); );
const amountIn = paidIn ? paidIn.amount : v4?.amountIn; const amountIn = paidIn ? paidIn.amount : v4?.amountIn;
@@ -1920,17 +1944,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router still runs it. // router still runs it.
const setInputHere = inputEstablished && !inputSetBefore; const setInputHere = inputEstablished && !inputSetBefore;
if (setInputHere) inputPaidByUser = paidIn !== undefined; if (setInputHere) inputPaidByUser = paidIn !== undefined;
if (v4) { for (const p of paidByUser) {
for (const p of v4.paidByUser) { if (setInputHere && p === paidIn) continue;
if (setInputHere && p === paidIn) continue; addUserPaid(p.currency, p.amount);
addUserPaid(
p.currency,
p.amount,
routerMayHoldEth &&
p.currency ===
"0x0000000000000000000000000000000000000000",
);
}
} }
if (!v4 || v4.settleUnread) { if (!v4 || v4.settleUnread) {
spentUnread(); spentUnread();
@@ -1978,15 +1994,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addPayee(u.recipient); addPayee(u.recipient);
// It pays out all the router's WETH, as ETH. Into the // It pays out all the router's WETH, as ETH. Into the
// router, the router then holds as ETH the WETH a // router, the router then holds as ETH the WETH a
// step paid it. // step paid it. That amount is known when the router
const heldWeth = WETH_ADDRESSES.some((w) => // held a known amount of WETH and no ETH before.
const heldWeth = WETH_ADDRESSES.filter((w) =>
heldByRouter.has(w), heldByRouter.has(w),
); );
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w)); const ethAmount =
if (isRouter(u.recipient) && heldWeth) { 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( heldByRouter.set(
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000",
null, ethAmount,
); );
} }
} else { } else {
@@ -2040,8 +2064,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// being of another token or coming after an amount that is not a // being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the // lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure. A take of at most its amount (see // total unknown, with no figure.
// userPaid) is never added, and gets lines of its own too.
// //
// A PERMIT2_PERMIT names Token In only when its amount stands as the // A PERMIT2_PERMIT names Token In only when its amount stands as the
// Amount figure in place of the total, which it does only in the // Amount figure in place of the total, which it does only in the
@@ -2076,10 +2099,10 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
permit.beforeEveryTake && permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step); !stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as // What the steps take that the Amount line does not give, as
// { token, amount, atMost, recipient }. A null token is a step this // { token, amount, recipient }. A null token is a step this decoder
// decoder did not read (see spentUnread()), or a token it does not // did not read (see spentUnread()), or a token it does not know. Only
// know. `atMost` is as in `userPaid`. Only the take of a WRAP_ETH that // the take of a WRAP_ETH that pays elsewhere has a recipient (see
// pays elsewhere has a recipient (see wrapEthRecipient). // wrapEthRecipient).
const spentFromSender = []; const spentFromSender = [];
let total = null; let total = null;
// The takes from the sender that `total` adds up, each a quantity, in // The takes from the sender that `total` adds up, each a quantity, in
@@ -2125,7 +2148,6 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (ofTokenIn && permitStands) continue; if (ofTokenIn && permitStands) continue;
if ( if (
ofTokenIn && ofTokenIn &&
!t.atMost &&
typeof total === "bigint" && typeof total === "bigint" &&
typeof t.amount === "bigint" typeof t.amount === "bigint"
) { ) {
@@ -2230,9 +2252,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// V2's already-paid zero, neither of which is a quantity at all (see // 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 // OPEN_DELTA and ALREADY_PAID), and an unbounded permit. Those two
// tests come first — the sentinels are not bigints and cannot be // tests come first — the sentinels are not bigints and cannot be
// compared with one. `atMost` says the amount is a maximum (see // compared with one.
// userPaid). const spentText = (amount, info) => {
const spentText = (amount, info, atMost = false) => {
if (amount === OPEN_DELTA) { if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} }
@@ -2245,7 +2266,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (amount >= MAX_UINT160) { if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" }; return { raw: "Unlimited", display: "Unlimited" };
} }
if (hasV2OrV3ExactOut || atMost) { if (hasV2OrV3ExactOut) {
// A V2 or V3 exact-out swap spends at most this figure, // A V2 or V3 exact-out swap spends at most this figure,
// whichever steps gave it (an amountInMax, the WRAP_ETH of a // whichever steps gave it (an amountInMax, the WRAP_ETH of a
// swap paid in ETH, a permit, or several steps' total), so it // swap paid in ETH, a permit, or several steps' total), so it
@@ -2299,14 +2320,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // 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 // decoder does not know is said so, and a step it did not read gets
// that one line alone. A take with no recipient goes to the swap step // that one line alone. A take with no recipient goes to the swap step
// that spends it. A take with a recipient goes to that recipient; // that spends it. A take with a recipient goes there, and MSG_SENDER
// `MSG_SENDER` means the sender. // there is the sender.
for (const s of spentFromSender) { for (const s of spentFromSender) {
details.push({ details.push({
label: "Moved from the sender", label: "Moved from the sender",
value: present(s.amount) value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources), s.atMost) ? spentText(s.amount, tokenInfo(s.token, sources)).display
.display
: UNDECODED_CURRENCY, : UNDECODED_CURRENCY,
}); });
if (present(s.token)) { if (present(s.token)) {
+27 -249
View File
@@ -1428,8 +1428,8 @@ describe("uniswap decoder", () => {
]; ];
// execute() of these command bytes and inputs, decoded as sent by // execute() of these command bytes and inputs, decoded as sent by
// the user to `router`. // the user.
function decodeSteps(commands, inputs, router = ROUTER_ADDR) { function decodeSteps(commands, inputs) {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
commands.map(() => "uint8"), commands.map(() => "uint8"),
@@ -1438,7 +1438,7 @@ describe("uniswap decoder", () => {
inputs, inputs,
9999999999n, 9999999999n,
); );
return uniswap.decode(data, router, "0x1", {}, USER_ADDR); return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
} }
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the // The first step: a V3 exact-in of 600 USDT for USDC, paid by the
@@ -2470,20 +2470,13 @@ describe("uniswap decoder", () => {
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays a // https://git.eeqj.de/sneak/AutistMask/issues/665 — V4Router pays ETH
// SETTLE of ETH from the router's own ETH, whichever payer it names. // from the router's own ETH, whoever a SETTLE names as payer. ETH the
// After an earlier step that may leave ETH in the router, the ETH that // router holds from an UNWRAP_WETH into it pays first, so the sender
// the user pays a V4 step reads as a maximum, on lines of its own. // pays only the ETH beyond it. On next each row also took the V4
// Every row reads at least the ETH that can leave the wallet. The two // step's whole ETH from the sender: 1 ETH more in the first two, and
// rows marked "reads as on next" read the same on next. On next every // 1.5 ETH in the last.
// other row added that ETH to the Amount line, or read it as an exact const unwrapIntoRouter = encodeWrapEth(ROUTER_ADDR, 0n); // UNWRAP_WETH, same encoding
// amount.
//
// ADDRESS_THIS is the router on every release.
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const WRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, ONE_ETH);
// UNWRAP_WETH has the layout of WRAP_ETH.
const UNWRAP_INTO_ROUTER = encodeWrapEth(ADDRESS_THIS, 0n);
// A V4 step swapping `amount` of ETH for USDC, with a SETTLE of // A V4 step swapping `amount` of ETH for USDC, with a SETTLE of
// OPEN_DELTA by the user. // OPEN_DELTA by the user.
const v4EthForUsdc = (amount) => const v4EthForUsdc = (amount) =>
@@ -2499,257 +2492,42 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n), encodeV4TakeAll(USDC_ADDR, 0n),
], ],
); );
const V4_ONE_ETH = v4EthForUsdc(ONE_ETH);
// A TRANSFER or PAY_PORTION of `amount` of `token` to another
// address.
const payOut = (token, amount) =>
coder.encode(
["address", "address", "uint256"],
[token, OTHER_ADDR, amount],
);
// An ACROSS_V4_DEPOSIT_V3 of `amount` of ETH (`useNative` set), from
// the sender to the sender on chain id 8453.
const acrossEth = (amount) =>
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
USER_ADDR,
USER_ADDR,
WETH_ADDR,
WETH_ADDR,
amount,
1n,
8453n,
ZERO_ADDR,
0,
0,
0,
"0x",
true,
],
],
);
// The lines of ETH the sender pays a V4 step, of `amount`.
const ethLines = (amount) => [
["Moved from the sender", amount],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
test.each([ test.each([
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 ETH that the user settles, reads as on next",
commands: [0x0b, 0x10],
inputs: [WRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [["Amount", "2.0000 ETH"]],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x05, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
payOut(ZERO_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a PAY_PORTION of 5,000 bips of WETH to another address before the UNWRAP_WETH",
commands: [0x0b, 0x06, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
payOut(WETH_ADDR, 5000n),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same with a WRAP_ETH of 1 ETH to another address after the UNWRAP_WETH",
commands: [0x0b, 0x0c, 0x0b, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
encodeWrapEth(OTHER_ADDR, ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "the same on UniversalRouterV2_1_2 with an ACROSS_V4_DEPOSIT_V3 of 1 ETH after the UNWRAP_WETH",
router: ROUTER_V2_1_2_ADDR,
commands: [0x0b, 0x0c, 0x40, 0x10],
inputs: [
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
acrossEth(ONE_ETH),
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
{
kind: "a V3 exact-in of 600 USDT that the user pays, a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0b, 0x0c, 0x10],
inputs: [
V3_600,
WRAP_INTO_ROUTER,
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
],
},
{ {
kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", kind: "a V3 exact-in of 600 USDT for WETH paid to the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x00, 0x0c, 0x10], commands: [0x00, 0x0c, 0x10],
inputs: [ inputs: [
encodeV3SwapExactIn(ADDRESS_THIS, USDT_600, 1n, [ encodeV3SwapExactIn(ROUTER_ADDR, USDT_600, 1n, [
USDT_ADDR, USDT_ADDR,
WETH_ADDR, WETH_ADDR,
]), ]),
UNWRAP_INTO_ROUTER, unwrapIntoRouter,
V4_ONE_ETH, v4EthForUsdc(ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 1.0000 ETH"),
], ],
lines: [["Amount", "600.0000 USDT"]],
}, },
{ {
kind: "a V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles", kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles",
commands: [0x10, 0x10],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
ZERO_ADDR,
USDT_600,
1n,
),
settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n),
],
),
v4EthForUsdc(5n * ONE_ETH),
],
lines: [
["Amount", "600.0000 USDT"],
...ethLines("Up to 5.0000 ETH"),
],
},
{
kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 exact-out paid in ETH with a SETTLE_ALL of at most 3 ETH, reads as on next",
commands: [0x0b, 0x0c, 0x10], commands: [0x0b, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, encodeWrapEth(ROUTER_ADDR, ONE_ETH),
UNWRAP_INTO_ROUTER, unwrapIntoRouter,
encodeV4Swap( v4EthForUsdc(ONE_ETH),
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
ZERO_ADDR,
USDC_ADDR,
390000000n,
3n * ONE_ETH,
),
encodeV4SettleAll(ZERO_ADDR, 3n * ONE_ETH),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
],
lines: [
...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED),
], ],
lines: [["Amount", "1.0000 ETH"]],
}, },
{ {
kind: "a WRAP_ETH of 1 ETH into the router, a V3 exact-out of at most 1 WETH paid from the router, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", kind: "a WRAP_ETH of 1 ETH into the router, an UNWRAP_WETH into the router, then a V4 swap of 1.5 ETH that the user settles",
commands: [0x0b, 0x01, 0x0c, 0x10], commands: [0x0b, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, encodeWrapEth(ROUTER_ADDR, ONE_ETH),
encodeV3SwapExactOut( unwrapIntoRouter,
USER_ADDR, v4EthForUsdc((ONE_ETH * 3n) / 2n),
390000000n,
ONE_ETH,
[USDC_ADDR, WETH_ADDR],
false,
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
], ],
lines: [["Amount", "1.5000 ETH"]],
}, },
{ ])("$kind", ({ commands, inputs, lines }) => {
kind: "a WRAP_ETH of 1 ETH into the router, a V4 exact-out paid from the router's WETH, an UNWRAP_WETH into the router, then a V4 swap of 1 ETH that the user settles", const result = decodeSteps(commands, inputs);
commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [
WRAP_INTO_ROUTER,
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
WETH_ADDR,
USDC_ADDR,
390000000n,
ONE_ETH,
),
settle(WETH_ADDR, 0n, false),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
UNWRAP_INTO_ROUTER,
V4_ONE_ETH,
],
lines: [
["Amount", "1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"),
],
},
])("$kind", ({ router, commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router);
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });