diff --git a/package.json b/package.json index dbd600b..a1170a8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "license": "ISC", "dependencies": { "@bonfida/spl-name-service": "^3.0.7", + "@coral-xyz/anchor": "0.29", "@langchain/core": "^0.3.18", "@langchain/groq": "^0.1.2", "@langchain/langgraph": "^0.2.27", @@ -26,9 +27,12 @@ "@metaplex-foundation/umi": "^0.9.2", "@metaplex-foundation/umi-bundle-defaults": "^0.9.2", "@metaplex-foundation/umi-web3js-adapters": "^0.9.2", + "@orca-so/common-sdk": "0.6.4", + "@orca-so/whirlpools-sdk": "^0.13.12", "@solana/spl-token": "^0.4.9", "@solana/web3.js": "^1.95.4", "bs58": "^6.0.0", + "decimal.js": "^10.4.3", "dotenv": "^16.4.5", "form-data": "^4.0.1", "langchain": "^0.3.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ed26d2..13834e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,9 @@ importers: bs58: specifier: ^6.0.0 version: 6.0.0 + decimal.js: + specifier: ^10.4.3 + version: 10.4.3 dotenv: specifier: ^16.4.5 version: 16.4.7 @@ -658,6 +661,9 @@ packages: resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} engines: {node: '>=0.10.0'} + decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + delay@5.0.0: resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} engines: {node: '>=10'} @@ -1965,6 +1971,8 @@ snapshots: decamelize@1.2.0: {} + decimal.js@10.4.3: {} + delay@5.0.0: {} delayed-stream@1.0.0: {} diff --git a/src/agent/index.ts b/src/agent/index.ts index b8aa154..847eb2e 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -18,9 +18,13 @@ import { getTokenDataByTicker, stakeWithJup, sendCompressedAirdrop, + createOrcaSingleSidedWhirlpool, + FEE_TIERS } from "../tools"; import { CollectionOptions, PumpFunTokenOptions } from "../types"; import { DEFAULT_OPTIONS } from "../constants"; +import { BN } from "@coral-xyz/anchor"; +import Decimal from "decimal.js"; /** * Main class for interacting with Solana blockchain @@ -159,4 +163,23 @@ export class SolanaAgentKit { shouldLog ); } + + async createOrcaSingleSidedWhirlpool( + depositTokenAmount: BN, + depositTokenMint: PublicKey, + otherTokenMint: PublicKey, + initialPrice: Decimal, + maxPrice: Decimal, + feeTier: keyof typeof FEE_TIERS, + ) { + return createOrcaSingleSidedWhirlpool( + this, + depositTokenAmount, + depositTokenMint, + otherTokenMint, + initialPrice, + maxPrice, + feeTier + ) + } } diff --git a/src/langchain/index.ts b/src/langchain/index.ts index 1f5f717..20991f7 100644 --- a/src/langchain/index.ts +++ b/src/langchain/index.ts @@ -4,6 +4,9 @@ import { PublicKey } from "@solana/web3.js"; import { toJSON } from "../utils/toJSON"; import { create_image } from "../tools/create_image"; import { fetchPrice } from "../tools/fetch_price"; +import { BN } from "@coral-xyz/anchor"; +import Decimal from "decimal.js"; +import { FEE_TIERS } from "../tools"; export class SolanaBalanceTool extends Tool { name = "solana_balance"; @@ -746,6 +749,61 @@ 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)`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + async _call(input: string): Promise { + try { + const inputFormat = JSON.parse(input); + const depositTokenAmount = new BN(inputFormat.depositTokenAmount); + const depositTokenMint = new PublicKey(inputFormat.depositTokenMint); + const otherTokenMint = new PublicKey(inputFormat.otherTokenMint); + const initialPrice = new Decimal(inputFormat.initialPrice); + const maxPrice = new Decimal(inputFormat.maxPrice); + 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.createOrcaSingleSidedWhirlpool( + depositTokenAmount, + depositTokenMint, + otherTokenMint, + initialPrice, + maxPrice, + feeTier, + ); + + return JSON.stringify({ + status: "success", + message: "Single-sided Whirlpool created successfully", + transaction: txId, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + + export function createSolanaTools(solanaKit: SolanaAgentKit) { return [ new SolanaBalanceTool(solanaKit), diff --git a/src/tools/create_orca_single_sided_whirlpool.ts b/src/tools/create_orca_single_sided_whirlpool.ts new file mode 100644 index 0000000..ad3f025 --- /dev/null +++ b/src/tools/create_orca_single_sided_whirlpool.ts @@ -0,0 +1,383 @@ +import { Keypair, PublicKey } 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, + 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"; + +/** + * 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.30: 64, + 0.65: 96, + 1.00: 128, + 2.00: 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 txId = await txBuilder.buildAndExecute(); + + return txId; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 80607f3..b0b3028 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -15,3 +15,5 @@ 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"; \ No newline at end of file