// Decode Uniswap Universal Router execute() calldata into human-readable // swap details. Designed to be extended with other DEX decoders later. const { Interface, AbiCoder, getBytes, getAddress, formatUnits, getCreate2Address, keccak256, solidityPacked, } = require("ethers"); const { truncateAmountNeverZero } = require("./amountDisplay"); const { resolveTokenDecimals, resolveTokenSymbol, unknownDecimalsAmount, } = require("./approvalAmount"); const { NETWORKS } = require("./networks"); const coder = AbiCoder.defaultAbiCoder(); const ROUTER_IFACE = new Interface([ "function execute(bytes commands, bytes[] inputs, uint256 deadline)", ]); // The Universal Router releases deployed at the addresses below, each with // three things read from its source at the commit linked for it: // // - `commandTypeMask`, the mask it reads a command's type with: its dispatcher // runs `commandType & Commands.COMMAND_TYPE_MASK`, and the bits above the // mask are the allow-revert flag (0x80) or ignored. The mask grew between // releases. Each is `COMMAND_TYPE_MASK` in the release's // `contracts/libraries/Commands.sol`, the same file as in the source // verified for its addresses. // - `commands`, the command it runs for each command type, by the name that // file gives it. Releases give some types different commands. A type the // file names no command for, or only a placeholder, is left out: the router // reverts on it, except UniversalRouterV1_2_V2Support on 0x14, which does // nothing. decode() names and reads each step as its release's command. // - `allowRevert`, the command types it lets fail without reverting the // transaction. Its `execute()`, in `contracts/UniversalRouter.sol`, runs each // step through `dispatch()`, in `contracts/base/Dispatcher.sol`, and reverts // the whole transaction when a step fails, unless the step's command byte // has the allow-revert flag set: then it goes on to the next step. Only for // these command types does `dispatch()` return a failure. Every other // command, among them each V2, V3 and V4 swap and each payment of ETH or an // ERC-20 token, succeeds or reverts the whole transaction itself, so the // flag has no effect on it. // // UniversalRouterV1: https://github.com/Uniswap/universal-router/tree/cda697da221e24ca143bc4f55023694a2a16796f/contracts const ROUTER_V1 = { commandTypeMask: 0x1f, commands: { 0x00: "V3_SWAP_EXACT_IN", 0x01: "V3_SWAP_EXACT_OUT", 0x02: "PERMIT2_TRANSFER_FROM", 0x03: "PERMIT2_PERMIT_BATCH", 0x04: "SWEEP", 0x05: "TRANSFER", 0x06: "PAY_PORTION", 0x08: "V2_SWAP_EXACT_IN", 0x09: "V2_SWAP_EXACT_OUT", 0x0a: "PERMIT2_PERMIT", 0x0b: "WRAP_ETH", 0x0c: "UNWRAP_WETH", 0x0d: "PERMIT2_TRANSFER_FROM_BATCH", 0x10: "SEAPORT", 0x11: "LOOKS_RARE_721", 0x12: "NFTX", 0x13: "CRYPTOPUNKS", 0x14: "LOOKS_RARE_1155", 0x15: "OWNER_CHECK_721", 0x16: "OWNER_CHECK_1155", 0x17: "SWEEP_ERC721", 0x18: "X2Y2_721", 0x19: "SUDOSWAP", 0x1a: "NFT20", 0x1b: "X2Y2_1155", 0x1c: "FOUNDATION", 0x1d: "SWEEP_ERC1155", }, // Its NFT market calls (0x10 to 0x14, 0x18 to 0x1c) and NFT owner checks // (0x15, 0x16). allowRevert: [ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c, ], }; // UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/tree/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts const ROUTER_V1_2 = { commandTypeMask: 0x3f, commands: { 0x00: "V3_SWAP_EXACT_IN", 0x01: "V3_SWAP_EXACT_OUT", 0x02: "PERMIT2_TRANSFER_FROM", 0x03: "PERMIT2_PERMIT_BATCH", 0x04: "SWEEP", 0x05: "TRANSFER", 0x06: "PAY_PORTION", 0x08: "V2_SWAP_EXACT_IN", 0x09: "V2_SWAP_EXACT_OUT", 0x0a: "PERMIT2_PERMIT", 0x0b: "WRAP_ETH", 0x0c: "UNWRAP_WETH", 0x0d: "PERMIT2_TRANSFER_FROM_BATCH", 0x0e: "BALANCE_CHECK_ERC20", 0x10: "SEAPORT_V1_5", 0x11: "LOOKS_RARE_V2", 0x12: "NFTX", 0x13: "CRYPTOPUNKS", 0x15: "OWNER_CHECK_721", 0x16: "OWNER_CHECK_1155", 0x17: "SWEEP_ERC721", 0x18: "X2Y2_721", 0x19: "SUDOSWAP", 0x1a: "NFT20", 0x1b: "X2Y2_1155", 0x1c: "FOUNDATION", 0x1d: "SWEEP_ERC1155", 0x1e: "ELEMENT_MARKET", 0x20: "SEAPORT_V1_4", 0x21: "EXECUTE_SUB_PLAN", 0x22: "APPROVE_ERC20", }, // BALANCE_CHECK_ERC20 (0x0e), its NFT market calls (0x10 to 0x13, 0x18 to // 0x1c, 0x1e, 0x20), its NFT owner checks (0x15, 0x16) and // EXECUTE_SUB_PLAN (0x21). allowRevert: [ 0x0e, 0x10, 0x11, 0x12, 0x13, 0x15, 0x16, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1e, 0x20, 0x21, ], }; // UniversalRouterV2: https://github.com/Uniswap/universal-router/tree/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts const ROUTER_V2 = { commandTypeMask: 0x3f, commands: { 0x00: "V3_SWAP_EXACT_IN", 0x01: "V3_SWAP_EXACT_OUT", 0x02: "PERMIT2_TRANSFER_FROM", 0x03: "PERMIT2_PERMIT_BATCH", 0x04: "SWEEP", 0x05: "TRANSFER", 0x06: "PAY_PORTION", 0x08: "V2_SWAP_EXACT_IN", 0x09: "V2_SWAP_EXACT_OUT", 0x0a: "PERMIT2_PERMIT", 0x0b: "WRAP_ETH", 0x0c: "UNWRAP_WETH", 0x0d: "PERMIT2_TRANSFER_FROM_BATCH", 0x0e: "BALANCE_CHECK_ERC20", 0x10: "V4_SWAP", 0x11: "V3_POSITION_MANAGER_PERMIT", 0x12: "V3_POSITION_MANAGER_CALL", 0x13: "V4_INITIALIZE_POOL", 0x14: "V4_POSITION_MANAGER_CALL", 0x21: "EXECUTE_SUB_PLAN", }, // PERMIT2_PERMIT_BATCH (0x03), PERMIT2_PERMIT (0x0a), BALANCE_CHECK_ERC20 // (0x0e), V3_POSITION_MANAGER_PERMIT, V3_POSITION_MANAGER_CALL, // V4_INITIALIZE_POOL and V4_POSITION_MANAGER_CALL (0x11 to 0x14), and // EXECUTE_SUB_PLAN (0x21). allowRevert: [0x03, 0x0a, 0x0e, 0x11, 0x12, 0x13, 0x14, 0x21], }; // UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/tree/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts // UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/tree/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts // The three files are the same in both releases. const ROUTER_V2_1 = { commandTypeMask: 0x7f, // UniversalRouterV2's, and two more. commands: { ...ROUTER_V2.commands, 0x07: "PAY_PORTION_FULL_PRECISION", 0x40: "ACROSS_V4_DEPOSIT_V3", }, // The same as UniversalRouterV2's. allowRevert: ROUTER_V2.allowRevert, }; // The Universal Router deployments on each network, by chain id, copied from // the deployment files Uniswap publishes, each address named by its key there // and given with the release it runs. The files also list // `UnsupportedProtocol`, which is not a router. const ROUTER_ADDRESSES = new Map([ [ NETWORKS.mainnet.chainId, // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/mainnet.json [ ["0xEf1c6E67703c7BD7107eed8303Fbe6EC2554BF6B", ROUTER_V1], // UniversalRouterV1 ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support ["0x66a9893cc07d91d95644aedd05d03f95e1dba8af", ROUTER_V2], // UniversalRouterV2 ["0x4C82D1fBFe28C977cBB58D8C7FF8FCF9F70a2cCA", ROUTER_V2_1], // UniversalRouterV2_1_1 ["0x23617e59A5925b2A4Bf75d73ff6711cD0b29De85", ROUTER_V2_1], // UniversalRouterV2_1_2 ], ], [ NETWORKS.sepolia.chainId, // https://github.com/Uniswap/universal-router/blob/543e1a19d6e21e31ced2512eec5792b50f13a0ba/deploy-addresses/sepolia.json [ ["0x3fC91A3afd70395Cd496C647d5a6CC9D4B2b7FAD", ROUTER_V1_2], // UniversalRouterV1_2_V2Support ["0x3a9d48ab9751398bbfa63ad67599bb04e4bdf98b", ROUTER_V2], // UniversalRouterV2 ["0x7DfD4F31be6814D2906BDE155c3e1B146EAc1468", ROUTER_V2_1], // UniversalRouterV2_1_1 ["0x7E4f6c5e954Da5c61B3423D81E2277431Ac043f3", ROUTER_V2_1], // UniversalRouterV2_1_2 ], ], ]); // The release that the Universal Router deployment at `address` on the // network with this chain id runs, or null when `address` is not one of them. // Address case is ignored. function routerRelease(address, chainId) { const routers = ROUTER_ADDRESSES.get(chainId) || []; const wanted = (address || "").toLowerCase(); const router = routers.find(([a]) => a.toLowerCase() === wanted); return router ? router[1] : null; } // The allow-revert flag on a command byte (see ROUTER_V1). const ALLOW_REVERT_FLAG = 0x80; // 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, ); } // The Steps line's name for each command in the releases' `commands` (see // ROUTER_V1). const COMMAND_NAMES = { V3_SWAP_EXACT_IN: "V3 Swap (Exact In)", V3_SWAP_EXACT_OUT: "V3 Swap (Exact Out)", PERMIT2_TRANSFER_FROM: "Permit2 Transfer", PERMIT2_PERMIT_BATCH: "Permit2 Permit Batch", SWEEP: "Sweep", TRANSFER: "Transfer", PAY_PORTION: "Pay Portion", PAY_PORTION_FULL_PRECISION: "Pay Portion (Full Precision)", V2_SWAP_EXACT_IN: "V2 Swap (Exact In)", V2_SWAP_EXACT_OUT: "V2 Swap (Exact Out)", PERMIT2_PERMIT: "Permit2 Permit", WRAP_ETH: "Wrap ETH", UNWRAP_WETH: "Unwrap WETH", PERMIT2_TRANSFER_FROM_BATCH: "Permit2 Transfer Batch", BALANCE_CHECK_ERC20: "Balance Check", V4_SWAP: "V4 Swap", V3_POSITION_MANAGER_PERMIT: "V3 Position Mgr Permit", V3_POSITION_MANAGER_CALL: "V3 Position Mgr Call", V4_INITIALIZE_POOL: "V4 Initialize Pool", V4_POSITION_MANAGER_CALL: "V4 Position Mgr Call", EXECUTE_SUB_PLAN: "Execute Sub-Plan", ACROSS_V4_DEPOSIT_V3: "Across Bridge Deposit", // The NFT commands of UniversalRouterV1 and UniversalRouterV1_2_V2Support, // and the latter's APPROVE_ERC20. SEAPORT: "Seaport", SEAPORT_V1_4: "Seaport V1.4", SEAPORT_V1_5: "Seaport V1.5", LOOKS_RARE_721: "LooksRare 721", LOOKS_RARE_1155: "LooksRare 1155", LOOKS_RARE_V2: "LooksRare V2", NFTX: "NFTX", CRYPTOPUNKS: "CryptoPunks", OWNER_CHECK_721: "Owner Check 721", OWNER_CHECK_1155: "Owner Check 1155", SWEEP_ERC721: "Sweep ERC-721", SWEEP_ERC1155: "Sweep ERC-1155", X2Y2_721: "X2Y2 721", X2Y2_1155: "X2Y2 1155", SUDOSWAP: "Sudoswap", NFT20: "NFT20", FOUNDATION: "Foundation", ELEMENT_MARKET: "Element Market", APPROVE_ERC20: "Approve ERC-20", }; // The swap's Amount and Min. received lines land on the same approval screen, // and Amount is carried to the wait/success/error screens as the ERC-20 line // is, so they take the same nonzero floor: a swap of an amount below 0.0001 is // not "0.0000", and a slippage floor of one base unit does not read as "you may // receive nothing". function formatAmount(raw, decimals) { return truncateAmountNeverZero(formatUnits(raw, decimals)); } // One wording for either side of the screen: a currency the calldata never // named. It reads as a refusal, the same stance unknownDecimalsAmount() takes // on a scale — not as a token name, and not as a quantity. The Paid to line // uses it too, when no step names anyone paid. const UNNAMED_CURRENCY = "Unknown (not named in the calldata)"; // The same refusal when a V4 swap action could not be read (see // decodeExactly()), or the transaction has an EXECUTE_SUB_PLAN step, whose // commands this decoder does not read. That action names both of its // currencies, and those commands may name one, so saying the calldata did not // name it would be false. The Paid to line uses it too, when a step may pay an // address this decoder did not read. const UNDECODED_CURRENCY = "Unknown (could not be decoded)"; // The Paid to line for each token paid to the router, the swap's output or // another, that no later step pays out of the router. const STAYS_IN_ROUTER = "The router, where it stays (no later step pays it out)"; // The Paid to line for an ACROSS_V4_DEPOSIT_V3 step (see // decodeAcrossV4DepositV3()). const PAID_TO_BRIDGE = "The bridge, which pays the deposit's recipient on another network"; // Explicit presence, never truthiness. Every gate in this file that guards a // decoded value goes through here: an address is never falsy once set, but an // amount of 0n is, and a gate that cannot tell a genuine zero from an absent // value is the trap this decoder has now been bitten by five times. function present(value) { return value !== null && value !== undefined; } // Uniswap V4 spells "use the whole open delta" as an amount of zero: // v4-periphery `src/libraries/ActionConstants.sol` declares // `uint128 internal constant OPEN_DELTA = 0` ("used to signal that an action // should use the input value of the open delta on the pool manager or of the // balance that the contract holds"), and `src/V4Router.sol` substitutes the // full open credit whenever an exact-in swap action's `amountIn` equals it: // // uint128 amountIn = params.amountIn; // if (amountIn == ActionConstants.OPEN_DELTA) { // amountIn = _getFullCredit(...).toUint128(); // } // // in both `_swapExactInputSingle` and `_swapExactInput`. Sentinel and literal // zero are the same uint128 word, so the encoding CANNOT distinguish them — // and the router does not try: it reads every zero as the sentinel, so in V4 // there is no such thing as an exact-in swap of literally zero. The amount is // therefore not stated by the calldata at all; it is whatever credit is open // at execution time. It is carried as this sentinel rather than as 0n because // printing "0.0000" would state the exact inverse of what will happen — // "nothing is being swapped" for a step that swaps the entire balance. // // `amountOutMinimum` gets no such mapping: V4Router compares it directly // (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so // a zero minimum is a literal zero slippage floor and is stated as one. Nor // does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput` // special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero — // so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2 // exact-in path gives zero a meaning of its own: see ALREADY_PAID. const OPEN_DELTA = Symbol("v4-open-delta"); // The Universal Router's V2 exact-in spells "the pair already holds the input // tokens" as an amount of zero: universal-router // `contracts/libraries/Constants.sol` declares // `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases // when a v2 pair has already received input tokens"), and // `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn` // equals it. The swap then spends whatever an earlier step sent to the pair. // As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say // that nothing is swapped. const ALREADY_PAID = Symbol("v2-already-paid"); // The router's whole-balance amount, universal-router // `ActionConstants.CONTRACT_BALANCE`: a V2 exact-in given it as `amountIn` // pays the pair all the router holds of the input token. const CONTRACT_BALANCE = 2n ** 255n; // The amount lines that state a fact instead of a quantity. Same register as // UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a // number — and deliberately not another phrasing of "not named": these say // different things. const OPEN_DELTA_AMOUNT = "All available (V4 open delta)"; const ALREADY_PAID_AMOUNT = "Whatever an earlier step sent to the pair (V2 already paid)"; const NO_MINIMUM = "None (no minimum guaranteed)"; // Permit2 amounts are uint160; the maximum is Permit2's "unbounded". const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff"); // WETH, the token WRAP_ETH turns ETH into and UNWRAP_WETH turns into ETH, on // each network, by chain id: `weth9` in the deployment parameters // V2_FACTORIES cites, which every router in ROUTER_ADDRESSES was deployed // with (their constructor arguments on the explorer). const WETH = new Map([ [NETWORKS.mainnet.chainId, "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"], [NETWORKS.sepolia.chainId, "0xfff9976782d46cc05630d1f6ebab18b2324d6b14"], ]); // Either one counts on either network as the WETH UNWRAP_WETH turns into ETH. const WETH_ADDRESSES = [...WETH.values()]; // 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, UNWRAP_WETH, // TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and, on every router but // UniversalRouterV1, PERMIT2_TRANSFER_FROM map their recipient through the // dispatcher's map(), and a V4 TAKE and TAKE_PORTION through V4Router's // _mapRecipient(); TAKE_ALL always pays the caller. // - constants: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Constants.sol // - map(), UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/libraries/Recipient.sol // - map(), UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol // - constants, V4: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/libraries/ActionConstants.sol // - _mapRecipient(): https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/base/BaseActionsRouter.sol // - TAKE, TAKE_ALL and TAKE_PORTION: https://github.com/Uniswap/v4-periphery/blob/444c526b77d804590f0d7bc5a481af5a3277c952/src/V4Router.sol const MSG_SENDER = "0x0000000000000000000000000000000000000001"; const ADDRESS_THIS = "0x0000000000000000000000000000000000000002"; // `decimals` is null when nothing knows this token's scale. It is not // defaulted to 18: the swap lines land on the same approval screen as the // ERC-20 line, and a scale guessed there is what showed a 1,000 USDT swap as // 0.000000000001. `sources` is { trackedTokens, wallets }, shaped as they are // on `state`; resolveTokenDecimals() reads the bundled list, then those. // // A null `address` means UNDETERMINED — the calldata named no currency for // that side — and is refused rather than named. It is not native ETH: Uniswap // V4 spells native ETH as `Currency.wrap(address(0))` (v4-core // `type Currency is address`), and a Currency is ABI-encoded as a plain // address word, so every decode site here gets back the truthy string // "0x0000000000000000000000000000000000000000" for it — never null. WRAP_ETH // sets that same explicit zero address, and an UNWRAP_WETH output is caught by // its caller before this is consulted, so nothing that genuinely is ETH // arrives null. Naming a null ETH states the wrong asset and formats its // amount at the wrong scale. function tokenInfo(address, sources) { if (!address) { return { symbol: null, decimals: null, address: null }; } if (address === "0x0000000000000000000000000000000000000000") { return { symbol: "ETH", decimals: 18, address: null }; } return { symbol: resolveTokenSymbol(address, sources), decimals: resolveTokenDecimals(address, sources), address, }; } // A swap amount line. With a scale it is the token quantity; with none it is // the base-unit integer with the unknown scale stated, the same refusal the // ERC-20 amount line makes, so the screen has one way of saying it. `display` // is the line on the screen, `raw` is what the status screens carry. function amountText(raw, info) { if (info.decimals === null) { const unknown = unknownDecimalsAmount(raw); return { raw: unknown, display: unknown }; } const formatted = formatAmount(raw, info.decimals); return { raw: formatted, display: formatted + (info.symbol ? " " + info.symbol : ""), }; } // The line giving a moved token under `label`, in the shape of the Token In // line, so the screens show it as they show that one. ETH has no address. function tokenMovedLine(label, token, sources) { const info = tokenInfo(token, sources); if (!present(info.address)) { return { label, value: info.symbol }; } return { label, value: info.symbol ? info.symbol + " (" + token + ")" : token, address: token, isToken: true, }; } // Decode PERMIT2_PERMIT (command 0x0a) input bytes. // ABI: ((address token, uint160 amount, uint48 expiration, uint48 nonce), // address spender, uint256 sigDeadline), bytes signature function decodePermit2(input) { try { const d = coder.decode( [ "tuple(tuple(address,uint160,uint48,uint48),address,uint256)", "bytes", ], input, ); return { token: d[0][0][0], amount: d[0][0][1], spender: d[0][1] }; } catch { return null; } } // Decode BALANCE_CHECK_ERC20 (command 0x0e) input bytes. The router passes the // check when `owner` holds at least `minBalance` of `token`, and reads `owner` // as written, without map(), on every release that has the command: // - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol // - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol // - UniversalRouterV2_1_1 and UniversalRouterV2_1_2, the same file at both // commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol // ABI: (address owner, address token, uint256 minBalance) function decodeBalanceCheck(input) { try { const d = coder.decode(["address", "address", "uint256"], input); return { owner: d[0], token: d[1], minBalance: d[2] }; } catch { return null; } } // Decode V2_SWAP_EXACT_IN (command 0x08) input bytes. // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // address[] path, bool payerIsUser) // A zero `amountIn` is read the way the router reads it, as ALREADY_PAID. // With `payerIsUser` false the router pays the input from its own balance. function decodeV2SwapExactIn(input) { try { const d = coder.decode( ["address", "uint256", "uint256", "address[]", "bool"], input, ); return { recipient: d[0], amountIn: d[1] === 0n ? ALREADY_PAID : d[1], amountOutMin: d[2], path: d[3], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], payerIsUser: d[4], }; } catch { return null; } } // Decode V2_SWAP_EXACT_OUT (command 0x09) input bytes. // ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // address[] path, bool payerIsUser) function decodeV2SwapExactOut(input) { try { const d = coder.decode( ["address", "uint256", "uint256", "address[]", "bool"], input, ); return { recipient: d[0], amountOut: d[1], amountInMax: d[2], tokenIn: d[3][0], tokenOut: d[3][d[3].length - 1], }; } catch { return null; } } // Decode V3 swap path (packed: token(20) + fee(3) + token(20) ...) function decodeV3Path(pathHex) { const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; if (hex.length < 40) return null; const tokenIn = "0x" + hex.slice(0, 40); const tokenOut = "0x" + hex.slice(-40); return { tokenIn, tokenOut }; } // Decode V3_SWAP_EXACT_IN (command 0x00) input bytes. // ABI: (address recipient, uint256 amountIn, uint256 amountOutMin, // bytes path, bool payerIsUser) // With `payerIsUser` false the router pays the input from its own balance. function decodeV3SwapExactIn(input) { try { const d = coder.decode( ["address", "uint256", "uint256", "bytes", "bool"], input, ); const path = decodeV3Path(d[3]); if (!path) return null; return { recipient: d[0], amountIn: d[1], amountOutMin: d[2], tokenIn: path.tokenIn, tokenOut: path.tokenOut, payerIsUser: d[4], }; } catch { return null; } } // 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; } } // TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION and PERMIT2_TRANSFER_FROM // read their parameters as each release's dispatcher reads them: // - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/base/Dispatcher.sol // - UniversalRouterV1_2_V2Support: https://github.com/Uniswap/universal-router/blob/41183d6eb154f0ab0e74a0e911a5ef9ea51fc4bd/contracts/base/Dispatcher.sol // - UniversalRouterV2: https://github.com/Uniswap/universal-router/blob/3663f6db6e2fe121753cd2d899699c2dc75dca86/contracts/base/Dispatcher.sol // - UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/base/Dispatcher.sol // - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/base/Dispatcher.sol // Decode TRANSFER (command 0x05) input bytes, and those of PAY_PORTION (0x06) // and PAY_PORTION_FULL_PRECISION (0x07), which share its layout. Each pays // `token` out of the router to the recipient. TRANSFER pays `amount`, or, of a // token other than ETH, all the router holds when `amount` is // CONTRACT_BALANCE; it sends ETH as written, so a TRANSFER of // CONTRACT_BALANCE of ETH (`token` the zero address) always reverts. // PAY_PORTION pays `amount` ten-thousandths of what the router holds, and // PAY_PORTION_FULL_PRECISION `amount` 1e18ths of it, of ETH too. // - UniversalRouterV1: https://github.com/Uniswap/universal-router/blob/cda697da221e24ca143bc4f55023694a2a16796f/contracts/modules/Payments.sol // - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/Payments.sol // ABI: (address token, address recipient, uint256 amount) function decodeTransfer(input) { try { const d = coder.decode(["address", "address", "uint256"], input); return { token: d[0], recipient: d[1], amount: d[2] }; } catch { return null; } } // Decode PERMIT2_TRANSFER_FROM (command 0x02) input bytes. Permit2 moves // `amount` of `token` from the transaction's sender to the recipient. // UniversalRouterV1 hands Permit2 the recipient as written, without map(), so // on that router address(1) and address(2) are paid as those addresses. // ABI: (address token, address recipient, uint160 amount) function decodePermit2TransferFrom(input) { try { const d = coder.decode(["address", "address", "uint160"], input); return { token: d[0], recipient: d[1], amount: d[2] }; } catch { return null; } } // Decode ACROSS_V4_DEPOSIT_V3 (command 0x40) input bytes. The router hands // the bridge `inputAmount` of `inputToken`, or all it holds of it when that is // CONTRACT_BALANCE; with `useNative` set it hands over its ETH instead. It // passes `depositor` and `recipient` on as written, without map(). The bridge // pays the recipient on another network what the deposit states, and refunds // a deposit nobody fills to the depositor, who may also change the deposit's // recipient. // - UniversalRouterV2_1_1: https://github.com/Uniswap/universal-router/blob/999d561c3ad58fb5cab91b602911f3c75591a9c7/contracts/modules/ChainedActions.sol // - UniversalRouterV2_1_2: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/modules/ChainedActions.sol // - parameters, the same at both commits: https://github.com/Uniswap/universal-router/blob/802fe4c18f47300e0f183e2a42e9146ec2ea9fc3/contracts/interfaces/IUniversalRouter.sol // - the bridge's depositV3() and its refund to the depositor: https://github.com/across-protocol/contracts/blob/950e9154f3d61e59590eb7d4403fae5d3cae84f8/contracts/spoke-pools/SpokePool.sol // ABI: (tuple(address depositor, address recipient, address inputToken, // address outputToken, uint256 inputAmount, uint256 outputAmount, // uint256 destinationChainId, address exclusiveRelayer, // uint32 quoteTimestamp, uint32 fillDeadline, // uint32 exclusivityDeadline, bytes message, bool useNative)) function decodeAcrossV4DepositV3(input) { try { const d = coder.decode( [ "tuple(address,address,address,address,uint256,uint256,uint256,address,uint32,uint32,uint32,bytes,bool)", ], input, ); return { depositor: d[0][0], recipient: d[0][1], inputToken: d[0][2], inputAmount: d[0][4], useNative: d[0][12], }; } catch { return null; } } // Decode WRAP_ETH (command 0x0b) input bytes. The router pays the WETH to the // recipient. // ABI: (address recipient, uint256 amount) function decodeWrapEth(input) { try { const d = coder.decode(["address", "uint256"], input); return { recipient: d[0], amount: d[1] }; } catch { return null; } } // Decode UNWRAP_WETH (command 0x0c) input bytes. The router turns its whole // WETH balance into ETH and pays it to the recipient. // ABI: (address recipient, uint256 amountMin) function decodeUnwrapWeth(input) { try { const d = coder.decode(["address", "uint256"], input); return { recipient: d[0] }; } catch { return null; } } // V4 inner action IDs const V4_SWAP_EXACT_IN_SINGLE = 0x06; const V4_SWAP_EXACT_IN = 0x07; const V4_SWAP_EXACT_OUT_SINGLE = 0x08; const V4_SWAP_EXACT_OUT = 0x09; const V4_SETTLE = 0x0b; const V4_SETTLE_ALL = 0x0c; const V4_TAKE = 0x0e; const V4_TAKE_ALL = 0x0f; const V4_TAKE_PORTION = 0x10; // A V4 exact-in `amountIn`, read the way V4Router reads it: zero is // ActionConstants.OPEN_DELTA, not a quantity of zero. See the OPEN_DELTA // comment above. The exact-OUT actions below decode no amounts at all, so // their own OPEN_DELTA mapping on `amountOut` never reaches the screen. function v4ExactInAmount(raw) { return raw === 0n ? OPEN_DELTA : raw; } // The V4 swap actions are read only in the parameter structs V4Router has // deployed on mainnet and Sepolia. v4-periphery `main` has since added a // `minHopPriceX36` field to all four (`src/interfaces/IV4Router.sol`), and // reading a shape nobody has deployed would be guessing. ethers alone would // read the newer structs anyway: it takes whatever word sits where a // deployed field would be, ignores bytes left over, and holds back an error // in a field until that field is used. The newer multi-hop exact-in struct // then yields the offset of its hop-price array as `amountIn`. So a decode // counts only when encoding the result again gives back the same bytes; // otherwise this throws, and decodeV4Swap() marks the step as not decoded. function decodeExactly(types, data) { const decoded = coder.decode(types, data); if (coder.encode(types, decoded) !== data) { throw new Error("Not in the deployed V4 parameter shape."); } return decoded; } // Decode one V4 swap action's parameters into the currency it spends // (`tokenIn`), the currency it buys (`tokenOut`) and, for an exact-in action, // its `amountIn` and `amountOutMin`. The exact-out actions decode no amounts. // A field the action does not state is null. Throws when the parameters are // not in the deployed shape (see decodeExactly()). function decodeV4SwapAction(actionId, param) { if (actionId === V4_SWAP_EXACT_IN) { // ExactInputParams: (address currencyIn, // tuple(address,uint24,int24,address,bytes)[] path, // uint128 amountIn, uint128 amountOutMin) const s = decodeExactly( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], param, ); const path = s[0][1]; return { tokenIn: s[0][0], tokenOut: path.length > 0 ? path[path.length - 1][0] : null, amountIn: v4ExactInAmount(s[0][2]), amountOutMin: s[0][3], }; } if (actionId === V4_SWAP_EXACT_IN_SINGLE) { // ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey, // bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData) const s = decodeExactly( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], param, ); const poolKey = s[0][0]; const zeroForOne = s[0][1]; return { tokenIn: zeroForOne ? poolKey[0] : poolKey[1], tokenOut: zeroForOne ? poolKey[1] : poolKey[0], amountIn: v4ExactInAmount(s[0][2]), amountOutMin: s[0][3], }; } if (actionId === V4_SWAP_EXACT_OUT) { // ExactOutputParams: (address currencyOut, // tuple(address,uint24,int24,address,bytes)[] path, // uint128 amountOut, uint128 amountInMaximum) const s = decodeExactly( [ "tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)", ], param, ); // The router works through the path from its last entry back to its // first, starting from currencyOut, so the first entry is what the // action spends. const path = s[0][1]; return { tokenIn: path.length > 0 ? path[0][0] : null, tokenOut: s[0][0], amountIn: null, amountOutMin: null, }; } // V4_SWAP_EXACT_OUT_SINGLE. ExactOutputSingleParams: (poolKey as above, // bool zeroForOne, uint128 amountOut, uint128 amountInMaximum, // bytes hookData) const s = decodeExactly( [ "tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)", ], param, ); const poolKey = s[0][0]; const zeroForOne = s[0][1]; return { tokenIn: zeroForOne ? poolKey[0] : poolKey[1], tokenOut: zeroForOne ? poolKey[1] : poolKey[0], amountIn: null, amountOutMin: null, }; } // The currencies in `list` that `other` does not hold, each listed once. function currenciesNotIn(list, other) { return [...new Set(list.filter((c) => !other.includes(c)))]; } // The amount that the swap actions on one side of a V4 step state together: // the one action's own amount, or the sum when there are several and each // states a quantity. Exact-out actions state none here, and the open delta is // not a quantity. Otherwise null. function totalAmount(amounts) { if (amounts.length === 1) return amounts[0]; if (amounts.length === 0) return null; if (!amounts.every((a) => typeof a === "bigint")) return null; return amounts.reduce((sum, a) => sum + a, 0n); } // Decode V4_SWAP (command 0x10) input bytes. // The input is ABI-encoded as (bytes actions, bytes[] params). Each swap // action gives the currency it spends and the currency it buys (see // decodeV4SwapAction()). The step's sides then follow three rules: // // 1. Token In is the currency SETTLE or SETTLE_ALL names; otherwise the one // currency that some swap action spends and none buys. Token Out is the // currency TAKE or TAKE_ALL names; otherwise the one currency that some // swap action buys and none spends. If there is not exactly one such // currency, that side is unknown. // 2. Amount is the amountIn of the swap action that spends Token In. If // several exact-in actions spend it, each with a stated amount, it is // their sum; in any other case there is none. Min. received is the same // for amountOutMin and the actions that buy Token Out. // 3. If any swap action in the step cannot be read, only SETTLE, SETTLE_ALL, // TAKE and TAKE_ALL name the sides, and there is no Amount or Min. // received. // // A currency a swap action does not name, such as what an exact-in action // with an empty path buys, is null and counts in rules 1 and 2 as a currency // like any other: a step of only that action has an unknown Token Out and // the action's amountOutMin as Min. received. // // `hasSwapAction` is true when the step has a swap action, read or not. // decode() then always sets the output side from this step, and sets the // input side from it only when no earlier step set it. A side it sets that // these rules leave unknown is set unknown, with no figure. A step with no // swap action sets only the sides SETTLE, SETTLE_ALL, TAKE and TAKE_ALL // name. // // `takes` is every TAKE, TAKE_ALL and TAKE_PORTION, as the currency it takes // and its recipient as written, MSG_SENDER for a TAKE_ALL. TAKE_PORTION takes // a share of the currency, so it does not name the output side as TAKE does. // `recipientUnread` is set when the step has a payout this decoder did not // read. `settledFromRouter` is every SETTLE that the router pays from its own // balance (`payerIsUser` false), as its currency and the amount it pays: a // quantity, CONTRACT_BALANCE (all the router holds), or null when this // decoder cannot tell (see below). function decodeV4Swap(input) { try { const d = coder.decode(["bytes", "bytes[]"], input); const actions = getBytes(d[0]); const params = d[1]; let settleToken = null; let takeToken = null; const takes = []; // Every SETTLE paid from the router, as its currency, its amount as // written and its index among the step's actions. const routerSettles = []; // The currency of every SETTLE and SETTLE_ALL. const settledCurrencies = []; // The index of the step's last swap action, read or not, or -1. let lastSwapAction = -1; // Set when an action of the step could not be read. let actionUnread = false; let recipientUnread = false; // The step's swap actions that could be read, as decodeV4SwapAction() // reads them. const swaps = []; // Set when a swap action's parameters are not in the deployed shape // (see decodeExactly()), so nothing was read from that action. SETTLE, // SETTLE_ALL, TAKE and TAKE_ALL may still name both currencies. let swapUndecoded = false; for (let i = 0; i < actions.length; i++) { const actionId = actions[i]; try { if (actionId === V4_SETTLE) { // SETTLE: (address currency, uint256 maxAmount, bool payerIsUser) const s = coder.decode( ["address", "uint256", "bool"], params[i], ); settleToken = s[0]; settledCurrencies.push(s[0]); if (!s[2]) { routerSettles.push({ currency: s[0], amount: s[1], index: i, }); } } else if (actionId === V4_SETTLE_ALL) { // SETTLE_ALL: (address currency, uint256 maxAmount) const s = coder.decode(["address", "uint256"], params[i]); settleToken = s[0]; settledCurrencies.push(s[0]); } else if (actionId === V4_TAKE) { // TAKE: (address currency, address recipient, uint256 amount) const t = coder.decode( ["address", "address", "uint256"], params[i], ); takeToken = t[0]; takes.push({ currency: t[0], recipient: t[1] }); } else if (actionId === V4_TAKE_ALL) { // TAKE_ALL: (address currency, uint256 minAmount) const t = coder.decode(["address", "uint256"], params[i]); takeToken = t[0]; takes.push({ currency: t[0], recipient: MSG_SENDER }); } else if (actionId === V4_TAKE_PORTION) { // TAKE_PORTION: (address currency, address recipient, uint256 bips) const t = coder.decode( ["address", "address", "uint256"], params[i], ); takes.push({ currency: t[0], recipient: t[1] }); } else if ( actionId === V4_SWAP_EXACT_IN_SINGLE || actionId === V4_SWAP_EXACT_IN || actionId === V4_SWAP_EXACT_OUT_SINGLE || actionId === V4_SWAP_EXACT_OUT ) { lastSwapAction = i; try { swaps.push(decodeV4SwapAction(actionId, params[i])); } catch { swapUndecoded = true; actionUnread = true; } } else { // V4Router reverts on any action not named above, so // none of them pays anyone. This decoder reads none. actionUnread = true; } } catch { // Skip sub-actions we can't decode. A TAKE, TAKE_ALL or // TAKE_PORTION among them is a payout this decoder did not // read. actionUnread = true; if ( actionId === V4_TAKE || actionId === V4_TAKE_ALL || actionId === V4_TAKE_PORTION ) { recipientUnread = true; } } } // What each SETTLE paid from the router pays. A quantity or // CONTRACT_BALANCE pays as written. A SETTLE of OPEN_DELTA (zero) // pays whatever the step still owes of its currency when it runs. // That is what the step's swap actions state they spend of it only // in the plain shape of Uniswap's own routes, so only then is it // given that amount: every action in the step was read; every swap // action names the currency it spends and the one it buys (an // exact-in action with an empty path swaps nothing); the SETTLE is // the step's only SETTLE or SETTLE_ALL of its currency and comes // after every swap action; no swap action buys that currency; and // the swap actions that spend it state a quantity together (see // totalAmount()). Otherwise it is given null. const settledFromRouter = routerSettles.map((s) => { if (s.amount !== 0n) { return { currency: s.currency, amount: s.amount }; } const stated = totalAmount( swaps .filter((w) => w.tokenIn === s.currency) .map((w) => w.amountIn), ); const paysWhatSwapsSpend = !actionUnread && swaps.every((w) => present(w.tokenIn) && present(w.tokenOut)) && settledCurrencies.filter((c) => c === s.currency).length === 1 && s.index > lastSwapAction && !swaps.some((w) => w.tokenOut === s.currency) && typeof stated === "bigint"; return { currency: s.currency, amount: paysWhatSwapsSpend ? stated : null, }; }); // Rule 3. if (swapUndecoded) { return { tokenIn: settleToken, tokenOut: takeToken, amountIn: null, amountOutMin: null, takes, settledFromRouter, recipientUnread, swapUndecoded, hasSwapAction: true, }; } const spent = swaps.map((s) => s.tokenIn); const bought = swaps.map((s) => s.tokenOut); // Rules 1 and 2 for Token In. let tokenIn = null; let amountIn = null; const spentOnly = currenciesNotIn(spent, bought); if (present(settleToken) || spentOnly.length === 1) { tokenIn = present(settleToken) ? settleToken : spentOnly[0]; amountIn = totalAmount( swaps .filter((s) => s.tokenIn === tokenIn) .map((s) => s.amountIn), ); } // Rules 1 and 2 for Token Out. let tokenOut = null; let amountOutMin = null; const boughtOnly = currenciesNotIn(bought, spent); if (present(takeToken) || boughtOnly.length === 1) { tokenOut = present(takeToken) ? takeToken : boughtOnly[0]; amountOutMin = totalAmount( swaps .filter((s) => s.tokenOut === tokenOut) .map((s) => s.amountOutMin), ); } return { tokenIn, tokenOut, amountIn, amountOutMin, takes, settledFromRouter, recipientUnread, swapUndecoded, hasSwapAction: swaps.length > 0, }; } catch { return null; } } // Try to decode a Universal Router execute() call sent from `fromAddress`, the // user, to `toAddress` on the network with chain id `chainId`. // Returns { name, description, details } matching the format used by // the approval UI, or null if the calldata is not a recognised execute() or // `toAddress` is not a Universal Router on that network: the same calldata // sent to any other contract is not a swap through the router, whatever that // contract does with it. function decode(data, toAddress, chainId, sources, fromAddress) { const release = routerRelease(toAddress, chainId); if (release === null) return null; const { commandTypeMask, commands, allowRevert } = release; try { const parsed = ROUTER_IFACE.parseTransaction({ data }); if (!parsed) return null; const commandsBytes = getBytes(parsed.args[0]); const inputs = parsed.args[1]; const deadline = parsed.args[2]; let inputToken = null; let inputAmount = null; let inputEstablished = false; let outputToken = null; let minOutput = null; let outputEstablished = false; let hasUnwrapWeth = false; let hasV2ExactOut = false; let swapUndecoded = false; let hasSubPlan = false; const commandNames = []; // THE INVARIANT: an amount and the token it is counted in always come // from the same hop. A figure is never rendered against a token that // did not supply it. // // Both sides are therefore set as a PAIR — never field by field, and // never on truthiness. An address is never falsy once set but an // amount of 0n is, so gating the two halves independently let a hop // with a zero amount fix the token and leave the amount open; the next // hop's figure was then displayed against the first hop's token, at the // first hop's scale. A V3 USDT->WETH hop with amountIn 0 followed by a // V2 WETH->USDC hop of 0.5e18 rendered "500000000000.0000 USDT". // // The input side is fixed by the first hop that states either half, the // output side by the last, because the final leg is what the user // receives. A V4 step with a swap action sets a side even when it // states neither half: the output side always, and the input side // when no earlier hop set it. A half the establishing hop did not // state stays null and the line says so, rather than being filled in // from a different hop. const setInput = (token, amount) => { inputToken = present(token) ? token : null; inputAmount = present(amount) ? amount : null; inputEstablished = true; }; const setInputOnce = (token, amount) => { if (inputEstablished) return; if (!present(token) && !present(amount)) return; setInput(token, amount); }; const setOutput = (token, amount) => { if (!present(token) && !present(amount)) return; outputToken = present(token) ? token : null; minOutput = present(amount) ? amount : null; 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; // Set when an ACROSS_V4_DEPOSIT_V3 step pays the bridge. let bridgeDeposit = false; // What each PERMIT2_TRANSFER_FROM moved from the sender into the // router, as { token, amount }. Later steps may leave part of it // there: nothing here checks that they spend all of it. const movedIntoRouter = []; // What each PERMIT2_TRANSFER_FROM not into the router moves from the // sender, in step order, whoever counts as paid, as // { token, recipient, amount, to }: `to` says where it goes, the // sender or the V2 pair the next swap spends, and is null when it // goes to `recipient` as written. A move into the router is in // `movedIntoRouter`, whose lines give its amount already. const movedFromSender = []; // Each token a step paid to the router that no later step paid out // or spent in full, by its address, lowercased, ETH as the zero // address. The router is paid what a swap step, V4 TAKE or // TAKE_PORTION pays it, the WETH of a WRAP_ETH into it, and the ETH // of an UNWRAP_WETH into it, in place of the WETH it held. All the // router holds of a token is paid out or spent by a SWEEP of that // token, an UNWRAP_WETH of WETH, a WRAP_ETH of CONTRACT_BALANCE of // ETH, a TRANSFER, PAY_PORTION, PAY_PORTION_FULL_PRECISION or // ACROSS_V4_DEPOSIT_V3 of all of it, or a swap step or V4 SETTLE paid // from the router (see spendFromRouter()). The value is the amount the // router holds, in base units, when a WRAP_ETH stated it, otherwise // null. const heldByRouter = new Map(); const isRouter = (recipient) => { const r = recipient.toLowerCase(); return r === ADDRESS_THIS || r === toAddress.toLowerCase(); }; // A swap step or V4 SETTLE pays `amount` of `token` from the router's // own balance. CONTRACT_BALANCE pays all of it. Any other quantity // counts against the amount a WRAP_ETH stated, and the steps pay all // of it once their amounts reach that one: a swap paid in ETH wraps // what its swap steps then spend. An amount that is not a quantity, // such as the null decodeV4Swap() gives a SETTLE whose payment it // cannot tell, or one of a token whose amount is not known, pays out // nothing this decoder can see. const spendFromRouter = (token, amount) => { const t = token.toLowerCase(); const held = heldByRouter.get(t); if (amount === CONTRACT_BALANCE) { heldByRouter.delete(t); } else if (typeof held === "bigint" && typeof amount === "bigint") { if (amount >= held) { heldByRouter.delete(t); } else { heldByRouter.set(t, held - amount); } } }; // A step pays `recipient`, an address other than the sender's or the // router's, as written. const addPaidElsewhere = (recipient) => { const r = recipient.toLowerCase(); if (!paidElsewhere.some((a) => a.toLowerCase() === r)) { paidElsewhere.push(recipient); } }; // A step pays `recipient`. `output` is the token a swap step or a V4 // TAKE or TAKE_PORTION pays; other steps pass none. const addPayee = (recipient, output) => { const r = recipient.toLowerCase(); if (isRouter(recipient)) { if (present(output)) { heldByRouter.set(output.toLowerCase(), null); } } else if (r === MSG_SENDER || r === sender) { paidToSender = true; } else { addPaidElsewhere(recipient); } }; // A step that pays `token` to `recipient` is a hop, not a payout, // when the very next step is a V2 exact-in whose input is already // paid, whose path starts at `token`, and whose pair (see v2PairFor()) // is `recipient`: Uniswap's mixed routes pay the next V2 pair // directly, and the router enforces that next swap's own minimum. // Its input is already paid when its amountIn is ALREADY_PAID, or // CONTRACT_BALANCE, which Uniswap's routes send and with which the // router adds only what it holds. const isHop = (i, token, recipient) => { if (i + 1 >= commandsBytes.length) return false; const nextCommand = commands[commandsBytes[i + 1] & commandTypeMask]; if (nextCommand !== "V2_SWAP_EXACT_IN") return false; const next = decodeV2SwapExactIn(inputs[i + 1]); return ( next !== null && (next.amountIn === ALREADY_PAID || next.amountIn === CONTRACT_BALANCE) && present(token) && next.path.length >= 2 && next.tokenIn.toLowerCase() === token.toLowerCase() && recipient.toLowerCase() === v2PairFor(chainId, next.path[0], next.path[1]).toLowerCase() ); }; for (let i = 0; i < commandsBytes.length; i++) { const cmdId = commandsBytes[i] & commandTypeMask; // The command this release runs for the step (see ROUTER_V1), // undefined for a type it names no command for. const command = commands[cmdId]; // The router goes on to the next step if this one fails (see // ROUTER_V1). The flag on any other command has no effect. const allowedToFail = (commandsBytes[i] & ALLOW_REVERT_FLAG) !== 0 && allowRevert.includes(cmdId); commandNames.push( present(command) ? COMMAND_NAMES[command] : "Command 0x" + cmdId.toString(16).padStart(2, "0"), ); try { if (command === "EXECUTE_SUB_PLAN") { // EXECUTE_SUB_PLAN runs a further list of commands held in // its input, which this decoder does not read. The step // says so, and after this loop neither side is named. // Those commands may pay any address. hasSubPlan = true; commandNames[i] += " (its steps are not shown)"; payeeUnread = true; } else if (command === "PERMIT2_PERMIT") { const p = decodePermit2(inputs[i]); // A permit states both halves itself, so it may replace an // input side an earlier hop established without breaking // the invariant. if (p) setInput(p.token, p.amount); } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { payeeUnread = true; } else if ( commandTypeMask === 0x1f && [MSG_SENDER, ADDRESS_THIS].includes( p.recipient.toLowerCase(), ) ) { // UniversalRouterV1, the router with the 0x1f mask, // pays address(1) and address(2) as written (see // decodePermit2TransferFrom()). addPaidElsewhere(p.recipient); movedFromSender.push({ ...p, to: null }); } else if (isRouter(p.recipient)) { movedIntoRouter.push(p); } else if (isHop(i, p.token, p.recipient)) { movedFromSender.push({ ...p, to: "The V2 pair the next swap spends", }); } else { addPayee(p.recipient); movedFromSender.push({ ...p, to: p.recipient.toLowerCase() === MSG_SENDER ? "The sender" : null, }); } } else if (command === "BALANCE_CHECK_ERC20") { const b = decodeBalanceCheck(inputs[i]); if (!b) { // The router still runs a check whose input does not // decode, such as an owner word with a nonzero top // byte, of which it reads the last 20 bytes, so the // owner may be any address. It sets no side, and the // step says so. commandNames[i] += " (could not be decoded)"; } else if (b.owner.toLowerCase() !== sender) { // The router reads the owner as written (see // decodeBalanceCheck()), so address(1) and address(2) // are not the sender and the router here. A check of // any address but the sender's says nothing about // what the user receives: it sets no side, and the // step says so. commandNames[i] += " (of another address)"; } else { // The router passes this check whenever the owner // holds at least minBalance, so a zero one guarantees // nothing and does not replace a minimum an earlier // step stated. A check the router lets fail guarantees // nothing either, so it is read as a zero one. Any // other minBalance sets the output side as a swap // does. It pays nobody. const minBalance = allowedToFail ? 0n : b.minBalance; if (!(minBalance === 0n && present(minOutput))) { setOutput(b.token, minBalance); } } } else if (command === "V3_SWAP_EXACT_IN") { const s = decodeV3SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); // Always update output: in multi-step swaps (V3 → V4), // the last swap step determines the final output token // and minimum received amount. setOutput(s.tokenOut, s.amountOutMin); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } } else { payeeUnread = true; } } else if (command === "V2_SWAP_EXACT_IN") { const s = decodeV2SwapExactIn(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountIn); setOutput(s.tokenOut, s.amountOutMin); if (!s.payerIsUser) { spendFromRouter(s.tokenIn, s.amountIn); } if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } } else { payeeUnread = true; } } else if (command === "V2_SWAP_EXACT_OUT") { // Buys exactly amountOut and spends at most amountInMax. hasV2ExactOut = true; const s = decodeV2SwapExactOut(inputs[i]); if (s) { setInputOnce(s.tokenIn, s.amountInMax); setOutput(s.tokenOut, s.amountOut); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } } else { payeeUnread = true; } } else if (command === "SWEEP") { const s = decodeSweep(inputs[i]); if (s) { addPayee(s.recipient); if (!isRouter(s.recipient)) { heldByRouter.delete(s.token.toLowerCase()); } } else { payeeUnread = true; } } else if ( command === "TRANSFER" || command === "PAY_PORTION" || command === "PAY_PORTION_FULL_PRECISION" ) { const t = decodeTransfer(inputs[i]); if (t) { if (!isHop(i, t.token, t.recipient)) { addPayee(t.recipient); } // The amount with which TRANSFER, PAY_PORTION or // PAY_PORTION_FULL_PRECISION pays all the router // holds of the token, as SWEEP does. const all = command === "TRANSFER" ? CONTRACT_BALANCE : command === "PAY_PORTION" ? 10000n : 10n ** 18n; // A TRANSFER of ETH has no such amount: the router // sends ETH as written, so a TRANSFER of // CONTRACT_BALANCE of ETH always reverts. const ethTransfer = command === "TRANSFER" && t.token === "0x0000000000000000000000000000000000000000"; if ( t.amount === all && !ethTransfer && !isRouter(t.recipient) ) { heldByRouter.delete(t.token.toLowerCase()); } } else { payeeUnread = true; } } else if (command === "ACROSS_V4_DEPOSIT_V3") { const a = decodeAcrossV4DepositV3(inputs[i]); if (a) { bridgeDeposit = true; // The bridge pays or refunds these as written, so // only the sender's own address is the sender. for (const r of [a.recipient, a.depositor]) { if (r.toLowerCase() !== sender) { addPaidElsewhere(r); } } // A deposit of CONTRACT_BALANCE pays all the router // holds of its token, or of ETH with useNative set. if (a.inputAmount === CONTRACT_BALANCE) { heldByRouter.delete( a.useNative ? "0x0000000000000000000000000000000000000000" : a.inputToken.toLowerCase(), ); } } else { payeeUnread = true; } } else if (command === "WRAP_ETH") { const w = decodeWrapEth(inputs[i]); if (w) { setInputOnce( "0x0000000000000000000000000000000000000000", w.amount, ); addPayee(w.recipient); // CONTRACT_BALANCE wraps all the router's ETH. if (w.amount === CONTRACT_BALANCE) { heldByRouter.delete( "0x0000000000000000000000000000000000000000", ); } // Into the router, the router holds the WETH. Its // amount is known when the step states it and the // router held no WETH before. if (isRouter(w.recipient) && w.amount > 0n) { const weth = WETH.get(chainId); heldByRouter.set( weth, w.amount === CONTRACT_BALANCE || heldByRouter.has(weth) ? null : w.amount, ); } } else { payeeUnread = true; } } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); if (v4) { // Each SETTLE paid from the router spends the amount // decodeV4Swap() says it pays. for (const s of v4.settledFromRouter) { spendFromRouter(s.currency, s.amount); } for (const t of v4.takes) { if (!isHop(i, t.currency, t.recipient)) { addPayee(t.recipient, t.currency); } } } if (!v4 || v4.recipientUnread) { payeeUnread = true; } if (v4 && v4.swapUndecoded) { // A swap action of this step went unread, so the step // is marked. swapUndecoded = true; commandNames[i] += " (could not be decoded)"; } if (v4 && v4.hasSwapAction) { // A step with a swap action always sets the output // side, as the last swap step wins, and sets the // input side only when no earlier step set it. Each // side it sets is set as a pair, even when the step // states neither half, so a side decodeV4Swap() left // unknown is set unknown with no figure. A token // stated without a figure, or a figure without a // token, likewise never sits beside an earlier step's // other half. if (!inputEstablished) { setInput(v4.tokenIn, v4.amountIn); } outputToken = v4.tokenOut; minOutput = v4.amountOutMin; outputEstablished = true; } else if (v4) { // A step with no swap action sets only the sides its // SETTLE, SETTLE_ALL, TAKE or TAKE_ALL names: // setInputOnce() and setOutput() leave alone a side // whose two halves are both unstated. setInputOnce(v4.tokenIn, v4.amountIn); setOutput(v4.tokenOut, v4.amountOutMin); } } else if (command === "UNWRAP_WETH") { hasUnwrapWeth = true; const u = decodeUnwrapWeth(inputs[i]); if (u) { addPayee(u.recipient); // It pays out all the router's WETH, as ETH. Into the // router, the router then holds as ETH the WETH a // step paid it. const heldWeth = WETH_ADDRESSES.some((w) => heldByRouter.has(w), ); WETH_ADDRESSES.forEach((w) => heldByRouter.delete(w)); if (isRouter(u.recipient) && heldWeth) { heldByRouter.set( "0x0000000000000000000000000000000000000000", null, ); } } else { payeeUnread = true; } } else { // Any other step may pay an address this decoder does not // read: V3_SWAP_EXACT_OUT, PERMIT2_TRANSFER_FROM_BATCH and // the NFT commands among them, and a type the release // names no command for. payeeUnread = true; } } catch { // Skip commands we can't decode. One may pay any address. payeeUnread = true; } if (allowedToFail) { commandNames[i] += " (allowed to fail)"; } } // The commands of an EXECUTE_SUB_PLAN step may spend the user's tokens // or pay the user, before or after any other step, and a permit among // them would replace the input side. What the other steps state is // then not the whole swap, so both sides are set unknown, with no // figure, wherever the step stands. if (hasSubPlan) { inputToken = null; inputAmount = null; outputToken = null; minOutput = null; outputEstablished = true; } // Resolve token info. A null token on either side means no step named // a currency for it, a V4 step with a swap action left it unknown, or // an EXECUTE_SUB_PLAN step did; tokenInfo() refuses rather than calling // it ETH. UNWRAP_WETH turns WETH into ETH, so it makes the output ETH // when the output side is WETH, or when no step set the output side. A // V4 step with a swap action, or an EXECUTE_SUB_PLAN step, did set it, // even when it set it unknown. Any other output keeps its own token // and figure: a swap that buys USDC and then unwraps the WETH it did // not spend receives USDC. const outputIsWeth = present(outputToken) && WETH_ADDRESSES.includes(outputToken.toLowerCase()); const inInfo = tokenInfo(inputToken, sources); const outInfo = hasUnwrapWeth && (outputIsWeth || !outputEstablished) ? { symbol: "ETH", decimals: 18, address: null } : tokenInfo(outputToken, sources); const inSymbol = inInfo.symbol; const outSymbol = outInfo.symbol; const name = inSymbol && outSymbol ? "Swap " + inSymbol + " \u2192 " + outSymbol : "Uniswap Swap"; const details = []; details.push({ label: "Protocol", value: "Uniswap Universal Router", address: toAddress, }); if (present(inputToken) && present(inInfo.address)) { const label = inSymbol ? inSymbol + " (" + inputToken + ")" : inputToken; details.push({ label: "Token In", value: label, address: inputToken, isToken: true, }); } else if (inSymbol === "ETH") { details.push({ label: "Token In", value: "ETH (native)" }); } else { // Nothing established the input token, so the line says that // rather than going missing or naming a token by default. Same // wording as the Token Out refusal below: the two sides of this // screen must not describe the same condition in two ways. details.push({ label: "Token In", value: swapUndecoded || hasSubPlan ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, }); } if (present(inputAmount)) { // Three amounts need no scale to describe and are named rather // than formatted: V4's open delta and V2's already-paid zero, // neither of which is a quantity at all (see OPEN_DELTA and // ALREADY_PAID), and an unbounded permit. Those two tests come // first — the sentinels are not bigints and cannot be compared // with one. let amount; if (inputAmount === OPEN_DELTA) { amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; } else if (inputAmount === ALREADY_PAID) { amount = { raw: ALREADY_PAID_AMOUNT, display: ALREADY_PAID_AMOUNT, }; } else if (inputAmount >= MAX_UINT160) { amount = { raw: "Unlimited", display: "Unlimited" }; } else if (hasV2ExactOut) { // A V2 exact-out swap spends at most this figure, whichever // step set the line (its amountInMax, the WRAP_ETH of a swap // paid in ETH, a permit), so it is said to be a maximum, in // `raw` too: the wait, success and error screens show `raw` as // the transaction's amount. const most = amountText(inputAmount, inInfo); amount = { raw: "Up to " + most.raw, display: "Up to " + most.display, }; } else { amount = amountText(inputAmount, inInfo); } details.push({ label: "Amount", value: amount.display, rawValue: amount.raw, }); } // What each PERMIT2_TRANSFER_FROM moves: its amount, its token and // where it goes, an address on a line of its own. for (const m of movedFromSender) { details.push({ label: "Moved from the sender", value: amountText(m.amount, tokenInfo(m.token, sources)) .display, }); details.push(tokenMovedLine("Token moved", m.token, sources)); details.push( present(m.to) ? { label: "Moved to", value: m.to } : { label: "Moved to", value: m.recipient, address: m.recipient, }, ); } // Keyed on the address, not the symbol: a token absent from the // bundled list has no symbol, and gating the line on one dropped it // entirely, leaving a Min. received figure with nothing saying what is // being received. The Token In line above already falls back to the // address; this does the same. if (present(outInfo.address)) { const label = outSymbol ? outSymbol + " (" + outInfo.address + ")" : outInfo.address; details.push({ label: "Token Out", value: label, address: outInfo.address, isToken: true, }); } else if (outSymbol) { details.push({ label: "Token Out", value: outSymbol }); } else { // Nothing established the output token, so the line says that // rather than going missing or naming a token by default, and a // Min. received figure below it is never attached to a token the // calldata did not state. details.push({ label: "Token Out", value: swapUndecoded || hasSubPlan ? UNDECODED_CURRENCY : UNNAMED_CURRENCY, }); } // A Paid to line names each other address paid, and says when a // step pays the bridge, when one moves the sender's tokens into the // router, when a token stays in the router, and when a payout could // not be read. The output is received by the sender only when some // step pays the sender and there is no such line. When no step names // anyone paid, one line says so. `paidTo` holds these lines and the // token line after each line about a token in the router. const outputHeld = present(outputToken) && heldByRouter.has(outputToken.toLowerCase()); const paidTo = paidElsewhere.map((address) => ({ label: "Paid to", value: address, address, })); if (bridgeDeposit) { paidTo.push({ label: "Paid to", value: PAID_TO_BRIDGE }); } for (const m of movedIntoRouter) { const info = tokenInfo(m.token, sources); paidTo.push({ label: "Paid to", value: "The router, " + amountText(m.amount, info).display + " from the sender (later steps may not spend all of it)", }); paidTo.push( tokenMovedLine("Token moved into the router", m.token, sources), ); } if (outputHeld) { paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); } // Any other token the router still holds after the last step, such // as one a swap step bought on the way to the output, what a V4 // SETTLE took beyond what the step's swap spent, or the WETH of a // WRAP_ETH, stays there too, and its line is followed by the token's. for (const token of heldByRouter.keys()) { if (present(outputToken) && token === outputToken.toLowerCase()) { continue; } paidTo.push({ label: "Paid to", value: STAYS_IN_ROUTER }); paidTo.push( tokenMovedLine( "Token moved into the router", getAddress(token), sources, ), ); } if (payeeUnread) { paidTo.push({ label: "Paid to", value: UNDECODED_CURRENCY }); } const received = paidToSender && paidTo.length === 0; if (!paidToSender && paidTo.length === 0) { paidTo.push({ label: "Paid to", value: UNNAMED_CURRENCY }); } details.push(...paidTo); if (present(minOutput)) { // A zero floor is the one case the user most needs stated: the // swap guarantees nothing back. It is said in words, in the same // register as UNNAMED_CURRENCY, because "0.0000 WETH" reads as an // artifact of the four-decimal rule rather than as "this may // return nothing" — and because it is true at every scale, so it // holds even when the output token's decimals are unknown. details.push({ label: received ? "Min. received" : "Min. paid out", value: minOutput === 0n ? NO_MINIMUM : amountText(minOutput, outInfo).display, }); } details.push({ label: "Steps", value: commandNames.join(" \u2192 ") }); // A JavaScript date reaches only to 275760-09-13 00:00:00 UTC. A // later deadline, such as the uint256 maximum, makes an invalid date, // and toISOString() throws on one, so that deadline is said in words. const deadlineDate = new Date(Number(deadline) * 1000); details.push({ label: "Deadline", value: isNaN(deadlineDate.getTime()) ? "After 275760-09-13 00:00:00 (no deadline in practice)" : deadlineDate.toISOString().replace("T", " ").slice(0, 19), }); return { name, description: "Swap via Uniswap Universal Router", details, }; } catch { return null; } } module.exports = { decode };