diff --git a/examples/tg-bot-starter/src/app/api/bot/route.ts b/examples/tg-bot-starter/src/app/api/bot/route.ts index ac63719..2dcdf97 100644 --- a/examples/tg-bot-starter/src/app/api/bot/route.ts +++ b/examples/tg-bot-starter/src/app/api/bot/route.ts @@ -1,8 +1,8 @@ -export const dynamic = 'force-dynamic'; -export const fetchCache = 'force-no-store'; +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; export const maxDuration = 300; -import { Bot, webhookCallback } from 'grammy'; +import { Bot, webhookCallback } from "grammy"; import { SolanaAgentKit, createSolanaTools } from "solana-agent-kit"; import { ChatOpenAI } from "@langchain/openai"; import { MemorySaver } from "@langchain/langgraph"; @@ -10,10 +10,11 @@ import { createReactAgent } from "@langchain/langgraph/prebuilt"; import { HumanMessage } from "@langchain/core/messages"; const token = process.env.TELEGRAM_BOT_TOKEN; -if (!token) throw new Error('TELEGRAM_BOT_TOKEN environment variable not found.'); +if (!token) { + throw new Error("TELEGRAM_BOT_TOKEN environment variable not found."); +} const bot = new Bot(token); - async function initializeAgent(userId: string) { try { const llm = new ChatOpenAI({ @@ -24,7 +25,7 @@ async function initializeAgent(userId: string) { const solanaKit = new SolanaAgentKit( process.env.SOLANA_PRIVATE_KEY!, process.env.RPC_URL, - process.env.OPENAI_API_KEY! + process.env.OPENAI_API_KEY!, ); const tools = createSolanaTools(solanaKit); @@ -51,24 +52,40 @@ async function initializeAgent(userId: string) { } } // Telegram bot handler -bot.on('message:text', async (ctx:any) => { +bot.on("message:text", async (ctx: any) => { const userId = ctx.from?.id.toString(); - if (!userId) return; - const {agent, config} = await initializeAgent(userId); - const stream = await agent.stream({ messages: [new HumanMessage(ctx.message.text)] }, config); - const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Timeout')), 20000)); + if (!userId) { + return; + } + const { agent, config } = await initializeAgent(userId); + const stream = await agent.stream( + { messages: [new HumanMessage(ctx.message.text)] }, + config, + ); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout")), 20000), + ); try { - for await (const chunk of await Promise.race([stream, timeoutPromise]) as AsyncIterable<{ agent?: any; tools?: any }>) { + for await (const chunk of (await Promise.race([ + stream, + timeoutPromise, + ])) as AsyncIterable<{ agent?: any; tools?: any }>) { if ("agent" in chunk) { - if (chunk.agent.messages[0].content) await ctx.reply(String(chunk.agent.messages[0].content)); - } + if (chunk.agent.messages[0].content) { + await ctx.reply(String(chunk.agent.messages[0].content)); + } + } } } catch (error: any) { - if (error.message === 'Timeout') { - await ctx.reply("I'm sorry, the operation took too long and timed out. Please try again."); + if (error.message === "Timeout") { + await ctx.reply( + "I'm sorry, the operation took too long and timed out. Please try again.", + ); } else { console.error("Error processing stream:", error); - await ctx.reply("I'm sorry, an error occurred while processing your request."); + await ctx.reply( + "I'm sorry, an error occurred while processing your request.", + ); } } }); @@ -77,10 +94,10 @@ bot.on('message:text', async (ctx:any) => { export const POST = async (req: Request) => { // Mark the function as a background function for Vercel const headers = new Headers(); - headers.set('x-vercel-background', 'true'); + headers.set("x-vercel-background", "true"); - const handler = webhookCallback(bot, 'std/http'); // Use the correct callback + const handler = webhookCallback(bot, "std/http"); // Use the correct callback // Handle the incoming webhook request return handler(req); -}; \ No newline at end of file +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fbafd2..7202c0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -442,8 +442,8 @@ packages: '@shikijs/engine-oniguruma@1.24.3': resolution: {integrity: sha512-iNnx950gs/5Nk+zrp1LuF+S+L7SKEhn8k9eXgFYPGhVshKppsYwRmW8tpmAMvILIMSDfrgqZ0w+3xWVQB//1Xw==} - '@shikijs/types@1.24.3': - resolution: {integrity: sha512-FPMrJ69MNxhRtldRk69CghvaGlbbN3pKRuvko0zvbfa2dXp4pAngByToqS5OY5jvN8D7LKR4RJE8UvzlCOuViw==} + '@shikijs/types@1.24.4': + resolution: {integrity: sha512-0r0XU7Eaow0PuDxuWC1bVqmWCgm3XqizIaT7SM42K03vc69LGooT0U8ccSR44xP/hGlNx4FKhtYpV+BU6aaKAA==} '@shikijs/vscode-textmate@9.3.1': resolution: {integrity: sha512-79QfK1393x9Ho60QFyLti+QfdJzRQCVLFb97kOIV7Eo9vQU/roINgk7m24uv0a7AUvN//RDH36FLjjK48v0s9g==} @@ -903,8 +903,8 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} - chalk@5.4.0: - resolution: {integrity: sha512-ZkD35Mx92acjB2yNJgziGqT9oKHEOxjTBTDRpOsRWtdecL/0jM3z5kM/CTzHWvHIen1GvkM85p6TuFfDGfc8/Q==} + chalk@5.4.1: + resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} check-error@2.1.1: @@ -939,8 +939,8 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-fetch@3.1.8: - resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + cross-fetch@3.2.0: + resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} @@ -1418,8 +1418,8 @@ packages: typeorm: optional: true - langsmith@0.2.13: - resolution: {integrity: sha512-16EOM5nhU6GlMCKGm5sgBIAKOKzS2d30qcDZmF21kSLZJiUhUNTROwvYdqgZLrGfIIzmSMJHCKA7RFd5qf50uw==} + langsmith@0.2.14: + resolution: {integrity: sha512-ClAuAgSf3m9miMYotLEaZKQyKdaWlfjhebCuYco8bc6g72dU2VwTg31Bv4YINBq7EH2i1cMwbOiJxbOXPqjGig==} peerDependencies: openai: '*' peerDependenciesMeta: @@ -1999,7 +1999,7 @@ snapshots: bs58: 4.0.1 buffer-layout: 1.2.2 camelcase: 6.3.0 - cross-fetch: 3.1.8 + cross-fetch: 3.2.0 crypto-hash: 1.3.0 eventemitter3: 4.0.7 pako: 2.1.0 @@ -2090,7 +2090,7 @@ snapshots: camelcase: 6.3.0 decamelize: 1.2.0 js-tiktoken: 1.0.16 - langsmith: 0.2.13(openai@4.77.0(zod@3.24.1)) + langsmith: 0.2.14(openai@4.77.0(zod@3.24.1)) mustache: 4.2.0 p-queue: 6.6.2 p-retry: 4.6.2 @@ -2399,13 +2399,13 @@ snapshots: - utf-8-validate '@scure/base@1.2.1': {} - + '@shikijs/engine-oniguruma@1.24.3': dependencies: - '@shikijs/types': 1.24.3 + '@shikijs/types': 1.24.4 '@shikijs/vscode-textmate': 9.3.1 - '@shikijs/types@1.24.3': + '@shikijs/types@1.24.4': dependencies: '@shikijs/vscode-textmate': 9.3.1 '@types/hast': 3.0.4 @@ -2572,24 +2572,24 @@ snapshots: '@solana/errors@2.0.0-preview.2': dependencies: - chalk: 5.4.0 + chalk: 5.4.1 commander: 12.1.0 '@solana/errors@2.0.0-preview.4(typescript@5.7.2)': dependencies: - chalk: 5.4.0 + chalk: 5.4.1 commander: 12.1.0 typescript: 5.7.2 '@solana/errors@2.0.0-rc.1(typescript@4.9.5)': dependencies: - chalk: 5.4.0 + chalk: 5.4.1 commander: 12.1.0 typescript: 4.9.5 '@solana/errors@2.0.0-rc.1(typescript@5.7.2)': dependencies: - chalk: 5.4.0 + chalk: 5.4.1 commander: 12.1.0 typescript: 5.7.2 @@ -3136,7 +3136,7 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 - chalk@5.4.0: {} + chalk@5.4.1: {} check-error@2.1.1: {} @@ -3160,7 +3160,7 @@ snapshots: create-require@1.1.1: {} - cross-fetch@3.1.8: + cross-fetch@3.2.0: dependencies: node-fetch: 2.7.0 transitivePeerDependencies: @@ -3612,7 +3612,7 @@ snapshots: js-tiktoken: 1.0.16 js-yaml: 4.1.0 jsonpointer: 5.0.1 - langsmith: 0.2.13(openai@4.77.0(zod@3.24.1)) + langsmith: 0.2.14(openai@4.77.0(zod@3.24.1)) openapi-types: 12.1.3 p-retry: 4.6.2 uuid: 10.0.0 @@ -3626,7 +3626,7 @@ snapshots: - encoding - openai - langsmith@0.2.13(openai@4.77.0(zod@3.24.1)): + langsmith@0.2.14(openai@4.77.0(zod@3.24.1)): dependencies: '@types/uuid': 10.0.0 commander: 10.0.1 @@ -3755,6 +3755,7 @@ snapshots: dependencies: wrappy: 1.0.2 + openai@4.77.0(zod@3.24.1): dependencies: '@types/node': 18.19.68 diff --git a/src/agent/index.ts b/src/agent/index.ts index 82686ec..461baf5 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -25,10 +25,13 @@ import { getTokenDataByTicker, stakeWithJup, sendCompressedAirdrop, - createOrcaSingleSidedWhirlpool, + orcaCreateSingleSidedLiquidityPool, + orcaCreateCLMM, + orcaOpenCenteredPositionWithLiquidity, + orcaOpenSingleSidedPosition, + FEE_TIERS, fetchPrice, pythFetchPrice, - FEE_TIERS, getAllDomainsTLDs, getAllRegisteredAllDomains, getOwnedDomainsForTLD, @@ -36,9 +39,12 @@ import { getOwnedAllDomains, resolveAllDomains, create_gibwork_task, + orcaClosePosition, + orcaFetchPositions, rock_paper_scissor, create_TipLink, } from "../tools"; + import { CollectionDeployment, CollectionOptions, @@ -209,15 +215,28 @@ export class SolanaAgentKit { ); } - async createOrcaSingleSidedWhirlpool( - depositTokenAmount: BN, + async orcaClosePosition(positionMintAddress: PublicKey) { + return orcaClosePosition(this, positionMintAddress); + } + + async orcaCreateCLMM( + mintDeploy: PublicKey, + mintPair: PublicKey, + initialPrice: Decimal, + feeTier: keyof typeof FEE_TIERS, + ) { + return orcaCreateCLMM(this, mintDeploy, mintPair, initialPrice, feeTier); + } + + async orcaCreateSingleSidedLiquidityPool( + depositTokenAmount: number, depositTokenMint: PublicKey, otherTokenMint: PublicKey, initialPrice: Decimal, maxPrice: Decimal, feeTier: keyof typeof FEE_TIERS, ) { - return createOrcaSingleSidedWhirlpool( + return orcaCreateSingleSidedLiquidityPool( this, depositTokenAmount, depositTokenMint, @@ -228,6 +247,42 @@ export class SolanaAgentKit { ); } + async orcaFetchPositions() { + return orcaFetchPositions(this); + } + + async orcaOpenCenteredPositionWithLiquidity( + whirlpoolAddress: PublicKey, + priceOffsetBps: number, + inputTokenMint: PublicKey, + inputAmount: Decimal, + ) { + return orcaOpenCenteredPositionWithLiquidity( + this, + whirlpoolAddress, + priceOffsetBps, + inputTokenMint, + inputAmount, + ); + } + + async orcaOpenSingleSidedPosition( + whirlpoolAddress: PublicKey, + distanceFromCurrentPriceBps: number, + widthBps: number, + inputTokenMint: PublicKey, + inputAmount: Decimal, + ): Promise { + return orcaOpenSingleSidedPosition( + this, + whirlpoolAddress, + distanceFromCurrentPriceBps, + widthBps, + inputTokenMint, + inputAmount, + ); + } + async resolveAllDomains(domain: string): Promise { return resolveAllDomains(this, domain); } diff --git a/src/langchain/index.ts b/src/langchain/index.ts index ed805e6..e17d25a 100644 --- a/src/langchain/index.ts +++ b/src/langchain/index.ts @@ -796,17 +796,13 @@ export class SolanaCompressedAirdropTool extends Tool { } } -export class SolanaCreateSingleSidedWhirlpoolTool extends Tool { - name = "create_orca_single_sided_whirlpool"; - description = `Create a single-sided Whirlpool with liquidity. - - Inputs (input is a JSON string): - - depositTokenAmount: number, eg: 1000000000 (required, in units of deposit token including decimals) - - depositTokenMint: string, eg: "DepositTokenMintAddress" (required, mint address of deposit token) - - otherTokenMint: string, eg: "OtherTokenMintAddress" (required, mint address of other token) - - initialPrice: number, eg: 0.001 (required, initial price of deposit token in terms of other token) - - maxPrice: number, eg: 5.0 (required, maximum price at which liquidity is added) - - feeTier: number, eg: 0.30 (required, fee tier for the pool)`; +export class SolanaClosePostition extends Tool { + name = "orca_close_position"; + description = `Closes an existing liquidity position in an Orca Whirlpool. This function fetches the position + details using the provided mint address and closes the position with a 1% slippage. + + Inputs (JSON string): + - positionMintAddress: string, the address of the position mint that represents the liquidity position.`; constructor(private solanaKit: SolanaAgentKit) { super(); @@ -815,7 +811,102 @@ export class SolanaCreateSingleSidedWhirlpoolTool extends Tool { async _call(input: string): Promise { try { const inputFormat = JSON.parse(input); - const depositTokenAmount = new BN(inputFormat.depositTokenAmount); + const positionMintAddress = new PublicKey( + inputFormat.positionMintAddress, + ); + + const txId = await this.solanaKit.orcaClosePosition(positionMintAddress); + + return JSON.stringify({ + status: "success", + message: "Liquidity position closed successfully.", + transaction: txId, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaOrcaCreateCLMM extends Tool { + name = "orca_create_clmm"; + description = `Create a Concentrated Liquidity Market Maker (CLMM) pool on Orca, the most efficient and capital-optimized CLMM on Solana. This function initializes a CLMM pool but does not add liquidity. You can add liquidity later using a centered position or a single-sided position. + + Inputs (JSON string): + - mintDeploy: string, the mint of the token you want to deploy (required). + - mintPair: string, The mint of the token you want to pair the deployed mint with (required). + - initialPrice: number, initial price of mintA in terms of mintB, e.g., 0.001 (required). + - feeTier: number, fee tier in bps. Options: 1, 2, 4, 5, 16, 30, 65, 100, 200 (required).`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + async _call(input: string): Promise { + try { + const inputFormat = JSON.parse(input); + const mintA = new PublicKey(inputFormat.mintDeploy); + const mintB = new PublicKey(inputFormat.mintPair); + const initialPrice = new Decimal(inputFormat.initialPrice); + const feeTier = inputFormat.feeTier; + + if (!feeTier || !(feeTier in FEE_TIERS)) { + throw new Error( + `Invalid feeTier. Available options: ${Object.keys(FEE_TIERS).join( + ", ", + )}`, + ); + } + + const txId = await this.solanaKit.orcaCreateCLMM( + mintA, + mintB, + initialPrice, + feeTier, + ); + + return JSON.stringify({ + status: "success", + message: + "CLMM pool created successfully. Note: No liquidity was added.", + transaction: txId, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaOrcaCreateSingleSideLiquidityPool extends Tool { + name = "orca_create_single_sided_liquidity_pool"; + description = `Create a single-sided liquidity pool on Orca, the most efficient and capital-optimized CLMM platform on Solana. + + This function initializes a single-sided liquidity pool, ideal for community driven project, fair launches, and fundraising. Minimize price impact by setting a narrow price range. + + Inputs (JSON string): + - depositTokenAmount: number, in units of the deposit token including decimals, e.g., 1000000000 (required). + - depositTokenMint: string, mint address of the deposit token, e.g., "DepositTokenMintAddress" (required). + - otherTokenMint: string, mint address of the other token, e.g., "OtherTokenMintAddress" (required). + - initialPrice: number, initial price of the deposit token in terms of the other token, e.g., 0.001 (required). + - maxPrice: number, maximum price at which liquidity is added, e.g., 5.0 (required). + - feeTier: number, fee tier for the pool in bps. Options: 1, 2, 4, 5, 16, 30, 65, 100, 200 (required).`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + async _call(input: string): Promise { + try { + const inputFormat = JSON.parse(input); + const depositTokenAmount = inputFormat.depositTokenAmount; const depositTokenMint = new PublicKey(inputFormat.depositTokenMint); const otherTokenMint = new PublicKey(inputFormat.otherTokenMint); const initialPrice = new Decimal(inputFormat.initialPrice); @@ -830,7 +921,7 @@ export class SolanaCreateSingleSidedWhirlpoolTool extends Tool { ); } - const txId = await this.solanaKit.createOrcaSingleSidedWhirlpool( + const txId = await this.solanaKit.orcaCreateSingleSidedLiquidityPool( depositTokenAmount, depositTokenMint, otherTokenMint, @@ -854,6 +945,137 @@ export class SolanaCreateSingleSidedWhirlpoolTool extends Tool { } } +export class SolanaOrcaFetchPositions extends Tool { + name = "orca_fetch_positions"; + description = `Fetch all the liquidity positions in an Orca Whirlpool by owner. Returns an object with positiont mint addresses as keys and position status details as values.`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + async _call(): Promise { + try { + const txId = await this.solanaKit.orcaFetchPositions(); + + return JSON.stringify({ + status: "success", + message: "Liquidity positions fetched.", + transaction: txId, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaOrcaOpenCenteredPosition extends Tool { + name = "orca_open_centered_position_with_liquidity"; + description = `Add liquidity to a CLMM by opening a centered position in an Orca Whirlpool, the most efficient liquidity pool on Solana. + + Inputs (JSON string): + - whirlpoolAddress: string, address of the Orca Whirlpool (required). + - priceOffsetBps: number, bps offset (one side) from the current pool price, e.g., 500 for 5% (required). + - inputTokenMint: string, mint address of the deposit token (required). + - inputAmount: number, amount of the deposit token, e.g., 100.0 (required).`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + async _call(input: string): Promise { + try { + const inputFormat = JSON.parse(input); + const whirlpoolAddress = new PublicKey(inputFormat.whirlpoolAddress); + const priceOffsetBps = parseInt(inputFormat.priceOffsetBps, 10); + const inputTokenMint = new PublicKey(inputFormat.inputTokenMint); + const inputAmount = new Decimal(inputFormat.inputAmount); + + if (priceOffsetBps < 0) { + throw new Error( + "Invalid distanceFromCurrentPriceBps. It must be equal or greater than 0.", + ); + } + + const txId = await this.solanaKit.orcaOpenCenteredPositionWithLiquidity( + whirlpoolAddress, + priceOffsetBps, + inputTokenMint, + inputAmount, + ); + + return JSON.stringify({ + status: "success", + message: "Centered liquidity position opened successfully.", + transaction: txId, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + +export class SolanaOrcaOpenSingleSidedPosition extends Tool { + name = "orca_open_single_sided_position"; + description = `Add liquidity to a CLMM by opening a single-sided position in an Orca Whirlpool, the most efficient liquidity pool on Solana. + + Inputs (JSON string): + - whirlpoolAddress: string, address of the Orca Whirlpool (required). + - distanceFromCurrentPriceBps: number, distance in basis points from the current price for the position (required). + - widthBps: number, width of the position in basis points (required). + - inputTokenMint: string, mint address of the deposit token (required). + - inputAmount: number, amount of the deposit token, e.g., 100.0 (required).`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + async _call(input: string): Promise { + try { + const inputFormat = JSON.parse(input); + const whirlpoolAddress = new PublicKey(inputFormat.whirlpoolAddress); + const distanceFromCurrentPriceBps = + inputFormat.distanceFromCurrentPriceBps; + const widthBps = inputFormat.widthBps; + const inputTokenMint = new PublicKey(inputFormat.inputTokenMint); + const inputAmount = new Decimal(inputFormat.inputAmount); + + if (distanceFromCurrentPriceBps < 0 || widthBps < 0) { + throw new Error( + "Invalid distanceFromCurrentPriceBps or width. It must be equal or greater than 0.", + ); + } + + const txId = await this.solanaKit.orcaOpenSingleSidedPosition( + whirlpoolAddress, + distanceFromCurrentPriceBps, + widthBps, + inputTokenMint, + inputAmount, + ); + + return JSON.stringify({ + status: "success", + message: "Single-sided liquidity position opened successfully.", + transaction: txId, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + export class SolanaRaydiumCreateAmmV4 extends Tool { name = "raydium_create_ammV4"; description = `Raydium's Legacy AMM that requiers an OpenBook marketID @@ -1302,9 +1524,9 @@ export class SolanaRockPaperScissorsTool extends Tool { const result = await this.solanaKit.rockPaperScissors( Number(parsedInput['"amount"']), parsedInput['"choice"'].replace(/^"|"$/g, "") as - | "rock" - | "paper" - | "scissors", + | "rock" + | "paper" + | "scissors", ); return JSON.stringify({ @@ -1394,7 +1616,12 @@ export function createSolanaTools(solanaKit: SolanaAgentKit) { new SolanaRaydiumCreateClmm(solanaKit), new SolanaRaydiumCreateCpmm(solanaKit), new SolanaOpenbookCreateMarket(solanaKit), - new SolanaCreateSingleSidedWhirlpoolTool(solanaKit), + new SolanaClosePostition(solanaKit), + new SolanaOrcaCreateCLMM(solanaKit), + new SolanaOrcaCreateSingleSideLiquidityPool(solanaKit), + new SolanaOrcaFetchPositions(solanaKit), + new SolanaOrcaOpenCenteredPosition(solanaKit), + new SolanaOrcaOpenSingleSidedPosition(solanaKit), new SolanaPythFetchPrice(solanaKit), new SolanaResolveDomainTool(solanaKit), new SolanaGetOwnedDomains(solanaKit), diff --git a/src/tools/create_orca_single_sided_whirlpool.ts b/src/tools/create_orca_single_sided_whirlpool.ts deleted file mode 100644 index d3038c1..0000000 --- a/src/tools/create_orca_single_sided_whirlpool.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { Keypair, PublicKey, Transaction } from "@solana/web3.js"; -import { SolanaAgentKit } from "../index"; -import { BN, Wallet } from "@coral-xyz/anchor"; -import { Decimal } from "decimal.js"; -import { - PDAUtil, - ORCA_WHIRLPOOL_PROGRAM_ID, - ORCA_WHIRLPOOLS_CONFIG, - WhirlpoolContext, - TickUtil, - PriceMath, - PoolUtil, - TokenExtensionContextForPool, - NO_TOKEN_EXTENSION_CONTEXT, - TokenExtensionUtil, - WhirlpoolIx, - IncreaseLiquidityQuoteParam, - increaseLiquidityQuoteByInputTokenWithParams, -} from "@orca-so/whirlpools-sdk"; -import { - Percentage, - resolveOrCreateATAs, - TransactionBuilder, -} from "@orca-so/common-sdk"; -import { - increaseLiquidityIx, - increaseLiquidityV2Ix, - initTickArrayIx, - openPositionWithTokenExtensionsIx, -} from "@orca-so/whirlpools-sdk/dist/instructions"; -import { - getAssociatedTokenAddressSync, - TOKEN_2022_PROGRAM_ID, -} from "@solana/spl-token"; -import { sendTx } from "../utils/send_tx"; - -/** - * Maps fee tier percentages to their corresponding tick spacing values in the Orca Whirlpool protocol. - * - * @remarks - * Fee tiers determine the percentage of fees collected on swaps, while tick spacing affects - * the granularity of price ranges for liquidity positions. - * - * For more details, refer to: - * - [Whirlpool Fees](https://orca-so.github.io/whirlpools/Architecture%20Overview/Whirlpool%20Fees) - * - [Whirlpool Parameters](https://orca-so.github.io/whirlpools/Architecture%20Overview/Whirlpool%20Parameters) - * - * @example - * const tickSpacing = FEE_TIERS[0.30]; // Returns 64 - */ -export const FEE_TIERS = { - 0.01: 1, - 0.02: 2, - 0.04: 4, - 0.05: 8, - 0.16: 16, - 0.3: 64, - 0.65: 96, - 1.0: 128, - 2.0: 256, -} as const; - -/** - * # Creates a single-sided Whirlpool. - * - * This function initializes a new Whirlpool (liquidity pool) on Orca and seeds it with liquidity from a single token. - * - * ## Example Usage: - * You created a new token called SHARK, and you want to set the initial price to 0.001 USDC. - * You set `depositTokenMint` to SHARK's mint address and `otherTokenMint` to USDC's mint address. - * You can minimize price impact for buyers in a few ways: - * 1. Increase the amount of tokens you deposit - * 2. Set the initial price very low - * 3. Set the maximum price closer to the initial price - * - * ### Note for experts: - * The Wrhirlpool program initializes the Whirlpool with the in a specific order. This might not be - * the order you expect, so the function checks the order and adjusts the inverts the prices. This means that - * on-chain the Whirlpool might be configured as USDC/SHARK instead of SHARK/USDC, and the on-chain price will - * be 1/`initialPrice`. This will not affect the price of the token as you intended it to be. - * - * @param agent - The `SolanaAgentKit` instance representing the wallet and connection details. - * @param depositTokenAmount - The amount of the deposit token (including the decimals) to contribute to the pool. - * @param depositTokenMint - The mint address of the token being deposited into the pool, eg. SHARK. - * @param otherTokenMint - The mint address of the other token in the pool, eg. USDC. - * @param initialPrice - The initial price of the deposit token in terms of the other token. - * @param maxPrice - The maximum price at which liquidity is added. - * @param feeTier - The fee tier percentage for the pool, determining tick spacing and fee collection rates. - * - * @returns A promise that resolves to a transaction ID (`string`) of the transaction creating the pool. - * - * @throws Will throw an error if: - * - Mint accounts for the tokens cannot be fetched. - * - Prices are out of bounds. - * - * @remarks - * This function is designed for single-sided deposits where users only contribute one type of token, - * and the function manages mint order and necessary calculations. - * - * @example - * ```typescript - * import { SolanaAgentKit } from "your-sdk"; - * import { PublicKey } from "@solana/web3.js"; - * import { BN } from "@coral-xyz/anchor"; - * import Decimal from "decimal.js"; - * - * const agent = new SolanaAgentKit(wallet, connection); - * const depositAmount = new BN(1_000_000_000_000); // 1 million SHARK if SHARK has 6 decimals - * const depositTokenMint = new PublicKey("DEPOSTI_TOKEN_ADDRESS"); - * const otherTokenMint = new PublicKey("OTHER_TOKEN_ADDRESS"); - * const initialPrice = new Decimal(0.001); - * const maxPrice = new Decimal(5.0); - * const feeTier = 0.30; - * - * const txId = await createOrcaSingleSidedWhirlpool( - * agent, - * depositAmount, - * depositTokenMint, - * otherTokenMint, - * initialPrice, - * maxPrice, - * feeTier, - * ); - * console.log(`Single sided whirlpool created in transaction: ${txId}`); - * ``` - */ -export async function createOrcaSingleSidedWhirlpool( - agent: SolanaAgentKit, - depositTokenAmount: BN, - depositTokenMint: PublicKey, - otherTokenMint: PublicKey, - initialPrice: Decimal, - maxPrice: Decimal, - feeTier: keyof typeof FEE_TIERS, -): Promise { - const wallet = new Wallet(agent.wallet); - const ctx = WhirlpoolContext.from( - agent.connection, - wallet, - ORCA_WHIRLPOOL_PROGRAM_ID, - ); - const fetcher = ctx.fetcher; - - const correctTokenOrder = PoolUtil.orderMints( - otherTokenMint, - depositTokenMint, - ).map((addr) => addr.toString()); - const isCorrectMintOrder = - correctTokenOrder[0] === depositTokenMint.toString(); - let mintA, mintB; - if (isCorrectMintOrder) { - [mintA, mintB] = [depositTokenMint, otherTokenMint]; - } else { - [mintA, mintB] = [otherTokenMint, depositTokenMint]; - initialPrice = new Decimal(1 / initialPrice.toNumber()); - maxPrice = new Decimal(1 / maxPrice.toNumber()); - } - const mintAAccount = await fetcher.getMintInfo(mintA); - const mintBAccount = await fetcher.getMintInfo(mintB); - if (mintAAccount === null || mintBAccount === null) { - throw Error("Mint account not found"); - } - const tickSpacing = FEE_TIERS[feeTier]; - const tickIndex = PriceMath.priceToTickIndex( - initialPrice, - mintAAccount.decimals, - mintBAccount.decimals, - ); - const initialTick = TickUtil.getInitializableTickIndex( - tickIndex, - tickSpacing, - ); - - const tokenExtensionCtx: TokenExtensionContextForPool = { - ...NO_TOKEN_EXTENSION_CONTEXT, - tokenMintWithProgramA: mintAAccount, - tokenMintWithProgramB: mintBAccount, - }; - const feeTierKey = PDAUtil.getFeeTier( - ORCA_WHIRLPOOL_PROGRAM_ID, - ORCA_WHIRLPOOLS_CONFIG, - tickSpacing, - ).publicKey; - const initSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(initialTick); - const tokenVaultAKeypair = Keypair.generate(); - const tokenVaultBKeypair = Keypair.generate(); - const whirlpoolPda = PDAUtil.getWhirlpool( - ORCA_WHIRLPOOL_PROGRAM_ID, - ORCA_WHIRLPOOLS_CONFIG, - mintA, - mintB, - FEE_TIERS[feeTier], - ); - const tokenBadgeA = PDAUtil.getTokenBadge( - ORCA_WHIRLPOOL_PROGRAM_ID, - ORCA_WHIRLPOOLS_CONFIG, - mintA, - ).publicKey; - const tokenBadgeB = PDAUtil.getTokenBadge( - ORCA_WHIRLPOOL_PROGRAM_ID, - ORCA_WHIRLPOOLS_CONFIG, - mintB, - ).publicKey; - const baseParamsPool = { - initSqrtPrice, - whirlpoolsConfig: ORCA_WHIRLPOOLS_CONFIG, - whirlpoolPda, - tokenMintA: mintA, - tokenMintB: mintB, - tokenVaultAKeypair, - tokenVaultBKeypair, - feeTierKey, - tickSpacing: tickSpacing, - funder: wallet.publicKey, - }; - const initPoolIx = !TokenExtensionUtil.isV2IxRequiredPool(tokenExtensionCtx) - ? WhirlpoolIx.initializePoolIx(ctx.program, baseParamsPool) - : WhirlpoolIx.initializePoolV2Ix(ctx.program, { - ...baseParamsPool, - tokenProgramA: tokenExtensionCtx.tokenMintWithProgramA.tokenProgram, - tokenProgramB: tokenExtensionCtx.tokenMintWithProgramB.tokenProgram, - tokenBadgeA, - tokenBadgeB, - }); - const initialTickArrayStartTick = TickUtil.getStartTickIndex( - initialTick, - tickSpacing, - ); - const initialTickArrayPda = PDAUtil.getTickArray( - ctx.program.programId, - whirlpoolPda.publicKey, - initialTickArrayStartTick, - ); - - const txBuilder = new TransactionBuilder( - ctx.provider.connection, - ctx.provider.wallet, - ctx.txBuilderOpts, - ); - txBuilder.addInstruction(initPoolIx); - txBuilder.addInstruction( - initTickArrayIx(ctx.program, { - startTick: initialTickArrayStartTick, - tickArrayPda: initialTickArrayPda, - whirlpool: whirlpoolPda.publicKey, - funder: wallet.publicKey, - }), - ); - - let tickLowerIndex, tickUpperIndex; - if (isCorrectMintOrder) { - tickLowerIndex = initialTick; - tickUpperIndex = PriceMath.priceToTickIndex( - maxPrice, - mintAAccount.decimals, - mintBAccount.decimals, - ); - } else { - tickLowerIndex = PriceMath.priceToTickIndex( - maxPrice, - mintAAccount.decimals, - mintBAccount.decimals, - ); - tickUpperIndex = initialTick; - } - const tickLowerInitializableIndex = TickUtil.getInitializableTickIndex( - tickLowerIndex, - tickSpacing, - ); - const tickUpperInitializableIndex = TickUtil.getInitializableTickIndex( - tickUpperIndex, - tickSpacing, - ); - if ( - !TickUtil.checkTickInBounds(tickLowerInitializableIndex) || - !TickUtil.checkTickInBounds(tickUpperInitializableIndex) - ) { - throw Error("Prices out of bounds"); - } - const increasLiquidityQuoteParam: IncreaseLiquidityQuoteParam = { - inputTokenAmount: new BN(depositTokenAmount), - inputTokenMint: depositTokenMint, - tokenMintA: mintA, - tokenMintB: mintB, - tickCurrentIndex: initialTick, - sqrtPrice: initSqrtPrice, - tickLowerIndex: tickLowerInitializableIndex, - tickUpperIndex: tickUpperInitializableIndex, - tokenExtensionCtx: tokenExtensionCtx, - slippageTolerance: Percentage.fromFraction(0, 100), - }; - const liquidityInput = increaseLiquidityQuoteByInputTokenWithParams( - increasLiquidityQuoteParam, - ); - const { liquidityAmount: liquidity, tokenMaxA, tokenMaxB } = liquidityInput; - - const positionMintKeypair = Keypair.generate(); - const positionMintPubkey = positionMintKeypair.publicKey; - const positionPda = PDAUtil.getPosition( - ORCA_WHIRLPOOL_PROGRAM_ID, - positionMintPubkey, - ); - const positionTokenAccountAddress = getAssociatedTokenAddressSync( - positionMintPubkey, - wallet.publicKey, - ctx.accountResolverOpts.allowPDAOwnerAddress, - TOKEN_2022_PROGRAM_ID, - ); - const params = { - funder: wallet.publicKey, - owner: wallet.publicKey, - positionPda, - positionTokenAccount: positionTokenAccountAddress, - whirlpool: whirlpoolPda.publicKey, - tickLowerIndex: tickLowerInitializableIndex, - tickUpperIndex: tickUpperInitializableIndex, - }; - const positionIx = openPositionWithTokenExtensionsIx(ctx.program, { - ...params, - positionMint: positionMintPubkey, - withTokenMetadataExtension: true, - }); - - txBuilder.addInstruction(positionIx); - txBuilder.addSigner(positionMintKeypair); - - const [ataA, ataB] = await resolveOrCreateATAs( - ctx.connection, - wallet.publicKey, - [ - { tokenMint: mintA, wrappedSolAmountIn: tokenMaxA }, - { tokenMint: mintB, wrappedSolAmountIn: tokenMaxB }, - ], - () => ctx.fetcher.getAccountRentExempt(), - wallet.publicKey, - undefined, - ctx.accountResolverOpts.allowPDAOwnerAddress, - ctx.accountResolverOpts.createWrappedSolAccountMethod, - ); - const { address: tokenOwnerAccountA, ...tokenOwnerAccountAIx } = ataA; - const { address: tokenOwnerAccountB, ...tokenOwnerAccountBIx } = ataB; - - txBuilder.addInstruction(tokenOwnerAccountAIx); - txBuilder.addInstruction(tokenOwnerAccountBIx); - - const tickArrayLowerStartIndex = TickUtil.getStartTickIndex( - tickLowerInitializableIndex, - tickSpacing, - ); - const tickArrayUpperStartIndex = TickUtil.getStartTickIndex( - tickUpperInitializableIndex, - tickSpacing, - ); - const tickArrayLowerPda = PDAUtil.getTickArray( - ctx.program.programId, - whirlpoolPda.publicKey, - tickArrayLowerStartIndex, - ); - const tickArrayUpperPda = PDAUtil.getTickArray( - ctx.program.programId, - whirlpoolPda.publicKey, - tickArrayUpperStartIndex, - ); - if (tickArrayUpperStartIndex !== tickArrayLowerStartIndex) { - if (isCorrectMintOrder) { - txBuilder.addInstruction( - initTickArrayIx(ctx.program, { - startTick: tickArrayUpperStartIndex, - tickArrayPda: tickArrayUpperPda, - whirlpool: whirlpoolPda.publicKey, - funder: wallet.publicKey, - }), - ); - } else { - txBuilder.addInstruction( - initTickArrayIx(ctx.program, { - startTick: tickArrayLowerStartIndex, - tickArrayPda: tickArrayLowerPda, - whirlpool: whirlpoolPda.publicKey, - funder: wallet.publicKey, - }), - ); - } - } - - const baseParamsLiquidity = { - liquidityAmount: liquidity, - tokenMaxA, - tokenMaxB, - whirlpool: whirlpoolPda.publicKey, - positionAuthority: wallet.publicKey, - position: positionPda.publicKey, - positionTokenAccount: positionTokenAccountAddress, - tokenOwnerAccountA, - tokenOwnerAccountB, - tokenVaultA: tokenVaultAKeypair.publicKey, - tokenVaultB: tokenVaultBKeypair.publicKey, - tickArrayLower: tickArrayLowerPda.publicKey, - tickArrayUpper: tickArrayUpperPda.publicKey, - }; - - const liquidityIx = !TokenExtensionUtil.isV2IxRequiredPool(tokenExtensionCtx) - ? increaseLiquidityIx(ctx.program, baseParamsLiquidity) - : increaseLiquidityV2Ix(ctx.program, { - ...baseParamsLiquidity, - tokenMintA: mintA, - tokenMintB: mintB, - tokenProgramA: tokenExtensionCtx.tokenMintWithProgramA.tokenProgram, - tokenProgramB: tokenExtensionCtx.tokenMintWithProgramB.tokenProgram, - }); - txBuilder.addInstruction(liquidityIx); - - const txPayload = await txBuilder.build({ - maxSupportedTransactionVersion: "legacy", - }); - - if (txPayload.transaction instanceof Transaction) { - try { - const txId = await sendTx(agent, txPayload.transaction, [ - positionMintKeypair, - tokenVaultAKeypair, - tokenVaultBKeypair, - ]); - return txId; - } catch (error) { - throw new Error(`Failed to create pool: ${JSON.stringify(error)}`); - } - } else { - throw new Error("Failed to create pool: Transaction not created"); - } -} diff --git a/src/tools/deploy_token.ts b/src/tools/deploy_token.ts index 7532eef..798e020 100644 --- a/src/tools/deploy_token.ts +++ b/src/tools/deploy_token.ts @@ -59,7 +59,7 @@ export async function deploy_token( mint: mint.publicKey, tokenStandard: TokenStandard.Fungible, tokenOwner: fromWeb3JsPublicKey(agent.wallet_address), - amount: initialSupply, + amount: initialSupply * Math.pow(10, decimals), }), ); } diff --git a/src/tools/index.ts b/src/tools/index.ts index 584af08..af70276 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -16,7 +16,12 @@ export * from "./get_token_data"; export * from "./stake_with_jup"; export * from "./fetch_price"; export * from "./send_compressed_airdrop"; -export * from "./create_orca_single_sided_whirlpool"; +export * from "./orca_close_position"; +export * from "./orca_create_clmm"; +export * from "./orca_create_single_sided_liquidity_pool"; +export * from "./orca_fetch_positions"; +export * from "./orca_open_centered_position_with_liquidity"; +export * from "./orca_open_single_sided_position"; export * from "./get_all_domains_tlds"; export * from "./get_all_registered_all_domains"; export * from "./get_owned_domains_for_tld"; diff --git a/src/tools/orca_close_position.ts b/src/tools/orca_close_position.ts new file mode 100644 index 0000000..4475692 --- /dev/null +++ b/src/tools/orca_close_position.ts @@ -0,0 +1,82 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { SolanaAgentKit } from "../agent"; +import { Wallet } from "@coral-xyz/anchor"; +import { + ORCA_WHIRLPOOL_PROGRAM_ID, + WhirlpoolContext, + buildWhirlpoolClient, + PDAUtil, +} from "@orca-so/whirlpools-sdk"; +import { sendTx } from "../utils/send_tx"; +import { Percentage } from "@orca-so/common-sdk"; + +/** + * # Closes a Liquidity Position in an Orca Whirlpool + * + * This function closes an existing liquidity position in a specified Orca Whirlpool. The user provides + * the position's mint address. + * + * ## Parameters + * - `agent`: The `SolanaAgentKit` instance representing the wallet and connection details. + * - `positionMintAddress`: The mint address of the liquidity position to close. + * + * ## Returns + * A `Promise` that resolves to a `string` containing the transaction ID of the transaction + * + * ## Notes + * - The function uses Orca’s SDK to interact with the specified Whirlpool and close the liquidity position. + * - A maximum slippage of 1% is assumed for liquidity provision during the position closing. + * - The function automatically fetches the associated Whirlpool address and position details using the provided mint address. + * + * ## Throws + * An error will be thrown if: + * - The specified position mint address is invalid or inaccessible. + * - The transaction fails to send. + * - Any required position or Whirlpool data cannot be fetched. + * + * @param agent - The `SolanaAgentKit` instance representing the wallet and connection. + * @param positionMintAddress - The mint address of the liquidity position to close. + * @returns A promise resolving to the transaction ID (`string`). + */ +export async function orcaClosePosition( + agent: SolanaAgentKit, + positionMintAddress: PublicKey, +): Promise { + try { + const wallet = new Wallet(agent.wallet); + const ctx = WhirlpoolContext.from( + agent.connection, + wallet, + ORCA_WHIRLPOOL_PROGRAM_ID, + ); + const client = buildWhirlpoolClient(ctx); + + const positionAddress = PDAUtil.getPosition( + ORCA_WHIRLPOOL_PROGRAM_ID, + positionMintAddress, + ); + const position = await client.getPosition(positionAddress.publicKey); + const whirlpoolAddress = position.getData().whirlpool; + const whirlpool = await client.getPool(whirlpoolAddress); + const txBuilder = await whirlpool.closePosition( + positionAddress.publicKey, + Percentage.fromFraction(1, 100), + ); + const txPayload = await txBuilder[0].build(); + const txPayloadDecompiled = TransactionMessage.decompile( + (txPayload.transaction as VersionedTransaction).message, + ); + const instructions = txPayloadDecompiled.instructions; + const signers = txPayload.signers as Keypair[]; + + const txId = await sendTx(agent, instructions, signers); + return txId; + } catch (error) { + throw new Error(`${error}`); + } +} diff --git a/src/tools/orca_create_clmm.ts b/src/tools/orca_create_clmm.ts new file mode 100644 index 0000000..9fcdc33 --- /dev/null +++ b/src/tools/orca_create_clmm.ts @@ -0,0 +1,132 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { SolanaAgentKit } from "../agent"; +import { Wallet } from "@coral-xyz/anchor"; +import { Decimal } from "decimal.js"; +import { + ORCA_WHIRLPOOL_PROGRAM_ID, + WhirlpoolContext, + PriceMath, + PoolUtil, + buildWhirlpoolClient, +} from "@orca-so/whirlpools-sdk"; +import { sendTx } from "../utils/send_tx"; +import { FEE_TIERS } from "./orca_create_single_sided_liquidity_pool"; + +/** + * # Creates a CLMM Pool (Concentrated Liquidity Market Maker Pool). + * + * This function initializes a new Whirlpool (CLMM Pool) on Orca. It only sets up the pool and does not seed it with liquidity. + * + * ## Example Usage: + * Suppose you want to create a CLMM pool with two tokens, SHARK and USDC, and set the initial price of SHARK to 0.001 USDC. + * You would call this function with `mintA` as SHARK's mint address and `mintB` as USDC's mint address. The pool is created + * with the specified fee tier and tick spacing associated with that fee tier. + * + * ### Note for Experts: + * The Whirlpool program determines the token mint order, which might not match your expectation. This function + * adjusts the input order as needed and inverts the initial price accordingly. + * + * @param agent - The `SolanaAgentKit` instance representing the wallet and connection details. + * @param mintDeploy - The mint of the token you want to deploy (e.g., SHARK). + * @param mintPair - The mint of the token you want to pair the deployed mint with (e.g., USDC). + * @param initialPrice - The initial price of `mintDeploy` in terms of `mintPair`. + * @param feeTier - The fee tier bps for the pool, determining tick spacing and fee collection rates. + * + * @returns A promise that resolves to a transaction ID (`string`) of the transaction creating the pool. + * + * @throws Will throw an error if: + * - Mint accounts for the tokens cannot be fetched. + * - The network is unsupported. + * + * @remarks + * This function only initializes the CLMM pool and does not add liquidity. For adding liquidity, you can use + * a separate function after the pool is successfully created. + * ``` + */ +export async function orcaCreateCLMM( + agent: SolanaAgentKit, + mintDeploy: PublicKey, + mintPair: PublicKey, + initialPrice: Decimal, + feeTier: keyof typeof FEE_TIERS, +): Promise { + try { + let whirlpoolsConfigAddress: PublicKey; + if (agent.connection.rpcEndpoint.includes("mainnet")) { + whirlpoolsConfigAddress = new PublicKey( + "2LecshUwdy9xi7meFgHtFJQNSKk4KdTrcpvaB56dP2NQ", + ); + } else if (agent.connection.rpcEndpoint.includes("devnet")) { + whirlpoolsConfigAddress = new PublicKey( + "FcrweFY1G9HJAHG5inkGB6pKg1HZ6x9UC2WioAfWrGkR", + ); + } else { + throw new Error("Unsupported network"); + } + const wallet = new Wallet(agent.wallet); + const ctx = WhirlpoolContext.from( + agent.connection, + wallet, + ORCA_WHIRLPOOL_PROGRAM_ID, + ); + const fetcher = ctx.fetcher; + const client = buildWhirlpoolClient(ctx); + + const correctTokenOrder = PoolUtil.orderMints(mintDeploy, mintPair).map( + (addr) => addr.toString(), + ); + const isCorrectMintOrder = correctTokenOrder[0] === mintDeploy.toString(); + let mintA; + let mintB; + if (!isCorrectMintOrder) { + [mintA, mintB] = [mintPair, mintDeploy]; + initialPrice = new Decimal(1 / initialPrice.toNumber()); + } else { + [mintA, mintB] = [mintDeploy, mintPair]; + } + const mintAAccount = await fetcher.getMintInfo(mintA); + const mintBAccount = await fetcher.getMintInfo(mintB); + if (mintAAccount === null || mintBAccount === null) { + throw Error("Mint account not found"); + } + + const tickSpacing = FEE_TIERS[feeTier]; + const initialTick = PriceMath.priceToInitializableTickIndex( + initialPrice, + mintAAccount.decimals, + mintBAccount.decimals, + tickSpacing, + ); + const { poolKey, tx: txBuilder } = await client.createPool( + whirlpoolsConfigAddress, + mintA, + mintB, + tickSpacing, + initialTick, + wallet.publicKey, + ); + + const txPayload = await txBuilder.build(); + const txPayloadDecompiled = TransactionMessage.decompile( + (txPayload.transaction as VersionedTransaction).message, + ); + const instructions = txPayloadDecompiled.instructions; + + const txId = await sendTx( + agent, + instructions, + txPayload.signers as Keypair[], + ); + return JSON.stringify({ + transactionId: txId, + whirlpoolAddress: poolKey.toString(), + }); + } catch (error) { + throw new Error(`${error}`); + } +} diff --git a/src/tools/orca_create_single_sided_liquidity_pool.ts b/src/tools/orca_create_single_sided_liquidity_pool.ts new file mode 100644 index 0000000..26792b8 --- /dev/null +++ b/src/tools/orca_create_single_sided_liquidity_pool.ts @@ -0,0 +1,422 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { SolanaAgentKit } from "../agent"; +import { BN, Wallet } from "@coral-xyz/anchor"; +import { Decimal } from "decimal.js"; +import { + PDAUtil, + ORCA_WHIRLPOOL_PROGRAM_ID, + WhirlpoolContext, + TickUtil, + PriceMath, + PoolUtil, + TokenExtensionContextForPool, + NO_TOKEN_EXTENSION_CONTEXT, + TokenExtensionUtil, + WhirlpoolIx, + IncreaseLiquidityQuoteParam, + increaseLiquidityQuoteByInputTokenWithParams, +} from "@orca-so/whirlpools-sdk"; +import { + Percentage, + resolveOrCreateATAs, + TransactionBuilder, +} from "@orca-so/common-sdk"; +import { + increaseLiquidityIx, + increaseLiquidityV2Ix, + initTickArrayIx, + openPositionWithTokenExtensionsIx, +} from "@orca-so/whirlpools-sdk/dist/instructions"; +import { + getAssociatedTokenAddressSync, + TOKEN_2022_PROGRAM_ID, +} from "@solana/spl-token"; +import { sendTx } from "../utils/send_tx"; + +/** + * Maps fee tier bps to their corresponding tick spacing values in the Orca Whirlpool protocol. + * + * @remarks + * Fee tiers determine the percentage of fees collected on swaps, while tick spacing affects + * the granularity of price ranges for liquidity positions. + * + * For more details, refer to: + * - [Whirlpool Fees](https://orca-so.github.io/whirlpools/Architecture%20Overview/Whirlpool%20Fees) + * - [Whirlpool Parameters](https://orca-so.github.io/whirlpools/Architecture%20Overview/Whirlpool%20Parameters) + * + * @example + * const tickSpacing = FEE_TIERS[1]; // returns 1 + */ +export const FEE_TIERS = { + 1: 1, + 2: 2, + 4: 4, + 5: 8, + 16: 16, + 30: 64, + 65: 96, + 100: 128, + 200: 256, +} as const; + +/** + * # Creates a single-sided liquidity pool. + * + * This function initializes a new Whirlpool (liquidity pool) on Orca and seeds it with liquidity from a single token. + * + * ## Example Usage: + * You created a new token called SHARK, and you want to set the initial price to 0.001 USDC. + * You set `depositTokenMint` to SHARK's mint address and `otherTokenMint` to USDC's mint address. + * You can minimize price impact for buyers in a few ways: + * 1. Increase the amount of tokens you deposit + * 2. Set the initial price very low + * 3. Set the maximum price closer to the initial price + * + * ### Note for experts: + * The Wrhirlpool program initializes the Whirlpool with the in a specific order. This might not be + * the order you expect, so the function checks the order and adjusts the inverts the prices. This means that + * on-chain the Whirlpool might be configured as USDC/SHARK instead of SHARK/USDC, and the on-chain price will + * be 1/`initialPrice`. This will not affect the price of the token as you intended it to be. + * + * @param agent - The `SolanaAgentKit` instance representing the wallet and connection details. + * @param depositTokenAmount - The amount of the deposit token to deposit in the pool. + * @param depositTokenMint - The mint address of the token being deposited into the pool, eg. SHARK. + * @param otherTokenMint - The mint address of the other token in the pool, eg. USDC. + * @param initialPrice - The initial price of the deposit token in terms of the other token. + * @param maxPrice - The maximum price at which liquidity is added. + * @param feeTier - The fee tier bps for the pool, determining tick spacing and fee collection rates. + * + * @returns A promise that resolves to a transaction ID (`string`) of the transaction creating the pool. + * + * @throws Will throw an error if: + * - Mint accounts for the tokens cannot be fetched. + * - Prices are out of bounds. + * + * @remarks + * This function is designed for single-sided deposits where users only contribute one type of token, + * and the function manages mint order and necessary calculations. + */ +export async function orcaCreateSingleSidedLiquidityPool( + agent: SolanaAgentKit, + depositTokenAmount: number, + depositTokenMint: PublicKey, + otherTokenMint: PublicKey, + initialPrice: Decimal, + maxPrice: Decimal, + feeTierBps: keyof typeof FEE_TIERS, +): Promise { + try { + let whirlpoolsConfigAddress: PublicKey; + if (agent.connection.rpcEndpoint.includes("mainnet")) { + whirlpoolsConfigAddress = new PublicKey( + "2LecshUwdy9xi7meFgHtFJQNSKk4KdTrcpvaB56dP2NQ", + ); + } else if (agent.connection.rpcEndpoint.includes("devnet")) { + whirlpoolsConfigAddress = new PublicKey( + "FcrweFY1G9HJAHG5inkGB6pKg1HZ6x9UC2WioAfWrGkR", + ); + } else { + throw new Error("Unsupported network"); + } + const wallet = new Wallet(agent.wallet); + const ctx = WhirlpoolContext.from( + agent.connection, + wallet, + ORCA_WHIRLPOOL_PROGRAM_ID, + ); + const fetcher = ctx.fetcher; + + const correctTokenOrder = PoolUtil.orderMints( + otherTokenMint, + depositTokenMint, + ).map((addr) => addr.toString()); + const isCorrectMintOrder = + correctTokenOrder[0] === depositTokenMint.toString(); + let mintA, mintB; + if (isCorrectMintOrder) { + [mintA, mintB] = [depositTokenMint, otherTokenMint]; + } else { + [mintA, mintB] = [otherTokenMint, depositTokenMint]; + initialPrice = new Decimal(1 / initialPrice.toNumber()); + maxPrice = new Decimal(1 / maxPrice.toNumber()); + } + const mintAAccount = await fetcher.getMintInfo(mintA); + const mintBAccount = await fetcher.getMintInfo(mintB); + if (mintAAccount === null || mintBAccount === null) { + throw Error("Mint account not found"); + } + const tickSpacing = FEE_TIERS[feeTierBps]; + const tickIndex = PriceMath.priceToTickIndex( + initialPrice, + mintAAccount.decimals, + mintBAccount.decimals, + ); + const initialTick = TickUtil.getInitializableTickIndex( + tickIndex, + tickSpacing, + ); + + const tokenExtensionCtx: TokenExtensionContextForPool = { + ...NO_TOKEN_EXTENSION_CONTEXT, + tokenMintWithProgramA: mintAAccount, + tokenMintWithProgramB: mintBAccount, + }; + const feeTierKey = PDAUtil.getFeeTier( + ORCA_WHIRLPOOL_PROGRAM_ID, + whirlpoolsConfigAddress, + tickSpacing, + ).publicKey; + const initSqrtPrice = PriceMath.tickIndexToSqrtPriceX64(initialTick); + const tokenVaultAKeypair = Keypair.generate(); + const tokenVaultBKeypair = Keypair.generate(); + const whirlpoolPda = PDAUtil.getWhirlpool( + ORCA_WHIRLPOOL_PROGRAM_ID, + whirlpoolsConfigAddress, + mintA, + mintB, + FEE_TIERS[feeTierBps], + ); + const tokenBadgeA = PDAUtil.getTokenBadge( + ORCA_WHIRLPOOL_PROGRAM_ID, + whirlpoolsConfigAddress, + mintA, + ).publicKey; + const tokenBadgeB = PDAUtil.getTokenBadge( + ORCA_WHIRLPOOL_PROGRAM_ID, + whirlpoolsConfigAddress, + mintB, + ).publicKey; + const baseParamsPool = { + initSqrtPrice, + whirlpoolsConfig: whirlpoolsConfigAddress, + whirlpoolPda, + tokenMintA: mintA, + tokenMintB: mintB, + tokenVaultAKeypair, + tokenVaultBKeypair, + feeTierKey, + tickSpacing: tickSpacing, + funder: wallet.publicKey, + }; + const initPoolIx = !TokenExtensionUtil.isV2IxRequiredPool(tokenExtensionCtx) + ? WhirlpoolIx.initializePoolIx(ctx.program, baseParamsPool) + : WhirlpoolIx.initializePoolV2Ix(ctx.program, { + ...baseParamsPool, + tokenProgramA: tokenExtensionCtx.tokenMintWithProgramA.tokenProgram, + tokenProgramB: tokenExtensionCtx.tokenMintWithProgramB.tokenProgram, + tokenBadgeA, + tokenBadgeB, + }); + const initialTickArrayStartTick = TickUtil.getStartTickIndex( + initialTick, + tickSpacing, + ); + const initialTickArrayPda = PDAUtil.getTickArray( + ctx.program.programId, + whirlpoolPda.publicKey, + initialTickArrayStartTick, + ); + + const txBuilder = new TransactionBuilder( + ctx.provider.connection, + ctx.provider.wallet, + ctx.txBuilderOpts, + ); + txBuilder.addInstruction(initPoolIx); + txBuilder.addInstruction( + initTickArrayIx(ctx.program, { + startTick: initialTickArrayStartTick, + tickArrayPda: initialTickArrayPda, + whirlpool: whirlpoolPda.publicKey, + funder: wallet.publicKey, + }), + ); + + let tickLowerIndex, tickUpperIndex; + if (isCorrectMintOrder) { + tickLowerIndex = initialTick; + tickUpperIndex = PriceMath.priceToTickIndex( + maxPrice, + mintAAccount.decimals, + mintBAccount.decimals, + ); + } else { + tickLowerIndex = PriceMath.priceToTickIndex( + maxPrice, + mintAAccount.decimals, + mintBAccount.decimals, + ); + tickUpperIndex = initialTick; + } + const tickLowerInitializableIndex = TickUtil.getInitializableTickIndex( + tickLowerIndex, + tickSpacing, + ); + const tickUpperInitializableIndex = TickUtil.getInitializableTickIndex( + tickUpperIndex, + tickSpacing, + ); + if ( + !TickUtil.checkTickInBounds(tickLowerInitializableIndex) || + !TickUtil.checkTickInBounds(tickUpperInitializableIndex) + ) { + throw Error("Prices out of bounds"); + } + depositTokenAmount = isCorrectMintOrder + ? depositTokenAmount * Math.pow(10, mintAAccount.decimals) + : depositTokenAmount * Math.pow(10, mintBAccount.decimals); + const increasLiquidityQuoteParam: IncreaseLiquidityQuoteParam = { + inputTokenAmount: new BN(depositTokenAmount), + inputTokenMint: depositTokenMint, + tokenMintA: mintA, + tokenMintB: mintB, + tickCurrentIndex: initialTick, + sqrtPrice: initSqrtPrice, + tickLowerIndex: tickLowerInitializableIndex, + tickUpperIndex: tickUpperInitializableIndex, + tokenExtensionCtx: tokenExtensionCtx, + slippageTolerance: Percentage.fromFraction(0, 100), + }; + const liquidityInput = increaseLiquidityQuoteByInputTokenWithParams( + increasLiquidityQuoteParam, + ); + const { liquidityAmount: liquidity, tokenMaxA, tokenMaxB } = liquidityInput; + + const positionMintKeypair = Keypair.generate(); + const positionMintPubkey = positionMintKeypair.publicKey; + const positionPda = PDAUtil.getPosition( + ORCA_WHIRLPOOL_PROGRAM_ID, + positionMintPubkey, + ); + const positionTokenAccountAddress = getAssociatedTokenAddressSync( + positionMintPubkey, + wallet.publicKey, + ctx.accountResolverOpts.allowPDAOwnerAddress, + TOKEN_2022_PROGRAM_ID, + ); + const params = { + funder: wallet.publicKey, + owner: wallet.publicKey, + positionPda, + positionTokenAccount: positionTokenAccountAddress, + whirlpool: whirlpoolPda.publicKey, + tickLowerIndex: tickLowerInitializableIndex, + tickUpperIndex: tickUpperInitializableIndex, + }; + const positionIx = openPositionWithTokenExtensionsIx(ctx.program, { + ...params, + positionMint: positionMintPubkey, + withTokenMetadataExtension: true, + }); + + txBuilder.addInstruction(positionIx); + txBuilder.addSigner(positionMintKeypair); + + const [ataA, ataB] = await resolveOrCreateATAs( + ctx.connection, + wallet.publicKey, + [ + { tokenMint: mintA, wrappedSolAmountIn: tokenMaxA }, + { tokenMint: mintB, wrappedSolAmountIn: tokenMaxB }, + ], + () => ctx.fetcher.getAccountRentExempt(), + wallet.publicKey, + undefined, + ctx.accountResolverOpts.allowPDAOwnerAddress, + "ata", + ); + const { address: tokenOwnerAccountA, ...tokenOwnerAccountAIx } = ataA; + const { address: tokenOwnerAccountB, ...tokenOwnerAccountBIx } = ataB; + + txBuilder.addInstruction(tokenOwnerAccountAIx); + txBuilder.addInstruction(tokenOwnerAccountBIx); + + const tickArrayLowerStartIndex = TickUtil.getStartTickIndex( + tickLowerInitializableIndex, + tickSpacing, + ); + const tickArrayUpperStartIndex = TickUtil.getStartTickIndex( + tickUpperInitializableIndex, + tickSpacing, + ); + const tickArrayLowerPda = PDAUtil.getTickArray( + ctx.program.programId, + whirlpoolPda.publicKey, + tickArrayLowerStartIndex, + ); + const tickArrayUpperPda = PDAUtil.getTickArray( + ctx.program.programId, + whirlpoolPda.publicKey, + tickArrayUpperStartIndex, + ); + if (tickArrayUpperStartIndex !== tickArrayLowerStartIndex) { + if (isCorrectMintOrder) { + txBuilder.addInstruction( + initTickArrayIx(ctx.program, { + startTick: tickArrayUpperStartIndex, + tickArrayPda: tickArrayUpperPda, + whirlpool: whirlpoolPda.publicKey, + funder: wallet.publicKey, + }), + ); + } else { + txBuilder.addInstruction( + initTickArrayIx(ctx.program, { + startTick: tickArrayLowerStartIndex, + tickArrayPda: tickArrayLowerPda, + whirlpool: whirlpoolPda.publicKey, + funder: wallet.publicKey, + }), + ); + } + } + + const baseParamsLiquidity = { + liquidityAmount: liquidity, + tokenMaxA, + tokenMaxB, + whirlpool: whirlpoolPda.publicKey, + positionAuthority: wallet.publicKey, + position: positionPda.publicKey, + positionTokenAccount: positionTokenAccountAddress, + tokenOwnerAccountA, + tokenOwnerAccountB, + tokenVaultA: tokenVaultAKeypair.publicKey, + tokenVaultB: tokenVaultBKeypair.publicKey, + tickArrayLower: tickArrayLowerPda.publicKey, + tickArrayUpper: tickArrayUpperPda.publicKey, + }; + + const liquidityIx = !TokenExtensionUtil.isV2IxRequiredPool( + tokenExtensionCtx, + ) + ? increaseLiquidityIx(ctx.program, baseParamsLiquidity) + : increaseLiquidityV2Ix(ctx.program, { + ...baseParamsLiquidity, + tokenMintA: mintA, + tokenMintB: mintB, + tokenProgramA: tokenExtensionCtx.tokenMintWithProgramA.tokenProgram, + tokenProgramB: tokenExtensionCtx.tokenMintWithProgramB.tokenProgram, + }); + txBuilder.addInstruction(liquidityIx); + + const txPayload = await txBuilder.build(); + const instructions = TransactionMessage.decompile( + (txPayload.transaction as VersionedTransaction).message, + ).instructions; + + const txId = await sendTx(agent, instructions, [ + positionMintKeypair, + tokenVaultAKeypair, + tokenVaultBKeypair, + ]); + return txId; + } catch (error) { + throw new Error(`Failed to send transaction: ${JSON.stringify(error)}`); + } +} diff --git a/src/tools/orca_fetch_positions.ts b/src/tools/orca_fetch_positions.ts new file mode 100644 index 0000000..f90b8a0 --- /dev/null +++ b/src/tools/orca_fetch_positions.ts @@ -0,0 +1,121 @@ +import { SolanaAgentKit } from "../agent"; +import { Wallet } from "@coral-xyz/anchor"; +import { + ORCA_WHIRLPOOL_PROGRAM_ID, + WhirlpoolContext, + buildWhirlpoolClient, + getAllPositionAccountsByOwner, + PriceMath, +} from "@orca-so/whirlpools-sdk"; + +interface PositionInfo { + whirlpoolAddress: string; + positionInRange: boolean; + distanceFromCenterBps: number; +} + +type PositionDataMap = { + [positionMintAddress: string]: PositionInfo; +}; + +/** + * # Fetches Liquidity Position Data in Orca Whirlpools + * + * Fetches data for all liquidity positions owned by the provided wallet, including: + * - Whirlpool address. + * - Whether the position is in range. + * - Distance from the center price to the current price in basis points. + * + * ## Parameters + * - `agent`: The `SolanaAgentKit` instance representing the wallet and connection. + * + * ## Returns + * A JSON string with an object mapping position mint addresses to position details: + * ```json + * { + * "positionMintAddress1": { + * "whirlpoolAddress": "whirlpoolAddress1", + * "positionInRange": true, + * "distanceFromCenterBps": 250 + * } + * } + * ``` + * + * ## Throws + * - If positions cannot be fetched or processed. + * - If the position mint address is invalid. + * + * @param agent - The `SolanaAgentKit` instance. + * @returns A JSON string with position data. + */ +export async function orcaFetchPositions( + agent: SolanaAgentKit, +): Promise { + try { + const wallet = new Wallet(agent.wallet); + const ctx = WhirlpoolContext.from( + agent.connection, + wallet, + ORCA_WHIRLPOOL_PROGRAM_ID, + ); + const client = buildWhirlpoolClient(ctx); + + const positions = await getAllPositionAccountsByOwner({ + ctx, + owner: agent.wallet.publicKey, + }); + const positionDatas = [ + ...positions.positions.entries(), + ...positions.positionsWithTokenExtensions.entries(), + ]; + const result: PositionDataMap = {}; + for (const [, positionData] of positionDatas) { + const positionMintAddress = positionData.positionMint; + const whirlpoolAddress = positionData.whirlpool; + const whirlpool = await client.getPool(whirlpoolAddress); + const whirlpoolData = whirlpool.getData(); + const sqrtPrice = whirlpoolData.sqrtPrice; + const currentTick = whirlpoolData.tickCurrentIndex; + const mintA = whirlpool.getTokenAInfo(); + const mintB = whirlpool.getTokenBInfo(); + const currentPrice = PriceMath.sqrtPriceX64ToPrice( + sqrtPrice, + mintA.decimals, + mintB.decimals, + ); + const lowerTick = positionData.tickLowerIndex; + const upperTick = positionData.tickUpperIndex; + const lowerPrice = PriceMath.tickIndexToPrice( + lowerTick, + mintA.decimals, + mintB.decimals, + ); + const upperPrice = PriceMath.tickIndexToPrice( + upperTick, + mintA.decimals, + mintB.decimals, + ); + const centerPosition = lowerPrice.add(upperPrice).div(2); + + const positionInRange = + currentTick > lowerTick && currentTick < upperTick ? true : false; + const distanceFromCenterBps = Math.ceil( + currentPrice + .sub(centerPosition) + .abs() + .div(centerPosition) + .mul(10000) + .toNumber(), + ); + + result[positionMintAddress.toString()] = { + whirlpoolAddress: whirlpoolAddress.toString(), + positionInRange, + distanceFromCenterBps, + }; + } + return JSON.stringify(result); + } catch (error) { + throw new Error(`${error}`); + } +} diff --git a/src/tools/orca_open_centered_position_with_liquidity.ts b/src/tools/orca_open_centered_position_with_liquidity.ts new file mode 100644 index 0000000..dfe8ee3 --- /dev/null +++ b/src/tools/orca_open_centered_position_with_liquidity.ts @@ -0,0 +1,161 @@ +import { + Keypair, + PublicKey, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { SolanaAgentKit } from "../agent"; +import { Wallet } from "@coral-xyz/anchor"; +import { Decimal } from "decimal.js"; +import { + ORCA_WHIRLPOOL_PROGRAM_ID, + WhirlpoolContext, + PriceMath, + buildWhirlpoolClient, + increaseLiquidityQuoteByInputToken, + TokenExtensionContextForPool, + NO_TOKEN_EXTENSION_CONTEXT, +} from "@orca-so/whirlpools-sdk"; + +import { sendTx } from "../utils/send_tx"; +import { Percentage } from "@orca-so/common-sdk"; +import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"; + +/** + * # Opens a Centered Liquidity Position in an Orca Whirlpool + * + * This function opens a centered liquidity position in a specified Orca Whirlpool. The user defines + * a basis point (bps) offset from the cuurent price of the pool to set the lower and upper bounds of the position. + * The user also specifies the token mint and the amount to deposit. The required amount of the other token + * is calculated automatically. + * + * ## Parameters + * - `agent`: The `SolanaAgentKit` instance representing the wallet and connection details. + * - `whirlpoolAddress`: The address of the Orca Whirlpool where the position will be opened. + * - `priceOffsetBps`: The basis point (bps) offset (on one side) from the current price fo the pool. For example, + * 500 bps (5%) creates a range from 95% to 105% of the current pool price. + * - `inputTokenMint`: The mint address of the token being deposited (e.g., USDC or another token). + * - `inputAmount`: The amount of the input token to deposit, specified as a `Decimal` value. + * + * ## Returns + * A `Promise` that resolves to the transaction ID (`string`) of the transaction that opens the position. + * + * ## Notes + * - The `priceOffsetBps` specifies the range symmetrically around the current price. + * - The specified `inputTokenMint` determines which token is deposited directly. The function calculates + * the required amount of the other token based on the specified price range. + * - This function supports Orca's token extensions for managing tokens with special behaviors. + * - The function assumes a maximum slippage of 1% for liquidity provision. + * + * ## Throws + * An error will be thrown if: + * - The specified Whirlpool address is invalid or inaccessible. + * - The transaction fails to send. + * - Any required mint information cannot be fetched. + * + * @param agent - The `SolanaAgentKit` instance representing the wallet and connection. + * @param whirlpoolAddress - The address of the Orca Whirlpool. + * @param priceOffsetBps - The basis point offset (one side) from the current pool price. + * @param inputTokenMint - The mint address of the token to deposit. + * @param inputAmount - The amount of the input token to deposit. + * @returns A promise resolving to the transaction ID (`string`). + */ +export async function orcaOpenCenteredPositionWithLiquidity( + agent: SolanaAgentKit, + whirlpoolAddress: PublicKey, + priceOffsetBps: number, + inputTokenMint: PublicKey, + inputAmount: Decimal, +): Promise { + try { + const wallet = new Wallet(agent.wallet); + const ctx = WhirlpoolContext.from( + agent.connection, + wallet, + ORCA_WHIRLPOOL_PROGRAM_ID, + ); + const client = buildWhirlpoolClient(ctx); + + const whirlpool = await client.getPool(whirlpoolAddress); + const whirlpoolData = whirlpool.getData(); + const mintInfoA = whirlpool.getTokenAInfo(); + const mintInfoB = whirlpool.getTokenBInfo(); + const price = PriceMath.sqrtPriceX64ToPrice( + whirlpoolData.sqrtPrice, + mintInfoA.decimals, + mintInfoB.decimals, + ); + + const lowerPrice = price.mul(1 - priceOffsetBps / 10000); + const upperPrice = price.mul(1 + priceOffsetBps / 10000); + const lowerTick = PriceMath.priceToInitializableTickIndex( + lowerPrice, + mintInfoA.decimals, + mintInfoB.decimals, + whirlpoolData.tickSpacing, + ); + const upperTick = PriceMath.priceToInitializableTickIndex( + upperPrice, + mintInfoA.decimals, + mintInfoB.decimals, + whirlpoolData.tickSpacing, + ); + + const txBuilderTickArrays = await whirlpool.initTickArrayForTicks([ + lowerTick, + upperTick, + ]); + let instructions: TransactionInstruction[] = []; + let signers: Keypair[] = []; + if (txBuilderTickArrays !== null) { + const txPayloadTickArrays = await txBuilderTickArrays.build(); + const txPayloadTickArraysDecompiled = TransactionMessage.decompile( + (txPayloadTickArrays.transaction as VersionedTransaction).message, + ); + const instructionsTickArrays = txPayloadTickArraysDecompiled.instructions; + instructions = instructions.concat(instructionsTickArrays); + signers = signers.concat(txPayloadTickArrays.signers as Keypair[]); + } + + const tokenExtensionCtx: TokenExtensionContextForPool = { + ...NO_TOKEN_EXTENSION_CONTEXT, + tokenMintWithProgramA: mintInfoA, + tokenMintWithProgramB: mintInfoB, + }; + const increaseLiquiditQuote = increaseLiquidityQuoteByInputToken( + inputTokenMint, + inputAmount, + lowerTick, + upperTick, + Percentage.fromFraction(1, 100), + whirlpool, + tokenExtensionCtx, + ); + const { positionMint, tx: txBuilder } = + await whirlpool.openPositionWithMetadata( + lowerTick, + upperTick, + increaseLiquiditQuote, + undefined, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const txPayload = await txBuilder.build(); + const txPayloadDecompiled = TransactionMessage.decompile( + (txPayload.transaction as VersionedTransaction).message, + ); + instructions = instructions.concat(txPayloadDecompiled.instructions); + signers = signers.concat(txPayload.signers as Keypair[]); + + const txId = await sendTx(agent, instructions, signers); + return JSON.stringify({ + transactionId: txId, + positionMint: positionMint.toString(), + }); + } catch (error) { + throw new Error(`${error}`); + } +} diff --git a/src/tools/orca_open_single_sided_position.ts b/src/tools/orca_open_single_sided_position.ts new file mode 100644 index 0000000..741069a --- /dev/null +++ b/src/tools/orca_open_single_sided_position.ts @@ -0,0 +1,177 @@ +import { + Keypair, + PublicKey, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { SolanaAgentKit } from "../agent"; +import { Wallet } from "@coral-xyz/anchor"; +import { Decimal } from "decimal.js"; +import { + ORCA_WHIRLPOOL_PROGRAM_ID, + WhirlpoolContext, + PriceMath, + buildWhirlpoolClient, + increaseLiquidityQuoteByInputToken, + TokenExtensionContextForPool, + NO_TOKEN_EXTENSION_CONTEXT, +} from "@orca-so/whirlpools-sdk"; +import { sendTx } from "../utils/send_tx"; +import { Percentage } from "@orca-so/common-sdk"; +import { TOKEN_2022_PROGRAM_ID } from "@solana/spl-token"; + +/** + * # Opens a Single-Sided Liquidity Position in an Orca Whirlpool + * + * This function opens a single-sided liquidity position in a specified Orca Whirlpool. The user specifies + * a basis point (bps) offset from the current price for the lower bound and a width (bps) for the range width. + * The required amount of the other token is calculated automatically. + * + * ## Parameters + * - `agent`: The `SolanaAgentKit` instance representing the wallet and connection details. + * - `whirlpoolAddress`: The address of the Orca Whirlpool where the position will be opened. + * - `distanceFromCurrentPriceBps`: The basis point offset from the current price for the lower bound. + * - `widthBps`: The width of the range as a percentage increment from the lower bound. + * - `inputTokenMint`: The mint address of the token being deposited (e.g., USDC or another token). + * - `inputAmount`: The amount of the input token to deposit, specified as a `Decimal` value. + * + * ## Returns + * A `Promise` that resolves to the transaction ID (`string`) of the transaction that opens the position. + * + * ## Notes + * - The `distanceFromCurrentPriceBps` specifies the starting point of the range. + * - The `widthBps` determines the range size from the lower bound. + * - The specified `inputTokenMint` determines which token is deposited directly. + * + * @param agent - The `SolanaAgentKit` instance representing the wallet and connection. + * @param whirlpoolAddress - The address of the Orca Whirlpool. + * @param distanceFromCurrentPriceBps - The basis point offset from the current price for the lower bound. + * @param widthBps - The width of the range as a percentage increment from the lower bound. + * @param inputTokenMint - The mint address of the token to deposit. + * @param inputAmount - The amount of the input token to deposit. + * @returns A promise resolving to the transaction ID (`string`). + */ +export async function orcaOpenSingleSidedPosition( + agent: SolanaAgentKit, + whirlpoolAddress: PublicKey, + distanceFromCurrentPriceBps: number, + widthBps: number, + inputTokenMint: PublicKey, + inputAmount: Decimal, +): Promise { + try { + const wallet = new Wallet(agent.wallet); + const ctx = WhirlpoolContext.from( + agent.connection, + wallet, + ORCA_WHIRLPOOL_PROGRAM_ID, + ); + const client = buildWhirlpoolClient(ctx); + + const whirlpool = await client.getPool(whirlpoolAddress); + const whirlpoolData = whirlpool.getData(); + const mintInfoA = whirlpool.getTokenAInfo(); + const mintInfoB = whirlpool.getTokenBInfo(); + const price = PriceMath.sqrtPriceX64ToPrice( + whirlpoolData.sqrtPrice, + mintInfoA.decimals, + mintInfoB.decimals, + ); + + const isTokenA = inputTokenMint.equals(mintInfoA.mint); + let lowerBoundPrice; + let upperBoundPrice; + let lowerTick; + let upperTick; + if (isTokenA) { + lowerBoundPrice = price.mul(1 + distanceFromCurrentPriceBps / 10000); + upperBoundPrice = lowerBoundPrice.mul(1 + widthBps / 10000); + upperTick = PriceMath.priceToInitializableTickIndex( + upperBoundPrice, + mintInfoA.decimals, + mintInfoB.decimals, + whirlpoolData.tickSpacing, + ); + lowerTick = PriceMath.priceToInitializableTickIndex( + lowerBoundPrice, + mintInfoA.decimals, + mintInfoB.decimals, + whirlpoolData.tickSpacing, + ); + } else { + lowerBoundPrice = price.mul(1 - distanceFromCurrentPriceBps / 10000); + upperBoundPrice = lowerBoundPrice.mul(1 - widthBps / 10000); + lowerTick = PriceMath.priceToInitializableTickIndex( + upperBoundPrice, + mintInfoA.decimals, + mintInfoB.decimals, + whirlpoolData.tickSpacing, + ); + upperTick = PriceMath.priceToInitializableTickIndex( + lowerBoundPrice, + mintInfoA.decimals, + mintInfoB.decimals, + whirlpoolData.tickSpacing, + ); + } + + const txBuilderTickArrays = await whirlpool.initTickArrayForTicks([ + lowerTick, + upperTick, + ]); + let txIds: string = ""; + if (txBuilderTickArrays !== null) { + const txPayloadTickArrays = await txBuilderTickArrays.build(); + const txPayloadTickArraysDecompiled = TransactionMessage.decompile( + (txPayloadTickArrays.transaction as VersionedTransaction).message, + ); + const instructions = txPayloadTickArraysDecompiled.instructions; + const signers = txPayloadTickArrays.signers as Keypair[]; + + const tickArrayTxId = await sendTx(agent, instructions, signers); + txIds += tickArrayTxId + ","; + } + + const tokenExtensionCtx: TokenExtensionContextForPool = { + ...NO_TOKEN_EXTENSION_CONTEXT, + tokenMintWithProgramA: mintInfoA, + tokenMintWithProgramB: mintInfoB, + }; + const increaseLiquiditQuote = increaseLiquidityQuoteByInputToken( + inputTokenMint, + inputAmount, + lowerTick, + upperTick, + Percentage.fromFraction(1, 100), + whirlpool, + tokenExtensionCtx, + ); + const { positionMint, tx: txBuilder } = + await whirlpool.openPositionWithMetadata( + lowerTick, + upperTick, + increaseLiquiditQuote, + undefined, + undefined, + undefined, + TOKEN_2022_PROGRAM_ID, + ); + + const txPayload = await txBuilder.build(); + const txPayloadDecompiled = TransactionMessage.decompile( + (txPayload.transaction as VersionedTransaction).message, + ); + const instructions = txPayloadDecompiled.instructions; + const signers = txPayload.signers as Keypair[]; + + const positionTxId = await sendTx(agent, instructions, signers); + txIds += positionTxId; + + return JSON.stringify({ + transactionIds: txIds, + positionMint: positionMint.toString(), + }); + } catch (error) { + throw new Error(`${error}`); + } +} diff --git a/src/utils/send_tx.ts b/src/utils/send_tx.ts index 5af33b7..11923b5 100644 --- a/src/utils/send_tx.ts +++ b/src/utils/send_tx.ts @@ -1,70 +1,71 @@ import { SolanaAgentKit } from "../agent"; -import { Transaction, Keypair, TransactionInstruction } from "@solana/web3.js"; -import { Connection, ComputeBudgetProgram } from "@solana/web3.js"; +import { + Keypair, + Signer, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; +import { ComputeBudgetProgram } from "@solana/web3.js"; + +const feeTiers = { + min: 0.01, + mid: 0.5, + max: 0.95, +}; /** * Get priority fees for the current block * @param connection - Solana RPC connection * @returns Priority fees statistics and instructions for different fee levels */ -export async function getPriorityFees(connection: Connection): Promise<{ - min: number; - median: number; - max: number; - instructions?: { - low: TransactionInstruction; - medium: TransactionInstruction; - high: TransactionInstruction; - }; +export async function getComputeBudgetInstructions( + agent: SolanaAgentKit, + instructions: TransactionInstruction[], + feeTier: keyof typeof feeTiers, +): Promise<{ + blockhash: string; + computeBudgetLimitInstruction: TransactionInstruction; + computeBudgetPriorityFeeInstructions: TransactionInstruction; }> { - try { - // Get recent prioritization fees - const priorityFees = await connection.getRecentPrioritizationFees(); + const blockhash = (await agent.connection.getLatestBlockhash()).blockhash; + const messageV0 = new TransactionMessage({ + payerKey: agent.wallet_address, + recentBlockhash: blockhash, + instructions: instructions, + }).compileToV0Message(); + const transaction = new VersionedTransaction(messageV0); + const simulatedTx = agent.connection.simulateTransaction(transaction); + const estimatedComputeUnits = (await simulatedTx).value.unitsConsumed; + const safeComputeUnits = Math.ceil( + estimatedComputeUnits + ? Math.max(estimatedComputeUnits + 100000, estimatedComputeUnits * 1.2) + : 200000, + ); + const computeBudgetLimitInstruction = + ComputeBudgetProgram.setComputeUnitLimit({ + units: safeComputeUnits, + }); - if (!priorityFees.length) { - return { - min: 0, - median: 0, - max: 0, - }; - } + const priorityFee = await agent.connection + .getRecentPrioritizationFees() + .then( + (fees) => + fees.sort((a, b) => a.prioritizationFee - b.prioritizationFee)[ + Math.floor(fees.length * feeTiers[feeTier]) + ].prioritizationFee, + ); - // Sort fees by value - const sortedFees = priorityFees - .map((x) => x.prioritizationFee) - .sort((a, b) => a - b); + const computeBudgetPriorityFeeInstructions = + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: priorityFee, + }); - // Calculate statistics - const min = sortedFees[0] ?? 0; - const max = sortedFees[sortedFees.length - 1] ?? 0; - const mid = Math.floor(sortedFees.length / 2); - const median = - sortedFees.length % 2 === 0 - ? ((sortedFees[mid - 1] ?? 0) + (sortedFees[mid] ?? 0)) / 2 - : (sortedFees[mid] ?? 0); - - // Helper to create priority fee IX based on chosen strategy - const createPriorityFeeIx = (fee: number) => { - return ComputeBudgetProgram.setComputeUnitPrice({ - microLamports: fee, - }); - }; - - return { - min, - median, - max, - // Return instructions for different fee levels - instructions: { - low: createPriorityFeeIx(min), - medium: createPriorityFeeIx(median), - high: createPriorityFeeIx(max), - }, - }; - } catch (error) { - console.error("Error getting priority fees:", error); - throw error; - } + return { + blockhash, + computeBudgetLimitInstruction, + computeBudgetPriorityFeeInstructions, + }; } /** @@ -75,23 +76,53 @@ export async function getPriorityFees(connection: Connection): Promise<{ */ export async function sendTx( agent: SolanaAgentKit, - tx: Transaction, + instructions: TransactionInstruction[], otherKeypairs?: Keypair[], ) { - tx.recentBlockhash = (await agent.connection.getLatestBlockhash()).blockhash; - tx.feePayer = agent.wallet_address; - const fees = await getPriorityFees(agent.connection); - if (fees.instructions) { - tx.add(fees.instructions.medium!); - } + const ixComputeBudget = await getComputeBudgetInstructions( + agent, + instructions, + "mid", + ); + const allInstructions = [ + ixComputeBudget.computeBudgetLimitInstruction, + ixComputeBudget.computeBudgetPriorityFeeInstructions, + ...instructions, + ]; + const messageV0 = new TransactionMessage({ + payerKey: agent.wallet_address, + recentBlockhash: ixComputeBudget.blockhash, + instructions: allInstructions, + }).compileToV0Message(); + const transaction = new VersionedTransaction(messageV0); + transaction.sign([agent.wallet, ...(otherKeypairs ?? [])] as Signer[]); - tx.sign(agent.wallet, ...(otherKeypairs ?? [])); - const txid = await agent.connection.sendRawTransaction(tx.serialize()); - await agent.connection.confirmTransaction({ - signature: txid, - blockhash: (await agent.connection.getLatestBlockhash()).blockhash, - lastValidBlockHeight: (await agent.connection.getLatestBlockhash()) - .lastValidBlockHeight, - }); - return txid; + const timeoutMs = 90000; + const startTime = Date.now(); + while (Date.now() - startTime < timeoutMs) { + const transactionStartTime = Date.now(); + + const signature = await agent.connection.sendTransaction(transaction, { + maxRetries: 0, + skipPreflight: false, + }); + + const statuses = await agent.connection.getSignatureStatuses([signature]); + if (statuses.value[0]) { + if (!statuses.value[0].err) { + return signature; + } else { + throw new Error( + `Transaction failed: ${statuses.value[0].err.toString()}`, + ); + } + } + + const elapsedTime = Date.now() - transactionStartTime; + const remainingTime = Math.max(0, 1000 - elapsedTime); + if (remainingTime > 0) { + await new Promise((resolve) => setTimeout(resolve, remainingTime)); + } + } + throw new Error("Transaction timeout"); } diff --git a/test/index.ts b/test/index.ts index 7cbf8bc..6fb00f4 100644 --- a/test/index.ts +++ b/test/index.ts @@ -37,7 +37,7 @@ async function initializeAgent() { try { const llm = new ChatOpenAI({ modelName: "gpt-4o-mini", - temperature: 0.7, + temperature: 0.3, }); let walletDataStr: string | null = null;