Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14,255 changes: 11,199 additions & 3,056 deletions openapi.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"prepare": "pnpm husky"
},
"dependencies": {
"@coral-xyz/anchor": "^0.30.1",
"@coral-xyz/anchor": "~0.29.0",
"@ethersproject/abstract-provider": "5.7.0",
"@ethersproject/address": "5.7.0",
"@ethersproject/contracts": "5.7.0",
Expand All @@ -55,6 +55,10 @@
"@ledgerhq/hw-transport-node-hid-singleton": "^6.31.8",
"@meteora-ag/dlmm": "1.7.5",
"@orca-so/common-sdk": "^0.6.11",
"@orca-so/whirlpools": "^4.0.0",
"@orca-so/whirlpools-client": "^4.0.1",
"@orca-so/whirlpools-core": "^2.0.0",
"@orca-so/whirlpools-sdk": "^0.16.0",
"@pancakeswap/permit2-sdk": "^1.1.5",
"@pancakeswap/sdk": "^5.8.16",
"@pancakeswap/smart-router": "^7.5.2",
Expand All @@ -67,6 +71,8 @@
"@pancakeswap/v4-sdk": "^0.1.8",
"@raydium-io/raydium-sdk-v2": "0.1.141-alpha",
"@sinclair/typebox": "^0.33.22",
"@solana-program/token-2022": "^0.6.0",
"@solana/kit": "3.0.3",

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 SDK orca-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/kit as 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:

  • appending ATA / wrapSOL ix if necessary
  • initializing tick-arrays before tick-array operations
  • ...etc

"@solana/spl-token": "0.4.8",
"@solana/spl-token-registry": "^0.2.4574",
"@solana/web3.js": "^1.98.0",
Expand Down
991 changes: 975 additions & 16 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { configRoutes } from './config/config.routes';
import { register0xRoutes } from './connectors/0x/0x.routes';
import { jupiterRoutes } from './connectors/jupiter/jupiter.routes';
import { meteoraRoutes } from './connectors/meteora/meteora.routes';
import { orcaRoutes } from './connectors/orca/orca.routes';
import { pancakeswapRoutes } from './connectors/pancakeswap/pancakeswap.routes';
import { pancakeswapSolRoutes } from './connectors/pancakeswap-sol/pancakeswap-sol.routes';
import { raydiumRoutes } from './connectors/raydium/raydium.routes';
Expand Down Expand Up @@ -86,6 +87,10 @@ const swaggerOptions = {
name: '/connector/meteora',
description: 'Meteora connector endpoints',
},
{
name: '/connector/orca',
description: 'Orca connector endpoints',
},
{
name: '/connector/raydium',
description: 'Raydium connector endpoints',
Expand Down Expand Up @@ -246,6 +251,9 @@ const configureGatewayServer = () => {
// Meteora routes
app.register(meteoraRoutes.clmm, { prefix: '/connectors/meteora/clmm' });

// // Orca routes
app.register(orcaRoutes.clmm, { prefix: '/connectors/orca/clmm' });

// Raydium routes
app.register(raydiumRoutes.amm, { prefix: '/connectors/raydium/amm' });
app.register(raydiumRoutes.clmm, { prefix: '/connectors/raydium/clmm' });
Expand Down
28 changes: 17 additions & 11 deletions src/chains/solana/solana.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Contributor Author

@mlguys mlguys Nov 3, 2025

Choose a reason for hiding this comment

The 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.

Choose a reason for hiding this comment

The 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.

Choose a reason for hiding this comment

The 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;
Expand All @@ -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),
);
Expand All @@ -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;
}
});

Expand Down
240 changes: 240 additions & 0 deletions src/connectors/orca/clmm-routes/addLiquidity.ts
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(

Choose a reason for hiding this comment

The 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;
Loading