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

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 is contained in:
2026-10-19 09:13:41 +00:00
committed by sneak
parent 5e4b8ff3b0
commit 8a95935bc7
11 changed files with 1453 additions and 112 deletions
+39 -4
View File
@@ -1144,8 +1144,9 @@ fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line **Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below), do not always read as a number. They can decoded swap (see TxApproval below; `Min. received` reads `Min. paid out` unless
read: only the user is paid, see Transaction Decoding), do not always read as a
number. They can read:
- A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known: - A formatted quantity, e.g. `17.1900 USDT`, when the token's scale is known:
truncated to four decimals, with the floor and the zero cases described above. truncated to four decimals, with the floor and the zero cases described above.
@@ -2288,7 +2289,9 @@ view would leave a wallet one click from deletion.
- Site origin (bold, scheme and port included) + "wants to send a - Site origin (bold, scheme and port included) + "wants to send a
transaction" transaction"
- Decoded action (if calldata is recognized): action name, token details, - Decoded action (if calldata is recognized): action name, token details,
amounts, steps, deadline (see Transaction Decoding) amounts, steps, deadline, and unless a swap pays only the From address, a
`Paid to` line for each other address it pays, or saying that its output
stays in the router or who it pays is unknown (see Transaction Decoding)
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Contract: color dot + full address + etherscan link, token symbol label if - Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx known; for a contract creation, the same sentence as on WaitTx
@@ -2923,7 +2926,39 @@ Currently supported:
listed as `Execute Sub-Plan (its steps are not shown)`, and whatever the other 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` 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 read `Unknown (could not be decoded)`, and there is no `Amount` or
`Min. received` line. `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`.
### Non-Goals Forever ### Non-Goals Forever
+6 -4
View File
@@ -79,10 +79,10 @@ function tokenAmountText(rawAmount, decimals, symbol) {
}; };
} }
// Try to decode calldata sent to `toAddress` on the network with chain id // Try to decode calldata sent from `fromAddress` to `toAddress` on the network
// `chainId`, using known ABIs. // with chain id `chainId`, using known ABIs.
// Returns { name, description, details } or null. // 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; if (!data || data === "0x" || data.length < 10) return null;
// Where a token's scale is looked for, for every decoder below: the ERC-20 // 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, toAddress,
chainId, chainId,
decimalsSources, decimalsSources,
fromAddress,
); );
if (routerResult) return routerResult; 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. // 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 // Decoded for the network the transaction is for, which the Network line
// names. // names, and the address the From line names.
const decoded = decodeCalldata( const decoded = decodeCalldata(
approvedTx.data, approvedTx.data,
toAddr || "", toAddr || "",
approvedTx.chainId, approvedTx.chainId,
details.approvedFrom,
); );
if (decoded && decoded.details) { if (decoded && decoded.details) {
// The asset the status summary is counted in: an ERC-20 call's Token // The asset the status summary is counted in: an ERC-20 call's Token
+3 -1
View File
@@ -316,11 +316,13 @@ async function loadFullTxDetails(txHash, chainId) {
// Decoded against the address the transaction was sent to, as the // Decoded against the address the transaction was sent to, as the
// explorer reports it, not the history entry's `to`: for a token // explorer reports it, not the history entry's `to`: for a token
// transfer that is the transfer's recipient, not the token contract. // 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( const decoded = decodeCalldata(
inputData, inputData,
txData.to?.hash || "", txData.to?.hash || "",
currentNetwork().chainId, currentNetwork().chainId,
txData.from?.hash || "",
); );
if (decoded) { if (decoded) {
// Render decoded calldata matching approval view style // Render decoded calldata matching approval view style
+290 -32
View File
@@ -1,7 +1,15 @@
// Decode Uniswap Universal Router execute() calldata into human-readable // Decode Uniswap Universal Router execute() calldata into human-readable
// swap details. Designed to be extended with other DEX decoders later. // 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 { truncateAmountNeverZero } = require("./amountDisplay");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
@@ -65,6 +73,55 @@ function routerCommandTypeMask(address, chainId) {
return router ? router[1] : null; 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 // Universal Router command types, each read from its command byte with the
// router's mask (see ROUTER_ADDRESSES) // router's mask (see ROUTER_ADDRESSES)
const COMMAND_NAMES = { const COMMAND_NAMES = {
@@ -101,16 +158,23 @@ function formatAmount(raw, decimals) {
// One wording for either side of the screen: a currency the calldata never // One wording for either side of the screen: a currency the calldata never
// named. It reads as a refusal, the same stance unknownDecimalsAmount() takes // 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)"; const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
// The same refusal when a V4 swap action could not be read (see // The same refusal when a V4 swap action could not be read (see
// decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose // decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose
// commands this decoder does not read. That action names both of its // 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 // 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)"; 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 // 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 // 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 // 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. // that nothing is swapped.
const ALREADY_PAID = Symbol("v2-already-paid"); 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 // 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 // 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 // number — and deliberately not another phrasing of "not named": these say
@@ -179,6 +248,21 @@ const WETH_ADDRESSES = [
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14", "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 // `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 // 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 // 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, input,
); );
return { return {
recipient: d[0],
amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
amountOutMin: d[2], amountOutMin: d[2],
path: d[3],
tokenIn: d[3][0], tokenIn: d[3][0],
tokenOut: d[3][d[3].length - 1], tokenOut: d[3][d[3].length - 1],
}; };
@@ -285,6 +371,7 @@ function decodeV2SwapExactOut(input) {
input, input,
); );
return { return {
recipient: d[0],
amountOut: d[1], amountOut: d[1],
amountInMax: d[2], amountInMax: d[2],
tokenIn: d[3][0], tokenIn: d[3][0],
@@ -316,6 +403,7 @@ function decodeV3SwapExactIn(input) {
const path = decodeV3Path(d[3]); const path = decodeV3Path(d[3]);
if (!path) return null; if (!path) return null;
return { return {
recipient: d[0],
amountIn: d[1], amountIn: d[1],
amountOutMin: d[2], amountOutMin: d[2],
tokenIn: path.tokenIn, 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) // ABI: (address recipient, uint256 amount)
function decodeWrapEth(input) { function decodeWrapEth(input) {
try { try {
const d = coder.decode(["address", "uint256"], input); 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 { } catch {
return null; return null;
} }
@@ -346,6 +459,7 @@ const V4_SETTLE = 0x0b;
const V4_SETTLE_ALL = 0x0c; const V4_SETTLE_ALL = 0x0c;
const V4_TAKE = 0x0e; const V4_TAKE = 0x0e;
const V4_TAKE_ALL = 0x0f; const V4_TAKE_ALL = 0x0f;
const V4_TAKE_PORTION = 0x10;
// A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // 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 // 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 // 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 // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL
// name. // 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) { function decodeV4Swap(input) {
try { try {
const d = coder.decode(["bytes", "bytes[]"], input); const d = coder.decode(["bytes", "bytes[]"], input);
@@ -508,6 +626,8 @@ function decodeV4Swap(input) {
let settleToken = null; let settleToken = null;
let takeToken = null; let takeToken = null;
const takes = [];
let recipientUnread = false;
// The step's swap actions that could be read, as decodeV4SwapAction() // The step's swap actions that could be read, as decodeV4SwapAction()
// reads them. // reads them.
const swaps = []; const swaps = [];
@@ -537,10 +657,12 @@ function decodeV4Swap(input) {
params[i], params[i],
); );
takeToken = t[0]; takeToken = t[0];
takes.push({ currency: t[0], recipient: t[1] });
} else if (actionId === V4_TAKE_ALL) { } else if (actionId === V4_TAKE_ALL) {
// TAKE_ALL: (address currency, uint256 minAmount) // TAKE_ALL: (address currency, uint256 minAmount)
const t = coder.decode(["address", "uint256"], params[i]); const t = coder.decode(["address", "uint256"], params[i]);
takeToken = t[0]; takeToken = t[0];
takes.push({ currency: t[0], recipient: MSG_SENDER });
} else if ( } else if (
actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN_SINGLE ||
actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_IN ||
@@ -552,9 +674,19 @@ function decodeV4Swap(input) {
} catch { } catch {
swapUndecoded = true; 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 { } 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, tokenOut: takeToken,
amountIn: null, amountIn: null,
amountOutMin: null, amountOutMin: null,
takes,
recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: true, hasSwapAction: true,
}; };
@@ -604,6 +738,8 @@ function decodeV4Swap(input) {
tokenOut, tokenOut,
amountIn, amountIn,
amountOutMin, amountOutMin,
takes,
recipientUnread,
swapUndecoded, swapUndecoded,
hasSwapAction: swaps.length > 0, hasSwapAction: swaps.length > 0,
}; };
@@ -612,14 +748,14 @@ function decodeV4Swap(input) {
} }
} }
// Try to decode a Universal Router execute() call sent to `toAddress` on the // Try to decode a Universal Router execute() call sent from `fromAddress`, the
// network with chain id `chainId`. // user, to `toAddress` on the network with chain id `chainId`.
// Returns { name, description, details } matching the format used by // Returns { name, description, details } matching the format used by
// the approval UI, or null if the calldata is not a recognised execute() or // 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 // `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 // sent to any other contract is not a swap through the router, whatever that
// contract does with it. // contract does with it.
function decode(data, toAddress, chainId, sources) { function decode(data, toAddress, chainId, sources, fromAddress) {
const commandTypeMask = routerCommandTypeMask(toAddress, chainId); const commandTypeMask = routerCommandTypeMask(toAddress, chainId);
if (commandTypeMask === null) return null; if (commandTypeMask === null) return null;
try { try {
@@ -678,6 +814,60 @@ function decode(data, toAddress, chainId, sources) {
outputEstablished = true; 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++) { for (let i = 0; i < commandsBytes.length; i++) {
const cmdId = commandsBytes[i] & commandTypeMask; const cmdId = commandsBytes[i] & commandTypeMask;
commandNames.push( commandNames.push(
@@ -690,30 +880,27 @@ function decode(data, toAddress, chainId, sources) {
// EXECUTE_SUB_PLAN runs a further list of commands held in // EXECUTE_SUB_PLAN runs a further list of commands held in
// its input, which this decoder does not read. The step // its input, which this decoder does not read. The step
// says so, and after this loop neither side is named. // says so, and after this loop neither side is named.
// Those commands may pay any address.
hasSubPlan = true; hasSubPlan = true;
commandNames[i] += " (its steps are not shown)"; commandNames[i] += " (its steps are not shown)";
} payeeUnread = true;
} else if (cmdId === 0x0a) {
if (cmdId === 0x0a) {
const p = decodePermit2(inputs[i]); const p = decodePermit2(inputs[i]);
// A permit states both halves itself, so it may replace an // A permit states both halves itself, so it may replace an
// input side an earlier hop established without breaking // input side an earlier hop established without breaking
// the invariant. // the invariant.
if (p) setInput(p.token, p.amount); if (p) setInput(p.token, p.amount);
} } else if (cmdId === 0x0e) {
if (cmdId === 0x0e) {
const b = decodeBalanceCheck(inputs[i]); const b = decodeBalanceCheck(inputs[i]);
// The router passes this check whenever the owner holds at // The router passes this check whenever the owner holds at
// least minBalance, so a zero one guarantees nothing and // least minBalance, so a zero one guarantees nothing and
// does not replace a minimum an earlier step stated. Any // does not replace a minimum an earlier step stated. Any
// other minBalance sets the output side as a swap does. // other minBalance sets the output side as a swap does.
// It pays nobody.
if (b && !(b.minBalance === 0n && present(minOutput))) { if (b && !(b.minBalance === 0n && present(minOutput))) {
setOutput(b.token, b.minBalance); setOutput(b.token, b.minBalance);
} }
} } else if (cmdId === 0x00) {
if (cmdId === 0x00) {
const s = decodeV3SwapExactIn(inputs[i]); const s = decodeV3SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
@@ -721,39 +908,69 @@ function decode(data, toAddress, chainId, sources) {
// the last swap step determines the final output token // the last swap step determines the final output token
// and minimum received amount. // and minimum received amount.
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
} }
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x08) {
if (cmdId === 0x08) {
const s = decodeV2SwapExactIn(inputs[i]); const s = decodeV2SwapExactIn(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountIn); setInputOnce(s.tokenIn, s.amountIn);
setOutput(s.tokenOut, s.amountOutMin); setOutput(s.tokenOut, s.amountOutMin);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
} }
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x09) {
if (cmdId === 0x09) {
// Buys exactly amountOut and spends at most amountInMax. // Buys exactly amountOut and spends at most amountInMax.
hasV2ExactOut = true; hasV2ExactOut = true;
const s = decodeV2SwapExactOut(inputs[i]); const s = decodeV2SwapExactOut(inputs[i]);
if (s) { if (s) {
setInputOnce(s.tokenIn, s.amountInMax); setInputOnce(s.tokenIn, s.amountInMax);
setOutput(s.tokenOut, s.amountOut); setOutput(s.tokenOut, s.amountOut);
if (!isHop(i, s.tokenOut, s.recipient)) {
addPayee(s.recipient, s.tokenOut);
} }
} else {
payeeUnread = true;
} }
} else if (cmdId === 0x04) {
if (cmdId === 0x0b) { 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]); const w = decodeWrapEth(inputs[i]);
if (w) { if (w) {
setInputOnce( setInputOnce(
"0x0000000000000000000000000000000000000000", "0x0000000000000000000000000000000000000000",
w.amount, w.amount,
); );
addPayee(w.recipient);
} else {
payeeUnread = true;
} }
} } else if (cmdId === 0x10) {
if (cmdId === 0x10) {
const v4 = decodeV4Swap(inputs[i]); 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) { if (v4 && v4.swapUndecoded) {
// A swap action of this step went unread, so the step // A swap action of this step went unread, so the step
// is marked. // is marked.
@@ -784,13 +1001,29 @@ function decode(data, toAddress, chainId, sources) {
setInputOnce(v4.tokenIn, v4.amountIn); setInputOnce(v4.tokenIn, v4.amountIn);
setOutput(v4.tokenOut, v4.amountOutMin); setOutput(v4.tokenOut, v4.amountOutMin);
} }
} } else if (cmdId === 0x0c) {
if (cmdId === 0x0c) {
hasUnwrapWeth = true; 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 { } 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)) { if (present(minOutput)) {
// A zero floor is the one case the user most needs stated: the // 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 // 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 // return nothing" — and because it is true at every scale, so it
// holds even when the output token's decimals are unknown. // holds even when the output token's decimals are unknown.
details.push({ details.push({
label: "Min. received", label: received ? "Min. received" : "Min. paid out",
value: value:
minOutput === 0n minOutput === 0n
? NO_MINIMUM ? NO_MINIMUM
+3
View File
@@ -68,10 +68,13 @@ function swapData(amountIn, amountOutMin) {
} }
function swapDetail(amountIn, amountOutMin, label) { function swapDetail(amountIn, amountOutMin, label) {
// Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode( const decoded = uniswap.decode(
swapData(amountIn, amountOutMin), swapData(amountIn, amountOutMin),
ROUTER, ROUTER,
"0x1", "0x1",
{},
RECIPIENT,
); );
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
+139 -62
View File
@@ -14,27 +14,25 @@ const DAI_ADDR = "0x6B175474E89094C44Da98b954EedeAC495271d0F";
const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14"; const SEPOLIA_WETH_ADDR = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a"; const USER_ADDR = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// AutistMask's first-ever swap, 2026-02-27. // AutistMask's first-ever swap, 2026-02-27, sent from USER_ADDR.
// Swapped USDT for ETH via Uniswap V4 Universal Router. // Swapped USDT for ETH via Uniswap V4 Universal Router.
// https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77 // https://etherscan.io/tx/0x6749f50c4e8f975b6d14780d5f539cf151d1594796ac49b7d6a5348ba0735e77
// This is that transaction's input, as a public Ethereum node returns it.
const FIRST_SWAP_CALLDATA = const FIRST_SWAP_CALLDATA =
"0x3593564c" + "0x3593564c" +
"000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a0" + "000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000069a1550f0000000000000000000000000000000000000000000000000000000000000002" +
"0000000000000000000000000000000000000000000000000000000069a1550f00000000000000000000000000000000000000000000000000000000000000020a10000000000000000000000000000000000000000000000000000000000000" + "0a100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" +
"0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001c0" + "0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff0000000000000000000000000000000000000000000000000000000069c8daf6" +
"0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000ffffffffffffffffffffffffffffffffffffffff" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e0" +
"0000000000000000000000000000000000000000000000000000000069c8daf6000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066a9893cc07d91d95644aedd05d03f95e1dba8af" + "0000000000000000000000000000000000000000000000000000000000000041230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"0000000000000000000000000000000000000000000000000000000069a154fe00000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000041" +
"230249bb7133205db7b2389b587c723cc182302907b9545dc40c59c33ad1d53078a65732f4182fedbc0d9d85c51d580bdc93db3556fac38f18e140da47d0eb631c00000000000000000000000000000000000000000000000000000000000000" +
"00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" + "00000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000003" +
"070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" + "070b0e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000220" +
"00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" + "00000000000000000000000000000000000000000000000000000000000002a000000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7" +
"0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e7" + "0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000007a1200000000000000000000000000000000000000000000000000000dcb050d338e70000000000000000000000000000000000000000000000000000000000000001" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000064" + "0000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000640000000000000000000000000000000000000000000000000000000000000001" +
"0000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060" +
"0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000060" +
"dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000066133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
"66133e8ea0f5d1d612d2502a968757d1048c214a0000000000000000000000000000000000000000000000000000000000000000756e6978000000000012";
const coder = AbiCoder.defaultAbiCoder(); const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([ const routerIface = new Interface([
@@ -332,7 +330,13 @@ describe("uniswap decoder", () => {
}); });
test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => { test("decodes first-ever AutistMask swap (PERMIT2_PERMIT + V4_SWAP)", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
FIRST_SWAP_CALLDATA,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 ETH"); expect(result.name).toBe("Swap USDT \u2192 ETH");
@@ -356,9 +360,11 @@ describe("uniswap decoder", () => {
expect(result.details.find((d) => d.label === "Amount").value).toBe( expect(result.details.find((d) => d.label === "Amount").value).toBe(
"Unlimited", "Unlimited",
); );
// Its TAKE pays the From address, so the minimum is received.
expect( expect(
result.details.find((d) => d.label === "Min. received").value, result.details.find((d) => d.label === "Min. received").value,
).toBe("0.0002 ETH"); ).toBe("0.0002 ETH");
expect(labels).not.toContain("Paid to");
// Real mainnet calldata is in the deployed shape, so its V4 step is // Real mainnet calldata is in the deployed shape, so its V4 step is
// not said to be undecoded. // not said to be undecoded.
@@ -373,6 +379,8 @@ describe("uniswap decoder", () => {
v4SwapCalldata(id, deployed), v4SwapCalldata(id, deployed),
ROUTER_ADDR, ROUTER_ADDR,
"0x1", "0x1",
{},
USER_ADDR,
); );
expect(detail(result, "Steps").value).toBe("V4 Swap"); expect(detail(result, "Steps").value).toBe("V4 Swap");
}, },
@@ -389,6 +397,8 @@ describe("uniswap decoder", () => {
v4SwapCalldata(id, newer), v4SwapCalldata(id, newer),
ROUTER_ADDR, ROUTER_ADDR,
"0x1", "0x1",
{},
USER_ADDR,
); );
expect(detail(result, "Amount")).toBeUndefined(); expect(detail(result, "Amount")).toBeUndefined();
expect(detail(result, "Min. received")).toBeUndefined(); expect(detail(result, "Min. received")).toBeUndefined();
@@ -419,7 +429,13 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
@@ -440,7 +456,13 @@ describe("uniswap decoder", () => {
[encodeV4Swap(new Uint8Array([id]), [newer])], [encodeV4Swap(new Uint8Array([id]), [newer])],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
@@ -470,7 +492,13 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Amount").value).toBe("2.0000 USDT"); expect(detail(result, "Amount").value).toBe("2.0000 USDT");
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
@@ -498,7 +526,13 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
@@ -520,7 +554,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (could not be decoded)", "Unknown (could not be decoded)",
); );
@@ -540,7 +574,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
@@ -567,7 +601,7 @@ describe("uniswap decoder", () => {
1767225600n, 1767225600n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDC → WETH"); expect(result.name).toBe("Swap USDC → WETH");
expect(detail(result, "Token In").address).toBe(USDC_ADDR); expect(detail(result, "Token In").address).toBe(USDC_ADDR);
expect(detail(result, "Token Out").address).toBe(WETH_ADDR); expect(detail(result, "Token Out").address).toBe(WETH_ADDR);
@@ -599,7 +633,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap ETH → USDC"); expect(result.name).toBe("Swap ETH → USDC");
const amount = detail(result, "Amount"); const amount = detail(result, "Amount");
@@ -628,7 +662,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDC → ETH"); expect(result.name).toBe("Swap USDC → ETH");
expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC"); expect(detail(result, "Amount").value).toBe("Up to 1500.0000 USDC");
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
@@ -671,7 +705,13 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Token Out").address).toBe(tokenOut); expect(detail(result, "Token Out").address).toBe(tokenOut);
expect(detail(result, "Min. received").value).toBe(minReceived); expect(detail(result, "Min. received").value).toBe(minReceived);
}, },
@@ -692,7 +732,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Min. received").value).toBe("1.9000 USDC"); expect(detail(result, "Min. received").value).toBe("1.9000 USDC");
@@ -725,7 +765,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.4000 ETH"); expect(detail(result, "Min. received").value).toBe("0.4000 ETH");
}); });
@@ -744,7 +784,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe("ETH"); expect(detail(result, "Token Out").value).toBe("ETH");
expect(detail(result, "Min. received").value).toBe("0.0005 ETH"); expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
}); });
@@ -769,7 +809,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
@@ -792,7 +832,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
}); });
@@ -804,7 +844,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const tokenIn = result.details.find((d) => d.label === "Token In"); const tokenIn = result.details.find((d) => d.label === "Token In");
@@ -827,12 +867,13 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// UNWRAP_WETH means output is native ETH // UNWRAP_WETH means output is native ETH
expect(result.name).toBe("Swap USDT \u2192 ETH"); expect(result.name).toBe("Swap USDT \u2192 ETH");
}); });
// No step pays anyone, so the minimum is not said to be received.
test("decodes BALANCE_CHECK_ERC20 for min output", () => { test("decodes BALANCE_CHECK_ERC20 for min output", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -843,10 +884,10 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const minOut = result.details.find((d) => d.label === "Min. received"); const minOut = result.details.find((d) => d.label === "Min. paid out");
expect(minOut).toBeDefined(); expect(minOut).toBeDefined();
expect(minOut.value).toContain("2.0000"); expect(minOut.value).toContain("2.0000");
expect(minOut.value).toContain("USDT"); expect(minOut.value).toContain("USDT");
@@ -863,7 +904,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// The V4 swap action's parameters are empty, so that step is said to // The V4 swap action's parameters are empty, so that step is said to
@@ -943,7 +984,13 @@ describe("uniswap decoder", () => {
[subPlan], [subPlan],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"Execute Sub-Plan (its steps are not shown)", "Execute Sub-Plan (its steps are not shown)",
@@ -1031,7 +1078,13 @@ describe("uniswap decoder", () => {
"$beside an EXECUTE_SUB_PLAN step does not name either side", "$beside an EXECUTE_SUB_PLAN step does not name either side",
({ commands, inputs, steps }) => { ({ commands, inputs, steps }) => {
const data = buildExecute(commands, inputs, 9999999999n); const data = buildExecute(commands, inputs, 9999999999n);
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe(steps); expect(detail(result, "Steps").value).toBe(steps);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
@@ -1046,7 +1099,13 @@ describe("uniswap decoder", () => {
); );
test("shows the deadline as a UTC date and time", () => { test("shows the deadline as a UTC date and time", () => {
const result = uniswap.decode(FIRST_SWAP_CALLDATA, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
FIRST_SWAP_CALLDATA,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51"); expect(detail(result, "Deadline").value).toBe("2026-02-27 08:25:51");
}); });
@@ -1064,7 +1123,7 @@ describe("uniswap decoder", () => {
2n ** 256n - 1n, 2n ** 256n - 1n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Swap USDT \u2192 WETH"); expect(result.name).toBe("Swap USDT \u2192 WETH");
expect(detail(result, "Deadline").value).toBe( expect(detail(result, "Deadline").value).toBe(
@@ -1079,7 +1138,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
const amount = result.details.find((d) => d.label === "Amount"); const amount = result.details.find((d) => d.label === "Amount");
@@ -1154,7 +1213,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap" // Before decodeV4Swap fix: name would be "Swap USDT → ETH" or "Uniswap Swap"
// After fix: correctly identifies both tokens from V4 sub-actions // After fix: correctly identifies both tokens from V4 sub-actions
@@ -1188,7 +1247,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
// The amount and the token it is counted in come from the same hop. // The amount and the token it is counted in come from the same hop.
@@ -1228,7 +1287,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
@@ -1273,7 +1332,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1302,7 +1361,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1339,7 +1398,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("WETH"); expect(detail(result, "Token In").value).toContain("WETH");
@@ -1356,9 +1415,12 @@ describe("uniswap decoder", () => {
// are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL, // are written. Each is tried with three endings: SETTLE_ALL and TAKE_ALL,
// SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the // SETTLE and TAKE, and CLOSE_CURRENCY for each currency, which leaves the
// sides to the swap actions alone. // sides to the swap actions alone.
// `minLabel` is the minimum's line: with no TAKE or TAKE_ALL, no step
// pays the user, so it is not said to be received.
const V4_STEP_ENDINGS = [ const V4_STEP_ENDINGS = [
{ {
ending: "SETTLE_ALL and TAKE_ALL", ending: "SETTLE_ALL and TAKE_ALL",
minLabel: "Min. received",
ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID], ids: [V4_SETTLE_ALL_ID, V4_TAKE_ALL_ID],
params: [ params: [
encodeV4SettleAll(USDT_ADDR, 1000000000n), encodeV4SettleAll(USDT_ADDR, 1000000000n),
@@ -1367,11 +1429,13 @@ describe("uniswap decoder", () => {
}, },
{ {
ending: "SETTLE and TAKE", ending: "SETTLE and TAKE",
minLabel: "Min. received",
ids: [V4_SETTLE_ID, V4_TAKE_ID], ids: [V4_SETTLE_ID, V4_TAKE_ID],
params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)], params: [encodeV4Settle(USDT_ADDR), encodeV4Take(USDC_ADDR)],
}, },
{ {
ending: "CLOSE_CURRENCY", ending: "CLOSE_CURRENCY",
minLabel: "Min. paid out",
ids: [ ids: [
V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID,
V4_CLOSE_CURRENCY_ID, V4_CLOSE_CURRENCY_ID,
@@ -1520,7 +1584,7 @@ describe("uniswap decoder", () => {
), ),
)( )(
"a V4 step of $step, then $ending, reads Swap USDT → USDC", "a V4 step of $step, then $ending, reads Swap USDT → USDC",
({ swaps, ids, params, amount, min }) => { ({ swaps, ids, params, amount, min, minLabel }) => {
const data = buildExecute( const data = buildExecute(
"0x10", "0x10",
[ [
@@ -1532,12 +1596,18 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
expect(detail(result, "Amount")?.value).toBe(amount); expect(detail(result, "Amount")?.value).toBe(amount);
expect(detail(result, "Min. received")?.value).toBe(min); expect(detail(result, minLabel)?.value).toBe(min);
}, },
); );
@@ -1584,7 +1654,13 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(
data,
ROUTER_ADDR,
"0x1",
{},
USER_ADDR,
);
expect(detail(result, "Steps").value).toBe( expect(detail(result, "Steps").value).toBe(
"V4 Swap (could not be decoded)", "V4 Swap (could not be decoded)",
); );
@@ -1643,7 +1719,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").address).toBe( expect(detail(result, "Token In").address).toBe(
USDT_ADDR.toLowerCase(), USDT_ADDR.toLowerCase(),
@@ -1699,7 +1775,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
expect(detail(result, "Token In").value).toBe( expect(detail(result, "Token In").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
@@ -1722,7 +1798,7 @@ describe("uniswap decoder", () => {
], ],
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(detail(result, "Token Out").value).toBe( expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)", "Unknown (not named in the calldata)",
); );
@@ -1756,7 +1832,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result.name).toBe("Swap USDT → USDC"); expect(result.name).toBe("Swap USDT → USDC");
expect(detail(result, "Token In").address).toBe(USDT_ADDR); expect(detail(result, "Token In").address).toBe(USDT_ADDR);
expect(detail(result, "Token Out").address).toBe(USDC_ADDR); expect(detail(result, "Token Out").address).toBe(USDC_ADDR);
@@ -1780,7 +1856,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token In").value).toContain("USDT"); expect(detail(result, "Token In").value).toContain("USDT");
@@ -1811,7 +1887,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("WETH"); expect(detail(result, "Token Out").value).toContain("WETH");
@@ -1832,7 +1908,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDC"); expect(detail(result, "Token Out").value).toContain("USDC");
@@ -1840,7 +1916,8 @@ describe("uniswap decoder", () => {
}); });
// With no minimum stated before it, a zero balance check is what sets the // With no minimum stated before it, a zero balance check is what sets the
// output side, and it guarantees nothing. // output side, and it guarantees nothing. No step pays anyone, so the
// minimum is not said to be received.
test("a zero balance check with no earlier minimum states no minimum", () => { test("a zero balance check with no earlier minimum states no minimum", () => {
const data = buildExecute( const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]), solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
@@ -1851,11 +1928,11 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(detail(result, "Token Out").value).toContain("USDT"); expect(detail(result, "Token Out").value).toContain("USDT");
expect(detail(result, "Min. received").value).toBe( expect(detail(result, "Min. paid out").value).toBe(
"None (no minimum guaranteed)", "None (no minimum guaranteed)",
); );
}); });
@@ -1871,7 +1948,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
@@ -1891,7 +1968,7 @@ describe("uniswap decoder", () => {
9999999999n, 9999999999n,
); );
const result = uniswap.decode(data, ROUTER_ADDR, "0x1"); const result = uniswap.decode(data, ROUTER_ADDR, "0x1", {}, USER_ADDR);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result.name).toBe("Uniswap Swap"); expect(result.name).toBe("Uniswap Swap");
+953
View File
@@ -0,0 +1,953 @@
// The address a swap's output is paid to
// (https://git.eeqj.de/sneak/AutistMask/issues/594).
//
// Every swap command, SWEEP, WRAP_ETH, UNWRAP_WETH and V4 TAKE names the
// address it pays, and TAKE_ALL pays the sender. The decoder read none of
// them: a swap whose output went to another address still read "Min.
// received", as if the user received it. The router reads address(1) as the
// sender and address(2) as the router itself, which holds what it is paid
// until a later step pays it out.
//
// The screens are driven against a minimal DOM stub in the shape
// tests/uniswapRouterAddress.test.js uses.
const { AbiCoder, Interface, solidityPacked } = require("ethers");
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: async (url) => ({
ok: true,
json: async () => mockExplorerAnswer(url),
}),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const uniswap = require("../src/shared/uniswap");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const transactionDetail = require("../src/popup/views/transactionDetail");
// UniversalRouterV2 on Ethereum Mainnet.
const ROUTER = "0x66a9893cc07d91d95644aedd05d03f95e1dba8af";
// UniversalRouterV2_1_2 on Ethereum Mainnet, whose mask reads 0x07 and 0x40.
const ROUTER_V2_1_2 = "0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85";
const USER = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER = "0x7777777777777777777777777777777777777777";
const MSG_SENDER = "0x0000000000000000000000000000000000000001";
const ADDRESS_THIS = "0x0000000000000000000000000000000000000002";
const ZERO = "0x0000000000000000000000000000000000000000";
const USDT = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
const USDC = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
const WETH = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
const ONE_WETH = 1000000000000000000n;
const USDT_2000 = 2000000000n;
const USDC_1990 = 1990000000n;
// The Paid to line's value when no address can be named.
const UNDECODED = "Unknown (could not be decoded)";
const UNNAMED = "Unknown (not named in the calldata)";
const STAYS_IN_ROUTER =
"The router, where it stays (no later step pays it out)";
const coder = AbiCoder.defaultAbiCoder();
const routerIface = new Interface([
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
]);
// execute() of `steps`, each [command, input].
function execute(...steps) {
const commands = steps.map(([command]) => command);
return routerIface.encodeFunctionData("execute", [
solidityPacked(
commands.map(() => "uint8"),
commands,
),
steps.map(([, input]) => input),
9999999999n,
]);
}
// V2_SWAP_EXACT_IN (0x08) and V2_SWAP_EXACT_OUT (0x09) share one layout.
function v2Swap(command, recipient, amount, limit, path, payerIsUser = true) {
return [
command,
coder.encode(
["address", "uint256", "uint256", "address[]", "bool"],
[recipient, amount, limit, path, payerIsUser],
),
];
}
function v3SwapExactIn(recipient, amountIn, amountOutMin, tokenIn, tokenOut) {
return [
0x00,
coder.encode(
["address", "uint256", "uint256", "bytes", "bool"],
[
recipient,
amountIn,
amountOutMin,
solidityPacked(
["address", "uint24", "address"],
[tokenIn, 3000, tokenOut],
),
true,
],
),
];
}
function sweep(token, recipient) {
return [
0x04,
coder.encode(["address", "address", "uint256"], [token, recipient, 0n]),
];
}
function wrapEth(recipient, amount) {
return [0x0b, coder.encode(["address", "uint256"], [recipient, amount])];
}
function unwrapWeth(recipient) {
return [0x0c, coder.encode(["address", "uint256"], [recipient, 0n])];
}
function balanceCheck(token, minBalance) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[USER, token, minBalance],
),
];
}
// `step`, a [command or action, input] pair, with the top byte of the input's
// word `index` set. The decoder cannot read that word as an address.
function withDirtyWord([id, input], index) {
const at = 2 + index * 64;
return [id, input.slice(0, at) + "ff" + input.slice(at + 2)];
}
// The V4 actions a step below is built from.
const SWAP_USDT_FOR_USDC = [
0x06, // SWAP_EXACT_IN_SINGLE
coder.encode(
[
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
],
[[[USDT, USDC, 100, 1, ZERO], true, USDT_2000, USDC_1990, "0x"]],
),
];
// SWAP_EXACT_IN_SINGLE with no parameters, which cannot be read.
const UNREADABLE_SWAP = [0x06, "0x"];
const SETTLE_USDT = [
0x0b,
coder.encode(["address", "uint256", "bool"], [USDT, 0n, true]),
];
function take(currency, recipient) {
return [
0x0e,
coder.encode(
["address", "address", "uint256"],
[currency, recipient, 0n],
),
];
}
function takeAll(currency) {
return [0x0f, coder.encode(["address", "uint256"], [currency, 0n])];
}
// A V4_SWAP step of `actions`, each [action, params].
function v4Swap(...actions) {
return [
0x10,
coder.encode(
["bytes", "bytes[]"],
[
solidityPacked(
actions.map(() => "uint8"),
actions.map(([action]) => action),
),
actions.map(([, params]) => params),
],
),
];
}
function decode(data, sender = USER, router = ROUTER) {
return uniswap.decode(data, router, "0x1", {}, sender);
}
function labels(result) {
return result.details.map((d) => d.label);
}
function detail(result, label) {
return result.details.find((d) => d.label === label);
}
function paidTo(result) {
return result.details
.filter((d) => d.label === "Paid to")
.map((d) => d.value);
}
// A swap paid to the user, the lines it has always had.
const PAID_TO_USER = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Min. received",
"Steps",
"Deadline",
];
const PAID_ELSEWHERE = [
"Protocol",
"Token In",
"Amount",
"Token Out",
"Paid to",
"Min. paid out",
"Steps",
"Deadline",
];
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapToWeth = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, ONE_WETH, USDT, WETH);
beforeEach(() => {
state.trackedTokens = [];
state.wallets = [];
});
describe("each swap command's recipient", () => {
test.each([
{
kind: "V2_SWAP_EXACT_IN",
data: (r) =>
execute(v2Swap(0x08, r, USDT_2000, ONE_WETH, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V2_SWAP_EXACT_OUT",
data: (r) =>
execute(v2Swap(0x09, r, ONE_WETH, USDT_2000, [USDT, WETH])),
min: "1.0000 WETH",
},
{
kind: "V3_SWAP_EXACT_IN",
data: (r) => execute(swapToWeth(r)),
min: "1.0000 WETH",
},
{
kind: "V4_SWAP with TAKE",
data: (r) =>
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, r))),
min: "1990.0000 USDC",
},
])(
"$kind: paid to the user it reads as before; paid to another address it names that address",
({ data, min }) => {
const viaPlaceholder = decode(data(MSG_SENDER));
expect(labels(viaPlaceholder)).toEqual(PAID_TO_USER);
expect(detail(viaPlaceholder, "Min. received").value).toBe(min);
// The sender's own address, in any case, reads the same.
expect(decode(data(USER), USER.toLowerCase()).details).toEqual(
viaPlaceholder.details,
);
const toOther = decode(data(OTHER));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe(min);
},
);
});
describe("an output the router holds", () => {
const toRouter = v3SwapExactIn(
ADDRESS_THIS,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
test("SWEEP pays it to the SWEEP's recipient", () => {
const direct = decode(
execute(
v3SwapExactIn(MSG_SENDER, USDT_2000, USDC_1990, USDT, USDC),
),
);
const toUser = decode(execute(toRouter, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const withoutSteps = (r) =>
r.details.filter((d) => d.label !== "Steps");
expect(withoutSteps(toUser)).toEqual(withoutSteps(direct));
const toOther = decode(execute(toRouter, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("with nothing paying it out, it stays in the router", () => {
const result = decode(execute(toRouter));
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
});
test("the router's own address as a recipient is read as address(2) is", () => {
const toRouterAddress = v3SwapExactIn(
ROUTER,
USDT_2000,
USDC_1990,
USDT,
USDC,
);
expect(paidTo(decode(execute(toRouterAddress)))).toEqual([
STAYS_IN_ROUTER,
]);
expect(
labels(decode(execute(toRouterAddress, sweep(USDC, MSG_SENDER)))),
).toEqual(PAID_TO_USER);
});
test("UNWRAP_WETH pays its WETH, as ETH, to the UNWRAP_WETH's recipient", () => {
const toWeth = swapToWeth(ADDRESS_THIS);
const toUser = decode(execute(toWeth, unwrapWeth(MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(detail(toUser, "Min. received").value).toBe("1.0000 ETH");
const toOther = decode(execute(toWeth, unwrapWeth(OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 ETH");
});
test("WRAP_ETH pays its WETH to the WRAP_ETH's recipient", () => {
const toUser = decode(
execute(wrapEth(ADDRESS_THIS, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(detail(toUser, "Token Out").value).toBe("ETH");
expect(paidTo(toUser)).toEqual([]);
const toOther = decode(
execute(wrapEth(OTHER, ONE_WETH), unwrapWeth(MSG_SENDER)),
);
expect(paidTo(toOther)).toEqual([OTHER]);
});
test("a V4 TAKE to the router, then SWEEP, pays the SWEEP's recipient", () => {
const step = v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
);
const toUser = decode(execute(step, sweep(USDC, MSG_SENDER)));
expect(labels(toUser)).toEqual(PAID_TO_USER);
const toOther = decode(execute(step, sweep(USDC, OTHER)));
expect(labels(toOther)).toEqual(PAID_ELSEWHERE);
expect(paidTo(toOther)).toEqual([OTHER]);
});
});
// Only a later SWEEP of the output token, or UNWRAP_WETH of WETH, pays the
// router's output out. A payment to the user of another token, of nothing, or
// before the swap leaves the output in the router.
describe("an output left in the router is not received", () => {
const toRouter = swapToWeth(ADDRESS_THIS);
test.each([
{
kind: "a SWEEP of WETH to the user, then the swap",
data: execute(sweep(WETH, MSG_SENDER), toRouter),
min: "1.0000 WETH",
},
{
kind: "an UNWRAP_WETH to the user, then the swap",
data: execute(unwrapWeth(MSG_SENDER), toRouter),
min: "1.0000 ETH",
},
{
kind: "the swap, then a WRAP_ETH of zero to the user",
data: execute(toRouter, wrapEth(MSG_SENDER, 0n)),
min: "1.0000 WETH",
},
{
kind: "the swap, then a SWEEP of a token it does not buy to the user",
data: execute(toRouter, sweep(USDC, MSG_SENDER)),
min: "1.0000 WETH",
},
])("$kind", ({ data, min }) => {
const result = decode(data);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe(min);
});
test("one V4 step: a TAKE of what it buys to the router, then a TAKE_ALL of it, which takes nothing", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, ADDRESS_THIS),
takeAll(USDC),
),
),
);
expect(detail(result, "Steps").value).toBe("V4 Swap");
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([STAYS_IN_ROUTER]);
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
});
// Uniswap's mixed routes pay a V2 pair directly when the next step swaps
// through it. That V2 swap spends what the pair holds: its amountIn is
// CONTRACT_BALANCE, paid from the router, or ALREADY_PAID. The pairs below
// are the addresses the routers find for the first two tokens of that V2
// swap's path.
describe("a swap paid to the pair the next V2 swap spends", () => {
// The WETH/USDC pair on Ethereum Mainnet, as its V2 factory reports it.
const PAIR = "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc";
const CONTRACT_BALANCE = 2n ** 255n;
const AMOUNTS_IN = [
{ kind: "CONTRACT_BALANCE", amountIn: CONTRACT_BALANCE },
{ kind: "ALREADY_PAID", amountIn: 0n },
];
const swapTo = (recipient) =>
v3SwapExactIn(recipient, USDT_2000, 0n, USDT, WETH);
const swapToPair = swapTo(PAIR);
const pairSpends = (amountIn, path) =>
v2Swap(0x08, MSG_SENDER, amountIn, USDC_1990, path, false);
test.each(AMOUNTS_IN)(
"a V3 swap, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = decode(
execute(swapToPair, pairSpends(amountIn, [WETH, USDC])),
);
expect(result.name).toBe("Swap USDT → USDC");
expect(labels(result)).toEqual(PAID_TO_USER);
expect(detail(result, "Amount").value).toBe("2000.0000 USDT");
expect(detail(result, "Min. received").value).toBe(
"1990.0000 USDC",
);
},
);
test.each(AMOUNTS_IN)(
"a V3 swap paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = decode(
execute(swapTo(OTHER), pairSpends(amountIn, [WETH, USDC])),
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. paid out").value).toBe(
"1990.0000 USDC",
);
},
);
test.each([
{ recipient: PAIR, lines: PAID_TO_USER, paid: [] },
{ recipient: OTHER, lines: PAID_ELSEWHERE, paid: [OTHER] },
])(
"a V4 step whose TAKE pays $recipient, then the V2 swap",
({ recipient, lines, paid }) => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, recipient),
),
v2Swap(
0x08,
MSG_SENDER,
CONTRACT_BALANCE,
ONE_WETH,
[USDC, WETH],
false,
),
),
);
expect(labels(result)).toEqual(lines);
expect(paidTo(result)).toEqual(paid);
},
);
describe("on Sepolia", () => {
const SEPOLIA = "0xaa36a7";
// UniversalRouterV2_1_2 on Sepolia.
const SEPOLIA_ROUTER = "0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3";
// The WETH/USDC pair the routers on Sepolia find. Sepolia's V2
// factory reports another address for it, and no contract sits at
// this one (see V2_FACTORIES in src/shared/uniswap.js).
const SEPOLIA_PAIR = "0x541b50d32B73E769ac42F0FddD86D15FcAa861Fb";
const SEPOLIA_UNI = "0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984";
const SEPOLIA_WETH = "0xfFf9976782d46CC05630D1f6eBAb18b2324d6B14";
const SEPOLIA_USDC = "0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238";
// UNI for WETH, paid to `recipient`, then WETH for USDC through the
// pair.
const route = (recipient, amountIn) =>
execute(
v3SwapExactIn(
recipient,
ONE_WETH,
0n,
SEPOLIA_UNI,
SEPOLIA_WETH,
),
v2Swap(
0x08,
MSG_SENDER,
amountIn,
USDC_1990,
[SEPOLIA_WETH, SEPOLIA_USDC],
false,
),
);
test.each(AMOUNTS_IN)(
"paid to the pair, then a V2 swap whose amountIn is $kind, reads as a swap paid to the user",
({ amountIn }) => {
const result = uniswap.decode(
route(SEPOLIA_PAIR, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_TO_USER);
},
);
test.each(AMOUNTS_IN)(
"paid to another address, then a V2 swap whose amountIn is $kind, names that address",
({ amountIn }) => {
const result = uniswap.decode(
route(OTHER, amountIn),
SEPOLIA_ROUTER,
SEPOLIA,
{},
USER,
);
expect(labels(result)).toEqual(PAID_ELSEWHERE);
expect(paidTo(result)).toEqual([OTHER]);
},
);
test("the same route on Ethereum Mainnet names the Sepolia pair", () => {
const result = decode(route(SEPOLIA_PAIR, 0n));
expect(paidTo(result)).toEqual([SEPOLIA_PAIR]);
});
});
test.each([
{
kind: "takes its input from the user",
next: v2Swap(0x08, MSG_SENDER, ONE_WETH, USDC_1990, [WETH, USDC]),
},
{
kind: "spends another token",
next: pairSpends(0n, [USDC, WETH]),
},
])("when the V2 swap $kind, the pair is named", ({ next }) => {
const result = decode(execute(swapToPair, next));
expect(paidTo(result)).toEqual([PAIR]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("every step's payee counts, whichever step sets the output", () => {
test("TAKE_ALL pays the user; a TAKE to another address beside it is named", () => {
const toUser = decode(
execute(v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, takeAll(USDC))),
);
expect(labels(toUser)).toEqual(PAID_TO_USER);
// TAKE with an amount of zero takes the whole open credit, so
// TAKE_ALL after it pays the user nothing.
const split = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDC),
),
),
);
expect(paidTo(split)).toEqual([OTHER]);
expect(detail(split, "Min. received")).toBeUndefined();
expect(detail(split, "Min. paid out").value).toBe("1990.0000 USDC");
});
test("a swap paid to another address, then a V4 step that only takes the same token", () => {
const emptyStep = v4Swap(takeAll(WETH));
const result = decode(execute(swapToWeth(OTHER), emptyStep));
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
const checked = decode(
execute(swapToWeth(OTHER), emptyStep, balanceCheck(WETH, ONE_WETH)),
);
expect(paidTo(checked)).toEqual([OTHER]);
expect(detail(checked, "Min. received")).toBeUndefined();
expect(detail(checked, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a swap paid to the router, a V4 step that only takes, then SWEEP to another address", () => {
const result = decode(
execute(
v3SwapExactIn(ADDRESS_THIS, USDT_2000, USDC_1990, USDT, USDC),
v4Swap(takeAll(USDC)),
sweep(USDC, OTHER),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
test("a swap paid to another address, then a small swap paid to the user", () => {
const result = decode(
execute(
swapToWeth(OTHER),
v2Swap(0x08, MSG_SENDER, 1000000n, 1n, [USDT, WETH]),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
});
test("one V4 step: a TAKE of what it buys to another address, then TAKE_ALL of another currency", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
take(USDC, OTHER),
takeAll(USDT),
),
),
);
expect(paidTo(result)).toEqual([OTHER]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("a payout that cannot be read is never said to be received", () => {
test("a V4 TAKE whose recipient word has its top byte set", () => {
const result = decode(
execute(
v4Swap(
SWAP_USDT_FOR_USDC,
SETTLE_USDT,
withDirtyWord(take(USDC, OTHER), 1),
),
),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1990.0000 USDC");
});
test.each([
{
kind: "V2_SWAP_EXACT_IN",
step: v2Swap(0x08, OTHER, 1n, 1n, [USDT, WETH]),
},
{
kind: "V2_SWAP_EXACT_OUT",
step: v2Swap(0x09, OTHER, 1n, 1n, [USDT, WETH]),
},
{ kind: "V3_SWAP_EXACT_IN", step: swapToWeth(OTHER) },
{ kind: "SWEEP", step: sweep(WETH, OTHER), word: 1 },
{ kind: "WRAP_ETH", step: wrapEth(OTHER, 1n) },
{ kind: "UNWRAP_WETH", step: unwrapWeth(OTHER) },
])(
"a $kind whose recipient word has its top byte set",
({ step, word = 0 }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, word)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out")).toBeDefined();
},
);
// The router reads only the low 32 bits of the action list's length, so
// the step still runs, and may pay anyone.
test("a V4_SWAP whose action list's length word has its top byte set", () => {
const step = v4Swap(SWAP_USDT_FOR_USDC, SETTLE_USDT, take(USDC, OTHER));
const result = decode(
execute(swapToWeth(MSG_SENDER), withDirtyWord(step, 2)),
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
test("an output set only by BALANCE_CHECK_ERC20", () => {
const result = decode(execute(balanceCheck(WETH, ONE_WETH)));
expect(paidTo(result)).toEqual([UNNAMED]);
expect(detail(result, "Min. received")).toBeUndefined();
expect(detail(result, "Min. paid out").value).toBe("1.0000 WETH");
});
// https://git.eeqj.de/sneak/AutistMask/issues/599 is to read these. Until
// then each is a payout this decoder does not read.
const transferShape = (command) => [
command,
coder.encode(["address", "address", "uint256"], [WETH, OTHER, 1n]),
];
test.each([
{ kind: "PERMIT2_TRANSFER_FROM", step: transferShape(0x02) },
{ kind: "TRANSFER", step: transferShape(0x05) },
{ kind: "PAY_PORTION", step: transferShape(0x06) },
{ kind: "PAY_PORTION_FULL_PRECISION", step: transferShape(0x07) },
{
kind: "PERMIT2_TRANSFER_FROM_BATCH",
step: [
0x0d,
coder.encode(
["tuple(address,address,uint160,address)[]"],
[[[USER, OTHER, 1n, WETH]]],
),
],
},
{
kind: "V3_SWAP_EXACT_OUT",
step: [0x01, swapToWeth(OTHER)[1]],
},
{
kind: "EXECUTE_SUB_PLAN",
step: [
0x21,
coder.encode(
["bytes", "bytes[]"],
["0x05", [transferShape(0x05)[1]]],
),
],
},
{
kind: "ACROSS_V4_DEPOSIT_V3",
step: [0x40, coder.encode(["address"], [OTHER])],
},
{
kind: "V4 TAKE_PORTION",
step: v4Swap([
0x10,
coder.encode(
["address", "address", "uint256"],
[WETH, OTHER, 10000n],
),
]),
},
])("a swap paid to the user, then $kind", ({ step }) => {
const result = decode(
execute(swapToWeth(MSG_SENDER), step),
USER,
ROUTER_V2_1_2,
);
expect(paidTo(result)).toEqual([UNDECODED]);
expect(detail(result, "Min. received")).toBeUndefined();
});
});
describe("paths whose recipients were untested", () => {
test("BALANCE_CHECK_ERC20 after a swap leaves who is paid as the swap says", () => {
const check = balanceCheck(WETH, ONE_WETH);
const toUser = decode(execute(swapToWeth(MSG_SENDER), check));
expect(paidTo(toUser)).toEqual([]);
expect(detail(toUser, "Min. received").value).toBe("1.0000 WETH");
const toOther = decode(execute(swapToWeth(OTHER), check));
expect(paidTo(toOther)).toEqual([OTHER]);
expect(detail(toOther, "Min. paid out").value).toBe("1.0000 WETH");
});
test("a V4 step whose swap action cannot be read still has its TAKE read", () => {
const step = (r) => v4Swap(UNREADABLE_SWAP, SETTLE_USDT, take(USDC, r));
const toUser = decode(execute(step(MSG_SENDER)));
expect(detail(toUser, "Steps").value).toBe(
"V4 Swap (could not be decoded)",
);
expect(paidTo(toUser)).toEqual([]);
expect(paidTo(decode(execute(step(OTHER))))).toEqual([OTHER]);
});
});
// The DOM stub both screens are drawn into.
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: () => false,
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// A swap of 2,000 USDT for at least 1 WETH, paid to `recipient`.
const swapPaidTo = (recipient) => execute(swapToWeth(recipient));
// Open the approval screen for a transaction from USER sending `data` to the
// router.
async function showApproval(data) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (_msg, reply) =>
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: USER,
approvedTx: {
type: 2,
from: USER,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x30d40",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: ROUTER,
value: "0x0",
data,
accessList: [],
},
}),
};
state.networkId = "mainnet";
state.currentView = null;
await approval.show(1);
return node("approve-tx-decoded-details").innerHTML;
}
// The transaction the explorer reports: from USER, sending `mockSentData` to
// the router.
const TX_HASH = "0x" + "5".repeat(64);
let mockSentData = null;
function mockExplorerAnswer(url) {
if (url.endsWith("/transactions/" + TX_HASH)) {
return {
from: { hash: USER },
to: { hash: ROUTER },
raw_input: mockSentData,
method: "execute",
};
}
return {};
}
// Open the transaction detail screen for that transaction, sending `data`.
async function showDetail(data) {
mockSentData = data;
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.networkId = "mainnet";
state.blockscoutUrl = "https://explorer.example/api/v2";
state.viewData = {};
state.viewStack = [];
state.currentView = null;
transactionDetail.show({
hash: TX_HASH,
from: USER,
to: ROUTER,
value: "",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
direction: "contract",
directionLabel: "Swap",
method: "execute",
chainId: "0x1",
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
return node("tx-detail-calldata-details").innerHTML;
}
describe("the screens judge the output against the transaction's sender", () => {
test("the approval screen, against its From address", async () => {
const toUser = await showApproval(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showApproval(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).toContain(OTHER);
expect(toOther).not.toContain("Min. received");
});
test("the transaction detail screen, against the sender the explorer reports", async () => {
const toUser = await showDetail(swapPaidTo(USER));
expect(toUser).toContain("Min. received");
expect(toUser).not.toContain("Paid to");
const toOther = await showDetail(swapPaidTo(OTHER));
expect(toOther).toContain("Paid to");
expect(toOther).not.toContain("Min. received");
});
});
+8 -1
View File
@@ -53,7 +53,14 @@ function unwrapData() {
} }
function detail(data, label, sources) { function detail(data, label, sources) {
const decoded = uniswap.decode(data, ROUTER, "0x1", sources || {}); // Sent by the address the swap pays, so the output is the user's.
const decoded = uniswap.decode(
data,
ROUTER,
"0x1",
sources || {},
RECIPIENT,
);
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
+3 -2
View File
@@ -87,7 +87,7 @@ function detail(data, label) {
describe("an execute() whose input currency never decoded", () => { describe("an execute() whose input currency never decoded", () => {
// A V4_SWAP whose sub-action params do not decode — an action encoding // A V4_SWAP whose sub-action params do not decode — an action encoding
// this decoder does not know — leaves every V4 token null. The // this decoder does not know — leaves every V4 token null. The
// BALANCE_CHECK still names the output, so the screen has a Min. received // BALANCE_CHECK still names the output, so the screen has a minimum
// figure and a Token Out, and previously claimed the user was paying ETH // figure and a Token Out, and previously claimed the user was paying ETH
// for them. // for them.
const data = () => const data = () =>
@@ -132,7 +132,8 @@ describe("an execute() that names only an output token", () => {
test("still states the output it did establish", () => { test("still states the output it did establish", () => {
expect(detail(data(), "Token Out").value).toContain("USDT"); expect(detail(data(), "Token Out").value).toContain("USDT");
expect(detail(data(), "Min. received").value).toBe("2.0000 USDT"); // No step pays anyone, so the minimum is not said to be received.
expect(detail(data(), "Min. paid out").value).toBe("2.0000 USDT");
}); });
}); });
+4 -2
View File
@@ -105,7 +105,7 @@ function v3ExactIn(tokenIn, tokenOut, amountIn, amountOutMin) {
} }
function detail(data, label) { function detail(data, label) {
const decoded = uniswap.decode(data, ROUTER, "0x1", {}); const decoded = uniswap.decode(data, ROUTER, "0x1", {}, USER);
expect(decoded).not.toBeNull(); expect(decoded).not.toBeNull();
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }
@@ -134,7 +134,9 @@ describe("a V4 step that states a Min. received but no output currency", () => {
}); });
test("refuses to scale Min. received rather than assuming 18", () => { test("refuses to scale Min. received rather than assuming 18", () => {
expect(detail(data(), "Min. received").value).toBe( // With no TAKE, no step pays the user either, so the line is not
// labelled received.
expect(detail(data(), "Min. paid out").value).toBe(
unknownDecimalsAmount(MIN_OUT), unknownDecimalsAmount(MIN_OUT),
); );
}); });
+2 -1
View File
@@ -82,7 +82,8 @@ function walletsHolding(token, decimals) {
// decodeCalldata() rather than uniswap.decode() directly, because the scale // decodeCalldata() rather than uniswap.decode() directly, because the scale
// sources the screen supplies are part of what is under test. // sources the screen supplies are part of what is under test.
function swapDetail(data, label) { function swapDetail(data, label) {
const decoded = decodeCalldata(data, ROUTER, "0x1"); // Sent by the address the swap pays, so the output is the user's.
const decoded = decodeCalldata(data, ROUTER, "0x1", RECIPIENT);
return decoded.details.find((d) => d.label === label); return decoded.details.find((d) => d.label === label);
} }