
DeFi Bots Series — Part 5: Live Rebalance on Meteora DLMM (RPC Profiles, Clean PnL & One-Sided Liquidity)
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:
-
Reliability under free RPC tiers. We were mixing executors and connections everywhere and bumping rate limits. Now a single
RpcProfileholds role-based connections (read/submit/confirm) with simple round-robin. Executors just pick from the profile. -
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 SDKStrategyType.
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
- 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/GetPoolInfoResult (abridged):
{
"poolAddress": "9SMp4yLKGtW9TnLimfVPkDARsyNSfJw43WMke4r7KoZj",
"tokenAMint": "pumpCmXqMfrsAkQ5r49WcJnRayYRqmXz6ae8H7H9Dfn",
"tokenBMint": "EPjF...Dt1v",
"binStep": 20,
"activeBin": -2516,
"tokenASymbol": "PUMP",
"tokenBSymbol": "USDC"
}- 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/ListPositionsResult (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.
- 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/RebalancePositionResult:
{
"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).
- 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_bon WITHDRAWAL. - Free RPC limits. Read/submit/confirm split reduces single-provider spikiness. Add more endpoints as needed.
- Mixed strategy enums. I standardized on
LiquidityStrategyacross create/rebalance and map to the SDK at the edge. - BigInt consistency. Raw amounts are
bigintuntil the SDK boundary (new BN(bigint.toString())). - Executor connection access. Code now uses
executor.readConnection | submitConnection | confirmConnectiondeliberately; 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.