diff --git a/package-lock.json b/package-lock.json index d7a4ec0..2166923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,11 +15,13 @@ "@coinbase/x402": "^2.1.0", "@metamask/smart-accounts-kit": "^0.4.0-beta.1", "@uniswap/sdk-core": "^7.12.2", + "@uniswap/v3-sdk": "^3.29.1", "@uniswap/v4-sdk": "^1.29.1", "@x402/core": "^2.11.0", "@x402/evm": "^2.11.0", "@x402/extensions": "^2.11.0", "hono": "^4.6.0", + "jsbi": "^3.1.4", "openai": "^4.70.0", "viem": "^2.21.0" }, diff --git a/package.json b/package.json index e7548d5..2a07cbf 100644 --- a/package.json +++ b/package.json @@ -21,11 +21,13 @@ "@coinbase/x402": "^2.1.0", "@metamask/smart-accounts-kit": "^0.4.0-beta.1", "@uniswap/sdk-core": "^7.12.2", + "@uniswap/v3-sdk": "^3.29.1", "@uniswap/v4-sdk": "^1.29.1", "@x402/core": "^2.11.0", "@x402/evm": "^2.11.0", "@x402/extensions": "^2.11.0", "hono": "^4.6.0", + "jsbi": "^3.1.4", "openai": "^4.70.0", "viem": "^2.21.0" }, diff --git a/src/llm/client.ts b/src/llm/client.ts index 7d8962f..c3114b3 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -57,7 +57,7 @@ import { buildRebalancePlan, chainName as crosschainChainName, } from "../services/crosschain.js"; -import { buildAddLiquidityTx, buildRemoveLiquidityTx, getVaultLPPositions, buildCollectFeesTx, buildBurnPositionTx, getPoolInfoById, getPositionDirect } from "../services/uniswapLP.js"; +import { buildAddLiquidityTx, buildRemoveLiquidityTx, buildInitializePoolTx, getVaultLPPositions, buildCollectFeesTx, buildBurnPositionTx, getPoolInfoById, getPositionDirect, POOL_MANAGER } from "../services/uniswapLP.js"; import { buildStakeCalldata, buildUndelegateStakeCalldata, @@ -105,6 +105,7 @@ export const TOOL_NAME_ALIASES: Record = { trade: "build_vault_swap", build_lp_add: "add_liquidity", build_lp_remove: "remove_liquidity", + init_pool: "initialize_pool", bridge_tokens: "crosschain_transfer", stake_grg: "grg_stake", equalize_nav: "crosschain_sync", @@ -4063,7 +4064,16 @@ export async function executeToolCall( `Currency 1: ${info.currency1}`, `Current Tick: ${info.currentTick}`, ``, - `To add liquidity: use fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}`, + info.initialized + ? info.poolKeyKnown + ? `To add liquidity: use fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}` + : `⚠️ Pool is initialized but the pool key could not be confirmed from on-chain Initialize event logs (RPC/node limitation). ` + + `The fee/tickSpacing/hooks/currency0/currency1 values shown may be placeholders or estimates. ` + + `Please verify the exact pool key (fee, tickSpacing, hooks) before calling add_liquidity to avoid a pool-key mismatch. ` + + `You can retry get_pool_info with a different RPC, or provide the known pool key directly.` + : info.poolKeyKnown + ? `⚠️ Pool is NOT initialized. Call initialize_pool first with fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}` + : `⚠️ Pool is NOT initialized. The pool key (fee, tickSpacing, hooks, token0, token1) could not be determined from the pool ID alone — please provide the full pool key so initialize_pool can be called with the correct parameters.`, ].join("\n"); return { message }; @@ -4120,6 +4130,59 @@ export async function executeToolCall( return { message, transaction, chainSwitch: chainSwitched }; } + case "initialize_pool": { + if (!ctx.operatorAddress) { + throw new Error("Wallet not connected. Connect your wallet first."); + } + + let chainSwitched: number | undefined; + if (args.chain) { + const match = resolveChainArg((args.chain as string).trim()); + if (match.id !== ctx.chainId) { + ctx.chainId = match.id; + chainSwitched = match.id; + } + } + + const result = await buildInitializePoolTx(env, { + tokenA: args.tokenA as string, + tokenB: args.tokenB as string, + fee: args.fee as number, + tickSpacing: args.tickSpacing as number | undefined, + hooks: args.hooks as Address | undefined, + sqrtPriceX96: args.sqrtPriceX96 as string | undefined, + amountA: args.amountA as string | undefined, + amountB: args.amountB as string | undefined, + }, ctx.chainId); + + const transaction: UnsignedTransaction = { + to: POOL_MANAGER[ctx.chainId], + data: result.calldata, + value: "0x0", + chainId: ctx.chainId, + gas: await estimateGas( + ctx.chainId, POOL_MANAGER[ctx.chainId], + result.calldata, "0x0", + ctx.operatorAddress, env.ALCHEMY_API_KEY, "lp", + ), + description: result.description, + operatorOnly: true, + }; + + const chainName = resolveChainName(ctx.chainId); + const message = [ + `✅ Initialize Pool ready`, + `${result.description}`, + `Pool ID: ${result.poolId}`, + `Chain: ${chainName}`, + ``, + `⚠️ This transaction must be signed and broadcast from your wallet (not the vault). ` + + `After confirmation, you can add liquidity via add_liquidity.`, + ].join("\n"); + + return { message, transaction, chainSwitch: chainSwitched }; + } + case "remove_liquidity": { if (!ctx.operatorAddress) { throw new Error("Wallet not connected. Connect your wallet first."); diff --git a/src/llm/prompts.ts b/src/llm/prompts.ts index eadfd21..4dd6970 100644 --- a/src/llm/prompts.ts +++ b/src/llm/prompts.ts @@ -105,7 +105,7 @@ export const DOMAIN_TOOLS: Record = { "gmx_claim_funding_fees", "gmx_get_markets", ], lp: [ - "get_pool_info", "add_liquidity", "remove_liquidity", + "get_pool_info", "initialize_pool", "add_liquidity", "remove_liquidity", "get_lp_positions", "collect_lp_fees", "burn_position", ], bridge: [ @@ -296,8 +296,16 @@ GMX INTENT PARSING: Ask the user for pool details or a pool ID, then call get_pool_info to discover the exact pool key. 1. Call get_pool_info if you only have the pool ID. -2. Call add_liquidity with ONE token amount — the backend computes the optimal counterpart. -3. tickRange options: "full" (entire range), "wide" (±50%), "narrow" (±5%), or exact "tickLower,tickUpper". +2. If the pool is NOT initialized, call initialize_pool first (provide both token amounts to compute the initial price). +3. Call add_liquidity with ONE token amount — the backend computes the optimal counterpart. +4. tickRange options: "full" (entire range), "wide" (±50%), "narrow" (±5%), or exact "tickLower,tickUpper". + +POOL INITIALIZATION: +- Uniswap v4 pools must be initialized before adding liquidity. +- initialize_pool targets the PoolManager directly (not the vault) and sets the initial price. +- Anyone can initialize a pool — it does not require vault ownership. +- After initialization, use add_liquidity to add liquidity through the vault. +- fee=0 does NOT auto-derive tickSpacing=32767. Always pass tickSpacing explicitly for non-standard pools. LP POSITION LIFECYCLE: Active → [remove_liquidity] → Closed (0 liquidity, fees may remain, NFT exists) diff --git a/src/llm/tools.ts b/src/llm/tools.ts index e082f18..f8b1a62 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -735,6 +735,9 @@ export const TOOL_DEFINITIONS = [ "the backend computes the optimal counterpart amount from the current pool price and tick range. " + "IMPORTANT: Uniswap v4 supports infinite fee tiers and custom tickSpacings — the fee and " + "tickSpacing must match the pool exactly or the transaction will fail. " + + "CRITICAL: fee=0 does NOT auto-derive tickSpacing=32767; the default for fee=0 is tickSpacing=1. " + + "Always provide tickSpacing explicitly for non-standard pools (e.g. oracle pools with tickSpacing=32767). " + + "If the pool is not yet initialized, call initialize_pool first. " + "If you only know the pool ID, call get_pool_info first to discover the exact pool key.", parameters: { type: "object", @@ -769,8 +772,10 @@ export const TOOL_DEFINITIONS = [ tickSpacing: { type: "number", description: - "Tick spacing for the pool — must match the pool exactly for non-standard pools. " + - "Auto-derived from fee if not specified: 100→1, 500→10, 3000→60, 6000→120, 10000→200. " + + "Tick spacing for the pool — REQUIRED when fee does not map to a standard tier. " + + "Auto-derived from fee if omitted: 100→1, 500→10, 3000→60, 6000→120, 10000→200. " + + "WARNING: fee=0 defaults to tickSpacing=1, NOT 32767. " + + "Oracle pools and full-range positions often use tickSpacing=32767 — pass it explicitly. " + "Use get_pool_info to retrieve the exact value for any pool.", }, tickRange: { @@ -794,6 +799,83 @@ export const TOOL_DEFINITIONS = [ }, }, }, + { + type: "function" as const, + function: { + name: "initialize_pool", + description: + "Initialize a Uniswap v4 pool on the PoolManager. " + + "ANYONE can initialize a pool — it does not require vault ownership. " + + "Initialization sets the initial price (sqrtPriceX96) and creates the pool so liquidity can be added. " + + "You must provide either (1) both token amounts, from which the initial price is computed, OR " + + "(2) an explicit sqrtPriceX96 string. " + + "After initialization, use add_liquidity to add liquidity through the vault. " + + "This transaction targets the PoolManager directly (not the vault) and must be signed by the operator.", + parameters: { + type: "object", + properties: { + tokenA: { + type: "string", + description: "First token — symbol (e.g., ETH, WBTC, USDC) or address", + }, + tokenB: { + type: "string", + description: "Second token — symbol (e.g., USDT, USDC, WBTC) or address", + }, + fee: { + type: "number", + description: + "Pool fee in hundredths of a bip — REQUIRED. " + + "Common values: 0=0%, 100=0.01%, 500=0.05%, 3000=0.30%, 10000=1%.", + }, + tickSpacing: { + type: "number", + description: + "Tick spacing for the pool — REQUIRED for non-standard pools. " + + "Auto-derived from fee if omitted: 100→1, 500→10, 3000→60, 6000→120, 10000→200. " + + "WARNING: fee=0 defaults to tickSpacing=1, NOT 32767.", + }, + hooks: { + type: "string", + description: "Hook contract address. Default: zero address (no hooks).", + }, + sqrtPriceX96: { + type: "string", + description: + "Explicit initial sqrtPriceX96 as a decimal string (Q64.96 fixed-point). " + + "If omitted, amountA and amountB must be provided to compute the price automatically.", + }, + amountA: { + type: "string", + description: + "Amount of tokenA to use for price computation (human-readable). " + + "Required if sqrtPriceX96 is omitted.", + }, + amountB: { + type: "string", + description: + "Amount of tokenB to use for price computation (human-readable). " + + "Required if sqrtPriceX96 is omitted.", + }, + chain: { + type: "string", + description: "Target chain name or ID (e.g., 'ethereum', 'base', '42161')", + }, + }, + required: ["tokenA", "tokenB", "fee"], + oneOf: [ + { + description: "Provide an explicit initial sqrtPriceX96", + required: ["sqrtPriceX96"], + }, + { + description: "Provide both token amounts to compute the initial price automatically", + required: ["amountA", "amountB"], + }, + ], + }, + }, + }, { type: "function" as const, function: { diff --git a/src/routes/tools.ts b/src/routes/tools.ts index f10663f..0eb518e 100644 --- a/src/routes/tools.ts +++ b/src/routes/tools.ts @@ -45,7 +45,7 @@ function getToolCategory(name: string): string { if (name.startsWith("get_swap_quote") || name === "build_vault_swap") return "Spot Trading"; if (name.startsWith("get_vault_info") || name === "get_token_balance" || name === "switch_chain") return "Vault Info"; if (name.startsWith("gmx_")) return "GMX Perpetuals"; - if (name.startsWith("get_pool_info") || name.startsWith("add_liquidity") || name.startsWith("remove_liquidity") || name.startsWith("collect_lp_fees") || name.startsWith("burn_position") || name.startsWith("get_lp_positions")) return "Uniswap v4 LP"; + if (name.startsWith("get_pool_info") || name.startsWith("add_liquidity") || name.startsWith("remove_liquidity") || name.startsWith("collect_lp_fees") || name.startsWith("burn_position") || name.startsWith("get_lp_positions") || name.startsWith("initialize_pool")) return "Uniswap v4 LP"; if (name.startsWith("crosschain_") || name.startsWith("get_aggregated_nav") || name.startsWith("get_rebalance_plan") || name.startsWith("verify_bridge_arrival")) return "Cross-Chain"; if (name.startsWith("grg_")) return "GRG Staking"; if (name === "deploy_smart_pool" || name === "fund_pool") return "Vault Management"; diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index 94e87aa..63d51f5 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -23,11 +23,14 @@ import { type Address, type Hex, encodeAbiParameters, + encodeFunctionData, keccak256, parseUnits, formatUnits, } from "viem"; import { Pool, Position, V4PositionManager } from "@uniswap/v4-sdk"; +import { TickMath } from "@uniswap/v3-sdk"; +import JSBI from "jsbi"; import { Token, Ether, Percent, type Currency } from "@uniswap/sdk-core"; import { encodeVaultModifyLiquidities, getTokenDecimals, getClient } from "./vault.js"; import { resolveTokenAddress } from "../config.js"; @@ -42,7 +45,7 @@ import { ERC20_ABI } from "../abi/erc20.js"; * Source: https://docs.uniswap.org/contracts/v4/deployments * NOTE: PoolManager is NOT at the same address on every chain. */ -const POOL_MANAGER: Record = { +export const POOL_MANAGER: Record = { 1: "0x000000000004444c5dc75cB358380D2e3dE08A90", // Ethereum 10: "0x9a13f98cb987694c9f086b1f5eb990eea8264ec3", // Optimism 56: "0x28e2ea090877bf75740558f6bfb36a5ffee9e9df", // BNB Chain @@ -73,6 +76,50 @@ const STATE_VIEW: Record = { /** Q96 = 2^96 for fixed-point sqrtPrice math */ const Q96 = 2n ** 96n; +/** TickMath absolute sqrtRatio bounds (from Uniswap v3 TickMath.sol) */ +const MIN_SQRT_RATIO = 4295128739n; +const MAX_SQRT_RATIO = 1461446703485210103287273052203988822378723970342n; + +/** Integer square root — returns floor(sqrt(n)) using Newton-Raphson. */ +function isqrt(n: bigint): bigint { + if (n === 0n) return 0n; + let x = n; + let y = (x + 1n) >> 1n; + while (y < x) { + x = y; + y = (x + n / x) >> 1n; + } + return x; +} + +/** + * Compute sqrtPriceX96 from a ratio of raw token amounts using integer math. + * Avoids floating-point precision loss for large ERC-20 base-unit amounts. + * + * sqrtPriceX96 = sqrt(amount1 / amount0) * 2^96 + * = floor(sqrt(amount1 * 2^192 / amount0)) + * + * @throws if either amount is zero (would produce an invalid or infinite price) + * @throws if the computed sqrtPriceX96 falls outside TickMath bounds (extreme price ratio) + */ +export function computeSqrtPriceX96FromAmounts(amount0: bigint, amount1: bigint): bigint { + if (amount0 <= 0n) throw new Error("amount0 must be greater than zero to compute initial pool price"); + if (amount1 <= 0n) throw new Error("amount1 must be greater than zero to compute initial pool price"); + const sqrtPriceX96 = isqrt(amount1 * Q96 * Q96 / amount0); + if (sqrtPriceX96 < MIN_SQRT_RATIO || sqrtPriceX96 >= MAX_SQRT_RATIO) { + throw new Error( + `Computed sqrtPriceX96 (${sqrtPriceX96}) is outside TickMath bounds. ` + + `Choose a price ratio that corresponds to a tick within [${MIN_TICK}, ${MAX_TICK}].` + ); + } + return sqrtPriceX96; +} + +/** Get the floor tick for a given sqrtPriceX96 using the official TickMath. */ +function getTickAtSqrtPriceX96(sqrtPriceX96: bigint): number { + return TickMath.getTickAtSqrtRatio(JSBI.BigInt(sqrtPriceX96.toString())); +} + /** Absolute tick bounds (for tickSpacing=1; aligned to actual spacing below) */ const MIN_TICK = -887272; const MAX_TICK = 887272; @@ -158,9 +205,6 @@ async function readPoolState( const tick = slot0Result[1]; const liquidity = liqResult; - if (sqrtPriceX96 === 0n) { - throw new Error("Pool not initialized or pool ID not found on this chain."); - } return { sqrtPriceX96, tick, liquidity }; } @@ -220,6 +264,26 @@ function getAmountsForLiquidity( // ── PoolKey Helpers ──────────────────────────────────────────────────── +/** + * Validate fee and tickSpacing against their on-chain ABI types. + * fee: uint24 (0 ≤ fee ≤ 16,777,215, integer) + * tickSpacing: int24, must be > 0 and ≤ 8,388,607 (integer) + */ +function validatePoolKeyNumerics(fee: number, tickSpacing: number): void { + if (!Number.isInteger(fee) || fee < 0 || fee > 0xffffff) { + throw new Error( + `Invalid fee: ${fee}. Fee must be a non-negative integer that fits in uint24 (0–16,777,215). ` + + `Common values: 100 (0.01%), 500 (0.05%), 3000 (0.3%), 10000 (1%).` + ); + } + if (!Number.isInteger(tickSpacing) || tickSpacing <= 0 || tickSpacing > 0x7fffff) { + throw new Error( + `Invalid tickSpacing: ${tickSpacing}. Tick spacing must be a positive integer that fits in int24 (1–8,388,607). ` + + `Common values: 1, 10, 60, 200.` + ); + } +} + /** Compute pool ID = keccak256(abi.encode(PoolKey)). */ function computePoolId(poolKey: PoolKey): Hex { return keccak256( @@ -360,6 +424,7 @@ export async function buildAddLiquidityTx( const fee = params.fee; const tickSpacing = params.tickSpacing ?? defaultTickSpacing(fee); const hooks = params.hooks ?? ZERO_ADDRESS; + validatePoolKeyNumerics(fee, tickSpacing); const poolKey: PoolKey = { currency0, currency1, fee, tickSpacing, hooks }; const poolId = computePoolId(poolKey); @@ -373,14 +438,36 @@ export async function buildAddLiquidityTx( poolState = await readPoolState(poolId, chainId, env.ALCHEMY_API_KEY); } catch (e) { const msg = e instanceof Error ? e.message : String(e); + // Distinguish between infrastructure failures (StateView not deployed, RPC errors) + // and pool-key mismatches so users are not misled into retrying with a different key. + const isInfraError = msg.includes("StateView not available") || msg.includes("StateView"); + if (isInfraError) { + throw new Error( + `Failed to read pool state on chain ${chainId}: ${msg}. ` + + `Ensure the StateView contract is deployed on this chain and the RPC is reachable.` + ); + } throw new Error( `Pool not found on chain ${chainId} with fee=${fee / 10000}%, tickSpacing=${tickSpacing}, hooks=${hooks}. ` + + `Computed pool ID: ${poolId}. ` + `Uniswap v4 requires an exact pool key match. ` + - `Use get_pool_info with the pool ID to discover the correct fee and tickSpacing. ` + + `If the pool is not yet initialized, call initialize_pool first. ` + `(${msg})` ); } + // 4a. sqrtPriceX96 = 0 means either the pool is uninitialized OR the pool key + // (fee/tickSpacing/hooks) doesn't match any existing pool. Both cases block LP. + if (poolState.sqrtPriceX96 === 0n) { + throw new Error( + `Pool ${poolId} returned sqrtPriceX96 = 0, which means either: ` + + `(a) the pool exists but has not been initialized yet, or ` + + `(b) the pool key (fee=${fee}, tickSpacing=${tickSpacing}, hooks=${hooks}) does not match any existing pool. ` + + `Use get_pool_info to verify the exact pool key. ` + + `If the pool is not yet initialized, use initialize_pool with the correct pool key and an initial price.` + ); + } + // 5. Resolve tick range const range = params.tickRange ?? "full"; const { tickLower, tickUpper } = resolveTickRange(range, poolState.tick, tickSpacing); @@ -471,6 +558,150 @@ export async function buildAddLiquidityTx( return { calldata: sdkCalldata as Hex, description, poolId, tickLower, tickUpper }; } +export interface InitializePoolParams { + tokenA: string; // symbol or address + tokenB: string; + fee: number; // fee in hundredths of a bip + tickSpacing?: number; // defaults based on fee tier + hooks?: Address; // hook contract (default: zero address) + /** Initial sqrtPriceX96 as a string. If omitted, computed from amountA + amountB. */ + sqrtPriceX96?: string; + amountA?: string; // human-readable amount of tokenA (required if sqrtPriceX96 omitted) + amountB?: string; // human-readable amount of tokenB (required if sqrtPriceX96 omitted) +} + +/** + * Build an unsigned transaction to initialize a Uniswap v4 pool. + * + * The transaction targets the PoolManager directly (not the vault), because + * the Rigoblock vault adapter does not expose `initializePool`. Anyone can + * initialize a pool — it does not need to be the vault owner. + * + * After initialization, use add_liquidity to add liquidity through the vault. + */ +export async function buildInitializePoolTx( + env: Env, + params: InitializePoolParams, + chainId: number, +): Promise<{ calldata: Hex; description: string; poolId: Hex; poolKey: PoolKey; sqrtPriceX96: string; operatorOnly: true }> { + const [addrA, addrB] = await Promise.all([ + resolveTokenAddress(chainId, params.tokenA), + resolveTokenAddress(chainId, params.tokenB), + ]); + + if (addrA.toLowerCase() === addrB.toLowerCase()) { + throw new Error(`tokenA and tokenB must be different tokens, but both resolved to ${addrA}.`); + } + + const isALower = addrA.toLowerCase() < addrB.toLowerCase(); + const currency0 = (isALower ? addrA : addrB) as Address; + const currency1 = (isALower ? addrB : addrA) as Address; + + const [dec0, dec1] = await Promise.all([ + getTokenDecimals(chainId, currency0, env.ALCHEMY_API_KEY), + getTokenDecimals(chainId, currency1, env.ALCHEMY_API_KEY), + ]); + + const fee = params.fee; + const tickSpacing = params.tickSpacing ?? defaultTickSpacing(fee); + const hooks = params.hooks ?? ZERO_ADDRESS; + validatePoolKeyNumerics(fee, tickSpacing); + + const poolKey: PoolKey = { currency0, currency1, fee, tickSpacing, hooks }; + const poolId = computePoolId(poolKey); + + try { + const state = await readPoolState(poolId, chainId, env.ALCHEMY_API_KEY); + if (state.sqrtPriceX96 !== 0n) { + throw new Error(`Pool ${poolId} is already initialized (sqrtPriceX96 = ${state.sqrtPriceX96.toString()}).`); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes("already initialized")) throw e; + // Only swallow StateView-unavailable errors — those are expected on chains without + // StateView deployment. Rethrow all other errors (e.g. RPC failures) so the caller + // gets a clear "cannot verify" message instead of silently proceeding to build a tx + // that might revert. + if (!msg.includes("StateView not available")) { + throw new Error(`Could not verify pool initialization state: ${msg}. Please retry or check your RPC connection.`); + } + // StateView not available on this chain — proceed without the pre-check. + } + + let sqrtPriceX96: bigint; + if (params.sqrtPriceX96 !== undefined) { + sqrtPriceX96 = BigInt(params.sqrtPriceX96); + if (sqrtPriceX96 <= 0n) { + throw new Error(`sqrtPriceX96 must be greater than zero (got ${sqrtPriceX96}).`); + } + if (sqrtPriceX96 < MIN_SQRT_RATIO || sqrtPriceX96 >= MAX_SQRT_RATIO) { + throw new Error( + `sqrtPriceX96 ${sqrtPriceX96} is outside TickMath bounds ` + + `[${MIN_SQRT_RATIO}, ${MAX_SQRT_RATIO}). ` + + `Use computeSqrtPriceX96FromAmounts (via amountA + amountB) to derive a valid initial price.` + ); + } + } else if (params.amountA !== undefined && params.amountB !== undefined) { + const raw0 = isALower ? params.amountA : params.amountB; + const raw1 = isALower ? params.amountB : params.amountA; + const amount0 = parseUnits(raw0, dec0); + const amount1 = parseUnits(raw1, dec1); + sqrtPriceX96 = computeSqrtPriceX96FromAmounts(amount0, amount1); + } else { + throw new Error( + `Either sqrtPriceX96 or both amounts (amountA + amountB) must be provided to compute the initial pool price.` + ); + } + + const tick = getTickAtSqrtPriceX96(sqrtPriceX96); + + // Build PoolManager.initialize(poolKey, sqrtPriceX96) calldata + const poolManager = POOL_MANAGER[chainId]; + if (!poolManager) { + throw new Error(`Uniswap v4 PoolManager not available on chain ${chainId}.`); + } + + const initializeAbi = [{ + name: "initialize", + type: "function" as const, + stateMutability: "nonpayable" as const, + inputs: [ + { + name: "key", + type: "tuple" as const, + components: [ + { name: "currency0", type: "address" as const }, + { name: "currency1", type: "address" as const }, + { name: "fee", type: "uint24" as const }, + { name: "tickSpacing", type: "int24" as const }, + { name: "hooks", type: "address" as const }, + ], + }, + { name: "sqrtPriceX96", type: "uint160" as const }, + ], + outputs: [{ name: "tick", type: "int24" as const }], + }] as const; + + const calldata = encodeFunctionData({ + abi: initializeAbi, + functionName: "initialize", + args: [poolKey, sqrtPriceX96], + }); + + const sym0 = isALower ? params.tokenA : params.tokenB; + const sym1 = isALower ? params.tokenB : params.tokenA; + const description = `[Uniswap v4] Initialize pool: ${sym0}/${sym1} (fee ${fee / 10000}%, tickSpacing ${tickSpacing}, hooks ${hooks}) at tick ${tick}, sqrtPriceX96 ${sqrtPriceX96.toString()}`; + + return { + calldata, + description, + poolId, + poolKey, + sqrtPriceX96: sqrtPriceX96.toString(), + operatorOnly: true, + }; +} + export interface RemoveLiquidityParams { tokenA: string; tokenB: string; @@ -605,6 +836,13 @@ export interface PoolInfo { /** The keccak256(abi.encode(PoolKey)) pool ID. */ poolId: Hex; initialized: boolean; + /** + * True when fee/tickSpacing/hooks/currency0/currency1 are authoritative values from + * the on-chain Initialize event. False when the pool is uninitialized and these fields + * are zero/unknown placeholders — callers must ask the user for the full pool key before + * presenting them as parameters for initialize_pool. + */ + poolKeyKnown: boolean; /** Fee tier in hundredths of a bip (e.g. 6000 = 0.60%). */ fee: number; /** Tick spacing — always exact (from Initialize event or provided). */ @@ -654,10 +892,6 @@ export async function getPoolInfoById( const tick = slot0Result[1]; const lpFee = slot0Result[3]; - if (sqrtPriceX96 === 0n) { - throw new Error(`Pool ${poolId} not found or not initialized on chain ${chainId}.`); - } - // 2. Fetch Initialize event to recover tickSpacing, hooks, and token addresses let fee: number = lpFee; let tickSpacing: number = defaultTickSpacing(lpFee); @@ -665,6 +899,7 @@ export async function getPoolInfoById( let currency0: Address = "0x0000000000000000000000000000000000000000" as Address; let currency1: Address = "0x0000000000000000000000000000000000000000" as Address; + let hasInitializeEvent = false; try { const logs = await client.getLogs({ address: poolManager, @@ -674,6 +909,7 @@ export async function getPoolInfoById( toBlock: "latest", }); if (logs.length > 0 && logs[0].args) { + hasInitializeEvent = true; const e = logs[0].args; fee = Number(e.fee ?? lpFee); tickSpacing = Number(e.tickSpacing ?? defaultTickSpacing(lpFee)); @@ -685,9 +921,29 @@ export async function getPoolInfoById( // Initialize event lookup failed (node limitation) — fee from slot0, ts estimated } + if (sqrtPriceX96 === 0n && !hasInitializeEvent) { + // Pool is uninitialized (slot0 returns 0 and no Initialize event found). + // Return an uninitialized PoolInfo so callers can guide the user to initialize_pool. + // Note: initialize_pool requires the full pool key (tokenA, tokenB, fee, tickSpacing, + // hooks) — it cannot be derived from a poolId alone. + return { + poolId, + initialized: false, + poolKeyKnown: false, + fee, + tickSpacing, + hooks, + currency0, + currency1, + sqrtPriceX96: "0", + currentTick: 0, + }; + } + return { poolId, - initialized: true, + initialized: hasInitializeEvent || sqrtPriceX96 !== 0n, + poolKeyKnown: hasInitializeEvent, fee, tickSpacing, hooks, diff --git a/tests/tools.test.ts b/tests/tools.test.ts index 44b7bb9..c27c052 100644 --- a/tests/tools.test.ts +++ b/tests/tools.test.ts @@ -26,6 +26,7 @@ const REQUIRED_TOOLS = [ "gmx_increase_position", // Uniswap LP "get_pool_info", + "initialize_pool", "add_liquidity", "remove_liquidity", "get_lp_positions", diff --git a/tests/uniswapLP.test.ts b/tests/uniswapLP.test.ts index 898096e..8b8f655 100644 --- a/tests/uniswapLP.test.ts +++ b/tests/uniswapLP.test.ts @@ -10,7 +10,7 @@ * Helper: pack tickLower into bits[31..8] and tickUpper into bits[55..32]. */ import { describe, it, expect } from "vitest"; -import { decodePositionInfo, buildBurnPositionTx, buildRemoveLiquidityTx, buildCollectFeesTx } from "../src/services/uniswapLP.js"; +import { decodePositionInfo, buildBurnPositionTx, buildRemoveLiquidityTx, buildCollectFeesTx, computeSqrtPriceX96FromAmounts } from "../src/services/uniswapLP.js"; import { Pool, Position, V4PositionManager } from "@uniswap/v4-sdk"; import { Token, Percent } from "@uniswap/sdk-core"; @@ -307,3 +307,60 @@ describe("buildCollectFeesTx", () => { }); }); +// ── computeSqrtPriceX96FromAmounts — integer math precision ────────── +// Validates that the integer-math implementation produces correct results +// without floating-point overflow or precision loss for ERC-20 base units. + +describe("computeSqrtPriceX96FromAmounts", () => { + const Q96 = 2n ** 96n; + + it("1:1 ratio returns exactly Q96 (sqrt(1) * 2^96)", () => { + // Any equal amounts produce price = 1, so sqrtPriceX96 = 2^96 + const result = computeSqrtPriceX96FromAmounts(10n ** 6n, 10n ** 6n); + expect(result).toBe(Q96); + }); + + it("4:1 ratio (amount1/amount0 = 4) returns 2 * Q96", () => { + // price = 4, sqrt(4) = 2, sqrtPriceX96 = 2 * Q96 + const result = computeSqrtPriceX96FromAmounts(1n, 4n); + expect(result).toBe(2n * Q96); + }); + + it("throws when amount0 is zero", () => { + expect(() => computeSqrtPriceX96FromAmounts(0n, 10n ** 6n)).toThrow("amount0 must be greater than zero"); + }); + + it("throws when amount1 is zero", () => { + expect(() => computeSqrtPriceX96FromAmounts(10n ** 6n, 0n)).toThrow("amount1 must be greater than zero"); + }); + + it("handles large ERC-20 base-unit amounts without precision loss", () => { + // 1 ETH (18 dec) vs 2000 USDC (6 dec): amount0=1e18, amount1=2000e6 + // price = 2000e6 / 1e18 = 2e-9; sqrtPriceX96 = sqrt(2e-9) * 2^96 ≈ 3.54e24 + // Key property: result must be > 0 (floating-point would lose precision for such small prices) + const amount0 = 10n ** 18n; // 1 ETH in wei + const amount1 = 2000n * 10n ** 6n; // 2000 USDC + const result = computeSqrtPriceX96FromAmounts(amount0, amount1); + expect(result).toBeGreaterThan(0n); + // sqrt(2e-9) * 2^96 ≈ 3.54e24 — verify we're in the right order of magnitude + expect(result).toBeGreaterThan(3_000_000_000_000_000_000_000_000n); + expect(result).toBeLessThan(4_000_000_000_000_000_000_000_000n); + }); + + it("throws when ratio is so extreme that sqrtPriceX96 is out of TickMath bounds", () => { + // Ratio 1:1e60 is far outside valid tick range + expect(() => computeSqrtPriceX96FromAmounts(1n, 10n ** 60n)).toThrow("outside TickMath bounds"); + }); + + it("result is symmetric: swapping amounts produces reciprocal sqrtPriceX96", () => { + // price(a/b) = 1 / price(b/a), so sqrtPriceX96(a,b) * sqrtPriceX96(b,a) ≈ Q96^2 + const r1 = computeSqrtPriceX96FromAmounts(1n * 10n ** 6n, 4n * 10n ** 6n); // price = 4 + const r2 = computeSqrtPriceX96FromAmounts(4n * 10n ** 6n, 1n * 10n ** 6n); // price = 0.25 + // r1 = 2*Q96, r2 = 0.5*Q96 → r1 * r2 = Q96^2 (exact for these amounts) + const product = r1 * r2; + const expected = Q96 * Q96; + // Allow ±2 for integer floor rounding of each sqrt value + const diff = product > expected ? product - expected : expected - product; + expect(diff).toBeLessThanOrEqual(2n); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0171adb..651705c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1744,7 +1744,7 @@ "@uniswap/v3-core" "^1.0.0" base64-sol "1.0.1" -"@uniswap/v3-sdk@3.29.1": +"@uniswap/v3-sdk@^3.29.1", "@uniswap/v3-sdk@3.29.1": version "3.29.1" resolved "https://registry.npmjs.org/@uniswap/v3-sdk/-/v3-sdk-3.29.1.tgz" integrity sha512-EYCzrfCCxc9DqUguw5rX+9774jvpVIvvrnyraJenZ371JiX/VC09/6OToKReu3gJfgITaH1BynMATwGkmOb2SQ==