Skip to content
Merged
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
140 changes: 82 additions & 58 deletions src/connectors/orca/clmm-routes/closePosition.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { Percentage, TransactionBuilder } from '@orca-so/common-sdk';
import {
TickArrayUtil,
ORCA_WHIRLPOOL_PROGRAM_ID,
WhirlpoolIx,
collectFeesQuote,
decreaseLiquidityQuoteByLiquidityWithParams,
TokenExtensionUtil,
IGNORE_CACHE,
Expand All @@ -18,7 +17,7 @@ import { ClosePositionResponse, ClosePositionResponseType } from '../../../schem
import { httpErrors } from '../../../services/error-handler';
import { logger } from '../../../services/logger';
import { Orca } from '../orca';
import { getTickArrayPubkeys, handleWsolAta } from '../orca.utils';
import { extractInnerTransferAmounts, getTickArrayPubkeys, handleWsolAta } from '../orca.utils';
import { OrcaClmmClosePositionRequest } from '../schemas';

export async function closePosition(
Expand Down Expand Up @@ -171,35 +170,15 @@ export async function closePosition(
}),
);

baseTokenAmountRemoved = Number(decreaseQuote.tokenEstA) / Math.pow(10, mintA.decimals);
quoteTokenAmountRemoved = Number(decreaseQuote.tokenEstB) / Math.pow(10, mintB.decimals);
// Note: baseTokenAmountRemoved/quoteTokenAmountRemoved are set from actual
// TX inner instruction transfers after execution, not from the estimate here.
}

// Step 3: Collect fees if there are fees owed or if we just removed liquidity
// Note: Fee amounts are derived from inner instruction transfers after execution,
// which gives us exact amounts from the collectFees instruction separately
// from the decreaseLiquidity instruction.
if (hasFees || hasLiquidity) {
const { lower, upper } = getTickArrayPubkeys(position.getData(), whirlpool.getData(), whirlpoolPubkey);
const lowerTickArray = await client.getFetcher().getTickArray(lower);
const upperTickArray = await client.getFetcher().getTickArray(upper);
if (!lowerTickArray || !upperTickArray) {
throw httpErrors.notFound('Tick array not found');
}

const collectQuote = collectFeesQuote({
position: position.getData(),
tickLower: TickArrayUtil.getTickFromArray(
lowerTickArray,
position.getData().tickLowerIndex,
whirlpool.getData().tickSpacing,
),
tickUpper: TickArrayUtil.getTickFromArray(
upperTickArray,
position.getData().tickUpperIndex,
whirlpool.getData().tickSpacing,
),
whirlpool: whirlpool.getData(),
tokenExtensionCtx: await TokenExtensionUtil.buildTokenExtensionContext(client.getFetcher(), whirlpool.getData()),
});

builder.addInstruction(
WhirlpoolIx.collectFeesV2Ix(client.getContext().program, {
position: positionPubkey,
Expand Down Expand Up @@ -235,10 +214,6 @@ export async function closePosition(
),
}),
);

// Use collectQuote for fee amounts (derived from on-chain position data)
baseFeeAmountCollected = Number(collectQuote.feeOwedA) / Math.pow(10, mintA.decimals);
quoteFeeAmountCollected = Number(collectQuote.feeOwedB) / Math.pow(10, mintB.decimals);
}

// Step 4: Auto-unwrap WSOL to native SOL after receiving all tokens
Expand Down Expand Up @@ -283,37 +258,86 @@ export async function closePosition(
}),
);

// Calculate rent refund by querying position account balances BEFORE the TX.
// These accounts will be closed by the transaction, so we must read them now.
// This is more accurate than deriving from wallet SOL changes, which can be
// skewed by ephemeral wSOL wrapper create/close cycles within the same TX.
const LAMPORT_TO_SOL = 1e-9;
const positionMintPubkey = position.getData().positionMint;
const positionTokenAccount = getAssociatedTokenAddressSync(
positionMintPubkey,
client.getContext().wallet.publicKey,
undefined,
isToken2022 ? TOKEN_2022_PROGRAM_ID : undefined,
);
const [positionMintBalance, positionDataBalance, positionAtaBalance] = await Promise.all([
solana.connection.getBalance(positionMintPubkey),
solana.connection.getBalance(positionPubkey),
solana.connection.getBalance(positionTokenAccount),
]);
const positionRentRefunded = (positionMintBalance + positionDataBalance + positionAtaBalance) * LAMPORT_TO_SOL;

logger.info(
`Position rent refund: mint=${positionMintBalance}, data=${positionDataBalance}, ata=${positionAtaBalance}, total=${positionRentRefunded} SOL`,
);

// Build, simulate, and send transaction
const txPayload = await builder.build();
await solana.simulateWithErrorHandling(txPayload.transaction);
const { signature, fee } = await solana.sendAndConfirmTransaction(txPayload.transaction, [wallet]);

// Token amounts are already set from quotes:
// - baseTokenAmountRemoved / quoteTokenAmountRemoved from decreaseQuote (Step 2)
// - baseFeeAmountCollected / quoteFeeAmountCollected from collectQuote (Step 3)
// Extract rent refund and actual token amounts from the confirmed transaction.
// Position accounts (mint, PDA, ATA) are closed by the TX, so their preBalance = rent refunded.
// Tick arrays are NOT closed (shared resources), so they are not included here.
const txData = await solana.connection.getTransaction(signature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
});

let positionRentRefunded = 0;

if (txData) {
const accountKeys = txData.transaction.message.getAccountKeys().staticAccountKeys;
const preBalances = txData.meta?.preBalances || [];
const postBalances = txData.meta?.postBalances || [];

// Position accounts whose rent gets refunded on close
const positionMintPubkey = position.getData().positionMint;
const positionTokenAccount = getAssociatedTokenAddressSync(
positionMintPubkey,
client.getContext().wallet.publicKey,
undefined,
isToken2022 ? TOKEN_2022_PROGRAM_ID : undefined,
);
const rentAccounts: PublicKey[] = [positionMintPubkey, positionPubkey, positionTokenAccount];

let totalRentLamports = 0;
for (const pubkey of rentAccounts) {
const idx = accountKeys.findIndex((key) => key.equals(pubkey));
if (idx !== -1 && postBalances[idx] === 0 && preBalances[idx] > 0) {
totalRentLamports += preBalances[idx];
logger.info(`Rent refunded from ${pubkey.toString()}: ${preBalances[idx]} lamports`);
}
}
positionRentRefunded = totalRentLamports / 1e9;

// Extract exact transfer amounts per Whirlpool instruction from inner instructions.
// The close TX has Whirlpool instructions in order:
// updateFeesAndRewards (no transfers) → decreaseLiquidity (transfers) → collectFees (transfers) → closePosition (no transfers)
// extractInnerTransferAmounts returns only groups that have transfers, so:
// transferGroups[0] = decreaseLiquidity amounts, transferGroups[1] = collectFees amounts
const tokenMintA = whirlpool.getTokenAInfo().address.toString();
const tokenMintB = whirlpool.getTokenBInfo().address.toString();

const { transferGroups } = await extractInnerTransferAmounts(
solana.connection,
signature,
ORCA_WHIRLPOOL_PROGRAM_ID.toString(),
[tokenMintA, tokenMintB],
);

if (transferGroups.length >= 2) {
// First transfer group: decreaseLiquidity (liquidity removed)
baseTokenAmountRemoved = transferGroups[0][0];
quoteTokenAmountRemoved = transferGroups[0][1];
// Second transfer group: collectFees (fees collected)
baseFeeAmountCollected = transferGroups[1][0];
quoteFeeAmountCollected = transferGroups[1][1];
} else if (transferGroups.length === 1) {
// Only one group with transfers — could be just decreaseLiquidity or just collectFees
if (hasLiquidity) {
baseTokenAmountRemoved = transferGroups[0][0];
quoteTokenAmountRemoved = transferGroups[0][1];
} else {
baseFeeAmountCollected = transferGroups[0][0];
quoteFeeAmountCollected = transferGroups[0][1];
}
}
// If transferGroups is empty, position had no liquidity and no fees — values stay at 0
}

logger.info(
`Position closed: removed=${baseTokenAmountRemoved.toFixed(6)} tokenA + ${quoteTokenAmountRemoved.toFixed(6)} tokenB, ` +
`fees=${baseFeeAmountCollected.toFixed(6)} tokenA + ${quoteFeeAmountCollected.toFixed(6)} tokenB, ` +
`rent refunded=${positionRentRefunded.toFixed(6)} SOL`,
);

return {
signature,
Expand Down
105 changes: 81 additions & 24 deletions src/connectors/orca/clmm-routes/openPosition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import { OpenPositionResponse, OpenPositionResponseType } from '../../../schemas
import { httpErrors } from '../../../services/error-handler';
import { logger } from '../../../services/logger';
import { Orca } from '../orca';
import { getTickArrayPubkeys, handleWsolAta } from '../orca.utils';
import { extractInnerTransferAmounts, getTickArrayPubkeys, handleWsolAta } from '../orca.utils';
import { OrcaClmmOpenPositionRequest } from '../schemas';

/**
* Initialize tick arrays if they don't exist
* Initialize tick arrays if they don't exist.
* Returns the pubkeys of any newly-created tick arrays so their rent
* can be included in the total position rent calculation.
*/
async function initializeTickArrays(
builder: TransactionBuilder,
Expand All @@ -36,9 +38,11 @@ async function initializeTickArrays(
whirlpoolPubkey: PublicKey,
lowerTickIndex: number,
upperTickIndex: number,
): Promise<void> {
): Promise<PublicKey[]> {
await whirlpool.refreshData();

const newTickArrayPubkeys: PublicKey[] = [];

const lowerTickArrayPda = PDAUtil.getTickArrayFromTickIndex(
lowerTickIndex,
whirlpool.getData().tickSpacing,
Expand All @@ -64,6 +68,7 @@ async function initializeTickArrays(
tickArrayPda: lowerTickArrayPda,
}),
);
newTickArrayPubkeys.push(lowerTickArrayPda.publicKey);
}

if (!upperTickArray && !upperTickArrayPda.publicKey.equals(lowerTickArrayPda.publicKey)) {
Expand All @@ -75,7 +80,10 @@ async function initializeTickArrays(
tickArrayPda: upperTickArrayPda,
}),
);
newTickArrayPubkeys.push(upperTickArrayPda.publicKey);
}

return newTickArrayPubkeys;
}

/**
Expand Down Expand Up @@ -216,8 +224,15 @@ export async function openPosition(
// Build transaction
const builder = new TransactionBuilder(client.getContext().connection, client.getContext().wallet);

// Initialize tick arrays if needed
await initializeTickArrays(builder, client, whirlpool, whirlpoolPubkey, lowerTickIndex, upperTickIndex);
// Initialize tick arrays if needed (returns pubkeys of newly-created arrays for rent tracking)
const newTickArrayPubkeys = await initializeTickArrays(
builder,
client,
whirlpool,
whirlpoolPubkey,
lowerTickIndex,
upperTickIndex,
);

// If we're adding liquidity, prepare WSOL wrapping FIRST (before opening position)
let baseTokenAmountAdded = 0;
Expand Down Expand Up @@ -449,26 +464,68 @@ export async function openPosition(
positionMintKeypair,
]);

// Calculate rent by querying the actual SOL balances of position accounts.
// This is more accurate than deriving from wallet SOL changes, which can be
// skewed by ephemeral wSOL wrapper create/close cycles within the same TX.
const LAMPORT_TO_SOL = 1e-9;
const positionTokenAccount = getAssociatedTokenAddressSync(
positionMintKeypair.publicKey,
client.getContext().wallet.publicKey,
undefined,
TOKEN_2022_PROGRAM_ID,
);
const [positionMintBalance, positionDataBalance, positionAtaBalance] = await Promise.all([
solana.connection.getBalance(positionMintKeypair.publicKey),
solana.connection.getBalance(positionPda.publicKey),
solana.connection.getBalance(positionTokenAccount),
]);
const positionRent = (positionMintBalance + positionDataBalance + positionAtaBalance) * LAMPORT_TO_SOL;
// Extract position rent from the confirmed transaction's postBalances.
// Newly-created accounts have preBalance=0, so their postBalance IS the rent.
// This captures ALL rent including tick arrays (~0.013 SOL each) that were
// previously missed when only querying 3 position accounts.
const txData = await solana.connection.getTransaction(signature, {
commitment: 'confirmed',
maxSupportedTransactionVersion: 0,
});

let positionRent = 0;

if (txData) {
const accountKeys = txData.transaction.message.getAccountKeys().staticAccountKeys;
const preBalances = txData.meta?.preBalances || [];
const postBalances = txData.meta?.postBalances || [];

// All accounts whose rent should be tracked: position mint, PDA, ATA, + tick arrays
const positionTokenAccount = getAssociatedTokenAddressSync(
positionMintKeypair.publicKey,
client.getContext().wallet.publicKey,
undefined,
TOKEN_2022_PROGRAM_ID,
);
const rentAccounts: PublicKey[] = [
positionMintKeypair.publicKey,
positionPda.publicKey,
positionTokenAccount,
...newTickArrayPubkeys,
];

let totalRentLamports = 0;
for (const pubkey of rentAccounts) {
const idx = accountKeys.findIndex((key) => key.equals(pubkey));
if (idx !== -1 && preBalances[idx] === 0 && postBalances[idx] > 0) {
totalRentLamports += postBalances[idx];
logger.info(`Rent for ${pubkey.toString()}: ${postBalances[idx]} lamports`);
}
}
positionRent = totalRentLamports / 1e9;

// Extract actual token amounts from the increaseLiquidity instruction's inner transfers.
// This avoids the SOL adjustment complexity (rent, fee) needed with aggregate balance changes.
if (shouldAddLiquidity) {
const tokenMintA = whirlpool.getTokenAInfo().address.toString();
const tokenMintB = whirlpool.getTokenBInfo().address.toString();

const { transferGroups } = await extractInnerTransferAmounts(
solana.connection,
signature,
ORCA_WHIRLPOOL_PROGRAM_ID.toString(),
[tokenMintA, tokenMintB],
);

// For open position, the only Whirlpool instruction with transfers is increaseLiquidity
if (transferGroups.length >= 1) {
baseTokenAmountAdded = transferGroups[0][0];
quoteTokenAmountAdded = transferGroups[0][1];
}
}
}

logger.info(
`Position rent: mint=${positionMintBalance}, data=${positionDataBalance}, ata=${positionAtaBalance}, total=${positionRent} SOL`,
);
logger.info(`Position rent: ${positionRent} SOL (${newTickArrayPubkeys.length} new tick arrays)`);

if (shouldAddLiquidity) {
logger.info(
Expand Down
Loading