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 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
+274 -49
View File
@@ -123,7 +123,10 @@ function v2PairFor(chainId, tokenA, tokenB) {
}
// 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 = {
0x00: "V3 Swap (Exact In)",
0x01: "V3 Swap (Exact Out)",
@@ -132,6 +135,7 @@ const COMMAND_NAMES = {
0x04: "Sweep",
0x05: "Transfer",
0x06: "Pay Portion",
0x07: "Pay Portion (Full Precision)",
0x08: "V2 Swap (Exact In)",
0x09: "V2 Swap (Exact Out)",
0x0a: "Permit2 Permit",
@@ -145,6 +149,7 @@ const COMMAND_NAMES = {
0x13: "V4 Initialize Pool",
0x14: "V4 Position Mgr Call",
0x21: "Execute Sub-Plan",
0x40: "Across Bridge Deposit",
};
// 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 =
"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
// 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
@@ -251,9 +261,11 @@ const WETH_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,
// 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
// map their recipient through the dispatcher's map(), and a V4 TAKE through
// V4Router's _mapRecipient(); TAKE_ALL always pays the caller.
// later step pays it out. The swap commands, SWEEP, WRAP_ETH, UNWRAP_WETH,
// TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and, on every router but
// 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
// - 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
@@ -426,6 +438,84 @@ 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, of a
// token other than ETH, all the router holds when `amount` is
// CONTRACT_BALANCE; it sends ETH as written, so a TRANSFER of
// CONTRACT_BALANCE of ETH (`token` the zero address) always reverts.
// PAY_PORTION pays `amount` ten-thousandths of what the router holds, and
// PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it, of ETH too.
// - 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
// recipient.
// ABI: (address recipient, uint256 amount)
@@ -615,9 +705,11 @@ function totalAmount(amounts) {
// swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name.
//
// `takes` is every TAKE and TAKE_ALL, as the currency it takes and its
// recipient as written, MSG_SENDER for a TAKE_ALL. `recipientUnread` is set
// when the step has a payout this decoder did not read.
// `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes
// and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes
// a share of the currency, so it does not name the output side as TAKE does.
// `recipientUnread` is set when the step has a payout this decoder did not
// read.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -663,6 +755,13 @@ function decodeV4Swap(input) {
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
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 (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
@@ -674,17 +773,18 @@ function decodeV4Swap(input) {
} catch {
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 {
// Skip sub-actions we can't decode. A TAKE or TAKE_ALL among
// them is a payout this decoder did not read.
if (actionId === V4_TAKE || actionId === V4_TAKE_ALL) {
// Skip sub-actions we can't decode. A TAKE, TAKE_ALL or
// TAKE_PORTION among them is a payout this decoder did not
// read.
if (
actionId === V4_TAKE ||
actionId === V4_TAKE_ALL ||
actionId === V4_TAKE_PORTION
) {
recipientUnread = true;
}
}
@@ -824,31 +924,47 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
const paidElsewhere = [];
// Set when a step may pay an address this decoder did not read.
let payeeUnread = false;
// The tokens, lowercased, that a swap step or a V4 TAKE paid to the
// router and no later SWEEP of that token, or UNWRAP_WETH of WETH,
// paid out.
// Set when an ACROSS_V4_DEPOSIT_V3 step pays the bridge.
let bridgeDeposit = false;
// 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 isRouter = (recipient) => {
const r = recipient.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
// TAKE pays; other steps pass none.
// TAKE or TAKE_PORTION pays; other steps pass none.
const addPayee = (recipient, output) => {
const r = recipient.toLowerCase();
if (isRouter(recipient)) {
if (present(output)) heldByRouter.add(output.toLowerCase());
} else if (r === MSG_SENDER || r === sender) {
paidToSender = true;
} else if (!paidElsewhere.some((a) => a.toLowerCase() === r)) {
paidElsewhere.push(recipient);
} else {
addPaidElsewhere(recipient);
}
};
// A swap step or V4 TAKE that pays `token` to `recipient` is a hop,
// not a payout, when the very next step is a V2 exact-in whose input
// is already paid, whose path starts at `token`, and whose pair (see
// v2PairFor()) is `recipient`: Uniswap's mixed routes pay the next V2
// pair directly, and the router enforces that next swap's own minimum.
// A step that pays `token` to `recipient` is a hop, not a payout,
// when the very next step is a V2 exact-in whose input is already
// paid, whose path starts at `token`, and whose pair (see v2PairFor())
// is `recipient`: Uniswap's mixed routes pay the next V2 pair
// directly, and the router enforces that next swap's own minimum.
// Its input is already paid when its amountIn is ALREADY_PAID, or
// CONTRACT_BALANCE, which Uniswap's routes send and with which the
// router adds only what it holds.
@@ -870,9 +986,14 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
for (let i = 0; i < commandsBytes.length; i++) {
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(
COMMAND_NAMES[cmdId] ||
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
name || "Command 0x" + cmdId.toString(16).padStart(2, "0"),
);
try {
@@ -890,6 +1011,25 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
// input side an earlier hop established without breaking
// the invariant.
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) {
const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at
@@ -948,6 +1088,67 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
} else {
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;
// A TRANSFER of ETH has no such amount: the router
// sends ETH as written, so a TRANSFER of
// CONTRACT_BALANCE of ETH always reverts.
const ethTransfer =
cmdId === 0x05 &&
t.token ===
"0x0000000000000000000000000000000000000000";
if (
t.amount === all &&
!ethTransfer &&
!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) {
const w = decodeWrapEth(inputs[i]);
if (w) {
@@ -1016,9 +1217,8 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
}
} else {
// Any other command may pay an address this decoder does
// not read: V3_SWAP_EXACT_OUT does, and so do TRANSFER,
// PAY_PORTION and the others
// https://git.eeqj.de/sneak/AutistMask/issues/599 lists.
// not read: V3_SWAP_EXACT_OUT and
// PERMIT2_TRANSFER_FROM_BATCH among them.
payeeUnread = true;
}
} catch {
@@ -1169,30 +1369,55 @@ function decode(data, toAddress, chainId, sources, fromAddress) {
});
}
// The output is received by the sender only when some step pays the
// sender, none pays another address, none may pay an address this
// decoder did not read, and the output token is not left in the
// router. Otherwise each other address paid is named, and further
// lines say when the output stays in the router, a payout could not
// be read, or no step names anyone paid.
// A Paid to line names each other address paid, and says when a
// step pays the bridge, when one moves the sender's tokens into the
// router, when the output stays in the router, and when a payout
// could not be read. The output is received by the sender only when
// some step pays the sender and there is no such line. When no step
// names anyone paid, one line says so. `paidTo` holds these lines and
// the token line after each move into the router.
const outputHeld =
present(outputToken) && heldByRouter.has(outputToken.toLowerCase());
const received =
paidToSender &&
paidElsewhere.length === 0 &&
!payeeUnread &&
!outputHeld;
for (const address of paidElsewhere) {
details.push({ label: "Paid to", value: address, address });
const paidTo = paidElsewhere.map((address) => ({
label: "Paid to",
value: address,
address,
}));
if (bridgeDeposit) {
paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE });
}
// Each move into the router is followed by a line giving the moved
// token's address, in the shape of the Token In line, so the screens
// show it as they show that one.
for (const m of movedIntoRouter) {
const info = tokenInfo(m.token, sources);
paidTo.push({
label: "Paid to",
value:
"The router, " +
amountText(m.amount, info).display +
" from the sender (later steps may not spend all of it)",
});
paidTo.push({
label: "Token moved into the router",
value: info.symbol
? info.symbol + " (" + m.token + ")"
: m.token,
address: m.token,
isToken: true,
});
}
if (outputHeld) {
details.push({ label: "Paid to", value: STAYS_IN_ROUTER });
paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER });
}
if (payeeUnread) {
details.push({ label: "Paid to", value: UNDECODED_CURRENCY });
} else if (!paidToSender && paidElsewhere.length === 0 && !outputHeld) {
details.push({ label: "Paid to", value: UNNAMED_CURRENCY });
paidTo.push({ label: "Paid to", value: UNDECODED_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)) {
// A zero floor is the one case the user most needs stated: the