The four V4 swap actions are read only as the parameter structs deployed on mainnet and Sepolia. ethers decoded the newer v4-periphery structs, which add minHopPriceX36, as the deployed ones without an error, and the newer multi-hop exact-in struct showed the offset of its hop-price array as the swap amount. A decode now counts only when encoding it again gives back the same bytes. When a swap action fails that, the Steps line lists its step as "V4 Swap (could not be decoded)", so the screen no longer looks complete without it. The README records the pinned shape. Model: opus-5-5
777 lines
32 KiB
JavaScript
777 lines
32 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, formatUnits } = require("ethers");
|
|
const { truncateAmountNeverZero } = require("./amountDisplay");
|
|
const {
|
|
resolveTokenDecimals,
|
|
resolveTokenSymbol,
|
|
unknownDecimalsAmount,
|
|
} = require("./approvalAmount");
|
|
|
|
const coder = AbiCoder.defaultAbiCoder();
|
|
|
|
const ROUTER_IFACE = new Interface([
|
|
"function execute(bytes commands, bytes[] inputs, uint256 deadline)",
|
|
]);
|
|
|
|
// Universal Router command IDs (lower 5 bits of each command byte)
|
|
const COMMAND_NAMES = {
|
|
0x00: "V3 Swap (Exact In)",
|
|
0x01: "V3 Swap (Exact Out)",
|
|
0x02: "Permit2 Transfer",
|
|
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 Batch",
|
|
0x0e: "Balance Check",
|
|
0x10: "V4 Swap",
|
|
0x11: "V3 Position Mgr Permit",
|
|
0x12: "V3 Position Mgr Call",
|
|
0x13: "V4 Initialize Pool",
|
|
0x14: "V4 Position Mgr Call",
|
|
0x21: "Execute Sub-Plan",
|
|
};
|
|
|
|
// 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.
|
|
const UNNAMED_CURRENCY = "Unknown (not named in the calldata)";
|
|
|
|
// 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 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 UNWRAP_WETH turns into ETH: on mainnet, then on Sepolia.
|
|
// decode() is not told the network, so it takes either.
|
|
const WETH_ADDRESSES = [
|
|
"0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2",
|
|
"0xfff9976782d46cc05630d1f6ebab18b2324d6b14",
|
|
];
|
|
|
|
// `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 : ""),
|
|
};
|
|
}
|
|
|
|
// 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.
|
|
// 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.
|
|
function decodeV2SwapExactIn(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
input,
|
|
);
|
|
return {
|
|
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
|
amountOutMin: d[2],
|
|
tokenIn: d[3][0],
|
|
tokenOut: d[3][d[3].length - 1],
|
|
};
|
|
} 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 {
|
|
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)
|
|
function decodeV3SwapExactIn(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
["address", "uint256", "uint256", "bytes", "bool"],
|
|
input,
|
|
);
|
|
const path = decodeV3Path(d[3]);
|
|
if (!path) return null;
|
|
return {
|
|
amountIn: d[1],
|
|
amountOutMin: d[2],
|
|
tokenIn: path.tokenIn,
|
|
tokenOut: path.tokenOut,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Decode WRAP_ETH (command 0x0b) input bytes.
|
|
// ABI: (address recipient, uint256 amount)
|
|
function decodeWrapEth(input) {
|
|
try {
|
|
const d = coder.decode(["address", "uint256"], input);
|
|
return { amount: d[1] };
|
|
} 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_TAKE = 0x0e;
|
|
|
|
// 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 V4_SWAP (command 0x10) input bytes.
|
|
// The input is ABI-encoded as (bytes actions, bytes[] params).
|
|
// We extract token addresses from SETTLE (input) and TAKE (output) sub-actions,
|
|
// and swap amounts from the swap sub-actions.
|
|
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;
|
|
let amountIn = null;
|
|
let amountOutMin = null;
|
|
// Set when a swap action's parameters are not in the deployed shape
|
|
// (see decodeExactly()), so nothing was read from that action. SETTLE
|
|
// and TAKE 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];
|
|
} else if (actionId === V4_TAKE) {
|
|
// TAKE: (address currency, address recipient, uint256 amount)
|
|
const t = coder.decode(
|
|
["address", "address", "uint256"],
|
|
params[i],
|
|
);
|
|
takeToken = t[0];
|
|
} else if (
|
|
actionId === V4_SWAP_EXACT_IN ||
|
|
actionId === V4_SWAP_EXACT_IN_SINGLE
|
|
) {
|
|
// Extract amounts from exact-in swap actions
|
|
if (actionId === V4_SWAP_EXACT_IN) {
|
|
// ExactInputParams: (address currencyIn,
|
|
// tuple(address,uint24,int24,address,bytes)[] path,
|
|
// uint128 amountIn, uint128 amountOutMin)
|
|
try {
|
|
const s = decodeExactly(
|
|
[
|
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
],
|
|
params[i],
|
|
);
|
|
if (!present(settleToken)) settleToken = s[0][0];
|
|
const path = s[0][1];
|
|
if (path.length > 0 && !present(takeToken)) {
|
|
takeToken = path[path.length - 1][0];
|
|
}
|
|
if (!present(amountIn)) {
|
|
amountIn = v4ExactInAmount(s[0][2]);
|
|
}
|
|
if (!present(amountOutMin)) {
|
|
amountOutMin = s[0][3];
|
|
}
|
|
} catch {
|
|
swapUndecoded = true;
|
|
}
|
|
} else {
|
|
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
|
|
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
|
try {
|
|
const s = decodeExactly(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
params[i],
|
|
);
|
|
const poolKey = s[0][0];
|
|
const zeroForOne = s[0][1];
|
|
if (!present(settleToken))
|
|
settleToken = zeroForOne
|
|
? poolKey[0]
|
|
: poolKey[1];
|
|
if (!present(takeToken))
|
|
takeToken = zeroForOne
|
|
? poolKey[1]
|
|
: poolKey[0];
|
|
if (!present(amountIn)) {
|
|
amountIn = v4ExactInAmount(s[0][2]);
|
|
}
|
|
if (!present(amountOutMin)) {
|
|
amountOutMin = s[0][3];
|
|
}
|
|
} catch {
|
|
swapUndecoded = true;
|
|
}
|
|
}
|
|
} else if (
|
|
actionId === V4_SWAP_EXACT_OUT ||
|
|
actionId === V4_SWAP_EXACT_OUT_SINGLE
|
|
) {
|
|
if (actionId === V4_SWAP_EXACT_OUT) {
|
|
try {
|
|
const s = decodeExactly(
|
|
[
|
|
"tuple(address,tuple(address,uint24,int24,address,bytes)[],uint128,uint128)",
|
|
],
|
|
params[i],
|
|
);
|
|
if (!present(takeToken)) takeToken = s[0][0];
|
|
const path = s[0][1];
|
|
if (path.length > 0 && !present(settleToken)) {
|
|
settleToken = path[path.length - 1][0];
|
|
}
|
|
} catch {
|
|
swapUndecoded = true;
|
|
}
|
|
} else {
|
|
try {
|
|
const s = decodeExactly(
|
|
[
|
|
"tuple(tuple(address,address,uint24,int24,address),bool,uint128,uint128,bytes)",
|
|
],
|
|
params[i],
|
|
);
|
|
const poolKey = s[0][0];
|
|
const zeroForOne = s[0][1];
|
|
if (!present(settleToken))
|
|
settleToken = zeroForOne
|
|
? poolKey[0]
|
|
: poolKey[1];
|
|
if (!present(takeToken))
|
|
takeToken = zeroForOne
|
|
? poolKey[1]
|
|
: poolKey[0];
|
|
} catch {
|
|
swapUndecoded = true;
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip sub-actions we can't decode
|
|
}
|
|
}
|
|
|
|
return {
|
|
tokenIn: settleToken,
|
|
tokenOut: takeToken,
|
|
amountIn,
|
|
amountOutMin,
|
|
swapUndecoded,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Try to decode a Universal Router execute() call.
|
|
// Returns { name, description, details } matching the format used by
|
|
// the approval UI, or null if the calldata is not a recognised execute().
|
|
function decode(data, toAddress, sources) {
|
|
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 hasUnwrapWeth = false;
|
|
let hasV2ExactOut = 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 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;
|
|
};
|
|
|
|
for (let i = 0; i < commandsBytes.length; i++) {
|
|
const cmdId = commandsBytes[i] & 0x1f;
|
|
commandNames.push(
|
|
COMMAND_NAMES[cmdId] ||
|
|
"Command 0x" + cmdId.toString(16).padStart(2, "0"),
|
|
);
|
|
|
|
try {
|
|
if (cmdId === 0x0a) {
|
|
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);
|
|
}
|
|
|
|
if (cmdId === 0x0e) {
|
|
const b = decodeBalanceCheck(inputs[i]);
|
|
// 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. Any
|
|
// other minBalance sets the output side as a swap does.
|
|
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
|
setOutput(b.token, b.minBalance);
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x00) {
|
|
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 (cmdId === 0x08) {
|
|
const s = decodeV2SwapExactIn(inputs[i]);
|
|
if (s) {
|
|
setInputOnce(s.tokenIn, s.amountIn);
|
|
setOutput(s.tokenOut, s.amountOutMin);
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x09) {
|
|
// 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 (cmdId === 0x0b) {
|
|
const w = decodeWrapEth(inputs[i]);
|
|
if (w) {
|
|
setInputOnce(
|
|
"0x0000000000000000000000000000000000000000",
|
|
w.amount,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x10) {
|
|
const v4 = decodeV4Swap(inputs[i]);
|
|
if (v4) {
|
|
// Without this, a swap action that went unread would
|
|
// look like one that had nothing to show.
|
|
if (v4.swapUndecoded) {
|
|
commandNames[i] += " (could not be decoded)";
|
|
}
|
|
setInputOnce(v4.tokenIn, v4.amountIn);
|
|
// Always update output: last swap step wins. A step
|
|
// that carries the Min. received figure but decoded no
|
|
// output currency makes the output *undetermined* — it
|
|
// is neither ETH nor whatever an earlier step named,
|
|
// and that figure is no longer counted in that token.
|
|
// Equally, a step that names an output currency but no
|
|
// minimum leaves Min. received unstated rather than
|
|
// keeping an earlier step's figure beside the new
|
|
// token. setOutput() is both rules; a step that states
|
|
// neither half leaves the output alone.
|
|
setOutput(v4.tokenOut, v4.amountOutMin);
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x0c) {
|
|
hasUnwrapWeth = true;
|
|
}
|
|
} catch {
|
|
// Skip commands we can't decode
|
|
}
|
|
}
|
|
|
|
// Resolve token info. A null token on either side means the calldata
|
|
// named no currency for it; 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.
|
|
// 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 outputUnset = !present(outputToken) && !present(minOutput);
|
|
const inInfo = tokenInfo(inputToken, sources);
|
|
const outInfo =
|
|
hasUnwrapWeth && (outputIsWeth || outputUnset)
|
|
? { 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: 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,
|
|
});
|
|
}
|
|
|
|
// 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: UNNAMED_CURRENCY });
|
|
}
|
|
|
|
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: "Min. received",
|
|
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 };
|