Skip to content

Commit f5df19c

Browse files
authored
Merge pull request #600 from hummingbot/feat/orca-connector-feb-2026
fix: use Token-2022 positions for full rent recovery on close for Orca
2 parents 8667a38 + a86c92b commit f5df19c

File tree

5 files changed

+91
-55
lines changed

5 files changed

+91
-55
lines changed

src/connectors/orca/clmm-routes/closePosition.ts

Lines changed: 29 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,9 @@ export async function closePosition(
236236
}),
237237
);
238238

239-
// Note: We'll extract actual fee amounts from balance changes after transaction
239+
// Use collectQuote for fee amounts (derived from on-chain position data)
240+
baseFeeAmountCollected = Number(collectQuote.feeOwedA) / Math.pow(10, mintA.decimals);
241+
quoteFeeAmountCollected = Number(collectQuote.feeOwedB) / Math.pow(10, mintB.decimals);
240242
}
241243

242244
// Step 4: Auto-unwrap WSOL to native SOL after receiving all tokens
@@ -281,52 +283,37 @@ export async function closePosition(
281283
}),
282284
);
283285

286+
// Calculate rent refund by querying position account balances BEFORE the TX.
287+
// These accounts will be closed by the transaction, so we must read them now.
288+
// This is more accurate than deriving from wallet SOL changes, which can be
289+
// skewed by ephemeral wSOL wrapper create/close cycles within the same TX.
290+
const LAMPORT_TO_SOL = 1e-9;
291+
const positionMintPubkey = position.getData().positionMint;
292+
const positionTokenAccount = getAssociatedTokenAddressSync(
293+
positionMintPubkey,
294+
client.getContext().wallet.publicKey,
295+
undefined,
296+
isToken2022 ? TOKEN_2022_PROGRAM_ID : undefined,
297+
);
298+
const [positionMintBalance, positionDataBalance, positionAtaBalance] = await Promise.all([
299+
solana.connection.getBalance(positionMintPubkey),
300+
solana.connection.getBalance(positionPubkey),
301+
solana.connection.getBalance(positionTokenAccount),
302+
]);
303+
const positionRentRefunded = (positionMintBalance + positionDataBalance + positionAtaBalance) * LAMPORT_TO_SOL;
304+
305+
logger.info(
306+
`Position rent refund: mint=${positionMintBalance}, data=${positionDataBalance}, ata=${positionAtaBalance}, total=${positionRentRefunded} SOL`,
307+
);
308+
284309
// Build, simulate, and send transaction
285310
const txPayload = await builder.build();
286311
await solana.simulateWithErrorHandling(txPayload.transaction);
287312
const { signature, fee } = await solana.sendAndConfirmTransaction(txPayload.transaction, [wallet]);
288313

289-
// Extract actual amounts from balance changes (more accurate than quotes)
290-
const tokenAAddress = whirlpool.getTokenAInfo().address.toString();
291-
const tokenBAddress = whirlpool.getTokenBInfo().address.toString();
292-
const tokenA = await solana.getToken(tokenAAddress);
293-
const tokenB = await solana.getToken(tokenBAddress);
294-
295-
const { balanceChanges } = await solana.extractBalanceChangesAndFee(
296-
signature,
297-
client.getContext().wallet.publicKey.toString(),
298-
[tokenAAddress, tokenBAddress],
299-
);
300-
301-
// Total balance changes (positive values = received)
302-
const totalBaseChange = Math.abs(balanceChanges[0]);
303-
const totalQuoteChange = Math.abs(balanceChanges[1]);
304-
305-
// If we removed liquidity, use the quote estimates as basis
306-
// Otherwise, all balance change is from fees
307-
if (hasLiquidity) {
308-
// We have estimates from decreaseQuote, but actual amounts might differ slightly
309-
// Use the estimates as reference, but ensure fees aren't negative
310-
baseFeeAmountCollected = Math.max(0, totalBaseChange - baseTokenAmountRemoved);
311-
quoteFeeAmountCollected = Math.max(0, totalQuoteChange - quoteTokenAmountRemoved);
312-
313-
// If fees would be negative, it means the estimate was slightly high
314-
// Adjust the liquidity removed to match actual total
315-
if (totalBaseChange < baseTokenAmountRemoved) {
316-
baseTokenAmountRemoved = totalBaseChange;
317-
baseFeeAmountCollected = 0;
318-
}
319-
if (totalQuoteChange < quoteTokenAmountRemoved) {
320-
quoteTokenAmountRemoved = totalQuoteChange;
321-
quoteFeeAmountCollected = 0;
322-
}
323-
} else {
324-
// No liquidity removed, all balance change is fees
325-
baseFeeAmountCollected = totalBaseChange;
326-
quoteFeeAmountCollected = totalQuoteChange;
327-
}
328-
329-
const positionRentRefunded = 0.00203928;
314+
// Token amounts are already set from quotes:
315+
// - baseTokenAmountRemoved / quoteTokenAmountRemoved from decreaseQuote (Step 2)
316+
// - baseFeeAmountCollected / quoteFeeAmountCollected from collectQuote (Step 3)
330317

331318
return {
332319
signature,

src/connectors/orca/clmm-routes/executeSwap.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
swapQuoteByOutputToken,
88
IGNORE_CACHE,
99
SwapQuote,
10+
TokenExtensionUtil,
1011
} from '@orca-so/whirlpools-sdk';
1112
import { getAssociatedTokenAddressSync } from '@solana/spl-token';
1213
import { PublicKey } from '@solana/web3.js';
@@ -187,16 +188,34 @@ export async function executeSwap(
187188
// Get oracle PDA
188189
const oraclePda = PDAUtil.getOracle(ORCA_WHIRLPOOL_PROGRAM_ID, whirlpoolPubkey);
189190

190-
// Add swap instruction
191+
// Add swap V2 instruction (supports Token-2022 tokens)
191192
builder.addInstruction(
192-
WhirlpoolIx.swapIx(client.getContext().program, {
193+
WhirlpoolIx.swapV2Ix(client.getContext().program, {
193194
...quote,
194195
whirlpool: whirlpoolPubkey,
195196
tokenAuthority: client.getContext().wallet.publicKey,
197+
tokenMintA: whirlpool.getTokenAInfo().address,
198+
tokenMintB: whirlpool.getTokenBInfo().address,
196199
tokenOwnerAccountA,
197200
tokenVaultA: whirlpool.getTokenVaultAInfo().address,
198201
tokenOwnerAccountB,
199202
tokenVaultB: whirlpool.getTokenVaultBInfo().address,
203+
tokenProgramA: mintA.tokenProgram,
204+
tokenProgramB: mintB.tokenProgram,
205+
tokenTransferHookAccountsA: await TokenExtensionUtil.getExtraAccountMetasForTransferHook(
206+
client.getContext().connection,
207+
mintA,
208+
tokenOwnerAccountA,
209+
whirlpool.getTokenVaultAInfo().address,
210+
client.getContext().wallet.publicKey,
211+
),
212+
tokenTransferHookAccountsB: await TokenExtensionUtil.getExtraAccountMetasForTransferHook(
213+
client.getContext().connection,
214+
mintB,
215+
tokenOwnerAccountB,
216+
whirlpool.getTokenVaultBInfo().address,
217+
client.getContext().wallet.publicKey,
218+
),
200219
oracle: oraclePda.publicKey,
201220
}),
202221
);

src/connectors/orca/clmm-routes/openPosition.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
IGNORE_CACHE,
1313
} from '@orca-so/whirlpools-sdk';
1414
import { Static } from '@sinclair/typebox';
15-
import { getAssociatedTokenAddressSync } from '@solana/spl-token';
15+
import { TOKEN_2022_PROGRAM_ID, getAssociatedTokenAddressSync } from '@solana/spl-token';
1616
import { Keypair, PublicKey } from '@solana/web3.js';
1717
import BN from 'bn.js';
1818
import { Decimal } from 'decimal.js';
@@ -135,6 +135,8 @@ async function addLiquidityInstructions(
135135
positionTokenAccount: getAssociatedTokenAddressSync(
136136
positionMintKeypair.publicKey,
137137
client.getContext().wallet.publicKey,
138+
undefined,
139+
TOKEN_2022_PROGRAM_ID,
138140
),
139141
tokenMintA: whirlpool.getTokenAInfo().address,
140142
tokenMintB: whirlpool.getTokenBInfo().address,
@@ -361,23 +363,24 @@ export async function openPosition(
361363
const positionMintKeypair = Keypair.generate();
362364
const positionPda = PDAUtil.getPosition(ORCA_WHIRLPOOL_PROGRAM_ID, positionMintKeypair.publicKey);
363365

364-
// Always use TOKEN_PROGRAM with metadata (standard Orca positions)
365-
// Position NFT token program is independent of pool's token programs
366-
const metadataPda = PDAUtil.getPositionMetadata(positionMintKeypair.publicKey);
366+
// Use Token-2022 position mint (embeds metadata in the mint account itself)
367+
// This ensures all rent is fully refundable on close (fixes #584)
367368
builder.addInstruction(
368-
WhirlpoolIx.openPositionWithMetadataIx(client.getContext().program, {
369+
WhirlpoolIx.openPositionWithTokenExtensionsIx(client.getContext().program, {
369370
funder: client.getContext().wallet.publicKey,
370371
whirlpool: whirlpoolPubkey,
371372
tickLowerIndex: lowerTickIndex,
372373
tickUpperIndex: upperTickIndex,
373374
owner: client.getContext().wallet.publicKey,
374-
positionMintAddress: positionMintKeypair.publicKey,
375+
positionMint: positionMintKeypair.publicKey,
375376
positionPda,
376377
positionTokenAccount: getAssociatedTokenAddressSync(
377378
positionMintKeypair.publicKey,
378379
client.getContext().wallet.publicKey,
380+
undefined,
381+
TOKEN_2022_PROGRAM_ID,
379382
),
380-
metadataPda,
383+
withTokenMetadataExtension: true,
381384
}),
382385
);
383386

@@ -446,7 +449,26 @@ export async function openPosition(
446449
positionMintKeypair,
447450
]);
448451

449-
const positionRent = 0.00203928; // Standard position account rent
452+
// Calculate rent by querying the actual SOL balances of position accounts.
453+
// This is more accurate than deriving from wallet SOL changes, which can be
454+
// skewed by ephemeral wSOL wrapper create/close cycles within the same TX.
455+
const LAMPORT_TO_SOL = 1e-9;
456+
const positionTokenAccount = getAssociatedTokenAddressSync(
457+
positionMintKeypair.publicKey,
458+
client.getContext().wallet.publicKey,
459+
undefined,
460+
TOKEN_2022_PROGRAM_ID,
461+
);
462+
const [positionMintBalance, positionDataBalance, positionAtaBalance] = await Promise.all([
463+
solana.connection.getBalance(positionMintKeypair.publicKey),
464+
solana.connection.getBalance(positionPda.publicKey),
465+
solana.connection.getBalance(positionTokenAccount),
466+
]);
467+
const positionRent = (positionMintBalance + positionDataBalance + positionAtaBalance) * LAMPORT_TO_SOL;
468+
469+
logger.info(
470+
`Position rent: mint=${positionMintBalance}, data=${positionDataBalance}, ata=${positionAtaBalance}, total=${positionRent} SOL`,
471+
);
450472

451473
if (shouldAddLiquidity) {
452474
logger.info(

test/connectors/orca/clmm-routes/executeSwap.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,15 @@ jest.mock('@orca-so/whirlpools-sdk', () => ({
1818
getOracle: jest.fn().mockReturnValue({ publicKey: 'oracle-pubkey' }),
1919
},
2020
WhirlpoolIx: {
21-
swapIx: jest.fn().mockReturnValue({
21+
swapV2Ix: jest.fn().mockReturnValue({
2222
instructions: [],
2323
cleanupInstructions: [],
2424
signers: [],
2525
}),
2626
},
27+
TokenExtensionUtil: {
28+
getExtraAccountMetasForTransferHook: jest.fn().mockResolvedValue([]),
29+
},
2730
IGNORE_CACHE: true,
2831
}));
2932
jest.mock('@orca-so/common-sdk', () => ({

test/connectors/orca/clmm-routes/openPosition.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ jest.mock('@orca-so/whirlpools-sdk', () => ({
1919
priceToTickIndex: jest.fn().mockReturnValue(-28800),
2020
},
2121
WhirlpoolIx: {
22-
openPositionIx: jest.fn().mockReturnValue({
22+
openPositionWithTokenExtensionsIx: jest.fn().mockReturnValue({
2323
instructions: [],
2424
cleanupInstructions: [],
2525
signers: [],
2626
}),
27-
increaseLiquidityIx: jest.fn().mockReturnValue({
27+
increaseLiquidityV2Ix: jest.fn().mockReturnValue({
2828
instructions: [],
2929
cleanupInstructions: [],
3030
signers: [],
@@ -42,8 +42,10 @@ jest.mock('@orca-so/whirlpools-sdk', () => ({
4242
}),
4343
TokenExtensionUtil: {
4444
isV2IxRequiredPool: jest.fn().mockReturnValue(false),
45+
buildTokenExtensionContext: jest.fn().mockResolvedValue({}),
4546
},
4647
ORCA_WHIRLPOOL_PROGRAM_ID: 'whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc',
48+
IGNORE_CACHE: true,
4749
}));
4850
jest.mock('@orca-so/common-sdk', () => ({
4951
Percentage: {
@@ -103,6 +105,9 @@ describe('POST /open-position', () => {
103105
signature: 'test-signature',
104106
fee: 0.000005,
105107
}),
108+
connection: {
109+
getBalance: jest.fn().mockResolvedValue(2039280), // ~0.00204 SOL rent per account
110+
},
106111
};
107112
(Solana.getInstance as jest.Mock).mockResolvedValue(mockSolana);
108113

0 commit comments

Comments
 (0)