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 recipient of every swap
step, SWEEP, UNWRAP_WETH, V4 TAKE and TAKE_ALL, reads the router's
placeholders as the router does (address(1) is the sender, address(2) the
router, which holds the output until a later SWEEP or UNWRAP_WETH pays it
out), and compares the result with the transaction's sender. When the output
goes anywhere else, a Paid to line names each address it is paid to and the
minimum reads Min. paid out. The approval and transaction detail screens pass
the sender in.

Model: opus-5-5
This commit is contained in:
2026-10-19 06:06:38 +00:00
parent 1f5cdeb7b4
commit 426f3e0d9a
10 changed files with 828 additions and 69 deletions
+146 -14
View File
@@ -8,6 +8,7 @@ const {
resolveTokenSymbol,
unknownDecimalsAmount,
} = require("./approvalAmount");
const { sameAddress } = require("./approvalVerify");
const { NETWORKS } = require("./networks");
const coder = AbiCoder.defaultAbiCoder();
@@ -179,6 +180,33 @@ const WETH_ADDRESSES = [
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
];
function isWeth(address) {
return present(address) && WETH_ADDRESSES.includes(address.toLowerCase());
}
// 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 the output until a
// later step pays it out. The swap commands, SWEEP 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 and TAKE_ALL: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
// The address a command's `recipient` pays, read as the router reads it, for a
// transaction from `sender` to the router at `router`.
function recipientAddress(recipient, sender, router) {
if (sameAddress(recipient, MSG_SENDER)) return sender;
if (sameAddress(recipient, ADDRESS_THIS)) return router;
return recipient;
}
// `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,6 +293,7 @@ function decodeV2SwapExactIn(input) {
input,
);
return {
recipient: d[0],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2],
tokenIn: d[3][0],
@@ -285,6 +314,7 @@ function decodeV2SwapExactOut(input) {
input,
);
return {
recipient: d[0],
amountOut: d[1],
amountInMax: d[2],
tokenIn: d[3][0],
@@ -316,6 +346,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,6 +357,30 @@ function decodeV3SwapExactIn(input) {
}
}
// 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 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;
}
}
// Decode WRAP_ETH (command 0x0b) input bytes.
// ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) {
@@ -500,6 +555,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.
//
// `outputRecipients` is the recipient of every TAKE and TAKE_ALL of Token
// Out, as written in the calldata (see recipientAddress()), TAKE_ALL's as
// MSG_SENDER. It is empty when none takes Token Out.
function decodeV4Swap(input) {
try {
const d = coder.decode(["bytes", "bytes[]"], input);
@@ -508,6 +567,8 @@ function decodeV4Swap(input) {
let settleToken = null;
let takeToken = null;
// Every TAKE and TAKE_ALL, as { currency, recipient }.
const takes = [];
// The step's swap actions that could be read, as decodeV4SwapAction()
// reads them.
const swaps = [];
@@ -537,10 +598,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 ||
@@ -558,6 +621,11 @@ function decodeV4Swap(input) {
}
}
const recipientsOf = (currency) =>
takes
.filter((t) => sameAddress(t.currency, currency))
.map((t) => t.recipient);
// Rule 3.
if (swapUndecoded) {
return {
@@ -565,6 +633,7 @@ function decodeV4Swap(input) {
tokenOut: takeToken,
amountIn: null,
amountOutMin: null,
outputRecipients: recipientsOf(takeToken),
swapUndecoded,
hasSwapAction: true,
};
@@ -604,6 +673,7 @@ function decodeV4Swap(input) {
tokenOut,
amountIn,
amountOutMin,
outputRecipients: recipientsOf(tokenOut),
swapUndecoded,
hasSwapAction: swaps.length > 0,
};
@@ -612,14 +682,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 {
@@ -636,6 +706,9 @@ function decode(data, toAddress, chainId, sources) {
let outputToken = null;
let minOutput = null;
let outputEstablished = false;
// The addresses the output side is paid to, each read as the router
// reads it (see recipientAddress()). Empty when no step read says.
let outputPaidTo = [];
let hasUnwrapWeth = false;
let hasV2ExactOut = false;
let swapUndecoded = false;
@@ -671,12 +744,24 @@ function decode(data, toAddress, chainId, sources) {
if (!present(token) && !present(amount)) return;
setInput(token, amount);
};
const setOutput = (token, amount) => {
// The step that sets the output side also says who it is paid to.
const setOutput = (token, amount, addresses) => {
if (!present(token) && !present(amount)) return;
outputToken = present(token) ? token : null;
minOutput = present(amount) ? amount : null;
outputPaidTo = addresses;
outputEstablished = true;
};
const paidTo = (recipients) =>
recipients.map((r) => recipientAddress(r, fromAddress, toAddress));
// SWEEP and UNWRAP_WETH pay out the router's whole balance, so an
// output the router holds goes to their recipient.
const payOutFromRouter = (recipient) => {
const [address] = paidTo([recipient]);
outputPaidTo = outputPaidTo.map((a) =>
sameAddress(a, toAddress) ? address : a,
);
};
for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask;
@@ -708,8 +793,9 @@ function decode(data, toAddress, chainId, sources) {
// 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, so who the output is paid to stays.
if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance);
setOutput(b.token, b.minBalance, outputPaidTo);
}
}
@@ -720,7 +806,11 @@ function decode(data, toAddress, chainId, sources) {
// Always update output: in multi-step swaps (V3 → V4),
// the last swap step determines the final output token
// and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin);
setOutput(
s.tokenOut,
s.amountOutMin,
paidTo([s.recipient]),
);
}
}
@@ -728,7 +818,11 @@ function decode(data, toAddress, chainId, sources) {
const s = decodeV2SwapExactIn(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin);
setOutput(
s.tokenOut,
s.amountOutMin,
paidTo([s.recipient]),
);
}
}
@@ -738,7 +832,18 @@ function decode(data, toAddress, chainId, sources) {
const s = decodeV2SwapExactOut(inputs[i]);
if (s) {
setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut);
setOutput(
s.tokenOut,
s.amountOut,
paidTo([s.recipient]),
);
}
}
if (cmdId === 0x04) {
const s = decodeSweep(inputs[i]);
if (s && sameAddress(s.token, outputToken)) {
payOutFromRouter(s.recipient);
}
}
@@ -775,6 +880,7 @@ function decode(data, toAddress, chainId, sources) {
}
outputToken = v4.tokenOut;
minOutput = v4.amountOutMin;
outputPaidTo = paidTo(v4.outputRecipients);
outputEstablished = true;
} else if (v4) {
// A step with no swap action sets only the sides its
@@ -782,12 +888,24 @@ function decode(data, toAddress, chainId, sources) {
// setInputOnce() and setOutput() leave alone a side
// whose two halves are both unstated.
setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin);
setOutput(
v4.tokenOut,
v4.amountOutMin,
paidTo(v4.outputRecipients),
);
}
}
if (cmdId === 0x0c) {
hasUnwrapWeth = true;
// It pays out the output when that is WETH, and when no
// step has set the output side: see the ETH output below.
const u = decodeUnwrapWeth(inputs[i]);
if (u && !outputEstablished) {
outputPaidTo = paidTo([u.recipient]);
} else if (u && isWeth(outputToken)) {
payOutFromRouter(u.recipient);
}
}
} catch {
// Skip commands we can't decode
@@ -804,6 +922,7 @@ function decode(data, toAddress, chainId, sources) {
inputAmount = null;
outputToken = null;
minOutput = null;
outputPaidTo = [];
outputEstablished = true;
}
@@ -816,12 +935,9 @@ function decode(data, toAddress, chainId, sources) {
// even when it set it unknown. Any other output keeps its own token
// and figure: a swap that buys USDC and then unwraps the WETH it did
// not spend receives USDC.
const outputIsWeth =
present(outputToken) &&
WETH_ADDRESSES.includes(outputToken.toLowerCase());
const inInfo = tokenInfo(inputToken, sources);
const outInfo =
hasUnwrapWeth && (outputIsWeth || !outputEstablished)
hasUnwrapWeth && (isWeth(outputToken) || !outputEstablished)
? { symbol: "ETH", decimals: 18, address: null }
: tokenInfo(outputToken, sources);
@@ -936,6 +1052,22 @@ function decode(data, toAddress, chainId, sources) {
});
}
// When any address the output is paid to is not the user's, each one
// is named, the user's own included, and the output is not said to be
// received. An output the router still holds is paid to the router.
const paidElsewhere = outputPaidTo.some(
(a) => !sameAddress(a, fromAddress),
);
if (paidElsewhere) {
const named = outputPaidTo.filter(
(a, i) =>
outputPaidTo.findIndex((b) => sameAddress(a, b)) === i,
);
for (const address of named) {
details.push({ label: "Paid to", value: address, address });
}
}
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 +1076,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: paidElsewhere ? "Min. paid out" : "Min. received",
value:
minOutput === 0n
? NO_MINIMUM