fix: the approval screen names the address a swap's output is paid to (closes #594)
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:
@@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) {
|
||||
};
|
||||
}
|
||||
|
||||
// Try to decode calldata sent to `toAddress` on the network with chain id
|
||||
// `chainId`, using known ABIs.
|
||||
// Try to decode calldata sent from `fromAddress` to `toAddress` on the network
|
||||
// with chain id `chainId`, using known ABIs.
|
||||
// Returns { name, description, details } or null.
|
||||
function decodeCalldata(data, toAddress, chainId) {
|
||||
function decodeCalldata(data, toAddress, chainId, fromAddress) {
|
||||
if (!data || data === "0x" || data.length < 10) return null;
|
||||
|
||||
// Where a token's scale is looked for, for every decoder below: the ERC-20
|
||||
@@ -191,6 +191,7 @@ function decodeCalldata(data, toAddress, chainId) {
|
||||
toAddress,
|
||||
chainId,
|
||||
decimalsSources,
|
||||
fromAddress,
|
||||
);
|
||||
if (routerResult) return routerResult;
|
||||
|
||||
@@ -277,11 +278,12 @@ function showTxApproval(details) {
|
||||
|
||||
// If this is an ERC-20 call, try to extract the real recipient and amount.
|
||||
// Decoded for the network the transaction is for, which the Network line
|
||||
// names.
|
||||
// names, and the address the From line names.
|
||||
const decoded = decodeCalldata(
|
||||
approvedTx.data,
|
||||
toAddr || "",
|
||||
approvedTx.chainId,
|
||||
details.approvedFrom,
|
||||
);
|
||||
if (decoded && decoded.details) {
|
||||
// The asset the status summary is counted in: an ERC-20 call's Token
|
||||
|
||||
@@ -316,11 +316,13 @@ async function loadFullTxDetails(txHash, chainId) {
|
||||
// Decoded against the address the transaction was sent to, as the
|
||||
// explorer reports it, not the history entry's `to`: for a token
|
||||
// transfer that is the transfer's recipient, not the token contract.
|
||||
// On the active network, the one whose explorer was just asked.
|
||||
// On the active network, the one whose explorer was just asked, and
|
||||
// from the sender it reports.
|
||||
const decoded = decodeCalldata(
|
||||
inputData,
|
||||
txData.to?.hash || "",
|
||||
currentNetwork().chainId,
|
||||
txData.from?.hash || "",
|
||||
);
|
||||
if (decoded) {
|
||||
// Render decoded calldata matching approval view style
|
||||
|
||||
+146
-14
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user