DeFi Bots Series — Part 5: Live Rebalance on Meteora DLMM (RPC Profiles, Clean PnL & One-Sided Liquidity)

DeFi Bots Series — Part 5: Live Rebalance on Meteora DLMM (RPC Profiles, Clean PnL & One-Sided Liquidity)

10/4/20256 min • defi
SolanaTelegramSupabasePrivyJupiterMeteoraDLMMBotsPnLTypeScript

TL;DR — I split read/submit/confirm connections into a single RpcProfile, pushed executors to consume those, killed legacy gRPC “executor config” coupling, unified LiquidityStrategy for create/rebalance, and fixed PnL by writing prices on withdrawals. Then I rebalanced live on Meteora (PUMP/USDC), closed the old range, re-opened one-sided above, and verified clean flows in Supabase.

This builds on Parts 1–4. Today I stop hand-waving and move real liquidity.

Why this refactor?

Two reasons:

  1. Reliability under free RPC tiers. We were mixing executors and connections everywhere and bumping rate limits. Now a single RpcProfile holds role-based connections (read/submit/confirm) with simple round-robin. Executors just pick from the profile.

  2. Strategy clarity. “Spot/Curve/BidAsk” vs “Centered/Symmetric/One-Sided” was confusing. We standardized on a single enum: LiquidityStrategy (CENTERED/SYMMETRIC/BID_ASK/CURVE/ONE_SIDED). Internally I map that to Meteora’s SDK StrategyType.

The building blocks

Role-based RPCs (one source of truth)

// rpc/profile.ts
export class RpcProfile {
  constructor(
    public readonly read: Connection[],
    public readonly submit: Connection[],
    public readonly confirm: Connection[]
  ) {}
  // ...RR pick per role...
}
// rpc/connection.ts
export function defaultRpcProfile(): RpcProfile {
  const read = [
    HELIUS_RPC_URL && { url: HELIUS_RPC_URL, wsUrl: HELIUS_RPC_WSS, label: "helius-read" },
    ALCHEMY_RPC_URL && { url: ALCHEMY_RPC_URL, label: "alc-read" },
    SOLANAVIBE_RPC_URL && { url: SOLANAVIBE_RPC_URL, label: "sv-read" },
  ].filter(Boolean);
 
  const submit = [
    QUICKNODE_RPC_URL && { url: QUICKNODE_RPC_URL, wsUrl: QUICKNODE_RPC_WSS, label: "qn-send" },
    HELIUS_RPC_URL && { url: HELIUS_RPC_URL, wsUrl: HELIUS_RPC_WSS, label: "helius-send" },
    ALCHEMY_RPC_URL && { url: ALCHEMY_RPC_URL, label: "alc-send" },
  ].filter(Boolean);
 
  const confirm = [
    HELIUS_RPC_URL && { url: HELIUS_RPC_URL, wsUrl: HELIUS_RPC_WSS, label: "helius-conf" },
    NEXTBLOCK_RPC_URL && { url: NEXTBLOCK_RPC_URL, label: "nb-conf" },
    SOLANAVIBE_RPC_URL && { url: SOLANAVIBE_RPC_URL, label: "sv-conf" },
  ].filter(Boolean);
 
  return RpcProfile.fromEndpoints({ read, submit, confirm });
}

And the local executor:

// infra/tx-executors/executorFactory.ts
export function createLocalExecutor(profile: RpcProfile, mode: ExecMode = "rpc", opts={}) {
  const submit = profile.pick("submit");
  const confirm = profile.pick("confirm");
  const read = profile.pick("read");
 
  if (mode === "jito") return new JitoExecutor(submit, confirm, read, /* ... */);
  if (mode === "nextblock") return new NextBlockExecutor(submit, confirm, read, /* ... */);
  return new RpcExecutor(submit, confirm, read);
}

No more sprinkling generated gRPC “executor config” enums through protocol code.

Unified liquidity planning

I consolidated range + amount logic into a planner that understands side, placement, interval, and pool orientation (flip):

// protocols/meteora/helpers/openPlan.ts
export type OpenPlanInput = {
  poolAddress: string;
  tokenAMint: string;
  tokenBMint: string;
  amountAui: number;  // UI units (caller perspective)
  amountBui: number;
  side: LiquiditySide;             // BOTH | TOKEN_A | TOKEN_B
  placement?: LiquidityStrategy;   // SYMMETRIC, ONE_SIDED, etc.
  interval?: number;               // default pool.binStep
};
 
export type OpenPlan = {
  minId: number; maxId: number; interval: number; flip: boolean;
  amountARaw: bigint; amountBRaw: bigint; // already oriented for the pool
  priceA: number; priceB: number; decA: number; decB: number;
};

I also collapsed “SDK strategy” to a mapper:

// meteora/helpers/utils.ts
export function strategyForPlacement(p?: LiquidityStrategy): StrategyType {
  switch (p) {
    case LiquidityStrategy.CURVE:   return StrategyType.Curve;
    case LiquidityStrategy.BID_ASK: return StrategyType.BidAsk;
    default:                        return StrategyType.Spot;
  }
}

So the only strategy you pass around now is LiquidityStrategy.

PnL: the missing piece — prices on withdrawals

To get honest PnL, we must ledger both sides with prices at the time of the event. I now record price_a/price_b on DEPOSIT and WITHDRAWAL:

// meteora/helpers/positionActions.ts (snippet)
const prices = await getPricesUsd([cached.token_a_mint, cached.token_b_mint]);
const priceA = prices[normalize(cached.token_a_mint)] ?? 0;
const priceB = prices[normalize(cached.token_b_mint)] ?? 0;
 
await storage.flow.insertFlow({
  user_id: userId,
  position_pubkey: positionPubkey,
  pool_address: poolAddress,
  token_a_mint: cached.token_a_mint,
  token_b_mint: cached.token_b_mint,
  amount_a: Number(xRemoved),
  amount_b: Number(yRemoved),
  price_a: priceA,
  price_b: priceB,
  event_type: "WITHDRAWAL",
});

getPositionPnL(...) then computes:

  • cost basis from flows,
  • market value = (liquidity + unclaimed fees) @ spot,
  • realised/unrealised and total PnL.

If prices are missing, I surface pnlUnknown=true and avoid garbage math.

The live test

  1. PoolInfo
docker run --rm -it --network=host \
  -v "$(pwd)/proto":/proto \
  fullstorydev/grpcurl:latest \
  -plaintext -import-path /proto \
  -proto meteora.proto -proto aigex_common.proto \
  -d '{"pool_address":"9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj"}' \
  localhost:50051 meteora.MeteoraService/GetPoolInfo

Result (abridged):

{
  "poolAddress": "9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj",
  "tokenAMint": "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn",
  "tokenBMint": "EPjF...Dt1v",
  "binStep": 20,
  "activeBin": -2516,
  "tokenASymbol": "PUMP",
  "tokenBSymbol": "USDC"
}
  1. Existing position snapshot
docker run --rm -it --network=host \
  -v "$(pwd)/proto":/proto \
  fullstorydev/grpcurl:latest \
  -plaintext -import-path /proto \
  -proto meteora.proto -proto aigex_common.proto \
  -d '{"poolAddress":"9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj","userId":"6683377476"}' \
  localhost:50051 meteora.MeteoraService/ListPositions

Result (abridged):

{
  "positions": [
    {
      "positionPubkey": "9qa5...Sgak",
      "minBinId": -2506,
      "maxBinId": -2494,
      "totalXAmount": 2968.643437,
      "unclaimedFeeUsd": 0.4836,
      "marketValueUsd": 19.8988,
      "tokenASymbol": "PUMP",
      "tokenBSymbol": "USDC"
    }
  ]
}

Out of range (active −2516 < min −2506), holding mostly PUMP on the left side.

  1. Rebalance (close-and-reopen)

I chose one-sided ABOVE with width 12 (i.e., sit above price and sell into rips):

docker run --rm -it --network=host \
  -v "$(pwd)/proto":/proto fullstorydev/grpcurl:latest \
  -plaintext -import-path /proto \
  -proto meteora.proto -proto aigex_common.proto \
  -d '{
    "user":{"userId":"6683377476","solAddr":"2yhmpEA...","walletId":"nor87s..."},
    "poolAddress":"9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj",
    "positionPublicKey":"9qa5y1vKeDXfGHt2yxhnjfJrZYetqxoFPmVnEW3uSgak",
    "side": 1,          // TOKEN_A (one-sided in A)
    "placement": 4,     // ONE_SIDED
    "interval": 12
  }' localhost:50051 meteora.MeteoraService/RebalancePosition

Result:

{
  "message": "Rebalance completed",
  "txHash": "5eiXw2kBf5C7YYnZDvwb1zFZuJ18aLU4HEbhjj8GLc8vE2XS3ainv34wcwNhMdvDywPb42RyXukejL62ZSwihhxY",
  "position": "HpUYYZTa...VB29",
  "poolAddress": "9SMp4yLK...",
  "tokenASymbol": "PUMP",
  "tokenBSymbol": "USDC",
  "amountA": 2983.486791,
  "minBinId": -2516,
  "maxBinId": -2504,
  "interval": 12
}

Key logs (abridged):

[processTx] ... ix programs = ... LBUZK... (Meteora)
Submit Rpc Tx time: 410 ms
[confirm] meteora ... confirmed in 1741ms
 Token balances ensured: A=3007994311 / B=2281794 (required+buf: A=2983486655 / B=1)
[openFreshPosition] create params: { amountARaw: "2983486655", amountBRaw: "1", strategy: 4, minBinId: -2516, maxBinId: -2504 }
[confirm] meteora ... confirmed in 2615ms
[rebalance] new position HpUYYZT... — https://solscan.io/tx/5eiXw2kB...

Exactly what we asked for: we reopened one-sided with width 12, anchored at the current active bin (−2516).

  1. Supabase ledger (flows + positions)

Flows show the WITHDRAWAL of the old position (with prices now recorded) and the DEPOSIT of the new one:

[
  {
    "position_pubkey": "9qa5...Sgak",
    "event_type": "WITHDRAWAL",
    "amount_a": "2968643437",
    "amount_b": "0",
    "price_a": "...",                   // now filled
    "price_b": "..."                    // now filled
  },
  {
    "position_pubkey": "HpUYYZTa...VB29",
    "event_type": "DEPOSIT",
    "amount_a": "2983486791",
    "amount_b": "0",
    "price_a": "0.006534...",
    "price_b": "0.999805..."
  }
]

Positions has the fresh range:

{
  "position_pubkey": "HpUYYZTa...VB29",
  "min_bin_id": -2516,
  "max_bin_id": -2504,
  "interval": 12,
  "amount_a": "2983486791",
  "amount_b": "0",
  "last_rebalanced_at": "..."
}

Strategy notes (why one-sided?)

  • ONE_SIDED above is a “sell the rips” stance: you start PUMP-heavy and scale out as price rises through your bins, earning fees on the way. You won’t chase dips; you’ll rebuy only if you later place a below-range or flip side.
  • If you want less inventory drift without perps, choose SYMMETRIC width 10–14 around active.
  • Want delta-neutral? Keep ONE_SIDED but pair with a 1.0× short on PUMP-perp sized to your current PUMP inventory. Re-target on bin moves or at intervals.

Gotchas I fixed along the way

  • Missing withdrawal prices → broken PnL. We now write price_a/price_b on WITHDRAWAL.
  • Free RPC limits. Read/submit/confirm split reduces single-provider spikiness. Add more endpoints as needed.
  • Mixed strategy enums. I standardized on LiquidityStrategy across create/rebalance and map to the SDK at the edge.
  • BigInt consistency. Raw amounts are bigint until the SDK boundary (new BN(bigint.toString())).
  • Executor connection access. Code now uses executor.readConnection | submitConnection | confirmConnection deliberately; no hidden singletons.

What’s next

  • Monitor wiring:

    • Watch a position → when out of range or edge proximity, propose or auto-rebalance.
    • Telegram pings with range bar, PnL (now correct), and decision text.
    • Cooldowns & last-action persistence in Supabase.
  • Inventory hygiene:

    • Optional sweep of non-stable bags (e.g., swap dust or >$10 positions to USDC via Jupiter).
    • If hedging, simple 1.0× target with periodic size checks.
  • Quality of life:

    • More RPCs in profile, basic latency telemetry.
    • Safer defaults for interval vs binStep and min range width.

That’s it. I moved real funds with a clean pipeline: RPC profiles → executor → planner → SDK → flows → PnL. The monitor can finally make decisions on trustworthy numbers.

Stay Updated

Get notified when I publish new articles about Web3 development, hackathon experiences, and cryptography insights.

You might also like

DeFi Bots Series — Part 4: Prepping the Monitor — Decimals, Prices, Symbols & Clean Balances

Before we let the monitor act on positions, we hardened the boring bits: one source of truth for decimals, fast prices, safe BigInt math, and clean balance reads. The goal is simple: trustworthy PnL so alerts and actions are correct.

DeFi Bots Series — Part 3: Telegram Bot Lite, Portfolio RPC, and a Lean Path to the Scheduler

I stripped our Telegram surface down to a fast, durable “Lite” mode: no Kafka, no AI agent in the middle—just clean wallet UX, on-chain balances via RPC, token prices from Jupiter, PnL wired to Supabase, and buttons that actually do something. This sets the table for the trading scheduler.

DeFi Bots Series — Part 7: The Monitor Test Saga (One-Sided Bids, Skew Rebalances, and Real PnL)

I stress-tested a one-sided, USDC-anchored LP strategy overnight: ~15 rebalances, lots of fee accrual, a few bugs, and a clearer picture of what to fix next. We tightened pool orientation, made SOL/WSOL funding sane, added skew gates (TVL/fees), and wrote proper lineage + flows. The monitor is quieter—until it needs not to be.