fix: the swap decoder reads who the router's transfer commands pay (closes #599)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION (0x07, on the routers with
the 0x7f mask), PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 (0x40) and V4
TAKE_PORTION were payouts the decoder did not read, so a swap using one read
Min. paid out with an unknown payee. Each recipient is now read: the sender,
the router, a hop into the next V2 pair, or another address, which is named.
Paying all the router holds of a token pays it out as SWEEP does. A
PERMIT2_TRANSFER_FROM into the router is shown with the amount it moves, and a
bridge deposit names the bridge and each of its recipient and depositor that
is not the sender; either keeps the minimum from reading received.

Model: opus-5-5
This commit is contained in:
2026-10-19 11:06:10 +00:00
parent 20e4f1652f
commit 6aa65ccdc6
4 changed files with 875 additions and 116 deletions
+54 -29
View File
@@ -2955,21 +2955,38 @@ Currently supported:
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for `UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag `UniversalRouterV2_1_1` and `UniversalRouterV2_1_2`. The allow-revert flag
(`0x80`) therefore does not change a step's name. The decoder does not read (`0x80`) therefore does not change a step's name. Only the routers with the
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that `0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as
step stands they may spend the user's tokens or pay the user. That step is `Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other `Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed
steps state, the summary names no swap direction, `Token In` and `Token Out` there as `Command 0x07`. The decoder does not read the commands inside an
read `Unknown (could not be decoded)`, and there is no `Amount` or `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and the user's tokens or pay the user. That step is listed as
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's `Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
sender. The router reads `address(1)` as the transaction's sender, and state, the summary names no swap direction, `Token In` and `Token Out` read
`address(2)` as the router itself, which holds what it is paid until a later `Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
step pays it out; a payment to the router, as `address(2)` or by its own line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
address, is not counted as paid out. When a swap step or `TAKE` pays the `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
router the swap's output token, the output stays in the router unless a later moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out. address it pays, and `TAKE_ALL` pays the transaction's sender. The router
A swap step or `TAKE` paying a token to the V2 pair that the very next step reads `address(1)` as the transaction's sender, and `address(2)` as the router
itself, which holds what it is paid until a later step pays it out; a payment
to the router, as `address(2)` or by its own address, is not counted as paid
out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
or `address(2)` as those addresses, and the decoder names them. A
`PERMIT2_TRANSFER_FROM` into the router is named with the amount it moves,
because the decoder does not check that later steps spend all of it.
`ACROSS_V4_DEPOSIT_V3` pays the bridge, which pays the deposit's recipient on
another network and refunds a deposit nobody fills to its depositor; the
router passes both on as written, so `address(1)` and `address(2)` there are
addresses, and each that is not the transaction's sender is named. 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`. 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` 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 (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 pay the next V2 pair directly, and the router enforces that swap's own
@@ -2979,21 +2996,29 @@ Currently supported:
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
deployed with placeholders for both values, so no V2 swap step can succeed on 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 that router.) Every other payment counts, in whichever step and of whatever
token. The minimum reads `Min. received` only when some step pays the token, a share of the output included: an interface fee paid to another
transaction's sender (the From address on TxApproval, the sender the explorer address by `PAY_PORTION`, `TRANSFER` or `TAKE_PORTION` is named, and the
reports on TransactionDetail), no step pays any other address, no step that minimum reads `Min. paid out`, because the swap's minimum includes the fee.
may pay an address went unread, and the output does not stay in the router. The minimum reads `Min. received` only when some step pays the transaction's
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names sender (the From address on TxApproval, the sender the explorer reports on
each other address paid, one reads 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; one reads
`The router, where it stays (no later step pays it out)` when the output stays `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 such a in the router; and one more reads `Unknown (could not be decoded)` when a step
step went unread, or `Unknown (not named in the calldata)` when no step pays that may pay an address went unread. When no step pays anyone, a single line
anyone. A step goes unread when its recipient does not decode as an address, reads `Unknown (not named in the calldata)`. Wherever there is a `Paid to`
or when it is a command or V4 action the decoder does not read: every command line, the minimum reads `Min. paid out`. A step goes unread when its recipient
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, does not decode as an address, or when it is a command the decoder does not
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and read: every command other than `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`,
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`,
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. `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` and `EXECUTE_SUB_PLAN`
among them).
### Non-Goals Forever ### Non-Goals Forever
+254 -49
View File
@@ -123,7 +123,10 @@ function v2PairFor(chainId, tokenA, tokenB) {
} }
// Universal Router command types, each read from its command byte with the // Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES) // router's mask (see ROUTER_ADDRESSES). Only the routers with the 0x7f mask
// have 0x07 and 0x40: 0x40 is above the other masks, and the other routers
// revert on 0x07, so decode() names it there as it names a command not
// listed here.
const COMMAND_NAMES = { const COMMAND_NAMES = {
0x00: "V3 Swap (Exact In)", 0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)", 0x01: "V3 Swap (Exact Out)",
@@ -132,6 +135,7 @@ const COMMAND_NAMES = {
0x04: "Sweep", 0x04: "Sweep",
0x05: "Transfer", 0x05: "Transfer",
0x06: "Pay Portion", 0x06: "Pay Portion",
0x07: "Pay Portion (Full Precision)",
0x08: "V2 Swap (Exact In)", 0x08: "V2 Swap (Exact In)",
0x09: "V2 Swap (Exact Out)", 0x09: "V2 Swap (Exact Out)",
0x0a: "Permit2 Permit", 0x0a: "Permit2 Permit",
@@ -145,6 +149,7 @@ const COMMAND_NAMES = {
0x13: "V4 Initialize Pool", 0x13: "V4 Initialize Pool",
0x14: "V4 Position Mgr Call", 0x14: "V4 Position Mgr Call",
0x21: "Execute Sub-Plan", 0x21: "Execute Sub-Plan",
0x40: "Across Bridge Deposit",
}; };
// The swap's Amount and Min. received lines land on the same approval screen, // The swap's Amount and Min. received lines land on the same approval screen,
@@ -175,6 +180,11 @@ const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
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)";
// The Paid to line for an ACROSS_V4_DEPOSIT_V3 step (see
// decodeAcrossV4DepositV3()).
const PAID_TO_BRIDGE =
"The bridge, which pays the deposit's recipient on another network";
// Explicit presence, never truthiness. Every gate in this file that guards a // Explicit presence, never truthiness. Every gate in this file that guards a
// decoded value goes through here: an address is never falsy once set, but an // decoded value goes through here: an address is never falsy once set, but an
// amount of 0n is, and a gate that cannot tell a genuine zero from an absent // amount of 0n is, and a gate that cannot tell a genuine zero from an absent
@@ -251,9 +261,11 @@ const WETH_ADDRESSES = [
// 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,
// and ADDRESS_THIS pays the router itself, which holds what it is paid until a // and ADDRESS_THIS pays the router itself, which holds what it is paid until a
// later step pays it out. The swap commands, SWEEP, WRAP_ETH and UNWRAP_WETH // later step pays it out. The swap commands, SWEEP, WRAP_ETH, UNWRAP_WETH,
// map their recipient through the dispatcher's map(), and a V4 TAKE through // TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and, on every router but
// V4Router's _mapRecipient(); TAKE_ALL always pays the caller. // UniversalRouterV1, PERMIT2_TRANSFER_FROM map their recipient through the
// dispatcher's map(), and a V4 TAKE and TAKE_PORTION through V4Router's
// _mapRecipient(); TAKE_ALL always pays the caller.
// - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol // - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol
// - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol // - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol
// - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol // - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
@@ -426,6 +438,82 @@ function decodeSweep(input) {
} }
} }
// TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and PERMIT2_TRANSFER_FROM
// read their parameters as each release's dispatcher reads them:
// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/base/Dispatcher.sol
// - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol
// - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol
// - UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/base/Dispatcher.sol
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol
// Decode TRANSFER (command 0x05) input bytes, and those of PAY_PORTION (0x06)
// and PAY_PORTION_FULL_PRECISION (0x07), which share its layout. Each pays
// `token` out of the router to the recipient. TRANSFER pays `amount`, or all
// the router holds when `amount` is CONTRACT_BALANCE. PAY_PORTION pays
// `amount` ten-thousandths of what the router holds, and
// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it.
// - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/modules/Payments.sol
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/Payments.sol
// ABI: (address token, address recipient, uint256 amount)
function decodeTransfer(input) {
try {
const d = coder.decode(["address", "address", "uint256"], input);
return { token: d[0], recipient: d[1], amount: d[2] };
} catch {
return null;
}
}
// Decode PERMIT2_TRANSFER_FROM (command 0x02) input bytes. Permit2 moves
// `amount` of `token` from the transaction's sender to the recipient.
// UniversalRouterV1 hands Permit2 the recipient as written, without map(), so
// on that router address(1) and address(2) are paid as those addresses.
// ABI: (address token, address recipient, uint160 amount)
function decodePermit2TransferFrom(input) {
try {
const d = coder.decode(["address", "address", "uint160"], input);
return { token: d[0], recipient: d[1], amount: d[2] };
} catch {
return null;
}
}
// Decode ACROSS_V4_DEPOSIT_V3 (command 0x40) input bytes. The router hands
// the bridge `inputAmount` of `inputToken`, or all it holds of it when that is
// CONTRACT_BALANCE; with `useNative` set it hands over its ETH instead. It
// passes `depositor` and `recipient` on as written, without map(). The bridge
// pays the recipient on another network what the deposit states, and refunds
// a deposit nobody fills to the depositor, who may also change the deposit's
// recipient.
// - UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/modules/ChainedActions.sol
// - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/ChainedActions.sol
// - parameters, the same at both commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/interfaces/IUniversalRouter.sol
// - the bridge's depositV3() and its refund to the depositor: https://github.com/across-protocol/contracts/blob/950e9154f3d61e59590eb7d4403fae5d3cae84f8/contracts/spoke-pools/SpokePool.sol
// ABI: (tuple(address depositor, address recipient, address inputToken,
// address outputToken, uint256 inputAmount, uint256 outputAmount,
// uint256 destinationChainId, address exclusiveRelayer,
// uint32 quoteTimestamp, uint32 fillDeadline,
// uint32 exclusivityDeadline, bytes message, bool useNative))
function decodeAcrossV4DepositV3(input) {
try {
const d = coder.decode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
input,
);
return {
depositor: d[0][0],
recipient: d[0][1],
inputToken: d[0][2],
inputAmount: d[0][4],
useNative: d[0][12],
};
} catch {
return null;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the // Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the
// recipient. // recipient.
// ABI: (address recipient, uint256 amount) // ABI: (address recipient, uint256 amount)
@@ -615,9 +703,11 @@ function totalAmount(amounts) {
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name. // name.
// //
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its // `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// when the step has a payout this decoder did not read. // 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.
function decodeV4Swap(input) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -663,6 +753,13 @@ function decodeV4Swap(input) {
const t = coder.decode(["address", "uint256"], params[i]); const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0]; takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER }); takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if (actionId === V4_TAKE_PORTION) {
// TAKE_PORTION: (address currency, address recipient, uint256 bips)
const t = coder.decode(
["address", "address", "uint256"],
params[i],
);
takes.push({ currency: t[0], recipient: t[1] });
} else if ( } else if (
actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN ||
@@ -674,17 +771,18 @@ function decodeV4Swap(input) {
} catch { } catch {
swapUndecoded = true; swapUndecoded = true;
} }
} else if (actionId === V4_TAKE_PORTION) {
// It pays a share of a currency to an address this decoder
// does not read
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
// V4Router reverts on any action not named here.
recipientUnread = true;
} }
// V4Router reverts on any action not named above, so none
// of them pays anyone.
} catch { } catch {
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among // Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// them is a payout this decoder did not read. // TAKE_PORTION among them is a payout this decoder did not
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) { // read.
if (
actionId === V4_TAKE ||
actionId === V4_TAKE_ALL ||
actionId === V4_TAKE_PORTION
) {
recipientUnread = true; recipientUnread = true;
} }
} }
@@ -824,31 +922,47 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const paidElsewhere = []; const paidElsewhere = [];
// Set when a step may pay an address this decoder did not read. // Set when a step may pay an address this decoder did not read.
let payeeUnread = false; let payeeUnread = false;
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the // Set when an ACROSS_V4_DEPOSIT_V3 step pays the bridge.
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH, let bridgeDeposit = false;
// paid out. // What each PERMIT2_TRANSFER_FROM moved from the sender into the
// 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(); const heldByRouter = new Set();
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 step pays `recipient`, an address other than the sender's or the
// router's, as written.
const addPaidElsewhere = (recipient) => {
const r = recipient.toLowerCase();
if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
paidElsewhere.push(recipient);
}
};
// A step pays `recipient`. `output` is the token a swap step or a V4 // A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE pays; other steps pass none. // TAKE or TAKE_PORTION pays; other steps pass none.
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.add(output.toLowerCase());
} else if (r === MSG_SENDER || r === sender) { } else if (r === MSG_SENDER || r === sender) {
paidToSender = true; paidToSender = true;
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { } else {
paidElsewhere.push(recipient); addPaidElsewhere(recipient);
} }
}; };
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop, // A step that pays `token` to `recipient` is a hop, not a payout,
// not a payout, when the very next step is a V2 exact-in whose input // when the very next step is a V2 exact-in whose input is already
// is already paid, whose path starts at `token`, and whose pair (see // paid, whose path starts at `token`, and whose pair (see v2PairFor())
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2 // is `recipient`: Uniswap's mixed routes pay the next V2 pair
// pair directly, and the router enforces that next swap's own minimum. // directly, and the router enforces that next swap's own minimum.
// Its input is already paid when its amountIn is ALREADY_PAID, or // Its input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the // CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds. // router adds only what it holds.
@@ -870,9 +984,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask; const cmdId = commandsBytes[i] & commandTypeMask;
// Only the routers with the 0x7f mask have command 0x07 (see
// COMMAND_NAMES).
const name =
cmdId === 0x07 && commandTypeMask !== 0x7f
? null
: COMMAND_NAMES[cmdId];
commandNames.push( commandNames.push(
COMMAND_NAMES[cmdId] || name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
); );
try { try {
@@ -890,6 +1009,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// input side an earlier hop established without breaking // input side an earlier hop established without breaking
// the invariant. // the invariant.
if (p) setInput(p.token, p.amount); if (p) setInput(p.token, p.amount);
} else if (cmdId === 0x02) {
const p = decodePermit2TransferFrom(inputs[i]);
if (!p) {
payeeUnread = true;
} else if (
commandTypeMask === 0x1f &&
[MSG_SENDER, ADDRESS_THIS].includes(
p.recipient.toLowerCase(),
)
) {
// UniversalRouterV1, the router with the 0x1f mask,
// pays address(1) and address(2) as written (see
// decodePermit2TransferFrom()).
addPaidElsewhere(p.recipient);
} else if (isRouter(p.recipient)) {
movedIntoRouter.push(p);
} else if (!isHop(i, p.token, p.recipient)) {
addPayee(p.recipient);
}
} else if (cmdId === 0x0e) { } else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at // The router passes this check whenever the owner holds at
@@ -948,6 +1086,56 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else { } else {
payeeUnread = true; payeeUnread = true;
} }
} else if (
cmdId === 0x05 ||
cmdId === 0x06 ||
(cmdId === 0x07 && commandTypeMask === 0x7f)
) {
const t = decodeTransfer(inputs[i]);
if (t) {
if (!isHop(i, t.token, t.recipient)) {
addPayee(t.recipient);
}
// The amount with which TRANSFER, PAY_PORTION or
// PAY_PORTION_FULL_PRECISION pays all the router
// holds of the token, as SWEEP does.
const all =
cmdId === 0x05
? CONTRACT_BALANCE
: cmdId === 0x06
? 10000n
: 10n ** 18n;
if (t.amount === all && !isRouter(t.recipient)) {
heldByRouter.delete(t.token.toLowerCase());
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x40) {
// Only the routers with the 0x7f mask read a command type
// of 0x40.
const a = decodeAcrossV4DepositV3(inputs[i]);
if (a) {
bridgeDeposit = true;
// The bridge pays or refunds these as written, so
// only the sender's own address is the sender.
for (const r of [a.recipient, a.depositor]) {
if (r.toLowerCase() !== sender) {
addPaidElsewhere(r);
}
}
// A deposit of CONTRACT_BALANCE pays all the router
// holds of its token, or of ETH with useNative set.
if (a.inputAmount === CONTRACT_BALANCE) {
heldByRouter.delete(
a.useNative
? "0x0000000000000000000000000000000000000000"
: a.inputToken.toLowerCase(),
);
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x0b) { } else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
@@ -1016,9 +1204,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} }
} else { } else {
// Any other command may pay an address this decoder does // Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER, // not read: V3_SWAP_EXACT_OUT and
// PAY_PORTION and the others // PERMIT2_TRANSFER_FROM_BATCH among them.
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
payeeUnread = true; payeeUnread = true;
} }
} catch { } catch {
@@ -1169,30 +1356,48 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}); });
} }
// The output is received by the sender only when some step pays the // A Paid to line names each other address paid, and says when a
// sender, none pays another address, none may pay an address this // step pays the bridge, when one moves the sender's tokens into the
// decoder did not read, and the output token is not left in the // router, when the output stays in the router, and when a payout
// router. Otherwise each other address paid is named, and further // could not be read. The output is received by the sender only when
// lines say when the output stays in the router, a payout could not // some step pays the sender and there is no such line. When no step
// be read, or no step names anyone paid. // names anyone paid, one line says so.
const outputHeld = const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const received = const paidTo = paidElsewhere.map((address) => ({
paidToSender && label: "Paid to",
paidElsewhere.length === 0 && value: address,
!payeeUnread && address,
!outputHeld; }));
for (const address of paidElsewhere) { if (bridgeDeposit) {
details.push({ label: "Paid to", value: address, address }); paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
// amountText() names the token only when it knows both its
// symbol and its scale.
const ofToken =
info.symbol && info.decimals !== null ? "" : " of " + m.token;
paidTo.push({
label: "Paid to",
value:
"The router, " +
amountText(m.amount, info).display +
ofToken +
" from the sender (later steps may not spend all of it)",
});
} }
if (outputHeld) { if (outputHeld) {
details.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
} }
if (payeeUnread) { if (payeeUnread) {
details.push({ label: "Paid to", value: UNDECODED_CURRENCY }); paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
details.push({ label: "Paid to", value: UNNAMED_CURRENCY });
} }
const received = paidToSender && paidTo.length === 0;
if (!paidToSender && paidTo.length === 0) {
paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY });
}
details.push(...paidTo);
if (present(minOutput)) { if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the // A zero floor is the one case the user most needs stated: the
+22 -1
View File
@@ -954,7 +954,28 @@ describe("uniswap decoder", () => {
router: "UniversalRouterV2_1_2", router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR, address: ROUTER_V2_1_2_ADDR,
command: "0x40", command: "0x40",
steps: "Command 0x40", steps: "Across Bridge Deposit",
},
// Only the routers with the 0x7f mask have 0x07, as
// PAY_PORTION_FULL_PRECISION; the others revert on it
// (https://git.eeqj.de/sneak/AutistMask/issues/599).
{
router: "UniversalRouterV1",
address: ROUTER_V1_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2",
address: ROUTER_ADDR,
command: "0x07",
steps: "Command 0x07",
},
{
router: "UniversalRouterV2_1_2",
address: ROUTER_V2_1_2_ADDR,
command: "0x07",
steps: "Pay Portion (Full Precision)",
}, },
])( ])(
"command $command sent to $router is listed as $steps", "command $command sent to $router is listed as $steps",
+545 -37
View File
@@ -1,12 +1,14 @@
// The address a swap's output is paid to // The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594). // (https://git.eeqj.de/sneak/AutistMask/issues/594 and
// https://git.eeqj.de/sneak/AutistMask/issues/599).
// //
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the // Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH, TRANSFER, PAY_PORTION,
// address it pays, and TAKE_ALL pays the sender. The decoder read none of // PAY_PORTION_FULL_PRECISION, PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 and
// them: a swap whose output went to another address still read "Min. // V4 TAKE and TAKE_PORTION names the address it pays, and TAKE_ALL pays the
// received", as if the user received it. The router reads address(1) as the // sender. The decoder read none of them: a swap whose output went to another
// sender and address(2) as the router itself, which holds what it is paid // address still read "Min. received", as if the user received it. The router
// until a later step pays it out. // reads address(1) as the sender and address(2) as the router itself, which
// holds what it is paid until a later step pays it out.
// //
// The screens are driven against a minimal DOM stub in the shape // The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses. // tests/uniswapRouterAddress.test.js uses.
@@ -41,6 +43,8 @@ const transactionDetail = require("../src/popup/views/transactionDetail");
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af"; const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40. // UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85"; const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
// UniversalRouterV1 on Ethereum Mainnet.
const ROUTER_V1 = "0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777"; const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001"; const MSG_SENDER = "0x0000000000000000000000000000000000000001";
@@ -53,12 +57,21 @@ const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const ONE_WETH = 1000000000000000000n; const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n; const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n; const USDC_1990 = 1990000000n;
// The router's whole-balance amount.
const CONTRACT_BALANCE = 2n ** 255n;
// The Paid to line's value when no address can be named. // The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)"; const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)"; const UNNAMED = "Unknown (not named in the calldata)";
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)";
const PAID_TO_BRIDGE =
"The bridge, which pays the deposit's recipient on another network";
// The Paid to line for a PERMIT2_TRANSFER_FROM of `amount` into the router.
const movedIntoRouter = (amount) =>
"The router, " +
amount +
" from the sender (later steps may not spend all of it)";
const coder = AbiCoder.defaultAbiCoder(); const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([ const routerIface = new Interface([
@@ -89,7 +102,14 @@ function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
]; ];
} }
function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) { function v3SwapExactIn(
recipient,
amountIn,
amountOutMin,
tokenIn,
tokenOut,
payerIsUser = true,
) {
return [ return [
0x00, 0x00,
coder.encode( coder.encode(
@@ -102,7 +122,7 @@ function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
["address", "uint24", "address"], ["address", "uint24", "address"],
[tokenIn, 3000, tokenOut], [tokenIn, 3000, tokenOut],
), ),
true, payerIsUser,
], ],
), ),
]; ];
@@ -115,6 +135,64 @@ function sweep(token, recipient) {
]; ];
} }
// TRANSFER (0x05), PAY_PORTION (0x06) and PAY_PORTION_FULL_PRECISION (0x07)
// share one layout. `amount` is a quantity, bips, or 1e18ths respectively.
function transfer(command, token, recipient, amount) {
return [
command,
coder.encode(
["address", "address", "uint256"],
[token, recipient, amount],
),
];
}
function permit2TransferFrom(token, recipient, amount) {
return [
0x02,
coder.encode(
["address", "address", "uint160"],
[token, recipient, amount],
),
];
}
// ACROSS_V4_DEPOSIT_V3 (0x40) of `inputAmount` of `inputToken`, bridged to
// chain id 8453.
function acrossDeposit(
depositor,
recipient,
inputToken,
inputAmount,
useNative = false,
) {
return [
0x40,
coder.encode(
[
"tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)",
],
[
[
depositor,
recipient,
inputToken,
inputToken,
inputAmount,
1n,
8453n,
ZERO,
0,
0,
0,
"0x",
useNative,
],
],
),
];
}
function wrapEth(recipient, amount) { function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
} }
@@ -150,6 +228,15 @@ const SWAP_USDT_FOR_USDC = [
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]], [[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
), ),
]; ];
const SWAP_USDT_FOR_ETH = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[ZERO, USDT, 500, 10, ZERO], false, USDT_2000, ONE_WETH, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read. // SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"]; const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [ const SETTLE_USDT = [
@@ -168,6 +255,15 @@ function take(currency, recipient) {
function takeAll(currency) { function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])]; return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
} }
function takePortion(currency, recipient, bips) {
return [
0x10,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, bips],
),
];
}
// A V4_SWAP step of `actions`, each [action, params]. // A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) { function v4Swap(...actions) {
@@ -422,6 +518,381 @@ describe("an output left in the router is not received", () => {
}); });
}); });
// 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.
describe("each transfer command's recipient", () => {
// Each pays out all of a swap's output. Paid to another address, this is
// the first shape the issue reports.
test.each([
{
kind: "TRANSFER of CONTRACT_BALANCE",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, r, CONTRACT_BALANCE),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 10,000 bips",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, r, 10000n),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION_FULL_PRECISION of 1e18",
data: (r) =>
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x07, WETH, r, 10n ** 18n),
),
router: ROUTER_V2_1_2,
min: "1.0000 WETH",
},
{
kind: "V4 TAKE_PORTION of 10,000 bips",
data: (r) =>
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takePortion(USDC, r, 10000n),
),
),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it is received, to the router it stays there, to another address that address is named",
({ data, router = ROUTER, min }) => {
const toUser = decode(data(MSG_SENDER), USER, router);
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Min. received").value).toBe(min);
expect(decode(data(USER), USER, router).details).toEqual(
toUser.details,
);
const toRouter = decode(data(ADDRESS_THIS), USER, router);
expect(labels(toRouter)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toRouter)).toEqual([STAYS_IN_ROUTER]);
expect(detail(toRouter, "Min. paid out").value).toBe(min);
const toOther = decode(data(OTHER), USER, router);
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
// The user's USDT, moved to `r`, then a swap of it paid to the user. A
// move to another address is the issue's second shape.
const movedThenSwapped = (r) =>
execute(
permit2TransferFrom(USDT, r, USDT_2000),
v3SwapExactIn(MSG_SENDER, USDT_2000, ONE_WETH, USDT, WETH, false),
);
test("PERMIT2_TRANSFER_FROM: to the user it pays no one else, to another address that address is named", () => {
for (const r of [MSG_SENDER, USER]) {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Min. received").value).toBe("1.0000 WETH");
}
const toOther = decode(movedThenSwapped(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
const alone = decode(
execute(permit2TransferFrom(USDT, OTHER, USDT_2000)),
);
expect(detail(alone, "Steps").value).toBe("Permit2 Transfer");
expect(paidTo(alone)).toEqual([OTHER]);
});
// Later steps may leave part of what a move into the router moves there,
// where anyone can sweep it in a later transaction.
test.each([
{ kind: "address(2)", r: ADDRESS_THIS },
{ kind: "the router's own address", r: ROUTER },
])(
"PERMIT2_TRANSFER_FROM into the router as $kind names what it moves, and nothing is received",
({ r }) => {
const result = decode(movedThenSwapped(r));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
test.each([
{ kind: "from the router's balance", payerIsUser: false },
{ kind: "from the user's", payerIsUser: true },
])(
"a move of 2,000 USDT into the router, then a swap of 1 USDT $kind paid to the user",
({ payerIsUser }) => {
const result = decode(
execute(
permit2TransferFrom(USDT, ADDRESS_THIS, USDT_2000),
v3SwapExactIn(
MSG_SENDER,
1000000n,
1n,
USDT,
WETH,
payerIsUser,
),
),
);
expect(detail(result, "Amount").value).toBe("1.0000 USDT");
expect(paidTo(result)).toEqual([movedIntoRouter("2000.0000 USDT")]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
test("a move into the router of a token the wallet does not know names the token", () => {
const unknown = "0x5555555555555555555555555555555555555555";
const result = decode(
execute(
permit2TransferFrom(unknown, ADDRESS_THIS, 5n),
swapToWeth(MSG_SENDER),
),
);
expect(paidTo(result)).toEqual([
movedIntoRouter("5 base units (decimals unknown) of " + unknown),
]);
});
test("UniversalRouterV1 pays a PERMIT2_TRANSFER_FROM to address(1) or address(2) as written, and maps a TRANSFER's", () => {
for (const r of [MSG_SENDER, ADDRESS_THIS]) {
const result = decode(movedThenSwapped(r), USER, ROUTER_V1);
expect(paidTo(result)).toEqual([r]);
expect(detail(result, "Min. received")).toBeUndefined();
}
const transferred = decode(
execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, MSG_SENDER, CONTRACT_BALANCE),
),
USER,
ROUTER_V1,
);
expect(labels(transferred)).toEqual(PAID_TO_USER);
});
test("TAKE_PORTION does not set Token Out: a TAKE_PORTION of another currency after the TAKE_ALL of the bought one", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takeAll(USDC),
takePortion(USDT, OTHER, 25n),
),
),
);
expect(detail(result, "Token Out").address).toBe(USDC);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a router without command 0x07 does not read it as PAY_PORTION_FULL_PRECISION", () => {
const result = decode(
execute(
swapToWeth(MSG_SENDER),
transfer(0x07, WETH, MSG_SENDER, 10n ** 18n),
),
);
expect(detail(result, "Steps").value).toBe(
"V3 Swap (Exact In) → Command 0x07",
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
// A payout of only part of what the router holds leaves the rest there.
describe("a payout of part of the router's output", () => {
test.each([
{
kind: "a TRANSFER of 1 base unit",
step: transfer(0x05, WETH, MSG_SENDER, 1n),
},
{
kind: "a PAY_PORTION of 5,000 bips",
step: transfer(0x06, WETH, MSG_SENDER, 5000n),
},
{
kind: "a PAY_PORTION_FULL_PRECISION of 1e18 less 1",
step: transfer(0x07, WETH, MSG_SENDER, 10n ** 18n - 1n),
},
])("$kind to the user leaves the rest in the router", ({ step }) => {
const result = decode(
execute(swapToWeth(ADDRESS_THIS), step),
USER,
ROUTER_V2_1_2,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// An interface fee: a share of the output to another address, the rest
// to the user. The swap's minimum includes the fee, so it is not what the
// user receives.
test.each([
{
kind: "PAY_PORTION of 25 bips, then SWEEP",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
sweep(WETH, MSG_SENDER),
),
min: "1.0000 WETH",
},
{
kind: "TRANSFER of a flat fee, then SWEEP",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x05, WETH, OTHER, ONE_WETH / 400n),
sweep(WETH, MSG_SENDER),
),
min: "1.0000 WETH",
},
{
kind: "PAY_PORTION of 25 bips, then UNWRAP_WETH",
data: execute(
swapToWeth(ADDRESS_THIS),
transfer(0x06, WETH, OTHER, 25n),
unwrapWeth(MSG_SENDER),
),
min: "1.0000 ETH",
},
{
kind: "V4 TAKE_PORTION of 25 bips, then TAKE_ALL",
data: execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
takePortion(USDC, OTHER, 25n),
takeAll(USDC),
),
),
min: "1990.0000 USDC",
},
])(
"a fee by $kind names the fee's recipient and calls nothing received",
({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(min);
},
);
});
// ACROSS_V4_DEPOSIT_V3 hands the bridge what the router holds. The bridge
// pays the deposit's recipient on another network, and refunds a deposit
// nobody fills to its depositor; the router passes both on as written.
describe("a bridge deposit", () => {
// A swap of 2,000 USDT for at least 1 WETH paid to the router, then a
// deposit of all the router's WETH.
const bridged = (depositor, recipient) =>
execute(
swapToWeth(ADDRESS_THIS),
acrossDeposit(depositor, recipient, WETH, CONTRACT_BALANCE),
);
test.each([
{
kind: "the user is its recipient and its depositor",
recipient: USER,
depositor: USER,
named: [],
},
{
kind: "another address is its recipient",
recipient: OTHER,
depositor: USER,
named: [OTHER],
},
{
kind: "another address is its depositor",
recipient: USER,
depositor: OTHER,
named: [OTHER],
},
{
kind: "address(1) is its recipient and address(2) its depositor",
recipient: MSG_SENDER,
depositor: ADDRESS_THIS,
named: [MSG_SENDER, ADDRESS_THIS],
},
])(
"when $kind, each that is not the user is named, and nothing is received",
({ recipient, depositor, named }) => {
const result = decode(
bridged(depositor, recipient),
USER,
ROUTER_V2_1_2,
);
expect(detail(result, "Steps").value).toBe(
"V3 Swap (Exact In) → Across Bridge Deposit",
);
expect(paidTo(result)).toEqual([...named, PAID_TO_BRIDGE]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
},
);
test("a deposit of part of the router's WETH leaves the rest there", () => {
const result = decode(
execute(
swapToWeth(ADDRESS_THIS),
acrossDeposit(USER, USER, WETH, ONE_WETH / 2n),
),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]);
});
test("a deposit of all the router's ETH pays out the ETH it holds; one of all its WETH does not", () => {
const ethToRouter = v4Swap(
SWAP_USDT_FOR_ETH,
SETTLE_USDT,
take(ZERO, ADDRESS_THIS),
);
const native = decode(
execute(
ethToRouter,
acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE, true),
),
USER,
ROUTER_V2_1_2,
);
expect(detail(native, "Token Out").value).toBe("ETH");
expect(paidTo(native)).toEqual([PAID_TO_BRIDGE]);
const weth = decode(
execute(
ethToRouter,
acrossDeposit(USER, USER, WETH, CONTRACT_BALANCE),
),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(weth)).toEqual([PAID_TO_BRIDGE, STAYS_IN_ROUTER]);
});
});
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps // Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is // through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below // CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
@@ -430,7 +901,6 @@ describe("an output left in the router is not received", () => {
describe("a swap paid to the pair the next V2 swap spends", () => { describe("a swap paid to the pair the next V2 swap spends", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it. // The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc"; const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const CONTRACT_BALANCE = 2n ** 255n;
const AMOUNTS_IN = [ const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE }, { kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n }, { kind: "ALREADY_PAID", amountIn: 0n },
@@ -498,6 +968,36 @@ describe("a swap paid to the pair the next V2 swap spends", () => {
}, },
); );
test.each([
{
kind: "ETH wrapped into the router, then a TRANSFER of all of it",
pay: (r) => [
wrapEth(ADDRESS_THIS, ONE_WETH),
transfer(0x05, WETH, r, CONTRACT_BALANCE),
],
},
{
kind: "a PERMIT2_TRANSFER_FROM of the user's WETH",
pay: (r) => [permit2TransferFrom(WETH, r, ONE_WETH)],
},
])(
"$kind, paid to the pair, is a hop; paid to another address, it is named",
({ pay }) => {
const route = (r) =>
execute(...pay(r), pairSpends(0n, [WETH, USDC]));
const toPair = decode(route(PAIR));
expect(paidTo(toPair)).toEqual([]);
expect(detail(toPair, "Min. received").value).toBe(
"1990.0000 USDC",
);
const toOther = decode(route(OTHER));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. received")).toBeUndefined();
},
);
describe("on Sepolia", () => { describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7"; const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia. // UniversalRouterV2_1_2 on Sepolia.
@@ -661,13 +1161,17 @@ describe("every step's payee counts, whichever step sets the output", () => {
}); });
describe("a payout that cannot be read is never said to be received", () => { describe("a payout that cannot be read is never said to be received", () => {
test("a V4 TAKE whose recipient word has its top byte set", () => { test.each([
{ kind: "TAKE", action: take(USDC, OTHER) },
{ kind: "TAKE_PORTION", action: takePortion(USDC, OTHER, 25n) },
])("a V4 $kind whose recipient word has its top byte set", ({ action }) => {
const result = decode( const result = decode(
execute( execute(
v4Swap( v4Swap(
SWAP_USDT_FOR_USDC, SWAP_USDT_FOR_USDC,
SETTLE_USDT, SETTLE_USDT,
withDirtyWord(take(USDC, OTHER), 1), withDirtyWord(action, 1),
takeAll(USDC),
), ),
), ),
); );
@@ -689,11 +1193,38 @@ describe("a payout that cannot be read is never said to be received", () => {
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 }, { kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) }, { kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) }, { kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
{
kind: "TRANSFER",
step: transfer(0x05, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PAY_PORTION",
step: transfer(0x06, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PAY_PORTION_FULL_PRECISION",
step: transfer(0x07, WETH, OTHER, 1n),
word: 1,
},
{
kind: "PERMIT2_TRANSFER_FROM",
step: permit2TransferFrom(WETH, OTHER, 1n),
word: 1,
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: acrossDeposit(USER, OTHER, WETH, 1n),
word: 2,
},
])( ])(
"a $kind whose recipient word has its top byte set", "a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => { ({ step, word = 0 }) => {
const result = decode( const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)), execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
USER,
ROUTER_V2_1_2,
); );
expect(paidTo(result)).toEqual([UNDECODED]); expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined();
@@ -719,17 +1250,8 @@ describe("a payout that cannot be read is never said to be received", () => {
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH"); expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
}); });
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until // Each is a payout this decoder does not read.
// then each is a payout this decoder does not read.
const transferShape = (command) => [
command,
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
];
test.each([ test.each([
{ kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) },
{ kind: "TRANSFER", step: transferShape(0x05) },
{ kind: "PAY_PORTION", step: transferShape(0x06) },
{ kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) },
{ {
kind: "PERMIT2_TRANSFER_FROM_BATCH", kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [ step: [
@@ -750,24 +1272,10 @@ describe("a payout that cannot be read is never said to be received", () => {
0x21, 0x21,
coder.encode( coder.encode(
["bytes", "bytes[]"], ["bytes", "bytes[]"],
["0x05", [transferShape(0x05)[1]]], ["0x05", [transfer(0x05, WETH, OTHER, 1n)[1]]],
), ),
], ],
}, },
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: [0x40, coder.encode(["address"], [OTHER])],
},
{
kind: "V4 TAKE_PORTION",
step: v4Swap([
0x10,
coder.encode(
["address", "address", "uint256"],
[WETH, OTHER, 10000n],
),
]),
},
])("a swap paid to the user, then $kind", ({ step }) => { ])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode( const result = decode(
execute(swapToWeth(MSG_SENDER), step), execute(swapToWeth(MSG_SENDER), step),