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

TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION (0x07, on the routers with
the 0x7f mask), PERMIT2_TRANSFER_FROM 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 under the rule swap steps follow: 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
part leaves the rest in the router. UniversalRouterV1 pays a
PERMIT2_TRANSFER_FROM to address(1) or address(2) as written. 0x07 and
ACROSS_V4_DEPOSIT_V3 (0x40) are named in Steps; the deposit stays unread.

Model: opus-5-5
This commit is contained in:
2026-10-19 09:56:19 +00:00
parent 9c67e87ae5
commit 46a39fd96b
4 changed files with 539 additions and 101 deletions
+48 -35
View File
@@ -2955,45 +2955,58 @@ 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
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID` itself, which holds what it is paid until a later step pays it out; a payment
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes to the router, as `address(2)` or by its own address, is not counted as paid
pay the next V2 pair directly, and the router enforces that swap's own out. `UniversalRouterV1` alone pays a `PERMIT2_TRANSFER_FROM` to `address(1)`
minimum. The decoder checks that the address paid is that pair, deriving it or `address(2)` as those addresses, and the decoder names them. When a swap
from the V2 factory and pair init code hash the network's routers were step, `TAKE` or `TAKE_PORTION` pays the router the swap's output token, the
deployed with and the first two tokens of that step's path; a payment to any output stays in the router unless a later step pays all the router holds of it
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was to anyone but the router: a `SWEEP` of that token, an `UNWRAP_WETH` when it is
deployed with placeholders for both values, so no V2 swap step can succeed on WETH, or a `TRANSFER` of `CONTRACT_BALANCE`, a `PAY_PORTION` of 10,000 bips or
that router.) Every other payment counts, in whichever step and of whatever a `PAY_PORTION_FULL_PRECISION` of 1e18 of that token. A step paying a token to
token. The minimum reads `Min. received` only when some step pays the the V2 pair that the very next step spends, as a V2 exact-in of that token
transaction's sender (the From address on TxApproval, the sender the explorer whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a hop, not
reports on TransactionDetail), no step pays any other address, no step that a payout: Uniswap's mixed routes pay the next V2 pair directly, and the router
may pay an address went unread, and the output does not stay in the router. enforces that swap's own minimum. The decoder checks that the address paid is
Otherwise it reads `Min. paid out`, a `Paid to` line after `Token Out` names that pair, deriving it from the V2 factory and pair init code hash the
each other address paid, one reads 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), no step
pays any other address, no step that may pay an address went unread, and the
output does not stay in the router. Otherwise it reads `Min. paid out`, a
`Paid to` line after `Token Out` names each other address paid, 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 such a
step went unread, or `Unknown (not named in the calldata)` when no step pays step went unread, or `Unknown (not named in the calldata)` when no step pays
anyone. A step goes unread when its recipient does not decode as an address, anyone. A step goes unread when its recipient does not decode as an address,
or when it is a command or V4 action the decoder does not read: every command or when it is a command the decoder does not read: every command other than
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`, `PERMIT2_PERMIT`, `PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and `V2_SWAP_EXACT_OUT`, `V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`,
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and `PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `WRAP_ETH`, `UNWRAP_WETH` and
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`. `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `PERMIT2_TRANSFER_FROM_BATCH`,
`EXECUTE_SUB_PLAN` and `ACROSS_V4_DEPOSIT_V3` among them).
### Non-Goals Forever ### Non-Goals Forever
+146 -32
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,
@@ -251,9 +256,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 +433,46 @@ 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
// `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] };
} 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 +662,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 +712,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 +730,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 +881,41 @@ 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 // The tokens, lowercased, that a swap step or a V4 TAKE or
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH, // TAKE_PORTION paid to the router and no later step paid out of it in
// paid out. // full: a SWEEP of that token, an UNWRAP_WETH of WETH, or a TRANSFER,
// PAY_PORTION or PAY_PORTION_FULL_PRECISION 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 +937,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 +962,23 @@ 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 (!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 +1037,31 @@ 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 === 0x0b) { } else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
@@ -1016,9 +1130,9 @@ 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,
// PAY_PORTION and the others // PERMIT2_TRANSFER_FROM_BATCH and ACROSS_V4_DEPOSIT_V3
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists. // among them.
payeeUnread = true; payeeUnread = true;
} }
} catch { } catch {
+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",
+323 -33
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 and V4 TAKE and
// them: a swap whose output went to another address still read "Min. // TAKE_PORTION names the address it pays, and TAKE_ALL pays the sender. The
// received", as if the user received it. The router reads address(1) as the // decoder read none of them: a swap whose output went to another address
// sender and address(2) as the router itself, which holds what it is paid // still read "Min. received", as if the user received it. The router reads
// until a later step pays it out. // 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,6 +57,8 @@ 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)";
@@ -89,7 +95,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 +115,7 @@ function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
["address", "uint24", "address"], ["address", "uint24", "address"],
[tokenIn, 3000, tokenOut], [tokenIn, 3000, tokenOut],
), ),
true, payerIsUser,
], ],
), ),
]; ];
@@ -115,6 +128,28 @@ 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],
),
];
}
function wrapEth(recipient, amount) { function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])]; return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
} }
@@ -168,6 +203,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 +466,216 @@ 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.
// The router spends what it is moved, so a move to the router pays no
// one. 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 or the router it pays no one else, to another address that address is named", () => {
for (const r of [MSG_SENDER, USER, ADDRESS_THIS, ROUTER]) {
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]);
});
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("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);
},
);
});
// 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 +684,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 +751,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 +944,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 +976,33 @@ 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,
},
])( ])(
"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 +1028,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,7 +1050,7 @@ 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]]],
), ),
], ],
}, },
@@ -758,16 +1058,6 @@ describe("a payout that cannot be read is never said to be received", () => {
kind: "ACROSS_V4_DEPOSIT_V3", kind: "ACROSS_V4_DEPOSIT_V3",
step: [0x40, coder.encode(["address"], [OTHER])], 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),