
DeFi Bots Series — Part 1: A Practical Meteora DLMM Scanner (From TXs to Pool Intents)
Goal: detect new DLMM positions opened by a specific address and turn them into actionable intents:
{ poolAddress, positionPda, tx, time }. This is the only signal the scheduler needs to decide “mirror now”.
Design constraints
- No heavy SDK calls in the hot path. I hit an SDK branch that assumed a
binArrayobject (binArray.version) and crashed insidegetPositionsByUserAndLbPair. Rather than fight it, I redesigned the scan to avoid bin arrays entirely. - Be precise: only surface real DLMM inits.
- Be fast: piggyback off Anchor discriminators and inner instructions to pinpoint what we need.
How the scanner works
- Pull recent signatures for the leader.
- For each tx, walk top-level instructions and bind inner instructions for that ix.
- When we see a DLMM init (by discriminator), search child ixs for a
SystemProgram.createAccount— that’s the new position PDA. - From the same ix’s account metas, batch-fetch account infos and keep only those owned by the DLMM program.
- Among those, detect the
lb_pairby its Anchor account discriminator. That’s the pool. - Emit
{ poolAddress, positionPda, tx, blockTime }. - Optionally enrich with
meteora.getPool(poolAddress)(safe, lightweight) so the scheduler knows token mints/symbols later.
Code
dlmmTxScanner.ts — extract (pool, positionPda) from txs
import { Connection, PublicKey } from "@solana/web3.js";
import bs58 from "bs58";
import crypto from "crypto";
import { logger } from "@/bootstrap/logger";
import { RPCs } from "@/rpc/connection";
const SYSTEM_PROGRAM_ID = new PublicKey("11111111111111111111111111111111");
// Anchor account discriminator: sha256("account:<name>") first 8 bytes
function accountDisc8(name: string): Buffer {
return crypto.createHash("sha256").update(`account:${name}`).digest().subarray(0, 8);
}
export type PositionInitHit = {
tx: string;
ixIndex: number;
blockTime?: number | null;
poolAddress: string;
positionPda: string;
};
export class DlmmTxScanner {
private conn: Connection;
private dlmm: PublicKey;
private readonly lbPairDisc = accountDisc8("lb_pair");
// known init instruction names (Anchor)
private static INIT_NAMES = [
"initialize_position",
"initialize_position_and_add_liquidity_by_strategy",
"initialize_bin_array",
];
private readonly discs: Buffer[];
constructor(dlmmProgramId: string, conn: Connection = RPCs.quicknode) {
this.conn = conn;
this.dlmm = new PublicKey(dlmmProgramId);
this.discs = DlmmTxScanner.INIT_NAMES.map((n) => DlmmTxScanner.discriminator(n));
logger.info(
`[scanner] using DLMM=${this.dlmm.toBase58()} discs=${this.discs
.map((d) => d.toString("hex"))
.join(",")}`
);
}
static discriminator(name: string): Buffer {
const h = crypto.createHash("sha256").update(`global:${name}`).digest();
return h.subarray(0, 8);
}
private isInitIx(programId: PublicKey, dataBytes?: Uint8Array): boolean {
if (!dataBytes || !programId.equals(this.dlmm) || dataBytes.length < 8) return false;
const head = Buffer.from(dataBytes.subarray(0, 8));
return this.discs.some((d) => d.equals(head));
}
async discoverPositionInitsForLeader(
leaderWallet: string,
limit = 40
): Promise<PositionInitHit[]> {
const owner = new PublicKey(leaderWallet);
const sigs = await this.conn.getSignaturesForAddress(owner, { limit });
const hits: PositionInitHit[] = [];
for (const s of sigs) {
const tx = await this.conn.getTransaction(s.signature, {
commitment: "finalized",
maxSupportedTransactionVersion: 0,
});
if (!tx) continue;
const msg = tx.transaction.message;
const acctKeys = msg.getAccountKeys({ accountKeysFromLookups: tx?.meta?.loadedAddresses });
const topLevel = msg.compiledInstructions ?? [];
for (let i = 0; i < topLevel.length; i++) {
const ix: any = topLevel[i];
const pid = acctKeys.get(ix.programIdIndex);
if (!pid) continue;
const idxs: number[] = (ix.accounts ?? ix.accountKeyIndexes ?? []) as number[];
let dataBytes: Uint8Array | undefined;
try {
if (typeof ix.data === "string") dataBytes = bs58.decode(ix.data);
else if (ix.data instanceof Uint8Array) dataBytes = ix.data;
} catch {}
const isDlmm = pid.equals(this.dlmm);
const isInit = this.isInitIx(pid, dataBytes);
if (!isDlmm && !isInit) continue;
// bind inner instructions for THIS top-level ix
const children = tx.meta?.innerInstructions?.find((x) => x.index === i)?.instructions ?? [];
// detect newly created position PDA
let positionPda: PublicKey | null = null;
for (const inx of children as any[]) {
const inPid = acctKeys.get(inx.programIdIndex);
if (!inPid || !inPid.equals(SYSTEM_PROGRAM_ID)) continue;
const a: number[] = (inx.accounts ?? inx.accountKeyIndexes ?? []) as number[];
if (a.length >= 2) {
const maybeNew = acctKeys.get(a[1]);
if (maybeNew) {
positionPda = maybeNew;
logger.info(`[scanner] detected position PDA (inner create): ${maybeNew.toBase58()}`);
break;
}
}
}
// Identify the lb_pair among DLMM-owned metas
const pubkeys = idxs.map((k) => acctKeys.get(k)).filter(Boolean) as PublicKey[];
const infos = await this.conn.getMultipleAccountsInfo(pubkeys, "confirmed");
let poolAddress: string | null = null;
for (let j = 0; j < pubkeys.length; j++) {
const accPk = pubkeys[j];
const info = infos[j];
if (!info) continue;
if (!info.owner.equals(this.dlmm)) continue;
if (positionPda && accPk.equals(positionPda)) continue;
if (accPk.equals(owner)) continue;
if (info.data?.length >= 8) {
const head = Buffer.from(info.data.subarray(0, 8));
if (head.equals(this.lbPairDisc)) {
poolAddress = accPk.toBase58();
break;
}
}
}
if (poolAddress && positionPda) {
hits.push({
tx: s.signature,
ixIndex: i,
blockTime: tx.blockTime ?? null,
poolAddress,
positionPda: positionPda.toBase58(),
});
}
}
}
// de-dup by position PDA
const seen = new Set<string>();
return hits.filter((h) => (seen.has(h.positionPda) ? false : (seen.add(h.positionPda), true)));
}
}dlmmTxScanner.ts — extract (pool, positionPda) from txs
import { MeteoraClient } from "@/protocols/meteora/MeteoraClient";
import { DlmmTxScanner, PositionInitHit } from "./scanners/dlmmTxScanner";
import { logger } from "@/bootstrap/logger";
export type RecentPoolIntent = PositionInitHit & { pool: any };
export async function findRecentPoolIntents(opts: {
dlmmProgramId: string;
leaderWallet: string;
scanLimit?: number;
meteora?: MeteoraClient;
}): Promise<RecentPoolIntent[]> {
const { dlmmProgramId, leaderWallet, scanLimit = 40 } = opts;
const meteora = opts.meteora ?? new MeteoraClient();
const scanner = new DlmmTxScanner(dlmmProgramId);
const inits = await scanner.discoverPositionInitsForLeader(leaderWallet, scanLimit);
if (!inits.length) return [];
const results: RecentPoolIntent[] = [];
for (const hit of inits) {
try {
const pool = await meteora.getPool(hit.poolAddress); // safe metadata
results.push({ ...hit, pool });
} catch (e: any) {
logger.info(`[recent-intents] skip pool=${hit.poolAddress} reason=${e?.message || e}`);
}
}
// keep latest per pool
const latestByPool = new Map<string, RecentPoolIntent>();
for (const r of results) {
const prev = latestByPool.get(r.poolAddress);
if (!prev || (r.blockTime ?? 0) > (prev.blockTime ?? 0)) latestByPool.set(r.poolAddress, r);
}
return Array.from(latestByPool.values());
}scan-once.ts — tiny CLI to print intents
import { MeteoraClient } from "@/protocols/meteora/MeteoraClient";
import { logger } from "@/bootstrap/logger";
import { findRecentPoolIntents } from "./recent-intents";
const DLMM_PROGRAM_ID = "LBUZKhRxPF3XUpBCjp4YzTKgLccjZhTSDM9YuVaPwxo";
async function main() {
const leader = process.argv[2] || "7KHx2Uc5qsqz652eXbu8Qtabi5KLxWJLgxFzcaBzP32i";
const scanLimit = parseInt(process.argv[3] || "80", 10);
logger.info(`🔎 Scanning recent txs for leader ${leader} (limit=${scanLimit}) with DLMM=${DLMM_PROGRAM_ID}...`);
const intents = await findRecentPoolIntents({
dlmmProgramId: DLMM_PROGRAM_ID,
leaderWallet: leader,
scanLimit,
meteora: new MeteoraClient(),
});
if (!intents.length) {
console.log("No recent DLMM position inits found for this wallet.");
return;
}
console.log("\n✅ Recent DLMM position intents (per pool, latest first):");
for (const it of intents) {
const symbols =
it.pool?.tokenSymbols ??
`${it.pool?.tokenXMint?.toString?.() ?? "?"}/${it.pool?.tokenYMint?.toString?.() ?? "?"}`;
console.log(`• ${it.poolAddress} — pos=${it.positionPda}, pair=${symbols}, tx=${it.tx}`);
}
console.log("");
}
main().catch((e) => {
logger.error(e);
console.error("Scanner failed:", e?.message || e);
process.exit(1);
});Test run
Command:
bun run src/bot/strategies/lp-copy/scan-once.ts 7KHx...P32i 100Output (trimmed for length):
[18:19:11.592] INFO: [scanner] using DLMM=... discs=dbc0ea47...,6de657a2...,235613b9...
[18:19:14.736] INFO: [scanner] detected position PDA (inner create): Dt8oBrtAWLwaGePGueZTJu8ceNRzVAPBxc4sCUh2ZcEw
...
✅ Recent DLMM position intents (per pool, latest first):
• 9kXysT39TsPmsgz4bPkVdZwbB4ANaBEredkywRy3Lg2F — pos=Dt8oBrtAWLwaGePGueZTJu8ceNRzVAPBxc4sCUh2ZcEw, pair=?/?, tx=VGqqsvMPZ1xSfAjc44pLM1PkdBmrcrNAvBdcM1GNJA6iwKm58HYY...
• JCWhn7o8Lrj8Sf5c6caWngksVD2zb9eMEbQS4z619kFB — pos=4NZhwe38PordHtQDduZEyAZeoFqcdqk3SoS2Vs7Cdh9h, pair=?/?, tx=Eo1o1Mh6wjJqDJs2SXG6UH1Y...
• ijvw7tJG58rLuEfkfB1gv88aRvLQ364RXvRz3bo4KVU — pos=6sv5JU6WauhdAHX8iwAfY2b8kzcbL1XMeM7sSwGnKUSn, pair=?/?, tx=27GUKPsxASTUneDtqwjUpsevV1f...
• Bxz2KioTFckVPtj8gRD1Hx1fzzzGDp9QwCwthTddGX8o — pos=Es2PRwKDA7AaTX9mSrzx4NPsQBpJVi8qWayWmwMaA4og, pair=?/?, tx=QkRZd7GDQDSMtxza5Mh5HJVz7EZQH...
• Cgnuirsk5dQ9Ka1Grnru7J8YW1sYncYUjiXvYxT7G4iZ — pos=CJZGgeCEcSGYwAJttVm1VaiNemQnaF6LHKpL6cgvcgJJ, pair=?/?, tx=5eKNuwxyva65QPMCu6RuPcZg86aNVq...
• BCKgV7XuX7FWPnXkWFT5iFcVt7HDXouQR73Y6jyjTAL3 — pos=25RAjkvPsCdUktYBdjFynk8jDFGuGE5y9Dc7KDSbMZEY, pair=?/?, tx=4iBrsF1E697H5asFnpL9VEhBXQwbX1eC8...
• 6YLmeY48wkhVdH1kW4K3TkSC74Qq2rjFzSiazLEmpJPk — pos=YkdvU2KDSgb9CLYNfyyfg9r7vZ5SGTGbEz6281sczLz, pair=?/?, tx=6LYVzugd1h5MmAGgJDMzZEHLi5E99Uf7XdN...You’ll notice pair=?/? — by design. I intentionally left symbol resolution for later, since the scheduler doesn’t need it to decide “mirror now”. We’ll wire token symbols/mints in the executor stage.
Why this approach is robust
- We don’t parse positions via bin arrays at scan time (no
binArray.versionlandmines). - We use program ownership and Anchor discriminators to confidently pick the lb_pair.
- We watch inner
SystemProgram.createAccountcalls to catch the exact position PDA that was created.
What’s next
- Scheduler: periodic scan, rate-limits, idempotent processing of intents (don’t re-open the same pool).
- LP executor: translate a
(poolAddress, positionPda)intent into an add-liquidity action with my own sizing; store the trade in Supabase. - Risk: TP/SL, rebalance jobs, and a tiny “vault” view of my holdings to reason about exposure.
- Symbols: resolve token pair & decimals for nicer logs and dashboards.
That’s up next in Part 2 — we’ll wire the scheduler and the first “open position” flow for Meteora.
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 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.

DeFi Bots Series — Part 7: The Monitor Test Saga (Making the Monitor Debuggable)
I debugged the PnL, refactored the messy code we left implemented in our last devlog and made everything more compact and debuggable.

DeFi Bots Series — Part 6: Base-Funded Opens and Sweeps, Clean PnL, and a Quiet (Smarter) Monitor
I moved position funding and settlements to a USDC base, fixed a sneaky PnL bug (price/mint orientation), taught the monitor to chill (cooldown + “in-range = HOLD”), and battle-tested open/close scripts with ledgered flows. It’s finally… boring—in the good way.