fix: any token a swap leaves in the router keeps it from reading as received (closes #606)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

The decoder checked only the swap's output for being left in the router.
It now records every token a step pays the router, the WETH of a
WRAP_ETH and the rest of a V4 SETTLE taken to the router among them, and
any token still there after the last step gets a Paid to line naming it,
so the minimum reads Min. paid out. Steps that spend all the router holds
of a token, and swap steps from the router that together spend what a
WRAP_ETH stated, count as spending it. A SETTLE of OPEN_DELTA from the
router counts only in the plain shape of Uniswap's own routes.

Model: opus-5-5
This commit is contained in:
2026-10-19 18:58:27 +00:00
parent 2c882c75d6
commit 56014a264b
4 changed files with 741 additions and 80 deletions
+58 -39
View File
@@ -2361,7 +2361,7 @@ view would leave a wallet one click from deletion.
transaction"
- Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that its output
`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
@@ -3054,49 +3054,68 @@ Currently supported:
such as one whose owner word has a nonzero top byte (the router reads the
owner from the word's last 20 bytes), likewise sets neither, and it is listed
as `Balance Check (could not be decoded)`. When a swap step, `TAKE` or
`TAKE_PORTION` pays the router the swap's output token, the output stays in
the router unless a later step pays all the router holds of it to anyone but
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a
`TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it
sets `useNative`. 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
`TAKE_PORTION` pays the router any token, the swap's output or another, a
`WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
paid it into ETH, that token stays in the router unless a later step pays out
or spends all the router holds of it: a `SWEEP` of that token to anyone but
the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
`CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
`PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
`CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2
or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
token from the router (`payerIsUser` false). Such steps also spend all the
WETH a `WRAP_ETH` paid the router once together they spend the amount the
`WRAP_ETH` states, as a swap paid in ETH does. 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
`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)`
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` when the output
stays in the router; 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`. A step goes unread when its
recipient does not decode as an address, or when it is a command the decoder
does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among
them), or a type its release names no command for.
reads `The router, where it stays (no later step pays it out)` for each token
that stays in the router, followed, for any token but the output, by a
`Token moved into the router` line giving that token in the same way (ETH by
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`. A step goes unread when its recipient does not
decode as an address, or when it is a command the decoder does not read: every
command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
(`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and
the NFT commands among them), or a type its release names no command for.
### Non-Goals Forever
+196 -37
View File
@@ -5,6 +5,7 @@ const {
Interface,
AbiCoder,
getBytes,
getAddress,
formatUnits,
getCreate2Address,
keccak256,
@@ -337,8 +338,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line when the swap's output is paid to the router and no later
// step pays it out of the router.
// The Paid to line for each token paid to the router, the swap's output or
// another, that no later step pays out of the router.
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
@@ -413,12 +414,16 @@ const NO_MINIMUM = "None (no minimum guaranteed)";
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
// Either one counts on either network.
const WETH_ADDRESSES = [
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
// WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on
// each network, by chain id: `weth9` in the deployment parameters
// V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed
// with (their constructor arguments on the explorer).
const WETH = new Map([
[NETWORKS.mainnet.chainId, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"],
[NETWORKS.sepolia.chainId, "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"],
]);
// Either one counts on either network as the WETH UNWRAP_WETH turns into ETH.
const WETH_ADDRESSES = [...WETH.values()];
// Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender,
@@ -522,6 +527,7 @@ function decodeBalanceCheck(input) {
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV2SwapExactIn(input) {
try {
const d = coder.decode(
@@ -535,6 +541,7 @@ function decodeV2SwapExactIn(input) {
path: d[3],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
payerIsUser: d[4],
};
} catch {
return null;
@@ -574,6 +581,7 @@ function decodeV3Path(pathHex) {
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// bytes path, bool payerIsUser)
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV3SwapExactIn(input) {
try {
const d = coder.decode(
@@ -588,6 +596,7 @@ function decodeV3SwapExactIn(input) {
amountOutMin: d[2],
tokenIn: path.tokenIn,
tokenOut: path.tokenOut,
payerIsUser: d[4],
};
} catch {
return null;
@@ -877,7 +886,10 @@ 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.
// 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).
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -887,6 +899,15 @@ 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 = [];
// 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;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
@@ -906,10 +927,19 @@ 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,
});
}
} 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]);
} else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode(
@@ -936,18 +966,23 @@ function decodeV4Swap(input) {
actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT
) {
lastSwapAction = i;
try {
swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch {
swapUndecoded = true;
actionUnread = true;
}
} else {
// V4Router reverts on any action not named above, so
// none of them pays anyone. This decoder reads none.
actionUnread = true;
}
// V4Router reverts on any action not named above, so none
// of them pays anyone.
} 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.
actionUnread = true;
if (
actionId === V4_TAKE ||
actionId === V4_TAKE_ALL ||
@@ -958,6 +993,41 @@ function decodeV4Swap(input) {
}
}
// 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 };
}
const stated = totalAmount(
swaps
.filter((w) => w.tokenIn === s.currency)
.map((w) => w.amountIn),
);
const paysWhatSwapsSpend =
!actionUnread &&
swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) &&
settledCurrencies.filter((c) => c === s.currency).length ===
1 &&
s.index > lastSwapAction &&
!swaps.some((w) => w.tokenOut === s.currency) &&
typeof stated === "bigint";
return {
currency: s.currency,
amount: paysWhatSwapsSpend ? stated : null,
};
});
// Rule 3.
if (swapUndecoded) {
return {
@@ -966,6 +1036,7 @@ function decodeV4Swap(input) {
amountIn: null,
amountOutMin: null,
takes,
settledFromRouter,
recipientUnread,
swapUndecoded,
hasSwapAction: true,
@@ -1007,6 +1078,7 @@ function decodeV4Swap(input) {
amountIn,
amountOutMin,
takes,
settledFromRouter,
recipientUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
@@ -1099,16 +1171,44 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it.
const movedIntoRouter = [];
// The tokens, lowercased, that a swap step or a V4 TAKE or
// TAKE_PORTION paid to the router and no later step paid out of it in
// full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER,
// PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of
// all the router holds of that token.
const heldByRouter = new Set();
// Each token a step paid to the router that no later step paid out
// or spent in full, by its address, lowercased, ETH as the zero
// address. The router is paid what a swap step, V4 TAKE or
// TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH
// of an UNWRAP_WETH into it, in place of the WETH it held. All the
// router holds of a token is paid out or spent by a SWEEP of that
// token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of
// 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
// from the router (see spendFromRouter()). The value is the amount the
// router holds, in base units, when a WRAP_ETH stated it, otherwise
// null.
const heldByRouter = new Map();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// 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
// counts against the amount a WRAP_ETH stated, and the steps pay 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,
// such as the null decodeV4Swap() gives a SETTLE whose payment it
// cannot tell, or one of a token whose amount is not known, pays out
// nothing this decoder can see.
const spendFromRouter = (token, amount) => {
const t = token.toLowerCase();
const held = heldByRouter.get(t);
if (amount === CONTRACT_BALANCE) {
heldByRouter.delete(t);
} else if (typeof held === "bigint" && typeof amount === "bigint") {
if (amount >= held) {
heldByRouter.delete(t);
} else {
heldByRouter.set(t, held - amount);
}
}
};
// A step pays `recipient`, an address other than the sender's or the
// router's, as written.
const addPaidElsewhere = (recipient) => {
@@ -1122,7 +1222,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase());
if (present(output)) {
heldByRouter.set(output.toLowerCase(), null);
}
} else if (r === MSG_SENDER || r === sender) {
paidToSender = true;
} else {
@@ -1243,6 +1345,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1254,6 +1359,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
@@ -1350,12 +1458,36 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount,
);
addPayee(w.recipient);
// CONTRACT_BALANCE wraps all the router's ETH.
if (w.amount === CONTRACT_BALANCE) {
heldByRouter.delete(
"0x0000000000000000000000000000000000000000",
);
}
// Into the router, the router holds the WETH. Its
// amount is known when the step states it and the
// router held no WETH before.
if (isRouter(w.recipient) && w.amount > 0n) {
const weth = WETH.get(chainId);
heldByRouter.set(
weth,
w.amount === CONTRACT_BALANCE ||
heldByRouter.has(weth)
? null
: w.amount,
);
}
} else {
payeeUnread = true;
}
} else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
// Each SETTLE paid from the router spends the amount
// decodeV4Swap() says it pays.
for (const s of v4.settledFromRouter) {
spendFromRouter(s.currency, s.amount);
}
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency);
@@ -1400,9 +1532,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
if (!isRouter(u.recipient)) {
WETH_ADDRESSES.forEach((w) =>
heldByRouter.delete(w),
// It pays out all the router's WETH, as ETH. Into the
// router, the router then holds as ETH the WETH a
// step paid it.
const heldWeth = WETH_ADDRESSES.some((w) =>
heldByRouter.has(w),
);
WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w));
if (isRouter(u.recipient) && heldWeth) {
heldByRouter.set(
"0x0000000000000000000000000000000000000000",
null,
);
}
} else {
@@ -1568,11 +1708,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A Paid to line names each other address paid, and says when a
// step pays the bridge, when one moves the sender's tokens into the
// router, when the output stays in the router, and when a payout
// could not be read. The output is received by the sender only when
// some step pays the sender and there is no such line. When no step
// names anyone paid, one line says so. `paidTo` holds these lines and
// the token line after each move into the router.
// router, when a token stays in the router, and when a payout could
// not be read. The output is received by the sender only when some
// step pays the sender and there is no such line. When no step names
// anyone paid, one line says so. `paidTo` holds these lines and the
// token line after each line about a token in the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({
@@ -1583,9 +1723,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
// Each move into the router is followed by a line giving the moved
// token's address, in the shape of the Token In line, so the screens
// show it as they show that one.
// The line giving a token in the router, in the shape of the Token In
// line, so the screens show it as they show that one. ETH has no
// address.
const tokenMovedLine = (token) => {
const info = tokenInfo(token, sources);
if (!present(info.address)) {
return {
label: "Token moved into the router",
value: info.symbol,
};
}
return {
label: "Token moved into the router",
value: info.symbol ? info.symbol + " (" + token + ")" : token,
address: token,
isToken: true,
};
};
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
paidTo.push({
@@ -1595,18 +1750,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push({
label: "Token moved into the router",
value: info.symbol
? info.symbol + " (" + m.token + ")"
: m.token,
address: m.token,
isToken: true,
});
paidTo.push(tokenMovedLine(m.token));
}
if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
}
// Any other token the router still holds after the last step, such
// as one a swap step bought on the way to the output, what a V4
// SETTLE took beyond what the step's swap spent, or the WETH of a
// WRAP_ETH, stays there too, and its line is followed by the token's.
for (const token of heldByRouter.keys()) {
if (present(outputToken) && token === outputToken.toLowerCase()) {
continue;
}
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push(tokenMovedLine(getAddress(token)));
}
if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
}
+4 -2
View File
@@ -739,7 +739,9 @@ describe("uniswap decoder", () => {
});
// Paid in ETH, with an exact-out step, the last swap step buys WETH, so
// UNWRAP_WETH makes the output ETH.
// UNWRAP_WETH makes the output ETH. That step is paid by the user, so the
// USDC the exact-out step bought stays in the router, and the minimum is
// not received (https://git.eeqj.de/sneak/AutistMask/issues/606).
test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute(
solidityPacked(
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
expect(detail(result, "Min. paid out").value).toBe("0.4000 ETH");
});
// Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
+483 -2
View File
@@ -4,9 +4,10 @@
// on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
// release runs for a command type
// (https://git.eeqj.de/sneak/AutistMask/issues/592), and the address whose
// (https://git.eeqj.de/sneak/AutistMask/issues/592), the address whose
// balance a BALANCE_CHECK_ERC20 checks
// (https://git.eeqj.de/sneak/AutistMask/issues/600).
// (https://git.eeqj.de/sneak/AutistMask/issues/600), and any token a swap
// leaves in the router (https://git.eeqj.de/sneak/AutistMask/issues/606).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -546,6 +547,486 @@ describe("an output left in the router is not received", () => {
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/606: any token a step pays the
// router stays there unless a later step pays out or spends all of it, not
// only the swap's output. A V4 SETTLE can take more of the user's tokens than
// the step's swap spends, a WRAP_ETH wraps the user's ETH into the router, and
// a swap can leave there the token it bought on the way to the output.
describe("any token left in the router is not received", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const USDT_5000 = 5000000000n;
// V4 SETTLE of `amount` of `currency` (zero: what the step's swap
// spends), paid by the user, or by the router when `payerIsUser` is false.
function settle(currency, amount, payerIsUser) {
return [
0x0b,
coder.encode(
["address", "uint256", "bool"],
[currency, amount, payerIsUser],
),
];
}
// V4 SWAP_EXACT_IN_SINGLE of `amountIn` of `currencyIn` (zero: the open
// delta) for at least `amountOutMin` of `currencyOut`.
function swapExactInSingle(
currencyIn,
currencyOut,
amountIn,
amountOutMin,
) {
return [
0x06,
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[
[
[currencyIn, currencyOut, 500, 10, ZERO],
true,
amountIn,
amountOutMin,
"0x",
],
],
),
];
}
// V4 SWAP_EXACT_IN of `amountIn` of `currencyIn` through one pool for
// each currency of `path` in turn, for at least `amountOutMin` of the
// last. An empty path swaps nothing.
function swapExactIn(currencyIn, path, amountIn, amountOutMin) {
return [
0x07,
coder.encode(
[
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
],
[
[
currencyIn,
path.map((c) => [c, 500, 10, ZERO, "0x"]),
amountIn,
amountOutMin,
],
],
),
];
}
// V4 SETTLE_ALL of `currency`, paid by the user.
function settleAll(currency, maxAmount) {
return [
0x0c,
coder.encode(["address", "uint256"], [currency, maxAmount]),
];
}
// The line giving a token left in the router.
const tokenLine = (token, symbol) => ({
label: "Token moved into the router",
value: symbol + " (" + token + ")",
address: token,
isToken: true,
});
test.each([
{
kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
take(USDT, ADDRESS_THIS),
takeAll(USDC),
),
),
left: tokenLine(USDT, "USDT"),
},
{
kind: "a V4 SETTLE of 5,000 USDT for a swap of 2,000, the rest taken to the router by TAKE_PORTION",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
takeAll(USDC),
takePortion(USDT, ADDRESS_THIS, 10000n),
),
),
left: tokenLine(USDT, "USDT"),
},
{
kind: "a WRAP_ETH into the router that nothing spends",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a swap of 0.5 WETH from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH / 2n,
USDC_1990,
WETH,
USDC,
false,
),
),
left: tokenLine(WETH, "WETH"),
},
// A SETTLE of OPEN_DELTA from the router pays only what the step
// still owes of its currency when it runs.
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.5 WETH with two SETTLEs of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH / 2n, USDC_1990),
settle(WETH, 0n, false),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of 0.75 WETH with a SETTLE of 0.25 WETH and one of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(
WETH,
USDC,
(ONE_WETH * 3n) / 4n,
USDC_1990,
),
settle(WETH, ONE_WETH / 4n, false),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 step that also buys WETH back, with a SETTLE of OPEN_DELTA from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
swapExactInSingle(USDC, WETH, 1000000n, 1n),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 SETTLE of OPEN_DELTA from the router before the swap, which the user settles",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
settle(WETH, 0n, false),
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, true),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
// 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.
{
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(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
withDirtyWord(settleAll(WETH, ONE_WETH), 0),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
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",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
withDirtyWord(settle(WETH, ONE_WETH, true), 0),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
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",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactIn(WETH, [], ONE_WETH - 1000n, 0n),
swapExactInSingle(WETH, USDC, 1000n, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a WRAP_ETH of 1 ETH into the router, then a V4 swap of all of it settled from the router, with an action the decoder does not read",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
[0x14, coder.encode(["address"], [WETH])],
),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, a TRANSFER of 1 wei of it to the WETH/USDC pair, then a V2 swap of what the pair holds",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, PAIR, 1n),
v2Swap(0x08, MSG_SENDER, 0n, USDC_1990, [WETH, USDC], false),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, then a swap of 1 wei of it from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
v2Swap(0x08, MSG_SENDER, 1n, USDC_1990, [WETH, USDC], false),
),
left: tokenLine(WETH, "WETH"),
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then another swap",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
left: { label: "Token moved into the router", value: "ETH" },
},
])("$kind", ({ data, left }) => {
const result = decode(data);
expect(labels(result)).toEqual([
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Token moved into the router",
"Min. paid out",
"Steps",
"Deadline",
]);
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");
});
// Uniswap's own routes: a swap paid in ETH wraps into the router what its
// swap steps then spend from it, and a route through several pools pays
// each token it buys on the way to the router and spends all of it in the
// next step (CONTRACT_BALANCE), turning ETH into WETH or back where the
// pools need it.
test.each([
{
kind: "a V4 SETTLE of what its swap spends",
data: execute(
v4Swap(
settle(USDT, USDT_2000, true),
SWAP_USDT_FOR_USDC,
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a V4 SETTLE of more than its swap spends, the rest taken to the user",
data: execute(
v4Swap(
settle(USDT, USDT_5000, true),
SWAP_USDT_FOR_USDC,
take(USDT, MSG_SENDER),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a swap of all of it from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
ONE_WETH,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then two swaps from the router that together spend all of it",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v3SwapExactIn(
MSG_SENDER,
(ONE_WETH * 3n) / 5n,
1n,
WETH,
USDC,
false,
),
v2Swap(
0x08,
MSG_SENDER,
(ONE_WETH * 2n) / 5n,
USDC_1990,
[WETH, USDC],
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a V4 swap of all of it settled from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactInSingle(WETH, USDC, ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH into the router, then a V4 swap of all of it through two pools, settled from the router",
data: execute(
wrapEth(ADDRESS_THIS, ONE_WETH),
v4Swap(
swapExactIn(WETH, [USDT, USDC], ONE_WETH, USDC_1990),
settle(WETH, 0n, false),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a WRAP_ETH of all the router's ETH, then a swap of all its WETH",
data: execute(
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, then a V2 swap of all of it",
data: execute(
swapToWeth(ADDRESS_THIS),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
[WETH, USDC],
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, then a V4 swap settling all of it from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
v4Swap(
settle(WETH, CONTRACT_BALANCE, false),
swapExactInSingle(WETH, USDC, 0n, USDC_1990),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
{
kind: "a V4 swap taking USDC to the router, then a V3 swap of all of it",
data: execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
USDC,
WETH,
false,
),
),
min: "1.0000 WETH",
},
{
kind: "a V4 swap taking ETH to the router, a WRAP_ETH of all of it, then a V3 swap of all the WETH",
data: execute(
v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
),
wrapEth(ADDRESS_THIS, CONTRACT_BALANCE),
v3SwapExactIn(
MSG_SENDER,
CONTRACT_BALANCE,
USDC_1990,
WETH,
USDC,
false,
),
),
min: "1990.0000 USDC",
},
{
kind: "a swap paying WETH to the router, an UNWRAP_WETH into the router, then a V4 swap settling all the ETH from the router",
data: execute(
swapToWeth(ADDRESS_THIS),
unwrapWeth(ADDRESS_THIS),
v4Swap(
settle(ZERO, CONTRACT_BALANCE, false),
swapExactInSingle(ZERO, USDC, 0n, USDC_1990),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])("$kind reads as a swap paid to the user", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe(min);
});
});
// https://git.eeqj.de/sneak/AutistMask/issues/599: TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION and V4 TAKE_PORTION pay a token to an address,
// and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.