1 Commits
Author SHA1 Message Date
clawbot 594e7cec27 fix: a swap split across steps the user pays for shows every amount it spends (closes #625)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The swap's Amount line took only the first swap step's amount. It now
gives the total the steps take of Token In: the amount of the step that
sets the input side plus what each later step the user pays takes of it.
A take of another token, one after an amount that is not a quantity, or
one the decoder cannot tell, gets lines of its own, and then so does a
step that sets the input side with no amount; an unread take, a
V3_SWAP_EXACT_OUT's included, leaves no figure wherever it stands. A V4
step counts every SETTLE paid by the user and every SETTLE_ALL. A
PERMIT2_PERMIT names Token In, and its amount stands only in the plain
shape of Uniswap's own routes, with every later step read.

Model: opus-5-5
2026-10-20 03:43:53 +00:00
4 changed files with 1306 additions and 129 deletions
+101 -45
View File
@@ -1190,14 +1190,8 @@ number. They can read:
`amountOut`, the exact amount it buys.
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
shows is a V4 exact-in `amountIn` of zero. V4 reads that zero as "use the
whole open delta", so the calldata states no quantity. The line shows the
amount of the step that sets the input side: the last `PERMIT2_PERMIT` step if
there is one, otherwise the first V2 or V3 exact-in, V2 exact-out, `WRAP_ETH`
or V4 swap step that names an input token or amount. A V4 step with a swap
action sets the input side when no earlier step set it, even when it names
neither, as described below. A V2 exact-out step gives its `amountInMax`, and
a V4 swap step the `amountIn` of the swap action that spends its input
currency.
whole open delta", so the calldata states no quantity. Transaction Decoding
below gives the steps whose amounts the line shows.
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
The router reads that zero as "the pair already holds the input tokens": the
@@ -2379,10 +2373,11 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, what each `PERMIT2_TRANSFER_FROM` moves and where to, steps,
deadline, and unless a swap pays only the From address, a `Paid to` line
for each other address it pays, or saying that a token stays in the router
or who it pays is unknown (see Transaction Decoding)
amounts, what each swap step spends that the `Amount` line does not give,
what each `PERMIT2_TRANSFER_FROM` moves and where to, steps, deadline, and
unless a swap pays only the From address, a `Paid to` line for each other
address it pays, or saying that a token stays in the router or who it pays
is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
@@ -3090,37 +3085,40 @@ Currently supported:
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
`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. The router pays a `SETTLE` of
`OPEN_DELTA` (zero) only what its step still owes of that token when the
`SETTLE` runs, so one from the router counts as spending the amounts the
step's swap actions state they spend of that token only when every action in
the step could be read, every swap action names the token it spends and the
token it buys (a swap action with an empty path swaps nothing), the `SETTLE`
is the step's only `SETTLE` or `SETTLE_ALL` of that token and comes after
every swap action, no swap action buys that token, and the swap actions that
spend it state a quantity together; otherwise it counts as spending nothing.
So the rest of a V4 `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 stays in the router when later steps do not spend all
of it, where anyone can take it in a later transaction. The router sends ETH
as written, so a `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and
pays out nothing. A step paying a token to the V2 pair that the very next step
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
pay the next V2 pair directly, and the router enforces that swap's own
minimum. The decoder checks that the address paid is that pair, deriving it
from the V2 factory and pair init code hash the network's routers were
deployed with and the first two tokens of that step's path; a payment to any
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
deployed with placeholders for both values, so no V2 swap step can succeed on
that router.) Every other payment counts, in whichever step and of whatever
token, a share of the output included: an interface fee paid to another
address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the
minimum reads `Min. paid out`, because the swap's minimum includes the fee.
The minimum reads `Min. received` only when some step pays the transaction's
sender (the From address on TxApproval, the sender the explorer reports on
TransactionDetail) and no `Paid to` line is shown. `Paid to` lines after
`Token Out` name each other address paid; one reads
WETH from it reads as leaving the WETH there. A `SETTLE` of `OPEN_DELTA`
(zero), and a `SETTLE_ALL`, pay only what the step still owes of their token
when they run. The decoder takes that to be the amounts the step's swap
actions state they spend of that token only when every action in the step
could be read, every swap action names the token it spends and the 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, and such a `SETTLE`
from the router counts as spending nothing. So the rest of a V4 `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 stays in the
router when later steps do not spend all of it, where anyone can take it in a
later transaction. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
values, so no V2 swap step can succeed on that router.) Every other payment
counts, in whichever step and of whatever token, a share of the output
included: an interface fee paid to another address by `PAY_PORTION`,
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `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
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
@@ -3132,8 +3130,66 @@ Currently supported:
name); and one more reads `Unknown (could not be decoded)` when a step that
may pay an address went unread. When no step pays anyone, a single line reads
`Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
minimum reads `Min. paid out`. After the `Amount` line (after `Token In` when
there is none), each `PERMIT2_TRANSFER_FROM` but one into the router, in step
minimum reads `Min. paid out`. The `Amount` line gives the total the steps
take of `Token In`, from the step that sets the input side on: the first V2 or
V3 exact-in, V2 exact-out, `WRAP_ETH` or V4 swap step that names an input
token or amount. A V4 step with a swap action sets the input side when no
earlier step set it, even when it names neither (see Display Consistency).
That step counts its own amount, whoever pays it: a V2 exact-out step its
`amountInMax`, and a V4 swap step the `amountIn` of the swap action that
spends its input currency. Each later V2 or V3 exact-in or V2 exact-out step
whose `payerIsUser` is true takes its amount, found in the same way, from the
user's wallet. So does every V4 step but the one that sets the input side,
with a swap action or without: it takes what its `SETTLE`s paid by the user
(`payerIsUser` true) and its `SETTLE_ALL`s pay, for each token on its own. 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. 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. `Token In` is the token of the last
`PERMIT2_PERMIT` the decoder reads, when there is one. Its amount, `Unlimited`
included, stands as the `Amount` figure in place of the total only in the
plain shape of Uniswap's own routes: it is the only `PERMIT2_PERMIT`, its
spender is the router, the router may not let it fail, no step before it takes
its token 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 the decoder
does not read in full may raise what Permit2 lets the router take, as a
`PERMIT2_PERMIT_BATCH` can: 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. In any other case the line gives the total, as with no
permit. When the step that sets the input side spends a token other than the
permit's, or one the decoder does not know, its amount gets lines of its own
and the total has no figure to start from: unless the permit's amount stands
there is no `Amount` line, and each take of the permit's token gets lines of
its own. 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 the step that sets the input side states no amount and
there is no `Amount` line, that step gets them too whenever another take's
lines give a figure, so that figure cannot read as all that leaves. They come
in step order, the step that sets the input side first when it has them:
`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
does not know gives the `Moved from the sender` line alone. A step may also
take from the user's wallet what the decoder did not read: a V4 step, other
than the one that sets the input side, whose input or one of whose `SETTLE`s
or `SETTLE_ALL`s it cannot read, which the router still runs; a V2 or V3
exact-in or V2 exact-out step or a `PERMIT2_TRANSFER_FROM` whose input it
cannot read; every `PERMIT2_TRANSFER_FROM_BATCH`; every `V3_SWAP_EXACT_OUT`,
which the decoder does not read, wherever it stands; and every step it fails
on. Each such step gives one `Moved from the sender` line among those, reading
`Unknown (could not be decoded)`. That line, or any line for a token the
decoder does not know, leaves no `Amount` line, whose figure might be too low,
and such a step before a `PERMIT2_PERMIT` keeps the permit's amount from
standing. Then each `PERMIT2_TRANSFER_FROM` but one into the router, in step
order, gives three lines: `Moved from the sender`, the amount it moves, for
example `1000000.0000 USDT`; `Token moved`, giving the token's address as the
`Token In` line does; and `Moved to`, which reads
+350 -81
View File
@@ -335,7 +335,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// commands this decoder does not read. That action names both of its
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read.
// address this decoder did not read, and so do the lines for what a step
// takes from the sender, when this decoder does not know it.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line for each token paid to the router, the swap's output or
@@ -356,6 +357,11 @@ function present(value) {
return value !== null && value !== undefined;
}
// Whether `a` and `b` are both known and the same address.
function sameAddress(a, b) {
return present(a) && present(b) && a.toLowerCase() === b.toLowerCase();
}
// Uniswap V4 spells "use the whole open delta" as an amount of zero:
// v4-periphery `src/libraries/ActionConstants.sol` declares
// `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action
@@ -566,6 +572,7 @@ function decodeV2SwapExactIn(input) {
// Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes.
// ABI: (address recipient, uint256 amountOut, uint256 amountInMax,
// address[] path, bool payerIsUser)
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV2SwapExactOut(input) {
try {
const d = coder.decode(
@@ -578,6 +585,7 @@ function decodeV2SwapExactOut(input) {
amountInMax: d[2],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
payerIsUser: d[4],
};
} catch {
return null;
@@ -901,10 +909,15 @@ function totalAmount(amounts) {
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
// `recipientUnread` is set when the step has a payout this decoder did not
// read. `settledFromRouter` is every SETTLE that the router pays from its own
// balance (`payerIsUser` false), as its currency and the amount it pays: a
// quantity, CONTRACT_BALANCE (all the router holds), or null when this
// decoder cannot tell (see below).
// read, and `settleUnread` when it has a SETTLE or SETTLE_ALL this decoder
// did not read. `settledFromRouter` is every SETTLE that the router pays from
// its own balance (`payerIsUser` false), as its currency and the amount it
// pays: a quantity, CONTRACT_BALANCE (all the router holds), or null when
// this decoder cannot tell (see below). `paidByUser` is what the user pays
// the step, once per currency, as that currency and the amount: the total of
// every SETTLE paid by the user (`payerIsUser` true) and every SETTLE_ALL,
// which V4Router always has the user pay, of that currency, or null when
// this decoder cannot tell what one of them pays.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -914,16 +927,18 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
const takes = [];
// Every SETTLE paid from the router, as its currency, its amount as
// written and its index among the step's actions.
const routerSettles = [];
// The currency of every SETTLE and SETTLE_ALL.
const settledCurrencies = [];
// Every SETTLE and SETTLE_ALL, as its currency, its amount as
// written, whether the user pays it, and its index among the step's
// actions. A SETTLE_ALL pays what a SETTLE of OPEN_DELTA (zero) paid
// by the user pays, all the step still owes of its currency, so it
// is kept as one; its maxAmount only caps that.
const settles = [];
// The index of the step's last swap action, read or not, or -1.
let lastSwapAction = -1;
// Set when an action of the step could not be read.
let actionUnread = false;
let recipientUnread = false;
let settleUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
@@ -942,19 +957,22 @@ function decodeV4Swap(input) {
params[i],
);
settleToken = s[0];
settledCurrencies.push(s[0]);
if (!s[2]) {
routerSettles.push({
currency: s[0],
amount: s[1],
index: i,
});
}
settles.push({
currency: s[0],
amount: s[1],
payerIsUser: s[2],
index: i,
});
} else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0];
settledCurrencies.push(s[0]);
settles.push({
currency: s[0],
amount: 0n,
payerIsUser: true,
index: i,
});
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -996,7 +1014,8 @@ function decodeV4Swap(input) {
} catch {
// Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// TAKE_PORTION among them is a payout this decoder did not
// read.
// read, and a SETTLE or SETTLE_ALL may take from the user
// what it did not read: the router still runs both.
actionUnread = true;
if (
actionId === V4_TAKE ||
@@ -1005,25 +1024,29 @@ function decodeV4Swap(input) {
) {
recipientUnread = true;
}
if (actionId === V4_SETTLE || actionId === V4_SETTLE_ALL) {
settleUnread = true;
}
}
}
// What each SETTLE paid from the router pays. A quantity or
// CONTRACT_BALANCE pays as written. A SETTLE of OPEN_DELTA (zero)
// pays whatever the step still owes of its currency when it runs.
// That is what the step's swap actions state they spend of it only
// in the plain shape of Uniswap's own routes, so only then is it
// given that amount: every action in the step was read; every swap
// action names the currency it spends and the one it buys (an
// exact-in action with an empty path swaps nothing); the SETTLE is
// the step's only SETTLE or SETTLE_ALL of its currency and comes
// after every swap action; no swap action buys that currency; and
// the swap actions that spend it state a quantity together (see
// totalAmount()). Otherwise it is given null.
const settledFromRouter = routerSettles.map((s) => {
if (s.amount !== 0n) {
return { currency: s.currency, amount: s.amount };
}
// What a SETTLE or SETTLE_ALL pays. A SETTLE of any amount but
// OPEN_DELTA (zero) pays as written: a quantity, or, for
// CONTRACT_BALANCE, as much as the router holds. A SETTLE of
// OPEN_DELTA, and a SETTLE_ALL, pay whatever the step still owes of
// the currency when they run. That is what the step's swap actions
// state they spend of it only in the plain shape of Uniswap's own
// routes, so only then is it given that amount: every action in the
// step was read; every swap action names the currency it spends and
// the one it buys (an exact-in action with an empty path swaps
// nothing); it is the step's only SETTLE or SETTLE_ALL of its
// currency and comes after every swap action; no swap action buys
// that currency and nothing 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 (see totalAmount()). Otherwise it is
// given null.
const pays = (s) => {
if (s.amount !== 0n) return s.amount;
const stated = totalAmount(
swaps
.filter((w) => w.tokenIn === s.currency)
@@ -1032,16 +1055,30 @@ function decodeV4Swap(input) {
const paysWhatSwapsSpend =
!actionUnread &&
swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) &&
settledCurrencies.filter((c) => c === s.currency).length ===
1 &&
settles.filter((c) => c.currency === s.currency).length === 1 &&
s.index > lastSwapAction &&
!swaps.some((w) => w.tokenOut === s.currency) &&
!takes.some((t) => t.currency === s.currency) &&
typeof stated === "bigint";
return {
currency: s.currency,
amount: paysWhatSwapsSpend ? stated : null,
};
});
return paysWhatSwapsSpend ? stated : null;
};
const settledFromRouter = settles
.filter((s) => !s.payerIsUser)
.map((s) => ({ currency: s.currency, amount: pays(s) }));
const paidByUser = [];
for (const s of settles.filter((c) => c.payerIsUser)) {
const amount = pays(s);
const paid = paidByUser.find((p) => p.currency === s.currency);
if (!paid) {
paidByUser.push({ currency: s.currency, amount });
} else {
paid.amount =
typeof paid.amount === "bigint" &&
typeof amount === "bigint"
? paid.amount + amount
: null;
}
}
// Rule 3.
if (swapUndecoded) {
@@ -1052,7 +1089,9 @@ function decodeV4Swap(input) {
amountOutMin: null,
takes,
settledFromRouter,
paidByUser,
recipientUnread,
settleUnread,
swapUndecoded,
hasSwapAction: true,
};
@@ -1094,7 +1133,9 @@ function decodeV4Swap(input) {
amountOutMin,
takes,
settledFromRouter,
paidByUser,
recipientUnread,
settleUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
@@ -1125,6 +1166,13 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
let inputToken = null;
let inputAmount = null;
let inputEstablished = false;
// The last PERMIT2_PERMIT this decoder read, or null (see
// permitStands after the loop).
let permit = null;
// The index of each step this decoder did not read in full: a
// command it does not read, a step whose input it cannot read, in
// whole or in part, and one it fails on (see permitStands).
const stepsUnread = [];
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
@@ -1135,8 +1183,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const commandNames = [];
// THE INVARIANT: an amount and the token it is counted in always come
// from the same hop. A figure is never rendered against a token that
// did not supply it.
// from the same hop, or from hops that all spend that token. A figure
// is never rendered against a token that did not supply it.
//
// Both sides are therefore set as a PAIR — never field by field, and
// never on truthiness. An address is never falsy once set but an
@@ -1152,7 +1200,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// states neither half: the output side always, and the input side
// when no earlier hop set it. A half the establishing hop did not
// state stays null and the line says so, rather than being filled in
// from a different hop.
// from a different hop. A PERMIT2_PERMIT sets no side here: after the
// loop, the last one read names Token In, and the Amount line is
// formed there, in one place (see permitStands).
const setInput = (token, amount) => {
inputToken = present(token) ? token : null;
inputAmount = present(amount) ? amount : null;
@@ -1170,6 +1220,29 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
outputEstablished = true;
};
// What each step but the one that sets the input side takes from the
// sender, as { token, amount }, in step order. After the loop each
// joins the Amount line or gets lines of its own (see permitStands).
const userPaid = [];
// A swap step the user pays for (`payerIsUser` true) takes `amount`
// of `token` from the sender. A V2 exact-in of ALREADY_PAID takes
// nothing.
const addUserPaid = (token, amount) => {
if (amount !== ALREADY_PAID) userPaid.push({ token, amount });
};
// A step may take from the sender what this decoder did not read: a
// V4 step, other than the one that sets the input side, whose input
// or one of whose SETTLEs or SETTLE_ALLs it cannot read; a V2 or V3
// exact-in or V2 exact-out step or PERMIT2_TRANSFER_FROM whose input
// it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; a V3_SWAP_EXACT_OUT;
// and a step it fails on. It is recorded with a null token and
// amount: one line says so, there is no Amount line, whose figure may
// then be too low, and the amount of a permit after it does not stand
// (see permitStands).
const spentUnread = () => {
userPaid.push({ token: null, amount: null });
};
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
// its own address, holds what it is paid until a later step pays it
@@ -1304,16 +1377,34 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
payeeUnread = true;
stepsUnread.push(i);
} else if (command === "PERMIT2_PERMIT") {
const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
if (p) {
// Whether a step before it took its token, or may
// have: the step that set the input side or one in
// `userPaid`, when its token is the permit's or not
// known.
const mayBeItsToken = (token) =>
!present(token) || sameAddress(token, p.token);
const tookBefore =
(inputEstablished && mayBeItsToken(inputToken)) ||
userPaid.some((t) => mayBeItsToken(t.token));
permit = {
...p,
step: i,
allowedToFail,
beforeEveryTake: !tookBefore,
};
} else {
stepsUnread.push(i);
}
} else if (command === "PERMIT2_TRANSFER_FROM") {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
} else if (
commandTypeMask === 0x1f &&
[MSG_SENDER, ADDRESS_THIS].includes(
@@ -1351,6 +1442,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// owner may be any address. It sets no side, and the
// step says so.
commandNames[i] += " (could not be decoded)";
stepsUnread.push(i);
} else if (b.owner.toLowerCase() !== sender) {
// The router reads the owner as written (see
// decodeBalanceCheck()), so address(1) and address(2)
@@ -1375,7 +1467,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else if (command === "V3_SWAP_EXACT_IN") {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
@@ -1388,11 +1484,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V3_SWAP_EXACT_OUT") {
// This decoder does not read it
// (https://git.eeqj.de/sneak/AutistMask/issues/601). It
// may pay any address and take from the sender, so
// wherever it stands it leaves no Amount figure.
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
} else if (command === "V2_SWAP_EXACT_IN") {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountIn);
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountIn);
}
setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
@@ -1402,20 +1512,34 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "V2_SWAP_EXACT_OUT") {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
if (!inputEstablished) {
setInputOnce(s.tokenIn, s.amountInMax);
} else if (s.payerIsUser) {
addUserPaid(s.tokenIn, s.amountInMax);
}
setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
} else if (command === "PERMIT2_TRANSFER_FROM_BATCH") {
// It moves the sender's tokens to any addresses, and this
// decoder does not read it.
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
} else if (command === "SWEEP") {
const s = decodeSweep(inputs[i]);
if (s) {
@@ -1425,6 +1549,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (
command === "TRANSFER" ||
@@ -1461,6 +1586,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (command === "ACROSS_V4_DEPOSIT_V3") {
const a = decodeAcrossV4DepositV3(inputs[i]);
@@ -1484,6 +1610,7 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (command === "WRAP_ETH") {
const w = decodeWrapEth(inputs[i]);
@@ -1514,9 +1641,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
const inputSetBefore = inputEstablished;
if (v4) {
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
@@ -1532,6 +1661,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
}
if (
!v4 ||
v4.recipientUnread ||
v4.settleUnread ||
v4.swapUndecoded
) {
stepsUnread.push(i);
}
if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step
// is marked.
@@ -1562,6 +1699,23 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin);
}
// Every V4 step but the one that sets the input side,
// with a swap action or without, takes from the sender
// what the user pays it (an amount an unread action may
// change is null). One whose input, or one of whose
// SETTLEs or SETTLE_ALLs, this decoder could not read may
// take anything: the router still runs it.
const setInputHere = inputEstablished && !inputSetBefore;
if (!setInputHere) {
if (v4) {
for (const p of v4.paidByUser) {
addUserPaid(p.currency, p.amount);
}
}
if (!v4 || v4.settleUnread) {
spentUnread();
}
}
} else if (command === "UNWRAP_WETH") {
hasUnwrapWeth = true;
const u = decodeUnwrapWeth(inputs[i]);
@@ -1582,26 +1736,109 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
payeeUnread = true;
stepsUnread.push(i);
}
} else {
// Any other step may pay an address this decoder does not
// read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and
// the NFT commands among them, and a type the release
// names no command for.
// read: PERMIT2_PERMIT_BATCH and the NFT commands among
// them, and a type the release names no command for.
payeeUnread = true;
stepsUnread.push(i);
}
} catch {
// Skip commands we can't decode. One may pay any address.
// Skip commands we can't decode. One may pay any address and
// take from the sender.
payeeUnread = true;
stepsUnread.push(i);
spentUnread();
}
if (allowedToFail) {
commandNames[i] += " (allowed to fail)";
}
}
// The Amount line, and the lines for what the steps take beyond it.
//
// The total is what the steps take of Token In: the amount of the
// step that set the input side, with each later take of that token
// added to it while both are quantities. A take that cannot be added,
// being of another token or coming after an amount that is not a
// quantity (a V4 open delta, ALREADY_PAID, or none stated), gets
// lines of its own. One whose amount is not known also leaves the
// total unknown, with no figure.
//
// The last PERMIT2_PERMIT read names Token In. Its amount stands as
// the Amount figure in place of the total only in the plain shape of
// Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is
// the router, the router may not let it fail, no step before it took
// its token or one not known, and this decoder read every step after
// it in full (see stepsUnread). Permit2 then lets the router take no
// more of the token than that amount, so the permit covers every take
// of it. A later step this decoder did not read may raise what Permit2
// lets the router take, as a PERMIT2_PERMIT_BATCH can. A step that
// may take from the sender what this decoder did not read leaves no
// figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN
// step leaves both sides unknown (below).
const stepCommands = Array.from(
commandsBytes,
(b) => commands[b & commandTypeMask],
);
const permitStands =
permit !== null &&
stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 &&
sameAddress(permit.spender, toAddress) &&
!permit.allowedToFail &&
permit.beforeEveryTake &&
!stepsUnread.some((step) => step > permit.step);
// What the steps take that the Amount line does not give, as
// { token, amount }. A null token is a step this decoder did not read
// (see spentUnread()), or a token it does not know.
const spentFromSender = [];
let total = null;
// The take of the step that set the input side, when the total
// starts from it and it stated no amount (see below), or null.
let firstTakeUnstated = null;
if (permit === null || sameAddress(inputToken, permit.token)) {
total = inputAmount;
if (inputEstablished && !present(inputAmount)) {
firstTakeUnstated = { token: inputToken, amount: null };
}
} else if (inputEstablished) {
// The step that set the input side spent a token other than the
// permit's, or one not known, so the total starts with no figure.
spentFromSender.push({ token: inputToken, amount: inputAmount });
}
if (permit !== null) inputToken = permit.token;
for (const t of userPaid) {
const ofTokenIn = sameAddress(t.token, inputToken);
if (ofTokenIn && permitStands) continue;
if (
ofTokenIn &&
typeof total === "bigint" &&
typeof t.amount === "bigint"
) {
total += t.amount;
continue;
}
spentFromSender.push(t);
if (ofTokenIn && !present(t.amount)) total = null;
}
inputAmount = permitStands ? permit.amount : total;
// With no Amount figure, a later take's own figure could read as all
// that leaves, so the step that set the input side, when it stated
// no amount, then gets lines of its own too, first, saying its amount
// is not known.
if (
!present(inputAmount) &&
firstTakeUnstated !== null &&
spentFromSender.some((s) => present(s.amount))
) {
spentFromSender.unshift(firstTakeUnstated);
}
// The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens
// or pay the user, before or after any other step, and a permit among
// them would replace the input side. What the other steps state is
// them would name Token In. What the other steps state is
// then not the whole swap, so both sides are set unknown, with no
// figure, wherever the step stands.
if (hasSubPlan) {
@@ -1672,37 +1909,49 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
if (present(inputAmount)) {
// Three amounts need no scale to describe and are named rather
// than formatted: V4's open delta and 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 tests come
// first — the sentinels are not bigints and cannot be compared
// with one.
let amount;
if (inputAmount === OPEN_DELTA) {
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
} else if (inputAmount === ALREADY_PAID) {
amount = {
// The text of an amount the swap steps spend, for the Amount line and
// the lines for other tokens below it. Three amounts need no scale to
// describe and are named rather than formatted: V4's open delta and
// 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
// tests come first — the sentinels are not bigints and cannot be
// compared with one.
const spentText = (amount, info) => {
if (amount === OPEN_DELTA) {
return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
}
if (amount === ALREADY_PAID) {
return {
raw: ALREADY_PAID_AMOUNT,
display: ALREADY_PAID_AMOUNT,
};
} else if (inputAmount >= MAX_UINT160) {
amount = { raw: "Unlimited", display: "Unlimited" };
} else if (hasV2ExactOut) {
}
if (amount >= MAX_UINT160) {
return { raw: "Unlimited", display: "Unlimited" };
}
if (hasV2ExactOut) {
// A V2 exact-out swap spends at most this figure, whichever
// step set the line (its amountInMax, the WRAP_ETH of a swap
// paid in ETH, a permit), so it is said to be a maximum, in
// `raw` too: the wait, success and error screens show `raw` as
// the transaction's amount.
const most = amountText(inputAmount, inInfo);
amount = {
// steps gave it (an amountInMax, the WRAP_ETH of a swap paid
// in ETH, a permit, or several steps' total), so it is said to
// be a maximum, in `raw` too: the wait, success and error
// screens show `raw` as the transaction's amount.
const most = amountText(amount, info);
return {
raw: "Up to " + most.raw,
display: "Up to " + most.display,
};
} else {
amount = amountText(inputAmount, inInfo);
}
return amountText(amount, info);
};
// A null token in `spentFromSender`, a step this decoder did not read
// or a token it does not know, leaves no Amount figure, which may
// then be too low.
if (
present(inputAmount) &&
spentFromSender.every((s) => present(s.token))
) {
const amount = spentText(inputAmount, inInfo);
details.push({
label: "Amount",
value: amount.display,
@@ -1710,6 +1959,26 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// What steps take from the sender beyond the Amount line, in the shape
// 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
// that one line alone.
for (const s of spentFromSender) {
details.push({
label: "Moved from the sender",
value: present(s.amount)
? spentText(s.amount, tokenInfo(s.token, sources)).display
: UNDECODED_CURRENCY,
});
if (present(s.token)) {
details.push(tokenMovedLine("Token moved", s.token, sources));
details.push({
label: "Moved to",
value: "The swap step that spends it",
});
}
}
// What each PERMIT2_TRANSFER_FROM moves: its amount, its token and
// where it goes, an address on a line of its own.
for (const m of movedFromSender) {
+826
View File
@@ -1289,6 +1289,832 @@ describe("uniswap decoder", () => {
expect(detail(result, "Amount").value).not.toContain("500000000000");
});
// https://git.eeqj.de/sneak/AutistMask/issues/625 — a route split across
// two swap steps the user pays for, with no PERMIT2_PERMIT, takes both
// steps' amounts from the wallet. On next at 671b4d3 the Amount line read
// only the first step's 600.0000 USDT.
describe("a swap split across steps the user pays for", () => {
const USDT_600 = 600000000n;
const USDT_400 = 400000000n;
const DAI_400 = 400000000000000000000n;
const OTHER_ADDR = "0x1111111111111111111111111111111111111111";
const UNDECODED = "Unknown (could not be decoded)";
// The lines between Token In and Token Out, as [label, value].
function linesBetweenTokens(result) {
const lines = result.details.map((d) => [d.label, d.value]);
const from = lines.findIndex(([label]) => label === "Token In");
const to = lines.findIndex(([label]) => label === "Token Out");
return lines.slice(from + 1, to);
}
// execute() of these command bytes and inputs, decoded as sent by
// the user.
function decodeSteps(commands, inputs) {
const data = buildExecute(
solidityPacked(
commands.map(() => "uint8"),
commands,
),
inputs,
9999999999n,
);
return uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
}
// The first step: a V3 exact-in of 600 USDT for USDC, paid by the
// user, which sets the input side.
const V3_600 = encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]);
// V4 SETTLE of `amount` of `currency`, and TAKE of `amount` of
// `currency` to `recipient`.
const settle = (currency, amount, payerIsUser) =>
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
);
const take = (currency, recipient, amount) =>
coder.encode(
["address", "address", "uint256"],
[currency, recipient, amount],
);
// `input` with the top byte of its word at `index` set. This decoder
// cannot read such a word as an address or a length; the router reads
// its low bytes and runs the step.
const dirtyWord = (input, index) => {
const at = 2 + index * 64;
return input.slice(0, at) + "ff" + input.slice(at + 2);
};
test("a split across a V3 and a V2 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Token In").address.toLowerCase()).toBe(
USDT_ADDR.toLowerCase(),
);
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Amount").rawValue).toBe("1000.0000");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// In the order of Uniswap's own V4 routes: the swap, then a SETTLE of
// OPEN_DELTA by the user, which pays what the swap spends.
test("a split across a V3 step and a V4 step the user pays for shows the total", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x10]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ID,
]),
[
encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
),
encodeV4Settle(USDT_ADDR), // paid by the user
encodeV4Take(USDC_ADDR),
],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("1000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
test("a second step the user pays for that spends another token shows that token's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x08]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(
USER_ADDR,
400000000000000000000n, // 400 DAI
390000000n,
[DAI_ADDR, USDC_ADDR],
),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(linesBetweenTokens(result)).toEqual([
["Amount", "600.0000 USDT"],
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
]);
});
// Every later V4 step takes from the sender what its SETTLEs paid by
// the user and its SETTLE_ALLs pay, each currency on its own, with a
// swap action or without. Each of these read 600.0000 USDT alone
// before.
const USDT_FOR_USDC_400 = encodeV4ExactInSingle(
USDT_ADDR,
USDC_ADDR,
USDT_400,
390000000n,
);
const DAI_FOR_USDC_400 = encodeV4ExactInSingle(
DAI_ADDR,
USDC_ADDR,
DAI_400,
390000000n,
);
const DAI_GROUP = [
["Moved from the sender", "400.0000 DAI"],
["Token moved", "DAI (" + DAI_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
test.each([
{
kind: "a V4 step paying USDT and DAI by a SETTLE_ALL of each",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
DAI_FOR_USDC_400,
encodeV4SettleAll(USDT_ADDR, USDT_400),
encodeV4SettleAll(DAI_ADDR, DAI_400),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"], ...DAI_GROUP],
},
{
kind: "a V4 step with a SETTLE of USDT by the user, then a SETTLE of WETH from the router",
actions: [
V4_SETTLE_ID,
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, USDT_400, true),
settle(WETH_ADDR, 2n ** 255n, false), // CONTRACT_BALANCE
USDT_FOR_USDC_400,
encodeV4ExactInSingle(WETH_ADDR, USDC_ADDR, 0n, 1n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "1000.0000 USDT"]],
},
{
kind: "a V4 step with no swap action: a SETTLE of 400 USDT by the user and a TAKE of it to another address",
actions: [V4_SETTLE_ID, V4_TAKE_ID],
params: [
settle(USDT_ADDR, USDT_400, true),
take(USDT_ADDR, OTHER_ADDR, USDT_400),
],
lines: [["Amount", "1000.0000 USDT"]],
},
// The SETTLE's amount, not the swap's, is what the user pays.
{
kind: "a V4 step swapping 400 USDT with a SETTLE of 5,000 USDT by the user, the rest taken to another address",
actions: [
V4_SETTLE_ID,
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_TAKE_ALL_ID,
],
params: [
settle(USDT_ADDR, 5000000000n, true),
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, 0n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [["Amount", "5600.0000 USDT"]],
},
// A SETTLE_ALL pays all the step owes, here what the TAKE takes as
// well as what the swap spends, so its amount is not known.
{
kind: "a V4 step with no swap action: a TAKE of 400 USDT to another address, then a SETTLE_ALL of USDT",
actions: [V4_TAKE_ID, V4_SETTLE_ALL_ID],
params: [
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, USDT_400),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
{
kind: "a V4 step swapping 400 USDT, with a TAKE of 400 USDT to another address before its SETTLE_ALL of USDT",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_TAKE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
take(USDT_ADDR, OTHER_ADDR, USDT_400),
encodeV4SettleAll(USDT_ADDR, 800000000n),
encodeV4TakeAll(USDC_ADDR, 0n),
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
],
},
])(
"a V3 step of 600 USDT, then $kind",
({ actions, params, lines }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// A step that may take from the sender what the decoder did not read
// leaves no Amount figure, which may be too low, and one line says so.
const UNREAD_SWAP = V4_SWAP_ACTION_SHAPES[0].newer;
test.each([
{
kind: "a V4 step whose SETTLE by the user cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(settle(USDT_ADDR, USDT_400, true), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
USDT_FOR_USDC_400,
dirtyWord(encodeV4SettleAll(USDT_ADDR, USDT_400), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
{
kind: "a V4 step whose swap action and SETTLE_ALL cannot be read",
actions: [
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ALL_ID,
V4_TAKE_ALL_ID,
],
params: [
UNREAD_SWAP,
dirtyWord(encodeV4SettleAll(WETH_ADDR, ONE_WETH), 0),
encodeV4TakeAll(USDC_ADDR, 0n),
],
},
])("a V3 step of 600 USDT, then $kind", ({ actions, params }) => {
const result = decodeSteps(
[0x00, 0x10],
[V3_600, encodeV4Swap(new Uint8Array(actions), params)],
);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
test.each([
{
kind: "a V3 step of 600 USDT, then a V4 step whose input cannot be read",
commands: [0x00, 0x10],
inputs: [
V3_600,
dirtyWord(
encodeV4Swap(new Uint8Array([V4_SETTLE_ID]), [
settle(USDT_ADDR, USDT_400, true),
]),
2,
),
],
},
{
kind: "a V4 step with no swap action whose SETTLE_ALL cannot be read, then a V3 step of 600 USDT",
commands: [0x10, 0x00],
inputs: [
encodeV4Swap(
new Uint8Array([V4_TAKE_ID, V4_SETTLE_ALL_ID]),
[
take(USDT_ADDR, OTHER_ADDR, USDT_400),
dirtyWord(
encodeV4SettleAll(USDT_ADDR, USDT_400),
0,
),
],
),
V3_600,
],
},
{
kind: "a V3 step of 600 USDT, then a V2 step the user pays for whose input cannot be read",
commands: [0x00, 0x08],
inputs: [
V3_600,
dirtyWord(
encodeV2SwapExactIn(OTHER_ADDR, USDT_400, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
0,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM whose input cannot be read",
commands: [0x00, 0x02],
inputs: [
V3_600,
dirtyWord(
coder.encode(
["address", "address", "uint160"],
[USDT_ADDR, OTHER_ADDR, USDT_400],
),
1,
),
],
},
{
kind: "a V3 step of 600 USDT, then a PERMIT2_TRANSFER_FROM_BATCH",
commands: [0x00, 0x0d],
inputs: [
V3_600,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER_ADDR, OTHER_ADDR, USDT_400, USDT_ADDR]]],
),
],
},
])("$kind", ({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
});
// A PERMIT2_PERMIT in the plain shape of Uniswap's own routes keeps
// its own amount: Permit2 lets the router take no more of the token,
// so the steps after it do not add to it.
test("a split after a PERMIT2_PERMIT shows the permit's amount", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8", "uint8"], [0x0a, 0x00, 0x08]),
[
encodePermit2(USDT_ADDR, 5000000000n, ROUTER_ADDR), // 5000 USDT
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("5000.0000 USDT");
expect(detail(result, "Moved from the sender")).toBeUndefined();
});
// Out of that shape the permit caps nothing the Amount line can rely
// on, so the line gives the total the steps take, as with no permit.
const ONE_USDT_PERMIT = (spender) =>
encodePermit2(USDT_ADDR, 1000000n, spender);
test.each([
{
kind: "to another spender",
commands: [0x0a],
permits: [ONE_USDT_PERMIT(OTHER_ADDR)],
},
{
kind: "that the router lets fail",
commands: [0x8a],
permits: [ONE_USDT_PERMIT(ROUTER_ADDR)],
},
{
kind: "followed by a second one",
commands: [0x0a, 0x0a],
permits: [
ONE_USDT_PERMIT(ROUTER_ADDR),
ONE_USDT_PERMIT(ROUTER_ADDR),
],
},
])(
"a split after a PERMIT2_PERMIT $kind shows the total the steps take",
({ commands, permits }) => {
const result = decodeSteps(
[...commands, 0x00, 0x08],
[
...permits,
V3_600,
encodeV2SwapExactIn(USER_ADDR, USDT_400, 390000000n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "1000.0000 USDT"],
]);
},
);
// A PERMIT2_PERMIT_BATCH, which this decoder does not read, may raise
// what the router may take of the permit's token again.
test("a split after a PERMIT2_PERMIT and a PERMIT2_PERMIT_BATCH shows the total the steps take", () => {
const permitBatch = coder.encode(
[
"tuple(tuple(address,uint160,uint48,uint48)[],address,uint256)",
"bytes",
],
[
[
[[USDT_ADDR, 10000000000n, 0, 0]], // 10,000 USDT
ROUTER_ADDR,
9999999999,
],
"0x1234",
],
);
const result = decodeSteps(
[0x0a, 0x03, 0x00, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
permitBatch,
V3_600,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
});
// Any other step this decoder does not read may do the same, for
// example through the hook of the pool a V4_INITIALIZE_POOL sets up.
test.each([
{
kind: "a V4_INITIALIZE_POOL",
command: 0x13,
input: coder.encode(
[POOL_KEY, "uint160"],
[
[USDC_ADDR, USDT_ADDR, 3000, 60, OTHER_ADDR],
79228162514264337593543950336n,
],
),
},
{
kind: "a SWEEP whose input cannot be read",
command: 0x04,
input: dirtyWord(
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
1,
),
},
])(
"a split after a PERMIT2_PERMIT, with $kind between its steps, shows the total the steps take",
({ command, input }) => {
const result = decodeSteps(
[0x0a, 0x00, command, 0x08],
[
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
input,
encodeV2SwapExactIn(USER_ADDR, 9400000000n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
],
);
expect(linesBetweenTokens(result)).toEqual([
["Amount", "10000.0000 USDT"],
]);
},
);
// A V3_SWAP_EXACT_OUT the user pays for, of at most `amountInMax`
// USDT for USDC. Its input is (recipient, amountOut, amountInMax,
// path, payerIsUser), laid out as encodeV3SwapExactIn() writes one;
// an exact-out path lists the token bought first.
const v3ExactOut = (amountInMax) =>
encodeV3SwapExactIn(USER_ADDR, 390000000n, amountInMax, [
USDC_ADDR,
USDT_ADDR,
]);
// A split exact-out route: a V3_SWAP_EXACT_OUT of at most 6,000 USDT
// and a V2_SWAP_EXACT_OUT of at most 4,000 USDT, both paid by the
// user, then a SWEEP of USDC to the user.
const EXACT_OUT_SPLIT = [
v3ExactOut(6000000000n),
encodeV2SwapExactOut(USER_ADDR, 390000000n, 4000000000n, [
USDT_ADDR,
USDC_ADDR,
]),
coder.encode(
["address", "address", "uint256"],
[USDC_ADDR, USER_ADDR, 0n],
),
];
// This decoder does not read a V3_SWAP_EXACT_OUT, so what one takes
// leaves no Amount figure wherever it stands, and a permit's amount
// does not stand.
test.each([
{
kind: "a V3 exact-in of 600 USDT, then a V3_SWAP_EXACT_OUT",
commands: [0x00, 0x01],
inputs: [V3_600, v3ExactOut(USDT_400)],
},
{
kind: "a PERMIT2_PERMIT of 10,000 USDT, then a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP",
commands: [0x0a, 0x01, 0x09, 0x04],
inputs: [
encodePermit2(USDT_ADDR, 10000000000n, ROUTER_ADDR),
...EXACT_OUT_SPLIT,
],
},
{
kind: "a V3_SWAP_EXACT_OUT, a V2_SWAP_EXACT_OUT and a SWEEP",
commands: [0x01, 0x09, 0x04],
inputs: EXACT_OUT_SPLIT,
},
{
kind: "a V3_SWAP_EXACT_OUT of at most 9,400 USDT, then a PERMIT2_PERMIT of 600 USDT and a V3 exact-in of 600 USDT",
commands: [0x01, 0x0a, 0x00],
inputs: [
v3ExactOut(9400000000n),
encodePermit2(USDT_ADDR, USDT_600, ROUTER_ADDR),
V3_600,
],
},
])(
"$kind, all paid by the user, shows no Amount figure",
({ commands, inputs }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual([
["Moved from the sender", UNDECODED],
]);
},
);
// A later step's amount that cannot be added to the Amount line,
// because the amount of the step that set the input side is not a
// quantity, gets lines of its own. When that step stated no amount,
// it gets lines of its own too, saying so, so the 400 USDT cannot
// read as all that leaves.
const V2_EXACT_OUT_400 = encodeV2SwapExactOut(
USER_ADDR,
390000000n,
USDT_400,
[USDT_ADDR, USDC_ADDR],
);
const UP_TO_400_USDT = [
["Moved from the sender", "Up to 400.0000 USDT"],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
const UNKNOWN_USDT = [
["Moved from the sender", UNDECODED],
["Token moved", "USDT (" + USDT_ADDR + ")"],
["Moved to", "The swap step that spends it"],
];
// A V4 exact-out step of at most 600 USDT for 1 WETH, in the shape
// of Uniswap's own routes: a SETTLE of OPEN_DELTA by the user pays
// what the swap spends.
const V4_EXACT_OUT_600 = encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_OUT_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactOutSingle(
USDT_ADDR,
WETH_ADDR,
ONE_WETH,
USDT_600,
),
encodeV4Settle(USDT_ADDR),
encodeV4TakeAll(WETH_ADDR, 0n),
],
);
test.each([
{
kind: "a V4 exact-out step with a SETTLE of OPEN_DELTA by the user",
commands: [0x10, 0x09],
first: V4_EXACT_OUT_600,
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
{
kind: "a V4 exact-in step of the open delta",
commands: [0x10, 0x09],
first: encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
encodeV4ExactInSingle(USDT_ADDR, USDC_ADDR, 0n, 1n),
encodeV4Settle(USDT_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
lines: [
["Amount", "All available (V4 open delta)"],
...UP_TO_400_USDT,
],
},
{
kind: "a V2 exact-in step of ALREADY_PAID",
commands: [0x08, 0x09],
first: encodeV2SwapExactIn(USER_ADDR, 0n, 1n, [
USDT_ADDR,
USDC_ADDR,
]),
lines: [
[
"Amount",
"Whatever an earlier step sent to the pair (V2 already paid)",
],
...UP_TO_400_USDT,
],
},
])(
"$kind, then a V2 exact-out of at most 400 USDT the user pays for, shows the 400 USDT",
({ commands, first, lines }) => {
const result = decodeSteps(commands, [first, V2_EXACT_OUT_400]);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// The V4 exact-out route above the other way round, where the later
// take's unknown amount leaves no figure at all, and other ways the
// step that sets the input side states no amount.
test.each([
{
kind: "a V2 exact-out of at most 400 USDT, then a V4 exact-out step of at most 600 USDT",
commands: [0x09, 0x10],
inputs: [V2_EXACT_OUT_400, V4_EXACT_OUT_600],
lines: UNKNOWN_USDT,
},
{
kind: "a PERMIT2_PERMIT of 1 USDT to another spender, a V4 exact-out step of at most 600 USDT, then a V2 exact-out of at most 400 USDT",
commands: [0x0a, 0x10, 0x09],
inputs: [
encodePermit2(USDT_ADDR, 1000000n, OTHER_ADDR),
V4_EXACT_OUT_600,
V2_EXACT_OUT_400,
],
lines: [...UNKNOWN_USDT, ...UP_TO_400_USDT],
},
{
kind: "a V4 step whose swap action cannot be read, with a SETTLE of WETH by the user, then a V2 exact-out of at most 400 USDT",
commands: [0x10, 0x09],
inputs: [
encodeV4Swap(
new Uint8Array([
V4_SWAP_EXACT_IN_SINGLE_ID,
V4_SETTLE_ID,
V4_TAKE_ALL_ID,
]),
[
UNREAD_SWAP,
encodeV4Settle(WETH_ADDR),
encodeV4TakeAll(USDC_ADDR, 0n),
],
),
V2_EXACT_OUT_400,
],
lines: [
["Moved from the sender", UNDECODED],
["Token moved", "WETH (" + WETH_ADDR + ")"],
["Moved to", "The swap step that spends it"],
...UP_TO_400_USDT,
],
},
])(
"$kind, all paid by the user, shows that an amount is not known",
({ commands, inputs, lines }) => {
const result = decodeSteps(commands, inputs);
expect(linesBetweenTokens(result)).toEqual(lines);
},
);
// A V2 exact-out step makes every amount the swap steps spend a maximum,
// the total included.
test("a split with a V2 exact-out step the user pays for shows the total as a maximum", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x00, 0x09]),
[
encodeV3SwapExactIn(USER_ADDR, USDT_600, 590000000n, [
USDT_ADDR,
USDC_ADDR,
]),
encodeV2SwapExactOut(USER_ADDR, 390000000n, USDT_400, [
USDT_ADDR,
USDC_ADDR,
]),
],
9999999999n,
);
const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull();
expect(detail(result, "Amount").value).toBe("Up to 1000.0000 USDT");
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/359 — the output half, in
// the shape the issue measured: a V4 step that states a minimum of zero
// and names no output currency.
+29 -3
View File
@@ -671,6 +671,7 @@ describe("any token left in the router is not received", () => {
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: tokenLine(WETH, "WETH"),
spent: "2000.0000 USDT",
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router",
@@ -697,6 +698,7 @@ describe("any token left in the router is not received", () => {
v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V3 swap of 1 WETH that the user pays for",
@@ -705,6 +707,7 @@ describe("any token left in the router is not received", () => {
v3SwapExactIn(MSG_SENDER, ONE_WETH, USDC_1990, WETH, USDC),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE of 1 WETH by the user",
@@ -717,6 +720,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
spent: "1.0000 WETH",
},
{
kind: "a swap paying WETH to the router, a WRAP_ETH of 1 ETH into the router, then a swap of 1 WETH from the router",
@@ -792,9 +796,13 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
// The step has two SETTLEs of WETH, so what the user pays is not
// known.
spent: UNDECODED,
},
// The user pays for the swap by a SETTLE_ALL or SETTLE whose currency
// the decoder cannot read, so the SETTLE from the router pays nothing.
// the decoder cannot read, so the SETTLE from the router pays nothing,
// and what the user pays is not known.
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE_ALL that cannot be read and a SETTLE of OPEN_DELTA from the router",
data: execute(
@@ -807,6 +815,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
unread: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 1 WETH with a SETTLE by the user that cannot be read and a SETTLE of OPEN_DELTA from the router",
@@ -820,6 +829,7 @@ describe("any token left in the router is not received", () => {
),
),
left: tokenLine(WETH, "WETH"),
unread: true,
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step with an exact-in of 1 WETH less 1,000 wei and an empty path, a swap of 1,000 wei, and a SETTLE of OPEN_DELTA from the router",
@@ -873,12 +883,20 @@ describe("any token left in the router is not received", () => {
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left }) => {
])("$kind", ({ data, left, spent, unread }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
// A later step whose SETTLE or SETTLE_ALL the decoder cannot read
// may take anything from the sender: one line says so, in place
// of the Amount line (https://git.eeqj.de/sneak/AutistMask/issues/625).
unread ? "Moved from the sender" : "Amount",
// A later swap step the user pays for spends `spent` of a token
// other than Token In.
...(spent
? ["Moved from the sender", "Token moved", "Moved to"]
: []),
"Token Out",
"Paid to",
"Token moved into the router",
@@ -886,6 +904,14 @@ describe("any token left in the router is not received", () => {
"Steps",
"Deadline",
]);
if (spent) {
expect(detail(result, "Moved from the sender").value).toBe(spent);
}
if (unread) {
expect(detail(result, "Moved from the sender").value).toBe(
UNDECODED,
);
}
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Token moved into the router")).toEqual(left);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");