|
|
@@ -135,7 +135,10 @@ export async function copyRemoveLiquidity(
|
|
|
fromBinId: Math.min(...binIds),
|
|
|
toBinId: Math.max(...binIds),
|
|
|
bps: new BN(bpsToRemove),
|
|
|
- shouldClaimAndClose: bpsToRemove >= 10000,
|
|
|
+ // Never use shouldClaimAndClose — its claim fee/reward instructions
|
|
|
+ // can fail with error 3007 (AccountOwnedByWrongProgram) on Token-2022
|
|
|
+ // pools. Close is handled separately by copyClosePosition instead.
|
|
|
+ shouldClaimAndClose: false,
|
|
|
})
|
|
|
|
|
|
let lastSignature = ''
|
|
|
@@ -203,7 +206,8 @@ export async function copyRebalanceLiquidity(
|
|
|
let minBinId = leaderOp.minBinId ?? 0
|
|
|
let maxBinId = leaderOp.maxBinId ?? 0
|
|
|
|
|
|
- // If we have delta-based adds, compute absolute bin IDs from current active bin
|
|
|
+ // If we have delta-based adds, compute absolute bin IDs from the current active bin
|
|
|
+ // (not the leader's active bin, since it may have moved)
|
|
|
if (leaderOp.rebalanceAdds && leaderOp.rebalanceAdds.length > 0) {
|
|
|
const allMinBins = leaderOp.rebalanceAdds.map(
|
|
|
(a) => activeId + a.minDeltaId
|
|
|
@@ -215,22 +219,88 @@ export async function copyRebalanceLiquidity(
|
|
|
maxBinId = Math.max(...allMaxBins)
|
|
|
}
|
|
|
|
|
|
+ // Validate bin range — if both are 0, the parameter extraction failed
|
|
|
+ if (minBinId === 0 && maxBinId === 0 && activeId !== 0) {
|
|
|
+ throw new Error(
|
|
|
+ `Rebalance failed: could not determine target bin range (activeBin=${activeId}). ` +
|
|
|
+ `Leader rebalanceAdds: ${JSON.stringify(leaderOp.rebalanceAdds)}`
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
console.log(
|
|
|
`[DLMM] Rebalancing: new bins [${minBinId}, ${maxBinId}], activeBin ${activeId}`
|
|
|
)
|
|
|
|
|
|
- // Scale amounts by copy ratio
|
|
|
- const followerAmountX = scaleAmount(
|
|
|
- leaderOp.totalXAmount ?? new BN(0),
|
|
|
- settings.copyRatio
|
|
|
- )
|
|
|
- const followerAmountY = scaleAmount(
|
|
|
- leaderOp.totalYAmount ?? new BN(0),
|
|
|
- settings.copyRatio
|
|
|
+ // For rebalance, use the follower's actual available token balances
|
|
|
+ // (not the leader's amounts — those are just safety caps from the IDL).
|
|
|
+ // After step 1 removed all liquidity, the tokens are in the follower's accounts.
|
|
|
+ const WSOL_MINT = new PublicKey(
|
|
|
+ 'So11111111111111111111111111111111111111112'
|
|
|
)
|
|
|
+ const tokenXMint = dlmmPool.tokenX.publicKey
|
|
|
+ const tokenYMint = dlmmPool.tokenY.publicKey
|
|
|
+
|
|
|
+ const SOL_RESERVE = 10_000_000 // Keep 0.01 SOL for tx fees
|
|
|
+
|
|
|
+ let followerAmountX = new BN(0)
|
|
|
+ let followerAmountY = new BN(0)
|
|
|
+
|
|
|
+ // Token X balance
|
|
|
+ if (tokenXMint.equals(WSOL_MINT)) {
|
|
|
+ const balance = await connection.getBalance(follower.publicKey)
|
|
|
+ followerAmountX = new BN(Math.max(0, balance - SOL_RESERVE))
|
|
|
+ } else {
|
|
|
+ const xAccounts = await connection.getParsedTokenAccountsByOwner(
|
|
|
+ follower.publicKey,
|
|
|
+ { mint: tokenXMint }
|
|
|
+ )
|
|
|
+ for (const { account } of xAccounts.value) {
|
|
|
+ followerAmountX = followerAmountX.add(
|
|
|
+ new BN(
|
|
|
+ (
|
|
|
+ account.data.parsed as {
|
|
|
+ info: { tokenAmount: { amount: string } }
|
|
|
+ }
|
|
|
+ ).info.tokenAmount.amount
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Token Y balance
|
|
|
+ if (tokenYMint.equals(WSOL_MINT)) {
|
|
|
+ const balance = await connection.getBalance(follower.publicKey)
|
|
|
+ followerAmountY = new BN(Math.max(0, balance - SOL_RESERVE))
|
|
|
+ } else {
|
|
|
+ const yAccounts = await connection.getParsedTokenAccountsByOwner(
|
|
|
+ follower.publicKey,
|
|
|
+ { mint: tokenYMint }
|
|
|
+ )
|
|
|
+ for (const { account } of yAccounts.value) {
|
|
|
+ followerAmountY = followerAmountY.add(
|
|
|
+ new BN(
|
|
|
+ (
|
|
|
+ account.data.parsed as {
|
|
|
+ info: { tokenAmount: { amount: string } }
|
|
|
+ }
|
|
|
+ ).info.tokenAmount.amount
|
|
|
+ )
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (followerAmountX.isZero() && followerAmountY.isZero()) {
|
|
|
+ throw new Error(
|
|
|
+ 'Rebalance failed: follower has no token balance to re-deposit after removing liquidity.'
|
|
|
+ )
|
|
|
+ }
|
|
|
|
|
|
const sdkStrategyType = toSdkStrategyType(leaderOp.strategyType)
|
|
|
|
|
|
+ console.log(
|
|
|
+ `[DLMM] Rebalance addLiquidity: strategy ${sdkStrategyType}, amountX ${followerAmountX.toString()}, amountY ${followerAmountY.toString()}`
|
|
|
+ )
|
|
|
+
|
|
|
const addLiqTx = await dlmmPool.addLiquidityByStrategy({
|
|
|
positionPubKey: followerPositionAddress,
|
|
|
user: follower.publicKey,
|
|
|
@@ -260,6 +330,18 @@ export async function copyClosePosition(
|
|
|
followerPositionAddress: PublicKey,
|
|
|
lbPairAddress: PublicKey
|
|
|
): Promise<string> {
|
|
|
+ // First check if the position account still exists on-chain.
|
|
|
+ // It may have been already closed by removeLiquidity(shouldClaimAndClose=true).
|
|
|
+ const positionAccountInfo = await connection.getAccountInfo(
|
|
|
+ followerPositionAddress
|
|
|
+ )
|
|
|
+ if (!positionAccountInfo) {
|
|
|
+ console.log(
|
|
|
+ `[DLMM] Position ${followerPositionAddress.toBase58()} already closed on-chain, skipping close`
|
|
|
+ )
|
|
|
+ return 'already-closed'
|
|
|
+ }
|
|
|
+
|
|
|
const dlmmPool = await DLMM.create(connection, lbPairAddress)
|
|
|
|
|
|
// Need full LbPosition object for closePosition
|
|
|
@@ -272,6 +354,12 @@ export async function copyClosePosition(
|
|
|
)
|
|
|
|
|
|
if (!position) {
|
|
|
+ // Position account exists but not found by SDK (edge case).
|
|
|
+ // This shouldn't happen if the account exists on-chain, but
|
|
|
+ // handle it gracefully.
|
|
|
+ console.warn(
|
|
|
+ `[DLMM] Position ${followerPositionAddress.toBase58()} exists on-chain but not returned by SDK`
|
|
|
+ )
|
|
|
throw new Error(
|
|
|
`Position ${followerPositionAddress.toBase58()} not found for close`
|
|
|
)
|