DeFi Bots Series — Part 1: A Practical Meteora DLMM Scanner (From TXs to Pool Intents)

DeFi Bots Series — Part 1: A Practical Meteora DLMM Scanner (From TXs to Pool Intents)

9/26/20256 min • defi
SolanaDLMMMeteoraTypeScriptBotsRPCAnchorCopytrading

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 binArray object (binArray.version) and crashed inside getPositionsByUserAndLbPair. 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

  1. Pull recent signatures for the leader.
  2. For each tx, walk top-level instructions and bind inner instructions for that ix.
  3. When we see a DLMM init (by discriminator), search child ixs for a SystemProgram.createAccount — that’s the new position PDA.
  4. From the same ix’s account metas, batch-fetch account infos and keep only those owned by the DLMM program.
  5. Among those, detect the lb_pair by its Anchor account discriminator. That’s the pool.
  6. Emit { poolAddress, positionPda, tx, blockTime }.
  7. 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 100

Output (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.version landmines).
  • We use program ownership and Anchor discriminators to confidently pick the lb_pair.
  • We watch inner SystemProgram.createAccount calls 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.