-
-
Notifications
You must be signed in to change notification settings - Fork 247
Feat/orca connector #547
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Feat/orca connector #547
Changes from all commits
60b0983
e78cbd0
0282e1e
2e6dc3f
554eed0
0a2863d
e934cf4
99a3819
c244ae9
63e3e7c
0819e9b
53a04b0
3390a75
c54d5ca
30baeeb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1508,12 +1508,14 @@ export class Solana { | |
| * @param signature Transaction signature | ||
| * @param owner Owner address (required for SPL tokens and SOL balance extraction) | ||
| * @param tokens Array of token mint addresses or 'SOL' for native SOL | ||
| * @param treatWsolAsSplToken If true, treats WSOL as a regular SPL token instead of native SOL (default: false for backward compatibility) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hey @fengtality, about this method, do we know the reason why we treat Wrapped SOL as native token? Since most of Orca operations do not auto wrap and un-wrap SOL so I have to add this flag so that Wrapped SOL can be treated as normal SPL token and therefore Wrapped SOL balance changes can be tracked properly, at least for Orca operations. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hey @mlguys, meeep from Orca here. You can use our helper function to create the wrap/unwrap/createATA for you with this util function when you construct the whirlpool instructions You can take a look at how it's being used across our SDK here. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if you end up using our new sdk, this is handled for you in our out of the box functions and this wouldn't be necessary. |
||
| * @returns Array of balance changes in the same order as tokens, and transaction fee | ||
| */ | ||
| async extractBalanceChangesAndFee( | ||
| signature: string, | ||
| owner: string, | ||
| tokens: string[], | ||
| treatWsolAsSplToken: boolean = false, | ||
| ): Promise<{ | ||
| balanceChanges: number[]; | ||
| fee: number; | ||
|
|
@@ -1536,11 +1538,16 @@ export class Solana { | |
| const postTokenBalances = txDetails.meta?.postTokenBalances || []; | ||
| const ownerPubkey = new PublicKey(owner); | ||
|
|
||
| const NATIVE_MINT = 'So11111111111111111111111111111111111111112'; | ||
|
|
||
| // Process each token and return array of balance changes | ||
| const balanceChanges = tokens.map((token) => { | ||
| // Check if this is native SOL | ||
| if (token === 'So11111111111111111111111111111111111111112') { | ||
| // For native SOL, we need to calculate from lamport balance changes | ||
| // Check if this is native SOL (WSOL) | ||
| const isNativeSOL = token === NATIVE_MINT; | ||
|
|
||
| // If it's WSOL and we DON'T want to treat it as SPL token, use native SOL balance logic | ||
| if (isNativeSOL && !treatWsolAsSplToken) { | ||
| // For native SOL, calculate from lamport balance changes (original behavior for backward compatibility) | ||
| const accountIndex = txDetails.transaction.message.accountKeys.findIndex((key) => | ||
| key.pubkey.equals(ownerPubkey), | ||
| ); | ||
|
|
@@ -1554,16 +1561,15 @@ export class Solana { | |
| const lamportChange = postBalances[accountIndex] - preBalances[accountIndex]; | ||
| return lamportChange * LAMPORT_TO_SOL; | ||
| } else { | ||
| // Token mint address provided - get SPL token balance change | ||
| const preBalance = | ||
| preTokenBalances.find((balance) => balance.mint === token && balance.owner === owner)?.uiTokenAmount | ||
| .uiAmount || 0; | ||
| // Token mint address provided - get SPL token balance change (including WSOL if treatWsolAsSplToken=true) | ||
| const preBalanceEntry = preTokenBalances.find((balance) => balance.mint === token && balance.owner === owner); | ||
| const preBalance = preBalanceEntry?.uiTokenAmount.uiAmount || 0; | ||
|
|
||
| const postBalance = | ||
| postTokenBalances.find((balance) => balance.mint === token && balance.owner === owner)?.uiTokenAmount | ||
| .uiAmount || 0; | ||
| const postBalanceEntry = postTokenBalances.find((balance) => balance.mint === token && balance.owner === owner); | ||
| const postBalance = postBalanceEntry?.uiTokenAmount.uiAmount || 0; | ||
|
|
||
| return postBalance - preBalance; | ||
| const diff = postBalance - preBalance; | ||
| return diff; | ||
| } | ||
| }); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,240 @@ | ||
| import { Percentage, TransactionBuilder } from '@orca-so/common-sdk'; | ||
| import { WhirlpoolIx, increaseLiquidityQuoteByInputTokenWithParams, TokenExtensionUtil } from '@orca-so/whirlpools-sdk'; | ||
| import { Static } from '@sinclair/typebox'; | ||
| import { getAssociatedTokenAddressSync } from '@solana/spl-token'; | ||
| import { PublicKey } from '@solana/web3.js'; | ||
| import BN from 'bn.js'; | ||
| import { Decimal } from 'decimal.js'; | ||
| import { FastifyPluginAsync, FastifyInstance } from 'fastify'; | ||
|
|
||
| import { Solana } from '../../../chains/solana/solana'; | ||
| import { AddLiquidityResponse, AddLiquidityResponseType } from '../../../schemas/clmm-schema'; | ||
| import { logger } from '../../../services/logger'; | ||
| import { Orca } from '../orca'; | ||
| import { getTickArrayPubkeys, handleWsolAta } from '../orca.utils'; | ||
| import { OrcaClmmAddLiquidityRequest } from '../schemas'; | ||
|
|
||
| async function addLiquidity( | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The following routes can be simplified to a single call to construct the ix & execution if you use our kit-sdk (toggle the "Typescript Kit" header when you visit our docs to see the examples):
The complexities such as input conversion, ATA/wSOL handling & other Orca specific operations are done for you in these methods. |
||
| fastify: FastifyInstance, | ||
| network: string, | ||
| address: string, | ||
| positionAddress: string, | ||
| baseTokenAmount: number, | ||
| quoteTokenAmount: number, | ||
| slippagePct: number, | ||
| ): Promise<AddLiquidityResponseType> { | ||
| // Validate at least one amount is provided | ||
| if ((!baseTokenAmount || baseTokenAmount <= 0) && (!quoteTokenAmount || quoteTokenAmount <= 0)) { | ||
| throw fastify.httpErrors.badRequest('At least one token amount must be provided and greater than 0'); | ||
| } | ||
|
|
||
| const solana = await Solana.getInstance(network); | ||
| const orca = await Orca.getInstance(network); | ||
| const wallet = await solana.getWallet(address); | ||
| const ctx = await orca.getWhirlpoolContextForWallet(address); | ||
| const positionPubkey = new PublicKey(positionAddress); | ||
|
|
||
| // Fetch position data | ||
| const position = await ctx.fetcher.getPosition(positionPubkey); | ||
| if (!position) { | ||
| throw fastify.httpErrors.notFound(`Position not found: ${positionAddress}`); | ||
| } | ||
|
|
||
| const positionMint = await ctx.fetcher.getMintInfo(position.positionMint); | ||
| if (!positionMint) { | ||
| throw fastify.httpErrors.notFound(`Position mint not found: ${position.positionMint.toString()}`); | ||
| } | ||
|
|
||
| // Fetch whirlpool data | ||
| const whirlpoolPubkey = position.whirlpool; | ||
| const whirlpool = await ctx.fetcher.getPool(whirlpoolPubkey); | ||
| if (!whirlpool) { | ||
| throw fastify.httpErrors.notFound(`Whirlpool not found: ${whirlpoolPubkey.toString()}`); | ||
| } | ||
|
|
||
| // Fetch token mint info | ||
| const mintA = await ctx.fetcher.getMintInfo(whirlpool.tokenMintA); | ||
| const mintB = await ctx.fetcher.getMintInfo(whirlpool.tokenMintB); | ||
| if (!mintA || !mintB) { | ||
| throw fastify.httpErrors.notFound('Token mint not found'); | ||
| } | ||
|
|
||
| // Determine which token to use as input (prefer base if both provided) | ||
| const useBaseToken = baseTokenAmount > 0; | ||
| const inputTokenAmount = useBaseToken ? baseTokenAmount : quoteTokenAmount; | ||
| const inputTokenMint = useBaseToken ? whirlpool.tokenMintA : whirlpool.tokenMintB; | ||
| const inputTokenDecimals = useBaseToken ? mintA.decimals : mintB.decimals; | ||
|
|
||
| // Convert input amount to BN | ||
| const amount = new BN(Math.floor(inputTokenAmount * Math.pow(10, inputTokenDecimals))); | ||
|
|
||
| // Get increase liquidity quote | ||
| const quote = increaseLiquidityQuoteByInputTokenWithParams({ | ||
| inputTokenAmount: amount, | ||
| inputTokenMint, | ||
| sqrtPrice: whirlpool.sqrtPrice, | ||
| tickCurrentIndex: whirlpool.tickCurrentIndex, | ||
| tickLowerIndex: position.tickLowerIndex, | ||
| tickUpperIndex: position.tickUpperIndex, | ||
| tokenExtensionCtx: await TokenExtensionUtil.buildTokenExtensionContext(ctx.fetcher, whirlpool), | ||
| tokenMintA: whirlpool.tokenMintA, | ||
| tokenMintB: whirlpool.tokenMintB, | ||
| slippageTolerance: Percentage.fromDecimal(new Decimal(slippagePct)), | ||
| }); | ||
|
|
||
| logger.info( | ||
| `Adding liquidity: ${(Number(quote.tokenEstA) / Math.pow(10, mintA.decimals)).toFixed(6)} tokenA, ${(Number(quote.tokenEstB) / Math.pow(10, mintB.decimals)).toFixed(6)} tokenB`, | ||
| ); | ||
|
|
||
| // Build transaction | ||
| const builder = new TransactionBuilder(ctx.connection, ctx.wallet); | ||
|
|
||
| const tokenOwnerAccountA = getAssociatedTokenAddressSync( | ||
| whirlpool.tokenMintA, | ||
| ctx.wallet.publicKey, | ||
| undefined, | ||
| mintA.tokenProgram, | ||
| ); | ||
| const tokenOwnerAccountB = getAssociatedTokenAddressSync( | ||
| whirlpool.tokenMintB, | ||
| ctx.wallet.publicKey, | ||
| undefined, | ||
| mintB.tokenProgram, | ||
| ); | ||
|
|
||
| // Handle WSOL wrapping for tokens (or create regular ATAs) | ||
| await handleWsolAta( | ||
| builder, | ||
| ctx, | ||
| whirlpool.tokenMintA, | ||
| tokenOwnerAccountA, | ||
| mintA.tokenProgram, | ||
| 'wrap', | ||
| quote.tokenMaxA, | ||
| ); | ||
| await handleWsolAta( | ||
| builder, | ||
| ctx, | ||
| whirlpool.tokenMintB, | ||
| tokenOwnerAccountB, | ||
| mintB.tokenProgram, | ||
| 'wrap', | ||
| quote.tokenMaxB, | ||
| ); | ||
|
|
||
| const { lower, upper } = getTickArrayPubkeys(position, whirlpool, whirlpoolPubkey); | ||
| builder.addInstruction( | ||
| WhirlpoolIx.increaseLiquidityV2Ix(ctx.program, { | ||
| liquidityAmount: quote.liquidityAmount, | ||
| tokenMaxA: quote.tokenMaxA, | ||
| tokenMaxB: quote.tokenMaxB, | ||
| position: positionPubkey, | ||
| positionAuthority: ctx.wallet.publicKey, | ||
| tokenMintA: whirlpool.tokenMintA, | ||
| tokenMintB: whirlpool.tokenMintB, | ||
| positionTokenAccount: getAssociatedTokenAddressSync( | ||
| position.positionMint, | ||
| ctx.wallet.publicKey, | ||
| undefined, | ||
| positionMint.tokenProgram, | ||
| ), | ||
| tickArrayLower: lower, | ||
| tickArrayUpper: upper, | ||
| tokenOwnerAccountA, | ||
| tokenOwnerAccountB, | ||
| tokenProgramA: mintA.tokenProgram, | ||
| tokenProgramB: mintB.tokenProgram, | ||
| tokenVaultA: whirlpool.tokenVaultA, | ||
| tokenVaultB: whirlpool.tokenVaultB, | ||
| whirlpool: whirlpoolPubkey, | ||
| tokenTransferHookAccountsA: await TokenExtensionUtil.getExtraAccountMetasForTransferHook( | ||
| ctx.provider.connection, | ||
| mintA, | ||
| tokenOwnerAccountA, | ||
| whirlpool.tokenVaultA, | ||
| ctx.wallet.publicKey, | ||
| ), | ||
| tokenTransferHookAccountsB: await TokenExtensionUtil.getExtraAccountMetasForTransferHook( | ||
| ctx.provider.connection, | ||
| mintB, | ||
| tokenOwnerAccountB, | ||
| whirlpool.tokenVaultB, | ||
| ctx.wallet.publicKey, | ||
| ), | ||
| }), | ||
| ); | ||
|
|
||
| // Build, simulate, and send transaction | ||
| const txPayload = await builder.build(); | ||
| await solana.simulateWithErrorHandling(txPayload.transaction, fastify); | ||
| const { signature, fee } = await solana.sendAndConfirmTransaction(txPayload.transaction, [wallet]); | ||
|
|
||
| // Extract added amounts from balance changes | ||
| const tokenA = await solana.getToken(whirlpool.tokenMintA.toString()); | ||
| const tokenB = await solana.getToken(whirlpool.tokenMintB.toString()); | ||
| if (!tokenA || !tokenB) { | ||
| throw fastify.httpErrors.notFound('Tokens not found for balance extraction'); | ||
| } | ||
|
|
||
| const { balanceChanges } = await solana.extractBalanceChangesAndFee( | ||
| signature, | ||
| ctx.wallet.publicKey.toString(), | ||
| [tokenA.address, tokenB.address], | ||
| true, | ||
| ); | ||
|
|
||
| logger.info( | ||
| `Liquidity added: ${Math.abs(balanceChanges[0]).toFixed(6)} ${tokenA.symbol}, ${Math.abs(balanceChanges[1]).toFixed(6)} ${tokenB.symbol}`, | ||
| ); | ||
|
|
||
| return { | ||
| signature, | ||
| status: 1, // CONFIRMED | ||
| data: { | ||
| fee, | ||
| baseTokenAmountAdded: Math.abs(balanceChanges[0]), | ||
| quoteTokenAmountAdded: Math.abs(balanceChanges[1]), | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| export const addLiquidityRoute: FastifyPluginAsync = async (fastify) => { | ||
| fastify.post<{ | ||
| Body: Static<typeof OrcaClmmAddLiquidityRequest>; | ||
| Reply: AddLiquidityResponseType; | ||
| }>( | ||
| '/add-liquidity', | ||
| { | ||
| schema: { | ||
| description: 'Add liquidity to an Orca position', | ||
| tags: ['/connector/orca'], | ||
| body: OrcaClmmAddLiquidityRequest, | ||
| response: { | ||
| 200: AddLiquidityResponse, | ||
| }, | ||
| }, | ||
| }, | ||
| async (request) => { | ||
| try { | ||
| const { walletAddress, positionAddress, baseTokenAmount, quoteTokenAmount, slippagePct = 1 } = request.body; | ||
| const network = request.body.network; | ||
|
|
||
| return await addLiquidity( | ||
| fastify, | ||
| network, | ||
| walletAddress, | ||
| positionAddress, | ||
| baseTokenAmount || 0, | ||
| quoteTokenAmount || 0, | ||
| slippagePct, | ||
| ); | ||
| } catch (e) { | ||
| logger.error(e); | ||
| if (e.statusCode) throw e; | ||
| throw fastify.httpErrors.internalServerError('Internal server error'); | ||
| } | ||
| }, | ||
| ); | ||
| }; | ||
|
|
||
| export default addLiquidityRoute; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are two ts-sdks in the Orca realm. The legacy-sdk
orca-so/whirlpools-sdk& the new kit-based SDKorca-so/whirlpools. They are both at feature parity.Looking at hummingbot's usage here, the requirement is mostly to construct the ix and executing it. If there are no issues adding
@solana/kitas a dependency, we'd highly recommend using the new SDK and not use the legacy-sdk since it's a lot easier to use for your use-case.For example, the following QoL type functionality are done for you: