Skip to main content

Overview

This tutorial shows you how to build an auto-rebalancing bot that:
  1. Monitors your open LP positions on a regular interval
  2. Detects when a position goes out of range
  3. Zap-Out the old position (withdraw liquidity)
  4. Zap-In a new position centered around the current price
The bot uses the LP Agent Open API for all operations — no direct smart contract interaction needed.
All transactions are landed via LP Agent’s built-in Jito integration, which provides a significantly better landing rate than submitting directly via RPC — and it’s completely free.

How Rebalancing Works

When the price moves outside your position’s bin range, the position stops earning fees. A rebalance closes the old position and opens a new one centered around the current price.
Price moves out of range:
  [-----old position-----]
                              ^ current price

After rebalance:
                     [-----new position-----]
                              ^ current price

Prerequisites

  • An LP Agent API key
  • A Solana wallet with SOL for transaction fees
  • Node.js >= 18
npm install @solana/web3.js bs58

Complete Bot Script

import { Keypair, Transaction, VersionedTransaction } from "@solana/web3.js";
import bs58 from "bs58";

// ===================== CONFIG =====================
const CONFIG = {
  API_BASE: "https://api.lpagent.io/open-api/v1",
  API_KEY: "your-api-key-here",
  PRIVATE_KEY: "your-base58-private-key",

  // Bot settings
  CHECK_INTERVAL_MS: 60_000,      // Check every 60 seconds
  SLIPPAGE_BPS: 500,              // 5% slippage tolerance
  BIN_RANGE: 34,                  // Bins on each side of active bin
  STRATEGY: "Spot" as const,      // "Spot" | "Curve" | "BidAsk"
  ZAP_OUT_OUTPUT: "both" as const,

  // Optional filters — only rebalance positions matching these
  POOL_FILTER: undefined as string | undefined,  // specific pool address, or undefined for all
};

const wallet = Keypair.fromSecretKey(bs58.decode(CONFIG.PRIVATE_KEY));
const OWNER = wallet.publicKey.toBase58();

// ===================== HELPERS =====================
async function apiCall(method: string, path: string, body?: object) {
  const res = await fetch(`${CONFIG.API_BASE}${path}`, {
    method,
    headers: { "Content-Type": "application/json", "x-api-key": CONFIG.API_KEY },
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) {
    const text = await res.text();
    throw new Error(`API ${method} ${path} failed: ${text}`);
  }
  return res.json();
}

function signTx(base64Tx: string): string {
  const buffer = Buffer.from(base64Tx, "base64");
  try {
    const tx = VersionedTransaction.deserialize(buffer);
    tx.sign([wallet]);
    return Buffer.from(tx.serialize()).toString("base64");
  } catch {
    const tx = Transaction.from(buffer);
    tx.partialSign(wallet);
    return tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString("base64");
  }
}

function log(msg: string) {
  console.log(`[${new Date().toISOString()}] ${msg}`);
}

function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// ===================== ZAP-OUT =====================
async function zapOut(positionId: string, bps: number = 10000): Promise<void> {
  log(`  Zap-Out: withdrawing ${bps / 100}% of position...`);

  const decreaseTx = await apiCall("POST", "/position/decrease-tx", {
    position_id: positionId,
    bps,
    owner: OWNER,
    slippage_bps: CONFIG.SLIPPAGE_BPS,
    output: CONFIG.ZAP_OUT_OUTPUT,
  });

  const signedCloseTxs = decreaseTx.data.closeTxsWithJito.map(signTx);
  const signedSwapTxs = decreaseTx.data.swapTxsWithJito.map(signTx);

  // Land via LP Agent's Jito integration (better landing rate than direct RPC — free!)
  const result = await apiCall("POST", "/position/landing-decrease-tx", {
    lastValidBlockHeight: decreaseTx.data.lastValidBlockHeight,
    closeTxs: [],
    swapTxs: [],
    closeTxsWithJito: signedCloseTxs,
    swapTxsWithJito: signedSwapTxs,
  });

  log(`  Zap-Out complete: ${result.data?.signature || "success"}`);
}

// ===================== ZAP-IN =====================
async function zapIn(poolAddress: string, amountSOL: number): Promise<string> {
  log(`  Zap-In: adding ${amountSOL} SOL to pool ${poolAddress}...`);

  // Get current active bin
  const info = await apiCall("GET", `/pools/${poolAddress}/info`);
  const activeBin = info.data.liquidityViz?.activeBin;

  if (!activeBin) {
    throw new Error("Could not get active bin for pool");
  }

  const fromBinId = activeBin.binId - CONFIG.BIN_RANGE;
  const toBinId = activeBin.binId + CONFIG.BIN_RANGE;
  log(`  New range: bin ${fromBinId} to ${toBinId} (active: ${activeBin.binId})`);

  // Generate zap-in transactions
  const addTx = await apiCall("POST", `/pools/${poolAddress}/add-tx`, {
    stratergy: CONFIG.STRATEGY,
    inputSOL: amountSOL,
    percentX: 0.5,
    fromBinId,
    toBinId,
    owner: OWNER,
    slippage_bps: CONFIG.SLIPPAGE_BPS,
    mode: "zap-in",
  });

  // Sign and land via Jito
  const signedSwapTxs = addTx.data.swapTxsWithJito.map(signTx);
  const signedAddTxs = addTx.data.addLiquidityTxsWithJito.map(signTx);

  const result = await apiCall("POST", "/pools/landing-add-tx", {
    lastValidBlockHeight: addTx.data.lastValidBlockHeight,
    swapTxsWithJito: signedSwapTxs,
    addLiquidityTxsWithJito: signedAddTxs,
    meta: addTx.data.meta,
  });

  log(`  Zap-In complete: https://solscan.io/tx/${result.data.signature}`);
  return addTx.data.meta.positionPubKey;
}

// ===================== REBALANCE LOGIC =====================
async function checkAndRebalance() {
  log("Checking positions...");

  const positionsRes = await apiCall("GET", `/lp-positions/opening?owner=${OWNER}`);
  const positions = positionsRes.data;

  if (!positions || positions.length === 0) {
    log("No open positions found");
    return;
  }

  log(`Found ${positions.length} open position(s)`);

  for (const pos of positions) {
    // Skip if pool filter is set and doesn't match
    if (CONFIG.POOL_FILTER && pos.pool !== CONFIG.POOL_FILTER) {
      continue;
    }

    const pair = pos.pairName || `${pos.token0Info?.token_symbol}/${pos.token1Info?.token_symbol}`;
    const value = parseFloat(pos.currentValue) || 0;
    const pnlPercent = pos.pnl?.percent || 0;

    log(`Position: ${pair} | Value: $${value.toFixed(2)} | PnL: ${pnlPercent.toFixed(2)}% | In Range: ${pos.inRange}`);

    if (pos.inRange) {
      log("  Position is in range, skipping");
      continue;
    }

    log("  Position is OUT OF RANGE — rebalancing...");

    try {
      // Step 1: Zap-Out (close the old position)
      await zapOut(pos.id);

      // Brief pause to let state settle
      await sleep(2000);

      // Step 2: Estimate how much SOL we recovered
      // Fetch the SOL balance to use for the new position
      const balanceRes = await apiCall("GET", `/token/balance?owner=${OWNER}`);
      const solBalance = balanceRes.data?.find(
        (t: any) => t.address === "So11111111111111111111111111111111111111112"
      );

      const availableSOL = solBalance ? parseFloat(solBalance.uiAmount) : 0;

      // Keep some SOL for transaction fees, use the rest
      const reserveSOL = 0.05;
      const reinvestSOL = Math.max(0, value / (solBalance?.price || 150) * 0.95); // rough estimate
      const zapInAmount = Math.min(reinvestSOL, availableSOL - reserveSOL);

      if (zapInAmount <= 0.001) {
        log("  Not enough SOL to re-enter position, skipping zap-in");
        continue;
      }

      log(`  Re-entering with ${zapInAmount.toFixed(4)} SOL...`);

      // Step 3: Zap-In (open new position)
      const newPosition = await zapIn(pos.pool, zapInAmount);
      log(`  Rebalance complete! New position: ${newPosition}`);
    } catch (error: any) {
      log(`  ERROR during rebalance: ${error.message}`);
      // Continue to next position instead of crashing
    }
  }
}

// ===================== BOT LOOP =====================
async function main() {
  log("=== LP Agent Auto-Rebalance Bot ===");
  log(`Owner: ${OWNER}`);
  log(`Check interval: ${CONFIG.CHECK_INTERVAL_MS / 1000}s`);
  log(`Bin range: +/- ${CONFIG.BIN_RANGE} bins`);
  log(`Strategy: ${CONFIG.STRATEGY}`);
  log(`Pool filter: ${CONFIG.POOL_FILTER || "all pools"}`);
  log("");

  while (true) {
    try {
      await checkAndRebalance();
    } catch (error: any) {
      log(`ERROR in check loop: ${error.message}`);
    }

    log(`Next check in ${CONFIG.CHECK_INTERVAL_MS / 1000}s...\n`);
    await sleep(CONFIG.CHECK_INTERVAL_MS);
  }
}

main().catch(console.error);

Configuration

SettingDefaultDescription
CHECK_INTERVAL_MS60000How often to check positions (ms)
SLIPPAGE_BPS500Slippage tolerance (500 = 5%)
BIN_RANGE34Number of bins on each side of active bin
STRATEGYSpotDistribution strategy: Spot, Curve, or BidAsk
ZAP_OUT_OUTPUTbothHow to receive withdrawn tokens
POOL_FILTERundefinedFilter to a specific pool address

How It Works

Check Loop

Every CHECK_INTERVAL_MS milliseconds, the bot:
  1. Fetches all open positions via GET /lp-positions/opening
  2. For each position, checks the inRange field
  3. If out of range, triggers a rebalance

Rebalance Flow

Position out of range detected

    ├─ 1. Zap-Out: POST /position/decrease-tx
    │     Sign close transactions
    │     Land via POST /position/landing-decrease-tx

    ├─ 2. Calculate reinvestment amount
    │     Check SOL balance via GET /token/balance
    │     Reserve SOL for fees

    └─ 3. Zap-In: POST /pools/{poolId}/add-tx
          Sign transactions
          Land via POST /pools/landing-add-tx

Customization Ideas

Add a Stop-Loss

Exit positions that have lost too much value instead of rebalancing:
const STOP_LOSS_PERCENT = -15; // -15% PnL threshold

if (pnlPercent < STOP_LOSS_PERCENT) {
  log(`  PnL ${pnlPercent}% below stop-loss, closing without re-entry`);
  await zapOut(pos.id);
  continue; // skip zap-in
}

Add Notifications

Send alerts to Discord or Telegram when a rebalance happens:
async function notify(message: string) {
  await fetch("https://discord.com/api/webhooks/YOUR_WEBHOOK", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ content: message }),
  });
}

// In the rebalance flow:
await notify(`Rebalanced ${pair}: closed old position, opened new one at bins ${fromBinId}-${toBinId}`);

Filter by Pool Type

Only rebalance DLMM positions (skip DAMM V2):
if (pos.protocol !== "meteora") {
  log("  Skipping non-DLMM position");
  continue;
}

Dynamic Bin Range

Adjust the bin range based on the pool’s volatility:
// Wider range for volatile pools, tighter for stable
const binStep = pos.poolInfo?.tickSpacing || 1;
const dynamicRange = binStep > 5 ? 20 : 50;

const fromBinId = activeBin.binId - dynamicRange;
const toBinId = activeBin.binId + dynamicRange;

Running in Production

With PM2

npm install -g pm2

# Start the bot
pm2 start bot.ts --name "lp-rebalance-bot" --interpreter ts-node

# View logs
pm2 logs lp-rebalance-bot

# Restart / Stop
pm2 restart lp-rebalance-bot
pm2 stop lp-rebalance-bot

With Docker

FROM node:22-slim
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
CMD ["npx", "ts-node", "bot.ts"]

Environment Variables

For production, load secrets from environment variables instead of hardcoding:
const CONFIG = {
  API_KEY: process.env.LP_AGENT_API_KEY!,
  PRIVATE_KEY: process.env.SOLANA_PRIVATE_KEY!,
  // ...
};

API Endpoints Used

EndpointPurpose
GET /lp-positions/openingFetch open positions and check if in range
GET /token/balanceCheck SOL balance for reinvestment
GET /pools/{poolId}/infoGet active bin for new position range
POST /position/decrease-txGenerate zap-out transactions
POST /position/landing-decrease-txLand zap-out transactions via Jito
POST /pools/{poolId}/add-txGenerate zap-in transactions
POST /pools/landing-add-txLand zap-in transactions via Jito

Tips

  • Check interval: 60s is a good starting point. Too frequent = unnecessary API calls. Too slow = missed fee earnings while out of range.
  • Bin range: Wider = fewer rebalances but lower APR. Tighter = more fees but more frequent rebalances (each costing gas).
  • Gas costs: Each rebalance costs ~0.01-0.03 SOL in transaction fees. Factor this into your profitability calculations.
  • Transaction landing: LP Agent’s landing endpoints use Jito bundles for better success rates — no need to set up your own RPC or Jito integration.
  • Error handling: The bot continues to the next position on error. Check logs regularly for recurring issues.

Next Steps