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 0x7f-mask
routers), PERMIT2_TRANSFER_FROM, ACROSS_V4_DEPOSIT_V3 (0x40) and V4
TAKE_PORTION were payouts the decoder did not read. 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 TRANSFER of CONTRACT_BALANCE of ETH reverts. A
PERMIT2_TRANSFER_FROM into the router shows the amount it moves and the
token's address, and a bridge deposit names each of its recipient and
depositor that is not the sender; either keeps the minimum from reading
received.

Model: opus-5-5
This commit was merged in pull request #604.
This commit is contained in:
2026-10-19 14:03:52 +02:00
parent 20e4f1652f
commit 99269675b7
4 changed files with 979 additions and 126 deletions
+67 -39
View File
@@ -2955,45 +2955,73 @@ Currently supported:
address uses: `0x1f` for `UniversalRouterV1`, `0x3f` for
`UniversalRouterV1_2_V2Support` and `UniversalRouterV2`, and `0x7f` for
`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
the commands inside an `EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that
step stands they may spend the user's tokens or pay the user. That step is
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other
steps state, the summary names no swap direction, `Token In` and `Token Out`
read `Unknown (could not be decoded)`, and there is no `Amount` or
`Min. received` line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
V4 `TAKE` names the address it pays, and `TAKE_ALL` pays the transaction's
sender. The router 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. When a swap step or `TAKE` pays the
router the swap's output token, the output stays in the router unless a later
`SWEEP` of that token, or a later `UNWRAP_WETH` when it is WETH, pays it out.
A swap step or `TAKE` paying a token to the V2 pair that the very next step
spends, as a V2 exact-in of that token whose `amountIn` is `ALREADY_PAID`
(zero) or `CONTRACT_BALANCE`, is a hop, not a payout: Uniswap's mixed routes
pay the next V2 pair directly, and the router enforces that swap's own
minimum. The decoder checks that the address paid is that pair, deriving it
from the V2 factory and pair init code hash the network's routers were
deployed with and the first two tokens of that step's path; a payment to any
other address is named. (Sepolia's `UniversalRouterV1_2_V2Support` was
deployed with placeholders for both values, so no V2 swap step can succeed on
that router.) Every other payment counts, in whichever step and of whatever
token. 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
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
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
other than `PERMIT2_PERMIT`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH` and
`BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`, `TRANSFER`, `PAY_PORTION` and
`EXECUTE_SUB_PLAN` among them), and V4 `TAKE_PORTION`.
(`0x80`) therefore does not change a step's name. Only the routers with the
`0x7f` mask have `PAY_PORTION_FULL_PRECISION` (`0x07`), listed as
`Pay Portion (Full Precision)`, and `ACROSS_V4_DEPOSIT_V3` (`0x40`), listed as
`Across Bridge Deposit`; the other routers revert on `0x07`, and it is listed
there as `Command 0x07`. The decoder does not read the commands inside an
`EXECUTE_SUB_PLAN` (`0x21`) step, and wherever that step stands they may spend
the user's tokens or pay the user. That step is listed as
`Execute Sub-Plan (its steps are not shown)`, and whatever the other steps
state, the summary names no swap direction, `Token In` and `Token Out` read
`Unknown (could not be decoded)`, and there is no `Amount` or `Min. received`
line. Each swap step, `SWEEP`, `WRAP_ETH`, `UNWRAP_WETH`, `TRANSFER`,
`PAY_PORTION`, `PAY_PORTION_FULL_PRECISION`, `PERMIT2_TRANSFER_FROM` (which
moves the user's own tokens) and V4 `TAKE` and `TAKE_PORTION` names the
address it pays, and `TAKE_ALL` pays the transaction's sender. The router
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`. The router sends ETH as written, so a `TRANSFER` of
`CONTRACT_BALANCE` of ETH always reverts and pays out nothing. A step paying a
token to the V2 pair that the very next step spends, as a V2 exact-in of that
token whose `amountIn` is `ALREADY_PAID` (zero) or `CONTRACT_BALANCE`, is a
hop, not a payout: Uniswap's mixed routes pay the next V2 pair directly, and
the router enforces that swap's own minimum. The decoder checks that the
address paid is that pair, deriving it from the V2 factory and pair init code
hash the network's routers were deployed with and the first two tokens of that
step's path; a payment to any other address is named. (Sepolia's
`UniversalRouterV1_2_V2Support` was deployed with placeholders for both
values, so no V2 swap step can succeed on that router.) Every other payment
counts, in whichever step and of whatever token, a share of the output
included: an interface fee paid to another address by `PAY_PORTION`,
`TRANSFER` or `TAKE_PORTION` is named, and the minimum reads `Min. paid out`,
because the swap's minimum includes the fee. The minimum reads `Min. received`
only when some step pays the transaction's sender (the From address on
TxApproval, the sender the explorer reports on TransactionDetail) and no
`Paid to` line is shown. `Paid to` lines after `Token Out` name each other
address paid; one reads
`The bridge, which pays the deposit's recipient on another network` for a
bridge deposit; one reads, for example,
`The router, 2000.0000 USDT from the sender (later steps may not spend all of it)`
for each move into the router, followed by a `Token moved into the router`
line that gives the moved token's address as the `Token In` line does; one
reads `The router, where it stays (no later step pays it out)` when the output
stays in the router; and one more reads `Unknown (could not be decoded)` when
a step that may pay an address went unread. When no step pays anyone, a single
line reads `Unknown (not named in the calldata)`. Wherever there is a
`Paid to` line, the minimum reads `Min. paid out`. A step goes unread when its
recipient does not decode as an address, or when it is a command the decoder
does not read: every command other than `PERMIT2_PERMIT`,
`PERMIT2_TRANSFER_FROM`, `V2_SWAP_EXACT_IN`, `V2_SWAP_EXACT_OUT`,
`V3_SWAP_EXACT_IN`, `V4_SWAP`, `SWEEP`, `TRANSFER`, `PAY_PORTION`,
`PAY_PORTION_FULL_PRECISION`, `ACROSS_V4_DEPOSIT_V3`, `WRAP_ETH`,
`UNWRAP_WETH` and `BALANCE_CHECK_ERC20` (`V3_SWAP_EXACT_OUT`,
`PERMIT2_TRANSFER_FROM_BATCH` and `EXECUTE_SUB_PLAN` among them).
### Non-Goals Forever