Token and amount were gated on truthiness and gated independently. An address is never falsy once set but 0n is, so a hop supplying a zero amount fixed the token permanently while leaving the amount open, and the next hop's figure was rendered against the first hop's token at that token's scale -- 0.5 WETH shown as 500000000000.0000 USDT. Nine defective gates are now presence checks, and fourteen address gates were converted defensively so a sixth instance of this class cannot grow. Zero is not one thing. Established from v4-periphery: OPEN_DELTA is 0 and V4Router substitutes the full credit unconditionally, so a zero V4 exact-in amount means "swap the whole balance" and now reads "All available (V4 open delta)" rather than 0.0000, which would assert the exact inverse. amountOutMinimum gets no such mapping and universal-router special-cases only CONTRACT_BALANCE, so a zero minimum and a zero V2/V3 amount stay literal -- a zero floor reads "None (no minimum guaranteed)". closes #364
687 lines
28 KiB
JavaScript
687 lines
28 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 { TOKEN_BY_ADDRESS } = require("./tokenList");
|
|
const { truncateAmountNeverZero } = require("./amountDisplay");
|
|
const {
|
|
resolveTokenDecimals,
|
|
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 do
|
|
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
|
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
|
// so a zero `amountIn` there is a literal zero and is displayed as one.
|
|
const OPEN_DELTA = Symbol("v4-open-delta");
|
|
|
|
// The two 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 a third phrasing of "not named": these
|
|
// say different things.
|
|
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
|
const NO_MINIMUM = "None (no minimum guaranteed)";
|
|
|
|
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
|
const MAX_UINT160 = BigInt("0xffffffffffffffffffffffffffffffffffffffff");
|
|
|
|
// `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 };
|
|
}
|
|
const t = TOKEN_BY_ADDRESS.get(address.toLowerCase());
|
|
return {
|
|
symbol: t ? t.symbol : null,
|
|
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)
|
|
function decodeV2SwapExactIn(input) {
|
|
try {
|
|
const d = coder.decode(
|
|
["address", "uint256", "uint256", "address[]", "bool"],
|
|
input,
|
|
);
|
|
return {
|
|
amountIn: 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)
|
|
//
|
|
// Nothing calls this: decode() has no 0x09 arm, so a V2 exact-out swap gets
|
|
// its command name and no token or amount detail. Kept for the fix, which is
|
|
// https://git.eeqj.de/sneak/AutistMask/issues/283.
|
|
// eslint-disable-next-line no-unused-vars
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
|
|
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 = coder.decode(
|
|
[
|
|
"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 {
|
|
// Fall through — SETTLE/TAKE will provide tokens
|
|
}
|
|
} else {
|
|
// ExactInputSingleParams: (tuple(address,address,uint24,int24,address) poolKey,
|
|
// bool zeroForOne, uint128 amountIn, uint128 amountOutMin, bytes hookData)
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"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 {
|
|
// Fall through
|
|
}
|
|
}
|
|
} else if (
|
|
actionId === V4_SWAP_EXACT_OUT ||
|
|
actionId === V4_SWAP_EXACT_OUT_SINGLE
|
|
) {
|
|
if (actionId === V4_SWAP_EXACT_OUT) {
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"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 {
|
|
// Fall through
|
|
}
|
|
} else {
|
|
try {
|
|
const s = coder.decode(
|
|
[
|
|
"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 {
|
|
// Fall through
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Skip sub-actions we can't decode
|
|
}
|
|
}
|
|
|
|
return {
|
|
tokenIn: settleToken,
|
|
tokenOut: takeToken,
|
|
amountIn,
|
|
amountOutMin,
|
|
};
|
|
} 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;
|
|
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]);
|
|
if (b) 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 === 0x0b) {
|
|
const w = decodeWrapEth(inputs[i]);
|
|
if (w) {
|
|
setInputOnce(
|
|
"0x0000000000000000000000000000000000000000",
|
|
w.amount,
|
|
);
|
|
}
|
|
}
|
|
|
|
if (cmdId === 0x10) {
|
|
const v4 = decodeV4Swap(inputs[i]);
|
|
if (v4) {
|
|
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 is the one output that is ETH without a currency to
|
|
// decode, and it is answered here rather than left to that rule.
|
|
const inInfo = tokenInfo(inputToken, sources);
|
|
const outInfo = hasUnwrapWeth
|
|
? { 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)) {
|
|
// Two amounts need no scale to describe and are named rather than
|
|
// formatted: V4's open delta, which is not a quantity at all (see
|
|
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
|
// first — the sentinel is not a bigint and cannot be compared with
|
|
// one.
|
|
let amount;
|
|
if (inputAmount === OPEN_DELTA) {
|
|
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
|
} else if (inputAmount >= MAX_UINT160) {
|
|
amount = { raw: "Unlimited", display: "Unlimited" };
|
|
} 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 ") });
|
|
|
|
const deadlineDate = new Date(Number(deadline) * 1000);
|
|
details.push({
|
|
label: "Deadline",
|
|
value: deadlineDate.toISOString().replace("T", " ").slice(0, 19),
|
|
});
|
|
|
|
return {
|
|
name,
|
|
description: "Swap via Uniswap Universal Router",
|
|
details,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
module.exports = { decode };
|