From 5a32bdba73b5115bc5969729a781fed89fd814df Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 18:09:24 +0200 Subject: [PATCH 01/10] fix add liquidity --- src/llm/client.ts | 60 ++++++++++++- src/llm/prompts.ts | 14 +++- src/llm/tools.ts | 76 ++++++++++++++++- src/routes/tools.ts | 2 +- src/services/uniswapLP.ts | 172 +++++++++++++++++++++++++++++++++++--- tests/tools.test.ts | 1 + 6 files changed, 307 insertions(+), 18 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 7d8962f..d659a14 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,9 @@ 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 + ? `To add liquidity: use fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}` + : `⚠️ Pool is NOT initialized. Call initialize_pool first with fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}`, ].join("\n"); return { message }; @@ -4120,6 +4123,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..e9100fa 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,73 @@ 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"], + }, + }, + }, { 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..7e3ee03 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,18 @@ const STATE_VIEW: Record = { /** Q96 = 2^96 for fixed-point sqrtPrice math */ const Q96 = 2n ** 96n; +/** Compute sqrtPriceX96 from a ratio of raw token amounts. */ +function computeSqrtPriceX96FromAmounts(amount0: bigint, amount1: bigint): bigint { + const price = Number(amount1) / Number(amount0); + const sqrtPrice = Math.sqrt(price); + return BigInt(Math.floor(sqrtPrice * Number(Q96))); +} + +/** 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 +173,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 }; } @@ -375,12 +387,22 @@ export async function buildAddLiquidityTx( const msg = e instanceof Error ? e.message : String(e); 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. Pool exists on-chain but has not been initialized (sqrtPriceX96 = 0). + if (poolState.sqrtPriceX96 === 0n) { + throw new Error( + `Pool ${poolId} is not initialized. ` + + `You must initialize the pool before adding liquidity. ` + + `Use the initialize_pool tool with the same pool key and an initial price (or both token amounts).` + ); + } + // 5. Resolve tick range const range = params.tickRange ?? "full"; const { tickLower, tickUpper } = resolveTickRange(range, poolState.tick, tickSpacing); @@ -471,6 +493,129 @@ 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), + ]); + + 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; + + const poolKey: PoolKey = { currency0, currency1, fee, tickSpacing, hooks }; + const poolId = computePoolId(poolKey); + + // Check if pool is already initialized + 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; + // Pool not found on StateView is OK — means it truly doesn't exist yet + } + + let sqrtPriceX96: bigint; + if (params.sqrtPriceX96 !== undefined) { + sqrtPriceX96 = BigInt(params.sqrtPriceX96); + } 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; @@ -654,10 +799,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 +806,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 +816,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 +828,18 @@ export async function getPoolInfoById( // Initialize event lookup failed (node limitation) — fee from slot0, ts estimated } + if (sqrtPriceX96 === 0n && !hasInitializeEvent) { + throw new Error( + `Pool ${poolId} not found on chain ${chainId}. ` + + `No Initialize event exists and slot0 shows sqrtPriceX96 = 0. ` + + `The pool may not exist, or the RPC node does not support log filtering. ` + + `Verify the pool ID and chain.` + ); + } + return { poolId, - initialized: true, + initialized: sqrtPriceX96 !== 0n, 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", From 115b3c5cda59c8189c3e1d06eb80381d65b279e4 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 18:23:51 +0200 Subject: [PATCH 02/10] fix: address Copilot review comments on initialize_pool - Replace floating-point sqrtPriceX96 math with pure BigInt integer arithmetic (Newton-Raphson isqrt) to avoid precision loss and Infinity/overflow for large ERC-20 base-unit amounts - Add input validation: throw when amount0 or amount1 is zero - Add TickMath bounds check on computed sqrtPriceX96 - Export computeSqrtPriceX96FromAmounts for unit testing - Add @uniswap/v3-sdk and jsbi as direct package.json dependencies (previously only transitive via @uniswap/v4-sdk) - Fix inaccurate catch comment in buildInitializePoolTx: readPoolState returns sqrtPriceX96=0 for uninitialized pools (does not throw); the catch block handles RPC/StateView deployment failures - Fix getPoolInfoById to return structured PoolInfo with initialized=false instead of throwing when pool is uninitialized, enabling get_pool_info to surface the initialization guidance message in client.ts - Add unit tests for computeSqrtPriceX96FromAmounts covering precision, edge cases, zero inputs, out-of-bounds ratios, and symmetry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 2 ++ src/services/uniswapLP.ts | 65 +++++++++++++++++++++++++++++++-------- tests/uniswapLP.test.ts | 59 ++++++++++++++++++++++++++++++++++- 3 files changed, 113 insertions(+), 13 deletions(-) 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/services/uniswapLP.ts b/src/services/uniswapLP.ts index 7e3ee03..2e86957 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -76,11 +76,42 @@ const STATE_VIEW: Record = { /** Q96 = 2^96 for fixed-point sqrtPrice math */ const Q96 = 2n ** 96n; -/** Compute sqrtPriceX96 from a ratio of raw token amounts. */ -function computeSqrtPriceX96FromAmounts(amount0: bigint, amount1: bigint): bigint { - const price = Number(amount1) / Number(amount0); - const sqrtPrice = Math.sqrt(price); - return BigInt(Math.floor(sqrtPrice * Number(Q96))); +/** 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) + */ +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. */ @@ -549,7 +580,8 @@ export async function buildInitializePoolTx( } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg.includes("already initialized")) throw e; - // Pool not found on StateView is OK — means it truly doesn't exist yet + // readPoolState returns sqrtPriceX96=0 for uninitialized pools (does not throw). + // A thrown error here indicates an RPC issue or missing StateView deployment — proceed. } let sqrtPriceX96: bigint; @@ -829,12 +861,21 @@ export async function getPoolInfoById( } if (sqrtPriceX96 === 0n && !hasInitializeEvent) { - throw new Error( - `Pool ${poolId} not found on chain ${chainId}. ` + - `No Initialize event exists and slot0 shows sqrtPriceX96 = 0. ` + - `The pool may not exist, or the RPC node does not support log filtering. ` + - `Verify the pool ID and chain.` - ); + // 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, + fee, + tickSpacing, + hooks, + currency0, + currency1, + sqrtPriceX96: "0", + currentTick: 0, + }; } return { diff --git a/tests/uniswapLP.test.ts b/tests/uniswapLP.test.ts index 898096e..f9e3016 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 + const product = r1 * r2; + const expected = Q96 * Q96; + // Allow ±1 for integer floor rounding + const diff = product > expected ? product - expected : expected - product; + expect(diff).toBeLessThanOrEqual(Q96); + }); +}); From 4bebd7fda9480601c9684708b60c961ac3d47ffc Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 18:31:42 +0200 Subject: [PATCH 03/10] fix: address round-2 Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Distinguish infrastructure errors (StateView not deployed, RPC failures) from pool-key mismatches in the readPoolState catch block so users are not misled into retrying with a different pool key when the backend simply cannot read on-chain state - Add poolKeyKnown: boolean to PoolInfo interface; set false for uninitialized pools (where fee/tickSpacing/hooks/currency0/currency1 are zero placeholders) and true when values come from the on-chain Initialize event - Update get_pool_info message in client.ts: when poolKeyKnown is false, ask the user for the full pool key instead of presenting placeholder values as valid initialize_pool parameters - Tighten symmetry test tolerance from Q96 (~7.9e28) to 2n, matching the comment intent of allowing ±1 floor rounding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 4 +++- src/services/uniswapLP.ts | 18 ++++++++++++++++++ tests/uniswapLP.test.ts | 6 +++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index d659a14..2e9870f 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -4066,7 +4066,9 @@ export async function executeToolCall( ``, info.initialized ? `To add liquidity: use fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}` - : `⚠️ Pool is NOT initialized. Call initialize_pool first with fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}`, + : 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 }; diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index 2e86957..575ed49 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -416,6 +416,15 @@ 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}. ` + @@ -782,6 +791,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). */ @@ -868,6 +884,7 @@ export async function getPoolInfoById( return { poolId, initialized: false, + poolKeyKnown: false, fee, tickSpacing, hooks, @@ -881,6 +898,7 @@ export async function getPoolInfoById( return { poolId, initialized: sqrtPriceX96 !== 0n, + poolKeyKnown: true, fee, tickSpacing, hooks, diff --git a/tests/uniswapLP.test.ts b/tests/uniswapLP.test.ts index f9e3016..8b8f655 100644 --- a/tests/uniswapLP.test.ts +++ b/tests/uniswapLP.test.ts @@ -356,11 +356,11 @@ describe("computeSqrtPriceX96FromAmounts", () => { // 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 + // r1 = 2*Q96, r2 = 0.5*Q96 → r1 * r2 = Q96^2 (exact for these amounts) const product = r1 * r2; const expected = Q96 * Q96; - // Allow ±1 for integer floor rounding + // Allow ±2 for integer floor rounding of each sqrt value const diff = product > expected ? product - expected : expected - product; - expect(diff).toBeLessThanOrEqual(Q96); + expect(diff).toBeLessThanOrEqual(2n); }); }); From 20e179cd58644888435f5a0f73a9c0b461d6c287 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 18:38:17 +0200 Subject: [PATCH 04/10] fix: validate user-provided sqrtPriceX96 in buildInitializePoolTx When params.sqrtPriceX96 is supplied directly, add explicit bounds validation (>0, within [MIN_SQRT_RATIO, MAX_SQRT_RATIO)) before passing the value to TickMath/encodeFunctionData, consistent with the checks already present in computeSqrtPriceX96FromAmounts. Throws a user-friendly error rather than a low-level revert when the value is out of range. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/uniswapLP.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index 575ed49..af7c20a 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -596,6 +596,16 @@ export async function buildInitializePoolTx( 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; From 4dbef72142f01069f30f050677a2f023f9e7c297 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 18:45:39 +0200 Subject: [PATCH 05/10] fix: add oneOf constraint to initialize_pool tool schema Encode the mutual-exclusion between sqrtPriceX96 and (amountA+amountB) directly in the JSON Schema parameters object. The top-level required array continues to mandate tokenA, tokenB, and fee; a oneOf clause adds that callers must also supply either sqrtPriceX96 OR both amountA AND amountB. This prevents the LLM from invoking the tool with an argument combination that would cause a runtime error in buildInitializePoolTx. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/tools.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/llm/tools.ts b/src/llm/tools.ts index e9100fa..9374183 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -863,6 +863,16 @@ export const TOOL_DEFINITIONS = [ }, }, 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"], + }, + ], }, }, }, @@ -871,7 +881,6 @@ export const TOOL_DEFINITIONS = [ function: { name: "remove_liquidity", description: - "Remove liquidity from a Uniswap v4 LP position through the vault's modifyLiquidities adapter. " + "Requires the position's ERC-721 token ID and the liquidity amount to remove. " + "Does NOT burn the NFT by default — the position remains as a closed (0-liquidity) record. " + "Use collect_lp_fees to harvest any fees, then burn_position to permanently delete the NFT.", From 6479030b893e907bec481f35c4a500cfe479c396 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 18:52:18 +0200 Subject: [PATCH 06/10] fix: restore remove_liquidity description and use hasInitializeEvent in initialized check Two fixes: 1. tools.ts: restore the leading sentence of remove_liquidity's description ('Remove liquidity from a Uniswap v4 LP position through the vault's modifyLiquidities adapter.') that was accidentally dropped when the oneOf clause was added to the adjacent initialize_pool tool in the previous commit. 2. uniswapLP.ts: in getPoolInfoById, treat hasInitializeEvent as authoritative for the initialized flag. Changed: initialized: sqrtPriceX96 !== 0n to: initialized: hasInitializeEvent || sqrtPriceX96 !== 0n An on-chain Initialize event is definitive proof the pool exists. If sqrtPriceX96 reads back as 0 due to a transient RPC/StateView issue, the pool should not be incorrectly classified as uninitialized (which would generate misleading 'call initialize_pool' guidance that would result in an on-chain revert). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/tools.ts | 1 + src/services/uniswapLP.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/llm/tools.ts b/src/llm/tools.ts index 9374183..f8b1a62 100644 --- a/src/llm/tools.ts +++ b/src/llm/tools.ts @@ -881,6 +881,7 @@ export const TOOL_DEFINITIONS = [ function: { name: "remove_liquidity", description: + "Remove liquidity from a Uniswap v4 LP position through the vault's modifyLiquidities adapter. " + "Requires the position's ERC-721 token ID and the liquidity amount to remove. " + "Does NOT burn the NFT by default — the position remains as a closed (0-liquidity) record. " + "Use collect_lp_fees to harvest any fees, then burn_position to permanently delete the NFT.", diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index af7c20a..9893034 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -907,7 +907,7 @@ export async function getPoolInfoById( return { poolId, - initialized: sqrtPriceX96 !== 0n, + initialized: hasInitializeEvent || sqrtPriceX96 !== 0n, poolKeyKnown: true, fee, tickSpacing, From b7950b38646200d3df7364ce6f851d7665ae9f5b Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 19:00:16 +0200 Subject: [PATCH 07/10] fix: improve JSDoc and catch block in buildInitializePoolTx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes in uniswapLP.ts: 1. JSDoc for computeSqrtPriceX96FromAmounts: add a second @throws entry documenting that the function also throws when the computed sqrtPriceX96 falls outside TickMath bounds (extreme price ratios). Previously only the zero-amount throw was documented. 2. buildInitializePoolTx catch block: only swallow StateView-unavailable errors (expected on chains without StateView deployment). Any other error — e.g. transient RPC failures — is now re-thrown as a user- friendly 'Could not verify pool initialization state' message. This prevents silently proceeding to build a transaction that would revert when the pre-initialization check failed for a non-structural reason. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/uniswapLP.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index 9893034..e2ea4ae 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -100,6 +100,7 @@ function isqrt(n: bigint): bigint { * = 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"); @@ -589,8 +590,14 @@ export async function buildInitializePoolTx( } catch (e) { const msg = e instanceof Error ? e.message : String(e); if (msg.includes("already initialized")) throw e; - // readPoolState returns sqrtPriceX96=0 for uninitialized pools (does not throw). - // A thrown error here indicates an RPC issue or missing StateView deployment — proceed. + // 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; From f9b98916d8ea22bf44874b9cff84ab4d6324f1fb Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 19:06:38 +0200 Subject: [PATCH 08/10] fix: poolKeyKnown flag, sqrtPriceX96=0 error message, update lockfiles - Set poolKeyKnown: hasInitializeEvent (not unconditionally true) so callers know when pool key fields come from the on-chain Initialize event vs. are placeholder/estimated values - Improve sqrtPriceX96=0 error in buildAddLiquidityTx to reflect both possible causes: pool uninitialized OR wrong pool key (fee/tickSpacing/hooks mismatch), and suggest get_pool_info to verify - Update package-lock.json and yarn.lock to include @uniswap/v3-sdk and jsbi as tracked direct runtime dependencies Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package-lock.json | 2 ++ src/services/uniswapLP.ts | 13 ++++++++----- yarn.lock | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) 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/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index e2ea4ae..ef1b17c 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -435,12 +435,15 @@ export async function buildAddLiquidityTx( ); } - // 4a. Pool exists on-chain but has not been initialized (sqrtPriceX96 = 0). + // 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} is not initialized. ` + - `You must initialize the pool before adding liquidity. ` + - `Use the initialize_pool tool with the same pool key and an initial price (or both token amounts).` + `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.` ); } @@ -915,7 +918,7 @@ export async function getPoolInfoById( return { poolId, initialized: hasInitializeEvent || sqrtPriceX96 !== 0n, - poolKeyKnown: true, + poolKeyKnown: hasInitializeEvent, fee, tickSpacing, hooks, 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== From 070b8acd0053041e232c0a5fd0e948b3603d96b3 Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 19:15:06 +0200 Subject: [PATCH 09/10] fix: poolKeyKnown warning in get_pool_info, same-token guard in buildInitializePoolTx that warns pool key fields may be placeholders (Initialize event not found) and asks user to verify before calling add_liquidity, instead of silently suggesting potentially wrong fee/tickSpacing/hooks values - In buildInitializePoolTx: add explicit check that tokenA and tokenB resolve to different addresses after resolution, with a clear error message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/llm/client.ts | 7 ++++++- src/services/uniswapLP.ts | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/llm/client.ts b/src/llm/client.ts index 2e9870f..c3114b3 100644 --- a/src/llm/client.ts +++ b/src/llm/client.ts @@ -4065,7 +4065,12 @@ export async function executeToolCall( `Current Tick: ${info.currentTick}`, ``, info.initialized - ? `To add liquidity: use fee=${info.fee}, tickSpacing=${info.tickSpacing}, hooks=${info.hooks}` + ? 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.`, diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index ef1b17c..37eb9f0 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -568,6 +568,10 @@ export async function buildInitializePoolTx( 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; From 199c1bc9ee0d7175279eb2471c632a3c8ed3c46d Mon Sep 17 00:00:00 2001 From: Gabriele Rigo Date: Mon, 25 May 2026 19:23:43 +0200 Subject: [PATCH 10/10] fix: validate fee and tickSpacing ABI ranges in pool key operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add validatePoolKeyNumerics() helper that checks fee (uint24: 0-16,777,215, integer) and tickSpacing (int24: >0 and ≤8,388,607, integer) before building pool key / pool ID. Called in both buildAddLiquidityTx and buildInitializePoolTx to catch invalid inputs with a clear message instead of low-level ABI encoding errors or on-chain reverts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/services/uniswapLP.ts | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/services/uniswapLP.ts b/src/services/uniswapLP.ts index 37eb9f0..63d51f5 100644 --- a/src/services/uniswapLP.ts +++ b/src/services/uniswapLP.ts @@ -264,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( @@ -404,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); @@ -584,11 +605,11 @@ export async function buildInitializePoolTx( 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); - // Check if pool is already initialized try { const state = await readPoolState(poolId, chainId, env.ALCHEMY_API_KEY); if (state.sqrtPriceX96 !== 0n) {