The swap decoder now gives three lines for every PERMIT2_TRANSFER_FROM it reads but a move into the router, after the swap's Amount line: the amount moved from the sender, the token with its address, and where it goes (the V2 pair the next swap spends, the sender, or the address itself on a line of its own). A move into the router keeps its Paid to and Token moved into the router lines, which already give its amount. Before, a move to another address or to the next V2 pair showed no amount at all. Who counts as paid, and so Min. received against Min. paid out, is unchanged. tokenMovedLine() moves to module level and takes its label as an argument. Model: opus-5-5
1863 lines
84 KiB
JavaScript
1863 lines
84 KiB
JavaScript
// 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 };
|