// 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, and so do the lines for what a step // takes from the sender, when this decoder does not know it. 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; } // Whether `a` and `b` are both known and the same address. function sameAddress(a, b) { return present(a) && present(b) && a.toLowerCase() === b.toLowerCase(); } // 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) // With `payerIsUser` false the router pays the input from its own balance. 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], payerIsUser: d[4], }; } catch { return null; } } // The first and last token of a V3 swap path (packed: token(20) + fee(3) + // token(20) ...), or null when it is not one token followed by whole hops of // a fee and a token (40 hex digits, then 46 per hop). The router reads only // whole hops and ignores any bytes after the last one, so the last 20 bytes // of such a path are not the token it reads there. function decodeV3Path(pathHex) { const hex = pathHex.startsWith("0x") ? pathHex.slice(2) : pathHex; if (hex.length < 40 || (hex.length - 40) % 46 !== 0) return null; const first = "0x" + hex.slice(0, 40); const last = "0x" + hex.slice(-40); return { first, last }; } // 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.first, tokenOut: path.last, payerIsUser: d[4], }; } catch { return null; } } // Decode V3_SWAP_EXACT_OUT (command 0x01) input bytes. // ABI: (address recipient, uint256 amountOut, uint256 amountInMax, // bytes path, bool payerIsUser) // The router reads an exact-out path from the token it buys to the token it // spends: it buys `amountOut` of the path's first token from the path's first // pool and pays at most `amountInMax` of its last token to its last pool // (`v3SwapExactOutput()` and `uniswapV3SwapCallback()` in each release's // `contracts/modules/uniswap/v3/V3SwapRouter.sol`, at the commit linked for // it above). // With `payerIsUser` false the router pays the input from its own balance. function decodeV3SwapExactOut(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], amountOut: d[1], amountInMax: d[2], tokenIn: path.last, tokenOut: path.first, 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. Under each of the three rules, rule 3 included, when the user pays // the step any of Token In (`paidByUser` below), decode() takes what the user // pays it of Token In as the step's amount in place of the rules' Amount, or // no amount when this decoder cannot tell what that is. // // `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, and `settleUnread` when it has a SETTLE or SETTLE_ALL 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). `paidByUser` is what the user pays // the step, once per currency, as that currency and the amount: the total of // every SETTLE paid by the user (`payerIsUser` true) and every SETTLE_ALL, // which V4Router always has the user pay, of that currency, or null when // this decoder cannot tell what one of them pays. 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 and SETTLE_ALL, as its currency, its amount as // written, whether the user pays it, and its index among the step's // actions. A SETTLE_ALL pays what a SETTLE of OPEN_DELTA (zero) paid // by the user pays, all the step still owes of its currency, so it // is kept as one; its maxAmount only caps that. const settles = []; // 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; let settleUnread = 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]; settles.push({ currency: s[0], amount: s[1], payerIsUser: s[2], 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]; settles.push({ currency: s[0], amount: 0n, payerIsUser: true, index: i, }); } 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, and a SETTLE or SETTLE_ALL may take from the user // what it did not read: the router still runs both. actionUnread = true; if ( actionId === V4_TAKE || actionId === V4_TAKE_ALL || actionId === V4_TAKE_PORTION ) { recipientUnread = true; } if (actionId === V4_SETTLE || actionId === V4_SETTLE_ALL) { settleUnread = true; } } } // What a SETTLE or SETTLE_ALL pays. A SETTLE of any amount but // OPEN_DELTA (zero) pays as written: a quantity, or, for // CONTRACT_BALANCE, as much as the router holds. A SETTLE of // OPEN_DELTA, and a SETTLE_ALL, pay whatever the step still owes of // the currency when they run. 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); it 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 nothing in the step takes it (a TAKE of a stated // amount adds to what the step owes); and the swap actions that spend // it state a quantity together (see totalAmount()). Otherwise it is // given null. const pays = (s) => { if (s.amount !== 0n) return 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)) && settles.filter((c) => c.currency === s.currency).length === 1 && s.index > lastSwapAction && !swaps.some((w) => w.tokenOut === s.currency) && !takes.some((t) => t.currency === s.currency) && typeof stated === "bigint"; return paysWhatSwapsSpend ? stated : null; }; const settledFromRouter = settles .filter((s) => !s.payerIsUser) .map((s) => ({ currency: s.currency, amount: pays(s) })); const paidByUser = []; for (const s of settles.filter((c) => c.payerIsUser)) { const amount = pays(s); const paid = paidByUser.find((p) => p.currency === s.currency); if (!paid) { paidByUser.push({ currency: s.currency, amount }); } else { paid.amount = typeof paid.amount === "bigint" && typeof amount === "bigint" ? paid.amount + amount : null; } } // Rule 3. if (swapUndecoded) { return { tokenIn: settleToken, tokenOut: takeToken, amountIn: null, amountOutMin: null, takes, settledFromRouter, paidByUser, recipientUnread, settleUnread, 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, paidByUser, recipientUnread, settleUnread, 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; // Whether the user pays the V4 step that set the input side any of // its input currency (see decodeV4Swap()). Only a V4 step can set the // input side with no amount, the one case in which this is read. let inputPaidByUser = false; // The last PERMIT2_PERMIT this decoder read, or null (see // permitStands after the loop). let permit = null; // The index of each step this decoder did not read in full: a // command it does not read, a step whose input it cannot read, in // whole or in part, and one it fails on (see permitStands). const stepsUnread = []; let outputToken = null; let minOutput = null; let outputEstablished = false; let hasUnwrapWeth = false; let hasV2OrV3ExactOut = 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, or from hops that all spend that token. 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. A PERMIT2_PERMIT sets no side here: after the // loop, the last one read names Token In, and the Amount line is // formed there, in one place (see permitStands). 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; }; // What the steps take from the sender beyond the amount of the step // that sets the input side, as { token, amount }, in step order. // After the loop each joins the Amount line or gets lines of its own // (see permitStands). const userPaid = []; // A swap step the user pays for (`payerIsUser` true) takes `amount` // of `token` from the sender. A V2 exact-in of ALREADY_PAID takes // nothing. const addUserPaid = (token, amount) => { if (amount !== ALREADY_PAID) userPaid.push({ token, amount }); }; // A step may take from the sender what this decoder did not read: a // V4 step whose input or one of whose SETTLEs or SETTLE_ALLs it // cannot read; a V2 or V3 swap step or PERMIT2_TRANSFER_FROM whose // input it cannot read; a PERMIT2_TRANSFER_FROM_BATCH; an // EXECUTE_SUB_PLAN; and a step it fails on. It is recorded with a null // token and amount: one line says so, there is no Amount line, whose // figure may then be too low, and the amount of a permit after it // does not stand (see permitStands). const spentUnread = () => { userPaid.push({ token: null, amount: null }); }; // 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 and take from the // sender. hasSubPlan = true; commandNames[i] += " (its steps are not shown)"; payeeUnread = true; stepsUnread.push(i); spentUnread(); } else if (command === "PERMIT2_PERMIT") { const p = decodePermit2(inputs[i]); if (p) { // Whether a step before it took its token, or may // have: the step that set the input side or one in // `userPaid`, when its token is the permit's or not // known. const mayBeItsToken = (token) => !present(token) || sameAddress(token, p.token); const tookBefore = (inputEstablished && mayBeItsToken(inputToken)) || userPaid.some((t) => mayBeItsToken(t.token)); permit = { ...p, step: i, allowedToFail, beforeEveryTake: !tookBefore, }; } else { stepsUnread.push(i); } } else if (command === "PERMIT2_TRANSFER_FROM") { const p = decodePermit2TransferFrom(inputs[i]); if (!p) { payeeUnread = true; stepsUnread.push(i); spentUnread(); } 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)"; stepsUnread.push(i); } 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) { if (!inputEstablished) { setInputOnce(s.tokenIn, s.amountIn); } else if (s.payerIsUser) { addUserPaid(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; stepsUnread.push(i); spentUnread(); } } else if (command === "V2_SWAP_EXACT_IN") { const s = decodeV2SwapExactIn(inputs[i]); if (s) { if (!inputEstablished) { setInputOnce(s.tokenIn, s.amountIn); } else if (s.payerIsUser) { addUserPaid(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; stepsUnread.push(i); spentUnread(); } } else if ( command === "V2_SWAP_EXACT_OUT" || command === "V3_SWAP_EXACT_OUT" ) { // Buys exactly amountOut and spends at most amountInMax. // Paid from the router, it spends an amount the calldata // does not state, so it spends nothing spendFromRouter() // could count. hasV2OrV3ExactOut = true; const s = command === "V2_SWAP_EXACT_OUT" ? decodeV2SwapExactOut(inputs[i]) : decodeV3SwapExactOut(inputs[i]); if (s) { if (!inputEstablished) { setInputOnce(s.tokenIn, s.amountInMax); } else if (s.payerIsUser) { addUserPaid(s.tokenIn, s.amountInMax); } setOutput(s.tokenOut, s.amountOut); if (!isHop(i, s.tokenOut, s.recipient)) { addPayee(s.recipient, s.tokenOut); } } else { payeeUnread = true; stepsUnread.push(i); spentUnread(); } } else if (command === "PERMIT2_TRANSFER_FROM_BATCH") { // It moves the sender's tokens to any addresses, and this // decoder does not read it. payeeUnread = true; stepsUnread.push(i); spentUnread(); } 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; stepsUnread.push(i); } } 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; stepsUnread.push(i); } } 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; stepsUnread.push(i); } } 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; stepsUnread.push(i); } } else if (command === "V4_SWAP") { const v4 = decodeV4Swap(inputs[i]); const inputSetBefore = inputEstablished; 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.recipientUnread || v4.settleUnread || v4.swapUndecoded ) { stepsUnread.push(i); } 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)"; } // The step's amount, if it sets the input side: what the // user pays it of its input currency (see // decodeV4Swap()), or, when the user pays none of it, // what its swap actions spend of it, whoever pays that. const paidIn = v4?.paidByUser.find( (p) => p.currency === v4.tokenIn, ); const amountIn = paidIn ? paidIn.amount : v4?.amountIn; 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, 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, amountIn); setOutput(v4.tokenOut, v4.amountOutMin); } // Every V4 step, with a swap action or without, takes // from the sender what the user pays it (an amount an // unread action may change is null), less what the step // that sets the input side counts as its amount. One // whose input, or one of whose SETTLEs or SETTLE_ALLs, // this decoder could not read may take anything: the // router still runs it. const setInputHere = inputEstablished && !inputSetBefore; if (setInputHere) inputPaidByUser = paidIn !== undefined; if (v4) { for (const p of v4.paidByUser) { if (setInputHere && p === paidIn) continue; addUserPaid(p.currency, p.amount); } } if (!v4 || v4.settleUnread) { spentUnread(); } } 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; stepsUnread.push(i); } } else { // Any other step may pay an address this decoder does not // read: PERMIT2_PERMIT_BATCH and the NFT commands among // them, and a type the release names no command for. payeeUnread = true; stepsUnread.push(i); } } catch { // Skip commands we can't decode. One may pay any address and // take from the sender. payeeUnread = true; stepsUnread.push(i); spentUnread(); } if (allowedToFail) { commandNames[i] += " (allowed to fail)"; } } // The Amount line, and the lines for what the steps take beyond it. // // The total is what the steps take of Token In: the amount of the // step that set the input side, with each later take of that token // added to it while both are quantities. A take that cannot be added, // being of another token or coming after an amount that is not a // quantity (a V4 open delta, ALREADY_PAID, or none stated), gets // lines of its own. One whose amount is not known also leaves the // total unknown, with no figure. // // The last PERMIT2_PERMIT read names Token In. Its amount stands as // the Amount figure in place of the total only in the plain shape of // Uniswap's own routes: it is the only PERMIT2_PERMIT, its spender is // the router, the router may not let it fail, no step before it took // its token or one not known, and this decoder read every step after // it in full (see stepsUnread). Permit2 then lets the router take no // more of the token than that amount, so the permit covers every take // of it. A later step this decoder did not read may raise what Permit2 // lets the router take, as a PERMIT2_PERMIT_BATCH can. A step that // may take from the sender what this decoder did not read leaves no // figure, permit or not (see spentUnread()), and an EXECUTE_SUB_PLAN // step leaves both sides unknown (below). const stepCommands = Array.from( commandsBytes, (b) => commands[b & commandTypeMask], ); const permitStands = permit !== null && stepCommands.filter((c) => c === "PERMIT2_PERMIT").length === 1 && sameAddress(permit.spender, toAddress) && !permit.allowedToFail && permit.beforeEveryTake && !stepsUnread.some((step) => step > permit.step); // What the steps take that the Amount line does not give, as // { token, amount }. A null token is a step this decoder did not read // (see spentUnread()), or a token it does not know. const spentFromSender = []; let total = null; // Whether the step that set the input side counts as a take from the // sender, which may get lines of its own. The amount it stated counts // whoever pays it. Stating none, it counts only when the user pays it // some of its input token: paid from the router, it takes nothing // from the sender. const hasFirstTake = inputEstablished && (present(inputAmount) || inputPaidByUser); // The take of the step that set the input side, when the total // starts from it and it stated no amount (see below), or null. let firstTakeUnstated = null; if (permit === null || sameAddress(inputToken, permit.token)) { total = inputAmount; if (hasFirstTake && !present(inputAmount)) { firstTakeUnstated = { token: inputToken, amount: null }; } } else if (hasFirstTake) { // The step that set the input side spent a token other than the // permit's, or one not known, so the total starts with no figure. spentFromSender.push({ token: inputToken, amount: inputAmount }); } if (permit !== null) inputToken = permit.token; for (const t of userPaid) { const ofTokenIn = sameAddress(t.token, inputToken); if (ofTokenIn && permitStands) continue; if ( ofTokenIn && typeof total === "bigint" && typeof t.amount === "bigint" ) { total += t.amount; continue; } spentFromSender.push(t); if (ofTokenIn && !present(t.amount)) total = null; } inputAmount = permitStands ? permit.amount : total; // With no Amount figure, a later take's own figure, or that of a // PERMIT2_TRANSFER_FROM of the same token, into the router or not, // could read as all that leaves, so the step that set the input side, // when the user pays it and it stated no amount, then gets lines of // its own too, first, saying its amount is not known. if ( !present(inputAmount) && firstTakeUnstated !== null && (spentFromSender.some((s) => present(s.amount)) || [...movedFromSender, ...movedIntoRouter].some((m) => sameAddress(m.token, firstTakeUnstated.token), )) ) { spentFromSender.unshift(firstTakeUnstated); } // 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 name Token In. 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, }); } // The text of an amount the swap steps spend, for the Amount line and // the lines for other tokens below it. 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. const spentText = (amount, info) => { if (amount === OPEN_DELTA) { return { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT }; } if (amount === ALREADY_PAID) { return { raw: ALREADY_PAID_AMOUNT, display: ALREADY_PAID_AMOUNT, }; } if (amount >= MAX_UINT160) { return { raw: "Unlimited", display: "Unlimited" }; } if (hasV2OrV3ExactOut) { // A V2 or V3 exact-out swap spends at most this figure, // whichever steps gave it (an amountInMax, the WRAP_ETH of a // swap paid in ETH, a permit, or several steps' total), 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(amount, info); return { raw: "Up to " + most.raw, display: "Up to " + most.display, }; } return amountText(amount, info); }; // A null token in `spentFromSender`, a step this decoder did not read // or a token it does not know, leaves no Amount figure, which may // then be too low. if ( present(inputAmount) && spentFromSender.every((s) => present(s.token)) ) { const amount = spentText(inputAmount, inInfo); details.push({ label: "Amount", value: amount.display, rawValue: amount.raw, }); } // What steps take from the sender beyond the Amount line, in the shape // of the lines below for a PERMIT2_TRANSFER_FROM. An amount this // decoder does not know is said so, and a step it did not read gets // that one line alone. for (const s of spentFromSender) { details.push({ label: "Moved from the sender", value: present(s.amount) ? spentText(s.amount, tokenInfo(s.token, sources)).display : UNDECODED_CURRENCY, }); if (present(s.token)) { details.push(tokenMovedLine("Token moved", s.token, sources)); details.push({ label: "Moved to", value: "The swap step that spends it", }); } } // 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 };