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. A swap step or V4 SETTLE that spends
all the router holds of a token, a WRAP_ETH of all its ETH, 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 as
its step's one SETTLE of that token, after every swap action, with none
buying it.

Model: opus-5-5
This commit is contained in:
2026-10-19 17:39:44 +00:00
parent d6fb002656
commit b07f250926
4 changed files with 625 additions and 78 deletions
+55 -39
View File
@@ -2355,7 +2355,7 @@ view would leave a wallet one click from deletion.
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline, and unless a swap pays only the From address, a 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) stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
@@ -3048,49 +3048,65 @@ Currently supported:
such as one whose owner word has a nonzero top byte (the router reads the 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 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 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 `TAKE_PORTION` pays the router any token, the swap's output or another, a
the router unless a later step pays all the router holds of it to anyone but `WRAP_ETH` pays it WETH, or an `UNWRAP_WETH` into the router turns WETH a step
the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is WETH, a paid it into ETH, that token stays in the router unless a later step pays out
`TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or a or spends all the router holds of it: a `SWEEP` of that token to anyone but
`PAY_PORTION_FULL_PRECISION` of 1e18 of that token, or an the router, an `UNWRAP_WETH` when it is WETH, a `WRAP_ETH` of
`ACROSS_V4_DEPOSIT_V3` of `CONTRACT_BALANCE` of that token, or of ETH when it `CONTRACT_BALANCE` when it is ETH, a `TRANSFER` of `CONTRACT_BALANCE`, a
sets `useNative`. The router sends ETH as written, so a `TRANSFER` of `PAY_PORTION` of 10,000 bips or a `PAY_PORTION_FULL_PRECISION` of 1e18 of that
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a token to anyone but the router, an `ACROSS_V4_DEPOSIT_V3` of
token to the V2 pair that the very next step spends, as a V2 exact-in of that `CONTRACT_BALANCE` of that token, or of ETH when it sets `useNative`, or a V2
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a or V3 exact-in swap step or V4 `SETTLE` that pays `CONTRACT_BALANCE` of that
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and token from the router (`payerIsUser` false). Such steps also spend all the
the router enforces that swap's own minimum. The decoder checks that the WETH a `WRAP_ETH` paid the router once together they spend the amount the
address paid is that pair, deriving it from the V2 factory and pair init code `WRAP_ETH` states, as a swap paid in ETH does; a `SETTLE` of `OPEN_DELTA`
hash the network's routers were deployed with and the first two tokens of that (zero) from the router counts as spending what its step's swap spends only
step's path; a payment to any other address is named. (Sepolia's when it is the step's only `SETTLE` or `SETTLE_ALL` of that token, comes after
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both every swap action in the step, and no swap action in the step buys that token,
values, so no V2 swap step can succeed on that router.) Every other payment because the router pays only what the step still owes of that token when the
counts, in whichever step and of whatever token, a share of the output `SETTLE` runs; otherwise it counts as spending nothing. So the rest of a V4
included: an interface fee paid to another address by `PAY_PORTION`, `SETTLE` of more than its swap spends, taken to the router, the WETH of a
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`, `WRAP_ETH`, and a token a swap pays the router on the way to the output, each
because the swap's minimum includes the fee. The minimum reads `Min. received` stays in the router when later steps do not spend all of it, where anyone can
only when some step pays the transaction's sender (the From address on take it in a later transaction. The router sends ETH as written, so a
TxApproval, the sender the explorer reports on TransactionDetail) and no `TRANSFER` of `CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other step paying a token to the V2 pair that the very next step spends, as a V2
address paid; one reads 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 `The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example, bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)` `The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router, followed by a `Token moved into the router` 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 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 reads `The router, where it stays (no later step pays it out)` for each token
stays in the router; and one more reads `Unknown (could not be decoded)` when that stays in the router, followed, for any token but the output, by a
a step that may pay an address went unread. When no step pays anyone, a single `Token moved into the router` line giving that token in the same way (ETH by
line reads `Unknown (not named in the calldata)`. Wherever there is a name); and one more reads `Unknown (could not be decoded)` when a step that
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its may pay an address went unread. When no step pays anyone, a single line reads
recipient does not decode as an address, or when it is a command the decoder `Unknown (not named in the calldata)`. Wherever there is a `Paid to` line, the
does not read: every command other than `PERMIT2_PERMIT`, minimum reads `Min. paid out`. A step goes unread when its recipient does not
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, decode as an address, or when it is a command the decoder does not read: every
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `SWEEP`, `TRANSFER`, `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`,
`PERMIT2_TRANSFER_FROM_BATCH`, `EXECUTE_SUB_PLAN` and the NFT commands among `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`, `UNWRAP_WETH` and `BALANCE_CHECK_ERC20`
them), or a type its release names no command for. (`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 ### Non-Goals Forever
+177 -35
View File
@@ -5,6 +5,7 @@ const {
Interface, Interface,
AbiCoder, AbiCoder,
getBytes, getBytes,
getAddress,
formatUnits, formatUnits,
getCreate2Address, getCreate2Address,
keccak256, keccak256,
@@ -337,8 +338,8 @@ const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// address this decoder did not read. // address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; 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 // The Paid to line for each token paid to the router, the swap's output or
// step pays it out of the router. // another, that no later step pays out of the router.
const STAYS_IN_ROUTER = const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)"; "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". // Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
// WETH, the token UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia. // WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on
// Either one counts on either network. // each network, by chain id: `weth9` in the deployment parameters
const WETH_ADDRESSES = [ // V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // with (their constructor arguments on the explorer).
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14", 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: // Two recipient values the router reads as placeholders, not as addresses:
// MSG_SENDER pays the caller of execute(), which is the transaction's sender, // 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, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// address[] path, bool payerIsUser) // address[] path, bool payerIsUser)
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID. // 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) { function decodeV2SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -535,6 +541,7 @@ function decodeV2SwapExactIn(input) {
path: d[3], path: d[3],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
payerIsUser: d[4],
}; };
} catch { } catch {
return null; return null;
@@ -574,6 +581,7 @@ function decodeV3Path(pathHex) {
// Decode V3_SWAP_EXACT_IN (command 0x00) input bytes. // Decode V3_SWAP_EXACT_IN (command 0x00) input bytes.
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
// bytes path, bool payerIsUser) // bytes path, bool payerIsUser)
// With `payerIsUser` false the router pays the input from its own balance.
function decodeV3SwapExactIn(input) { function decodeV3SwapExactIn(input) {
try { try {
const d = coder.decode( const d = coder.decode(
@@ -588,6 +596,7 @@ function decodeV3SwapExactIn(input) {
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: path.tokenIn, tokenIn: path.tokenIn,
tokenOut: path.tokenOut, tokenOut: path.tokenOut,
payerIsUser: d[4],
}; };
} catch { } catch {
return null; return null;
@@ -877,7 +886,10 @@ function totalAmount(amounts) {
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes // 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. // 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 // `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 the currency, the amount as written and
// its index among the step's actions, except that a SETTLE of OPEN_DELTA
// whose payment this decoder cannot follow has no amount (null; see below).
function decodeV4Swap(input) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -887,6 +899,11 @@ function decodeV4Swap(input) {
let settleToken = null; let settleToken = null;
let takeToken = null; let takeToken = null;
const takes = []; const takes = [];
const settledFromRouter = [];
// The currency of every SETTLE and SETTLE_ALL, in order.
const settledCurrencies = [];
// The index of the step's last swap action, read or not, or -1.
let lastSwapAction = -1;
let recipientUnread = false; let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction() // The step's swap actions that could be read, as decodeV4SwapAction()
// reads them. // reads them.
@@ -906,10 +923,19 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
settleToken = s[0]; settleToken = s[0];
settledCurrencies.push(s[0]);
if (!s[2]) {
settledFromRouter.push({
currency: s[0],
amount: s[1],
index: i,
});
}
} else if (actionId === V4_SETTLE_ALL) { } else if (actionId === V4_SETTLE_ALL) {
// SETTLE_ALL: (address currency, uint256 maxAmount) // SETTLE_ALL: (address currency, uint256 maxAmount)
const s = coder.decode(["address", "uint256"], params[i]); const s = coder.decode(["address", "uint256"], params[i]);
settleToken = s[0]; settleToken = s[0];
settledCurrencies.push(s[0]);
} else if (actionId === V4_TAKE) { } else if (actionId === V4_TAKE) {
// TAKE: (address currency, address recipient, uint256 amount) // TAKE: (address currency, address recipient, uint256 amount)
const t = coder.decode( const t = coder.decode(
@@ -936,6 +962,7 @@ function decodeV4Swap(input) {
actionId === V4_SWAP_EXACT_OUT_SINGLE || actionId === V4_SWAP_EXACT_OUT_SINGLE ||
actionId === V4_SWAP_EXACT_OUT actionId === V4_SWAP_EXACT_OUT
) { ) {
lastSwapAction = i;
try { try {
swaps.push(decodeV4SwapAction(actionId, params[i])); swaps.push(decodeV4SwapAction(actionId, params[i]));
} catch { } catch {
@@ -958,6 +985,24 @@ function decodeV4Swap(input) {
} }
} }
// A SETTLE of OPEN_DELTA (zero) paid from the router pays what the
// step still owes of its currency when it runs. That is what the
// step's swap actions spend of it only when the SETTLE is the step's
// one SETTLE or SETTLE_ALL of that currency, comes after every swap
// action, and no swap action buys that currency (an unread one may).
// Any other such SETTLE pays an amount this decoder cannot tell.
for (const s of settledFromRouter) {
const paysWhatSwapsSpend =
settledCurrencies.filter((c) => c === s.currency).length ===
1 &&
s.index > lastSwapAction &&
!swapUndecoded &&
!swaps.some((w) => w.tokenOut === s.currency);
if (s.amount === 0n && !paysWhatSwapsSpend) {
s.amount = null;
}
}
// Rule 3. // Rule 3.
if (swapUndecoded) { if (swapUndecoded) {
return { return {
@@ -966,6 +1011,7 @@ function decodeV4Swap(input) {
amountIn: null, amountIn: null,
amountOutMin: null, amountOutMin: null,
takes, takes,
settledFromRouter,
recipientUnread, recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: true, hasSwapAction: true,
@@ -1007,6 +1053,7 @@ function decodeV4Swap(input) {
amountIn, amountIn,
amountOutMin, amountOutMin,
takes, takes,
settledFromRouter,
recipientUnread, recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: swaps.length > 0, hasSwapAction: swaps.length > 0,
@@ -1099,16 +1146,43 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// router, as { token, amount }. Later steps may leave part of it // router, as { token, amount }. Later steps may leave part of it
// there: nothing here checks that they spend all of it. // there: nothing here checks that they spend all of it.
const movedIntoRouter = []; const movedIntoRouter = [];
// The tokens, lowercased, that a swap step or a V4 TAKE or // Each token a step paid to the router that no later step paid out
// TAKE_PORTION paid to the router and no later step paid out of it in // or spent in full, by its address, lowercased, ETH as the zero
// full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER, // address. The router is paid what a swap step, V4 TAKE or
// PAY_PORTION, PAY_PORTION_FULL_PRECISION or ACROSS_V4_DEPOSIT_V3 of // TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH
// all the router holds of that token. // of an UNWRAP_WETH into it, in place of the WETH it held. All the
const heldByRouter = new Set(); // 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 isRouter = (recipient) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.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. Another amount the
// step states 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 the step
// does not state, 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 // A step pays `recipient`, an address other than the sender's or the
// router's, as written. // router's, as written.
const addPaidElsewhere = (recipient) => { const addPaidElsewhere = (recipient) => {
@@ -1122,7 +1196,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const addPayee = (recipient, output) => { const addPayee = (recipient, output) => {
const r = recipient.toLowerCase(); const r = recipient.toLowerCase();
if (isRouter(recipient)) { 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) { } else if (r === MSG_SENDER || r === sender) {
paidToSender = true; paidToSender = true;
} else { } else {
@@ -1243,6 +1319,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
@@ -1254,6 +1333,9 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!s.payerIsUser) {
spendFromRouter(s.tokenIn, s.amountIn);
}
if (!isHop(i, s.tokenOut, s.recipient)) { if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut); addPayee(s.recipient, s.tokenOut);
} }
@@ -1350,12 +1432,45 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
w.amount, w.amount,
); );
addPayee(w.recipient); 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 { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (command === "V4_SWAP") { } else if (command === "V4_SWAP") {
const v4 = decodeV4Swap(inputs[i]); const v4 = decodeV4Swap(inputs[i]);
if (v4) { if (v4) {
// A SETTLE of OPEN_DELTA (zero) that decodeV4Swap()
// left at zero pays what the step's swap actions
// spend of its currency, which the step states as its
// amount when that currency is its Token In.
for (const s of v4.settledFromRouter) {
let amount = s.amount;
if (amount === 0n) {
amount =
s.currency === v4.tokenIn
? v4.amountIn
: null;
}
spendFromRouter(s.currency, amount);
}
for (const t of v4.takes) { for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) { if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency); addPayee(t.recipient, t.currency);
@@ -1400,9 +1515,17 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const u = decodeUnwrapWeth(inputs[i]); const u = decodeUnwrapWeth(inputs[i]);
if (u) { if (u) {
addPayee(u.recipient); addPayee(u.recipient);
if (!isRouter(u.recipient)) { // It pays out all the router's WETH, as ETH. Into the
WETH_ADDRESSES.forEach((w) => // router, the router then holds as ETH the WETH a
heldByRouter.delete(w), // 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 { } else {
@@ -1568,11 +1691,11 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// A Paid to line names each other address paid, and says when a // 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 // 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 // router, when a token stays in the router, and when a payout could
// could not be read. The output is received by the sender only when // not be read. The output is received by the sender only when some
// some step pays the sender and there is no such line. When no step // step pays the sender and there is no such line. When no step names
// names anyone paid, one line says so. `paidTo` holds these lines and // anyone paid, one line says so. `paidTo` holds these lines and the
// the token line after each move into the router. // token line after each line about a token in the router.
const outputHeld = const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const paidTo = paidElsewhere.map((address) => ({ const paidTo = paidElsewhere.map((address) => ({
@@ -1583,9 +1706,24 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
if (bridgeDeposit) { if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
} }
// Each move into the router is followed by a line giving the moved // The line giving a token in the router, in the shape of the Token In
// token's address, in the shape of the Token In line, so the screens // line, so the screens show it as they show that one. ETH has no
// show it as they show that one. // 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) { for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources); const info = tokenInfo(m.token, sources);
paidTo.push({ paidTo.push({
@@ -1595,18 +1733,22 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
amountText(m.amount, info).display + amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)", " from the sender (later steps may not spend all of it)",
}); });
paidTo.push({ paidTo.push(tokenMovedLine(m.token));
label: "Token moved into the router",
value: info.symbol
? info.symbol + " (" + m.token + ")"
: m.token,
address: m.token,
isToken: true,
});
} }
if (outputHeld) { if (outputHeld) {
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); 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) { if (payeeUnread) {
paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); 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 // 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", () => { test("an ETH-paid swap whose last step buys WETH, then UNWRAP_WETH, shows ETH", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked( solidityPacked(
@@ -767,7 +769,7 @@ describe("uniswap decoder", () => {
const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); 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. // Sepolia's WETH is a different contract; UNWRAP_WETH makes it ETH too.
+389 -2
View File
@@ -4,9 +4,10 @@
// on the steps that pay it // on the steps that pay it
// (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router // (https://git.eeqj.de/sneak/AutistMask/issues/602), the command each router
// release runs for a command type // 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 // 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, // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
@@ -546,6 +547,392 @@ 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",
],
],
),
];
}
// 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"),
},
{
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 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, // 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, // 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. // and PERMIT2_TRANSFER_FROM moves the user's own tokens to one.