fix: the approval screen names the address a swap's output is paid to (closes #594)
check / check (push) Canceled after 0s
e2e / e2e-chrome (push) Canceled after 0s
e2e / e2e-firefox (push) Canceled after 0s

The swap decoder read no recipient, so a swap paying its output to another
address still showed Min. received. It now reads the address each swap step,
SWEEP, WRAP_ETH, UNWRAP_WETH, V4 TAKE and TAKE_ALL pays. Min. received shows
only when some step pays the sender, none pays another address, none that may
pay one went unread, and the output does not stay in the router with no later
SWEEP or UNWRAP_WETH paying it out. A payment to the V2 pair the next V2 swap
spends as already paid is a hop, not a payout. Otherwise it reads Min. paid
out, with Paid to lines.

Model: opus-5-5
This commit was merged in pull request #598.
This commit is contained in:
2026-10-19 11:33:54 +02:00
parent 5e4b8ff3b0
commit e0e8de8111
11 changed files with 1453 additions and 112 deletions
+293 -35
View File
@@ -1,7 +1,15 @@
// Decode Uniswap Universal Router execute() calldata into human-readable
// swap details. Designed to be extended with other DEX decoders later.
const { Interface, AbiCoder, getBytes, formatUnits } = require("ethers");
const {
Interface,
AbiCoder,
getBytes,
formatUnits,
getCreate2Address,
keccak256,
solidityPacked,
} = require("ethers");
const { truncateAmountNeverZero } = require("./amountDisplay");
const {
resolveTokenDecimals,
@@ -65,6 +73,55 @@ function routerCommandTypeMask(address, chainId) {
return router ? router[1] : null;
}
// The Uniswap V2 factory and pair init code hash the Universal Routers on each
// network were deployed with. A V2 swap step swaps through the pair its path
// starts at, which the router finds as `UniswapV2Library.pairFor()` does: the
// CREATE2 address from the factory, with the hash of the path's first two
// tokens, sorted, as salt.
// - pairFor(): https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/uniswap/v2/UniswapV2Library.sol
// - mainnet: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeployMainnet.s.sol
// - Sepolia: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/script/deployParameters/DeploySepolia.s.sol
// Every router in ROUTER_ADDRESSES was deployed with these (their constructor
// arguments on the explorer), except Sepolia's UniversalRouterV1_2_V2Support,
// which was deployed with two placeholders: the V2 factory
// 0x5302086A3a25d473aAbBd0356eFf8Dd811a4d89B (the `UnsupportedProtocol` its
// deployment file lists) and a zero init code hash. On Sepolia no contract
// sits at a pair any router there finds (the hash below is not that of the
// factory's own pairs), so every V2 swap step they run reverts.
const V2_FACTORIES = new Map([
[
NETWORKS.mainnet.chainId,
{
v2Factory: "0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f",
pairInitCodeHash:
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
},
],
[
NETWORKS.sepolia.chainId,
{
v2Factory: "0xB7f907f7A9eBC822a80BD25E224be42Ce0A698A0",
pairInitCodeHash:
"0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f",
},
],
]);
// The address of the V2 pair of `tokenA` and `tokenB` on the network with this
// chain id, found as the router finds it (see V2_FACTORIES).
function v2PairFor(chainId, tokenA, tokenB) {
const { v2Factory, pairInitCodeHash } = V2_FACTORIES.get(chainId);
const [token0, token1] =
tokenA.toLowerCase() < tokenB.toLowerCase()
? [tokenA, tokenB]
: [tokenB, tokenA];
return getCreate2Address(
v2Factory,
keccak256(solidityPacked(["address", "address"], [token0, token1])),
pairInitCodeHash,
);
}
// Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES)
const COMMAND_NAMES = {
@@ -101,16 +158,23 @@ function formatAmount(raw, decimals) {
// One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes
// on a scale — not as a token name, and not as a quantity.
// on a scale — not as a token name, and not as a quantity. The Paid to line
// uses it too, when no step names anyone paid.
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// commands this decoder does not read. That action names both of its
// currencies, and those commands may name one, so saying the calldata did not
// name it would be false.
// name it would be false. The Paid to line uses it too, when a step may pay an
// address this decoder did not read.
const UNDECODED_CURRENCY = "Unknown (could not be decoded)";
// The Paid to line when the swap's output is paid to the router and no later
// step pays it out of the router.
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
// 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
@@ -160,6 +224,11 @@ const OPEN_DELTA = Symbol("v4-open-delta");
// that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid");
// The router's whole-balance amount, universal-router
// `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn`
// pays the pair all the router holds of the input token.
const CONTRACT_BALANCE = 2n ** 255n;
// The amount lines that state a fact instead of a quantity. Same register as
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
// number — and deliberately not another phrasing of "not named": these say
@@ -179,6 +248,21 @@ const WETH_ADDRESSES = [
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
// 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.
// - 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
// - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol
// - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol
// - TAKE, TAKE_ALL and TAKE_PORTION: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// `decimals` is null when nothing knows this token's scale. It is not
// defaulted to 18: the swap lines land on the same approval screen as the
// ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as
@@ -265,8 +349,10 @@ function decodeV2SwapExactIn(input) {
input,
);
return {
recipient: d[0],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2],
path: d[3],
tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1],
};
@@ -285,6 +371,7 @@ function decodeV2SwapExactOut(input) {
input,
);
return {
recipient: d[0],
amountOut: d[1],
amountInMax: d[2],
tokenIn: d[3][0],
@@ -316,6 +403,7 @@ function decodeV3SwapExactIn(input) {
const path = decodeV3Path(d[3]);
if (!path) return null;
return {
recipient: d[0],
amountIn: d[1],
amountOutMin: d[2],
tokenIn: path.tokenIn,
@@ -326,12 +414,37 @@ function decodeV3SwapExactIn(input) {
}
}
// Decode WRAP_ETH (command 0x0b) input bytes.
// Decode SWEEP (command 0x04) input bytes. The router pays its whole balance
// of `token` to the recipient.
// ABI: (address token, address recipient, uint256 amountMin)
function decodeSweep(input) {
try {
const d = coder.decode(["address", "address", "uint256"], 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
// recipient.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { amount: d[1] };
return { recipient: d[0], amount: d[1] };
} catch {
return null;
}
}
// Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole
// WETH balance into ETH and pays it to the recipient.
// ABI: (address recipient, uint256 amountMin)
function decodeUnwrapWeth(input) {
try {
const d = coder.decode(["address", "uint256"], input);
return { recipient: d[0] };
} catch {
return null;
}
@@ -346,6 +459,7 @@ const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f;
const V4_TAKE_PORTION = 0x10;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is
// ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA
@@ -500,6 +614,10 @@ function totalAmount(amounts) {
// these rules leave unknown is set unknown, with no figure. A step with no
// 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.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -508,6 +626,8 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
const takes = [];
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
@@ -537,10 +657,12 @@ function decodeV4Swap(input) {
params[i],
);
takeToken = t[0];
takes.push({ currency: t[0], recipient: t[1] });
} else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if (
actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN ||
@@ -552,9 +674,19 @@ 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;
}
} catch {
// Skip sub-actions we can't decode
// 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) {
recipientUnread = true;
}
}
}
@@ -565,6 +697,8 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
takes,
recipientUnread,
swapUndecoded,
hasSwapAction: true,
};
@@ -604,6 +738,8 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
takes,
recipientUnread,
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
@@ -612,14 +748,14 @@ function decodeV4Swap(input) {
}
}
// Try to decode a Universal Router execute() call sent to `toAddress` on the
// network with chain id `chainId`.
// Try to decode a Universal Router execute() call sent from `fromAddress`, the
// user, to `toAddress` on the network with chain id `chainId`.
// Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute() or
// `toAddress` is not a Universal Router on that network: the same calldata
// sent to any other contract is not a swap through the router, whatever that
// contract does with it.
function decode(data, toAddress, chainId, sources) {
function decode(data, toAddress, chainId, sources, fromAddress) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null;
try {
@@ -678,6 +814,60 @@ function decode(data, toAddress, chainId, sources) {
outputEstablished = true;
};
// Who the steps pay, in whichever step and of whatever token.
// MSG_SENDER is the sender. The router itself, as ADDRESS_THIS or by
// its own address, holds what it is paid until a later step pays it
// out.
const sender = (fromAddress || "").toLowerCase();
let paidToSender = false;
// Each other address paid, once.
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.
const heldByRouter = new Set();
const isRouter = (recipient) => {
const r = recipient.toLowerCase();
return r === ADDRESS_THIS || r === toAddress.toLowerCase();
};
// A step pays `recipient`. `output` is the token a swap step or a V4
// TAKE 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);
}
};
// 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.
// 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.
const isHop = (i, token, recipient) => {
if (i + 1 >= commandsBytes.length) return false;
if ((commandsBytes[i + 1] & commandTypeMask) !== 0x08) return false;
const next = decodeV2SwapExactIn(inputs[i + 1]);
return (
next !== null &&
(next.amountIn === ALREADY_PAID ||
next.amountIn === CONTRACT_BALANCE) &&
present(token) &&
next.path.length >= 2 &&
next.tokenIn.toLowerCase() === token.toLowerCase() &&
recipient.toLowerCase() ===
v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase()
);
};
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
commandNames.push(
@@ -690,30 +880,27 @@ function decode(data, toAddress, chainId, sources) {
// EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step
// says so, and after this loop neither side is named.
// Those commands may pay any address.
hasSubPlan = true;
commandNames[i] += " (its steps are not shown)";
}
if (cmdId === 0x0a) {
payeeUnread = true;
} else if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking
// the invariant.
if (p) setInput(p.token, p.amount);
}
if (cmdId === 0x0e) {
} else if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at
// least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does.
// It pays nobody.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
}
}
if (cmdId === 0x00) {
} else if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
@@ -721,39 +908,69 @@ function decode(data, toAddress, chainId, sources) {
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
}
}
if (cmdId === 0x08) {
} else if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
}
}
if (cmdId === 0x09) {
} else if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
}
} else {
payeeUnread = true;
}
}
if (cmdId === 0x0b) {
} else if (cmdId === 0x04) {
const s = decodeSweep(inputs[i]);
if (s) {
addPayee(s.recipient);
if (!isRouter(s.recipient)) {
heldByRouter.delete(s.token.toLowerCase());
}
} else {
payeeUnread = true;
}
} else if (cmdId === 0x0b) {
const w = decodeWrapEth(inputs[i]);
if (w) {
setInputOnce(
"0x0000000000000000000000000000000000000000",
w.amount,
);
addPayee(w.recipient);
} else {
payeeUnread = true;
}
}
if (cmdId === 0x10) {
} else if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]);
if (v4) {
for (const t of v4.takes) {
if (!isHop(i, t.currency, t.recipient)) {
addPayee(t.recipient, t.currency);
}
}
}
if (!v4 || v4.recipientUnread) {
payeeUnread = true;
}
if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step
// is marked.
@@ -784,13 +1001,29 @@ function decode(data, toAddress, chainId, sources) {
setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin);
}
}
if (cmdId === 0x0c) {
} else if (cmdId === 0x0c) {
hasUnwrapWeth = true;
const u = decodeUnwrapWeth(inputs[i]);
if (u) {
addPayee(u.recipient);
if (!isRouter(u.recipient)) {
WETH_ADDRESSES.forEach((w) =>
heldByRouter.delete(w),
);
}
} else {
payeeUnread = true;
}
} 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.
payeeUnread = true;
}
} catch {
// Skip commands we can't decode
// Skip commands we can't decode. One may pay any address.
payeeUnread = true;
}
}
@@ -936,6 +1169,31 @@ function decode(data, toAddress, chainId, sources) {
});
}
// 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.
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 });
}
if (outputHeld) {
details.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 });
}
if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the
// swap guarantees nothing back. It is said in words, in the same
@@ -944,7 +1202,7 @@ function decode(data, toAddress, chainId, sources) {
// return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown.
details.push({
label: "Min. received",
label: received ? "Min. received" : "Min. paid out",
value:
minOutput === 0n
? NO_MINIMUM