Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 88cee51f5e 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. The decoder subtracts only when it knows both amounts;
otherwise the sender's ETH reads as unknown. An UNWRAP_WETH into the
router keeps a known WETH amount as the ETH the router then holds. A
step that spends an unstated amount of a token from the router makes
the router's amount of that token unknown.

Model: opus-5-5
2026-10-27 01:01:21 +00:00
3 changed files with 249 additions and 300 deletions
+81 -77
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
@@ -3416,18 +3414,23 @@ Currently supported:
`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. A V2 or V3 exact-out step paid
from the router spends an amount the calldata does not state, at most its from the router spends an amount the calldata does not state, at most its
`amountInMax`, so the decoder counts it as spending nothing the router holds. `amountInMax`. The decoder then no longer knows how much of the step's input
A `SETTLE` of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step token the router holds. A V4 `SETTLE` from the router has the same effect when
still owes of their token when they run. The decoder takes that to be the the decoder does not know what that `SETTLE` pays (see below). An
amounts the step's swap actions state they spend of that token only when every `UNWRAP_WETH` into the router turns all the router's WETH into the same amount
action in the step could be read, every swap action names the token it spends of ETH. The decoder knows how much ETH that is only when it knew how much WETH
and the token it buys (a swap action with an empty path swaps nothing), the the router held and the router held no ETH before. A V4 `SETTLE` of ETH always
`SETTLE` or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that spends the router's own ETH, whichever payer it names (see below). A `SETTLE`
token and comes after every swap action, no swap action buys that token, no of `OPEN_DELTA` (zero), and a `SETTLE_ALL`, pay only what the step still owes
`TAKE`, `TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a of their token when they run. The decoder takes that to be the amounts the
stated amount adds to what the step owes), and the swap actions that spend it step's swap actions state they spend of that token only when every action in
state a quantity together; otherwise it does not know the amount, and such a the step could be read, every swap action names the token it spends and the
`SETTLE` from the router counts as spending nothing. So the rest of a V4 token it buys (a swap action with an empty path swaps nothing), the `SETTLE`
or `SETTLE_ALL` is the step's only `SETTLE` or `SETTLE_ALL` of that token and
comes after every swap action, no swap action buys that token, no `TAKE`,
`TAKE_ALL` or `TAKE_PORTION` in the step takes it (a `TAKE` of a stated amount
adds to what the step owes), and the swap actions that spend it state a
quantity together; otherwise it does not know the amount. So the rest of a V4
`SETTLE` of more than its swap spends, taken to the router, the WETH of a `SETTLE` of more than its swap spends, taken to the router, the WETH of a
`WRAP_ETH`, and a token a swap pays the router on the way to the output, each `WRAP_ETH`, and a token a swap pays the router on the way to the output, each
stays in the router when later steps do not spend all of it, where anyone can stays in the router when later steps do not spend all of it, where anyone can
@@ -3479,48 +3482,51 @@ 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. The decoder knows what the wallet pays only when it knows
whose `amountIn` is `ALREADY_PAID`. A later take of `Token In` is added to the how much ETH the router holds and how much the step would take. Otherwise that
total while both are quantities: a V3 exact-in of 600 USDT and a V2 exact-in payment reads as unknown, like any amount the decoder does not know. A later
of 400 USDT, both paid by the user, read `1000.0000 USDT`. When the decoder step paid from the router spends what the router holds and takes nothing, and
does not know a take's amount, the total is not known and there is no `Amount` so does a V2 exact-in whose `amountIn` is `ALREADY_PAID`. A later take of
line. A `PERMIT2_PERMIT` names `Token In` only when its amount, `Unlimited` `Token In` is added to the total while both are quantities: a V3 exact-in of
included, stands as the `Amount` figure in place of the total, which it does 600 USDT and a V2 exact-in of 400 USDT, both paid by the user, read
only in the plain shape of Uniswap's own routes: it comes before the step that `1000.0000 USDT`. When the decoder does not know a take's amount, the total is
sets the input side, it is the only `PERMIT2_PERMIT` in the transaction and not known and there is no `Amount` line. A `PERMIT2_PERMIT` names `Token In`
there is no `PERMIT2_PERMIT_BATCH`, its spender is the router, the router may only when its amount, `Unlimited` included, stands as the `Amount` figure in
not let it fail, no step before it takes its token, a `PERMIT2_TRANSFER_FROM` place of the total, which it does only in the plain shape of Uniswap's own
of it included, or a token the decoder does not know, and the decoder reads routes: it comes before the step that sets the input side, it is the only
every step after it in full. Permit2 then lets the router take no more of the `PERMIT2_PERMIT` in the transaction and there is no `PERMIT2_PERMIT_BATCH`,
token than that amount, so the permit covers every take of it. A step before its spender is the router, the router may not let it fail, no step before it
the permit takes under whatever allowance the router already had, so it may takes its token, a `PERMIT2_TRANSFER_FROM` of it included, or a token the
take more than the permit's amount, which would then read lower than what decoder does not know, and the decoder reads every step after it in full.
leaves the user's wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the Permit2 then lets the router take no more of the token than that amount, so
router, then a permit of 600 USDT to the router and a V3 exact-in of 2,000 the permit covers every take of it. A step before the permit takes under
USDT paid from the router, read `Amount 2000.0000 USDT`. A later permit or whatever allowance the router already had, so it may take more than the
batch may raise what Permit2 lets the router take, and so may a step the permit's amount, which would then read lower than what leaves the user's
decoder does not read in full: a command the decoder does not read (listed at wallet: a `PERMIT2_TRANSFER_FROM` of 2,000 USDT into the router, then a permit
the end of this section), a step whose input it cannot read, in whole or in of 600 USDT to the router and a V3 exact-in of 2,000 USDT paid from the
part, and a step it fails on. Every other `PERMIT2_PERMIT`, before the step router, read `Amount 2000.0000 USDT`. A later permit or batch may raise what
that sets the input side or after it, leaves `Token In` and the `Amount` line Permit2 lets the router take, and so may a step the decoder does not read in
as they read with no permit, unless it lets a spender other than the router full: a command the decoder does not read (listed at the end of this section),
take `Token In` (see below): a permit of 5,000 DAI to another address and then a step whose input it cannot read, in whole or in part, and a step it fails
a V3 exact-in of 600 USDT read `Swap USDT → USDC` with `Amount 600.0000 USDT`, on. Every other `PERMIT2_PERMIT`, before the step that sets the input side or
and so do that exact-in and then a permit of 5,000 DAI to the router. When a after it, leaves `Token In` and the `Amount` line as they read with no permit,
permit's amount stands and the step that sets the input side spends a token unless it lets a spender other than the router take `Token In` (see below): a
other than the permit's, or one the decoder does not know, that step's amount permit of 5,000 DAI to another address and then a V3 exact-in of 600 USDT read
gets lines of its own, unless it states no amount and the user pays it none of `Swap USDT → USDC` with `Amount 600.0000 USDT`, and so do that exact-in and
its input token. Each take the total does not count gives three lines after then a permit of 5,000 DAI to the router. When a permit's amount stands and
the `Amount` line (after `Token In` when there is none): one of a token other the step that sets the input side spends a token other than the permit's, or
than `Token In`; one of `Token In` after an amount that is not a quantity (a one the decoder does not know, that step's amount gets lines of its own,
V4 open delta, `ALREADY_PAID`, which keep their wording on the `Amount` line, unless it states no amount and the user pays it none of its input token. Each
or no stated amount, as of a V4 exact-out step); one whose amount the decoder take the total does not count gives three lines after the `Amount` line (after
does not know; and one of ETH that the user pays a V4 step when the router may `Token In` when there is none): one of a token other than `Token In`; one of
hold ETH. When there is no `Amount` line, each take the total counts gets them `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 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 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 (a V2 or V3 step whose `payerIsUser` is true, a V4 step the user pays some of
@@ -3542,26 +3548,24 @@ Currently supported:
its amount, worded as the `Amount` line words one, or 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
+92 -62
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,13 @@ 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. A step that spends an unstated
// amount of the token from the router makes the value 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,17 +1417,19 @@ 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. A null amount is one the calldata
// such as the null decodeV4Swap() gives a SETTLE whose payment it // does not state, as for a V2 or V3 exact-out or a SETTLE whose
// cannot tell, or one of a token whose amount is not known, pays out // payment decodeV4Swap() cannot tell. A null amount makes the
// nothing this decoder can see. // router's amount of the token unknown. ALREADY_PAID spends nothing.
const spendFromRouter = (token, amount) => { const spendFromRouter = (token, amount) => {
const t = token.toLowerCase(); const t = token.toLowerCase();
const held = heldByRouter.get(t); const held = heldByRouter.get(t);
if (amount === CONTRACT_BALANCE) { if (amount === CONTRACT_BALANCE) {
heldByRouter.delete(t); heldByRouter.delete(t);
} else if (amount === null) {
if (heldByRouter.has(t)) heldByRouter.set(t, null);
} else if (typeof held === "bigint" && typeof amount === "bigint") { } else if (typeof held === "bigint" && typeof amount === "bigint") {
if (amount >= held) { if (amount >= held) {
heldByRouter.delete(t); heldByRouter.delete(t);
@@ -1682,8 +1685,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
) { ) {
// Buys exactly amountOut and spends at most amountInMax. // Buys exactly amountOut and spends at most amountInMax.
// Paid from the router, it spends an amount the calldata // Paid from the router, it spends an amount the calldata
// does not state, so it spends nothing spendFromRouter() // does not state (see spendFromRouter()).
// could count.
hasV2OrV3ExactOut = true; hasV2OrV3ExactOut = true;
const s = const s =
command === "V2_SWAP_EXACT_OUT" command === "V2_SWAP_EXACT_OUT"
@@ -1697,6 +1699,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
addUserPaid(s.tokenIn, s.amountInMax); addUserPaid(s.tokenIn, s.amountInMax);
} }
setOutput(i, s.tokenOut, s.amountOut, [s.recipient]); setOutput(i, s.tokenOut, s.amountOut, [s.recipient]);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, null);
}
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
@@ -1825,20 +1830,49 @@ 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. This decoder knows the rest only when it knows
// how much ETH the router holds and how much the user
// would pay. Otherwise the rest is unknown.
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"
) {
paidByUser.push({
currency: p.currency,
amount: null,
});
} else if (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 +1897,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 +1954,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 +2004,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 +2074,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 +2109,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 +2158,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 +2262,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 +2276,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
@@ -2305,8 +2336,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
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)) {
+76 -161
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, and 1.5
// rows marked "reads as on next" read the same on next. On next every // 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,147 +2492,58 @@ 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", 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], commands: [0x0b, 0x0c, 0x10],
inputs: [WRAP_INTO_ROUTER, UNWRAP_INTO_ROUTER, V4_ONE_ETH], inputs: [
lines: [ encodeWrapEth(ROUTER_ADDR, ONE_ETH),
["Amount", "1.0000 ETH"], unwrapIntoRouter,
...ethLines("Up to 1.0000 ETH"), v4EthForUsdc(ONE_ETH),
], ],
lines: [["Amount", "1.0000 ETH"]],
}, },
{ {
kind: "the same with a TRANSFER of 1 ETH to another address after the UNWRAP_WETH", 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, 0x0c, 0x05, 0x10], commands: [0x0b, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, encodeWrapEth(ROUTER_ADDR, ONE_ETH),
UNWRAP_INTO_ROUTER, unwrapIntoRouter,
payOut(ZERO_ADDR, ONE_ETH), v4EthForUsdc((ONE_ETH * 3n) / 2n),
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"),
], ],
lines: [["Amount", "1.5000 ETH"]],
}, },
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// The lines for ETH the sender pays a V4 step, of an amount this
// decoder does not know.
const unknownEthLines = [
["Moved from the sender", UNDECODED],
["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
];
// https://git.eeqj.de/sneak/AutistMask/issues/665 — this decoder may
// not know how much ETH the router holds, or how much ETH the user
// pays a V4 step. The ETH the sender pays that step then reads as
// unknown. Counting the router as paying all of it would hide ETH
// that can leave the wallet.
test.each([
{ {
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"], ...unknownEthLines],
}, },
{ {
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 V4 swap of 600 USDT that takes ETH into the router, then a V4 swap of 5 ETH that the user settles",
@@ -2659,22 +2563,19 @@ describe("uniswap decoder", () => {
1n, 1n,
), ),
settle(USDT_ADDR, 0n, true), settle(USDT_ADDR, 0n, true),
take(ZERO_ADDR, ADDRESS_THIS, 0n), take(ZERO_ADDR, ROUTER_ADDR, 0n),
], ],
), ),
v4EthForUsdc(5n * ONE_ETH), v4EthForUsdc(5n * ONE_ETH),
], ],
lines: [ lines: [["Amount", "600.0000 USDT"], ...unknownEthLines],
["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", 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",
commands: [0x0b, 0x0c, 0x10], commands: [0x0b, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, encodeWrapEth(ROUTER_ADDR, ONE_ETH),
UNWRAP_INTO_ROUTER, unwrapIntoRouter,
encodeV4Swap( encodeV4Swap(
new Uint8Array([ new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID, V4_SWAP_EXACT_OUT_SINGLE_ID,
@@ -2695,14 +2596,26 @@ describe("uniswap decoder", () => {
], ],
lines: [ lines: [
...wrapLines("The swap step that spends it"), ...wrapLines("The swap step that spends it"),
...ethLines(UNDECODED), ...unknownEthLines,
], ],
}, },
])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
});
// https://git.eeqj.de/sneak/AutistMask/issues/665 — a step that spends
// an unstated amount of a token from the router makes the router's
// amount of that token unknown. In each row such a step spends the
// WETH of a WRAP_ETH of 1 ETH into the router. The UNWRAP_WETH then
// leaves an unknown amount of ETH there, so the ETH the sender pays
// the last step reads as unknown.
test.each([
{ {
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, 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",
commands: [0x0b, 0x01, 0x0c, 0x10], commands: [0x0b, 0x01, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, encodeWrapEth(ROUTER_ADDR, ONE_ETH),
encodeV3SwapExactOut( encodeV3SwapExactOut(
USER_ADDR, USER_ADDR,
390000000n, 390000000n,
@@ -2710,19 +2623,21 @@ describe("uniswap decoder", () => {
[USDC_ADDR, WETH_ADDR], [USDC_ADDR, WETH_ADDR],
false, false,
), ),
UNWRAP_INTO_ROUTER, unwrapIntoRouter,
V4_ONE_ETH, v4EthForUsdc(ONE_ETH),
], ],
lines: [ lines: [
["Amount", "Up to 1.0000 ETH"], ["Moved from the sender", "Up to 1.0000 ETH"],
...ethLines("Up to 1.0000 ETH"), ["Token moved", "ETH"],
["Moved to", "The swap step that spends it"],
...unknownEthLines,
], ],
}, },
{ {
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", 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",
commands: [0x0b, 0x10, 0x0c, 0x10], commands: [0x0b, 0x10, 0x0c, 0x10],
inputs: [ inputs: [
WRAP_INTO_ROUTER, encodeWrapEth(ROUTER_ADDR, ONE_ETH),
encodeV4Swap( encodeV4Swap(
new Uint8Array([ new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID, V4_SWAP_EXACT_OUT_SINGLE_ID,
@@ -2740,16 +2655,16 @@ describe("uniswap decoder", () => {
encodeV4TakeAll(USDC_ADDR, 0n), encodeV4TakeAll(USDC_ADDR, 0n),
], ],
), ),
UNWRAP_INTO_ROUTER, unwrapIntoRouter,
V4_ONE_ETH, v4EthForUsdc(ONE_ETH),
], ],
lines: [ lines: [
["Amount", "1.0000 ETH"], ...wrapLines("The swap step that spends it"),
...ethLines("Up to 1.0000 ETH"), ...unknownEthLines,
], ],
}, },
])("$kind", ({ router, commands, inputs, lines }) => { ])("$kind", ({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs, router); const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines); expect(linesBetweenTokens(result)).toEqual(lines);
}); });