DeFi Bots Series — Part 6: Base-Funded Opens and Sweeps, Clean PnL, and a Quiet (Smarter) Monitor

DeFi Bots Series — Part 6: Base-Funded Opens and Sweeps, Clean PnL, and a Quiet (Smarter) Monitor

10/7/202513 min • defi
SolanaMeteoraDLMMJupiterSupabasePrivyRPCBotsTypeScript

TL;DR

  • Funding: new ensureFromBaseBalances funds both sides from USDC, spending proportionally by USD need (with headroom). Swaps route via Jupiter; in RPC mode we don’t mutate JUP txs (pass-through).
  • Sweeps: new sweepLeftoversTo settles token leftovers to a sinkMint, which is used while rebalancing if we do not want to compoundFees, or in the bot after we close a position to settle to our base token (USDC) if config's sweepOnClose is true.
  • Swaps: exact-out works; we keep a conservative ExactIn from base path for steady funding. The scary 0x1788 slippage fail is handled elsewhere; not a thing here.
  • PnL: fixed a core bug—flows now record pool-oriented (X/Y) amounts and matching prices on both DEPOSIT and WITHDRAWAL, so PnL no longer shows phantom +200% after a fresh open.
  • Monitor: added cooldown after create and made “in-range ⇒ HOLD” the default. No more insta-closing green positions.
  • Reliability: errors are messaged to Telegram without killing the process.
  • DX: small folder reorg, polished scripts, and tighter logging.

Why the changes?

As we advance in the bot's development, while I advance in the refractoring of the old defi_server which was gRPC-heavily-oriented, we keep finding flaws and improvements. Let's make a pause and review what we have done so far:

1. Setting up the bot:

We took an initial expedition and created a minimal structure and implementation for the bot both in [Part 1: A Practical Meteora DLMM Scanner][https://www.reymom.xyz/blog/icramp/2025-09-26-defi-bots-series-taigo-lp-copy] and Part 2. It wasn't the final version of it but just the framework from which to start this complex architecture, and in latter posts we will take on this while we test to adapt and improve many of its initial features.

2. Taigo Lite Bot:

We then made a little incursion, as documented in Part 3: Telegram Bot Lite into adapting TAIGO's old telegram bot stack so we stripped it off the AI Agents and simplified execution, reduced completely API costs so we rely completely on onchain and free-RPC calls to render our Privy Wallet and allow related notifications in our new Taigo Lite.

We will mainly use it as a raw notification system for tracking our PNL seamlessly and other related operations and executions. Shoutout to taigo's crew: x.com/Taigo_AI for the great product they created, specially to my friend Vincent with whom we still explore, build and discuss farming and trading strategies.

3. Defi Server Revisited:

We then started looking deeper in the old defi server, making further refractors, stripping out old logic and pitfalls, and creating many new functions and implementations. In Part 4: Prepping the Monitor we start cleaning utilities, fixing PNL functions and other very important tasks related to our storage, reviewing Supabase schemas and APIs, fixing and improving the flow. We started adding and running tests in scripts/. In Part 5: Live Rebalance on Meteora DLMM we then went back hardcore to the core of the defi_server, revisited and adapter the connections, improving and standardizing RPC usage, and working on the rebalance flow for the first time. Now we are in Part 6, where we take on the lead and create a final version of all these core functions to Open Positions, Close Positions, Remove Liquidity and Reclaim, Rebalance, fixing many important issues along the way, up to the point to launch our first live monitor test.

We can now say we are ready to start working on the strategies and automatization, that we have our old defi_server adapted to our current purposes with a solid base for recording positions, flows, PNLs and operating on Meteora pools seamlessly. Let's go for that.

What it is left now?

After Part 5, we still had some big pain points:

  1. As the functionality set grows in meteora/, we find many obsolete functions and redundant code, plus the files are messy: we rewired everything and have a clean and refactored defi_server in all meteora-related code.
  2. Funding the pair was flaky. Swapping A↔B based on deficits created more surface area (and ExactOut misfires early on). Consolidating to USDC-funded opens is simpler and predictable.
  3. PnL lies when the ledger doesn’t match the pool’s orientation. We were recording A/B in CLI order while on-chain “A/B” is tokenX/tokenY (pool-oriented). That mismatch + missing prices on some flows produced surreal PnL. Fixed.

Meteora's implementation shape (cleaned)

src/protocols/meteora/
├── close.ts
├── docs.md
├── grpc/
   ├── executeTx.ts
   ├── monitor.ts
   └── pools.ts
├── helpers/
   ├── jupiterSwap.ts
   ├── liquidity.ts
   ├── monitor.ts
   ├── openPlan.ts
   ├── rebalance.ts
   └── utils.ts
├── MeteoraAdapter.ts
├── MeteoraClient.ts
├── models.ts
├── onchainPosition.ts
├── open.ts
├── removeLiquidity.ts
└── signSend.ts

Instead of using our old approach of spinning the server and then executing the position handling via GRPc, we now created some handy scripts:

scripts/meteora/
├── closePosition.ts
└── openPosition.ts

As we expand our testing, we will be creating more. These will server as the base to create a CLI application were all the functionality performed by the bot will be able to be performed directly using our defi CLI program.

Funding from a base (USDC), not from the pair

I replaced the old “swap A↔B to cover deficits” with a single-base funding flow:

  • Read wallet balances for tokenX, tokenY, and USDC.
  • If short on X and/or Y, fetch decimals + prices, compute USD need per side, add headroom (default +3%).
  • Spend USDC proportionally across X/Y using ExactIn; do a tiny top-up pass if still short.
  • Verify balances cover amountXRaw/amountYRaw.

Design choice: use ExactIn from USDC for calmness. It avoids “insufficient funds” at SPL-token transfer time and removes the need to pre-approve upstream quotes. In places where we must hit a target amount, we still have ExactOut, but normal funding works better as ExactIn.

Abridged call site (inside openFreshPosition):

const [mintForA, mintForB] = plan.flip
  ? [cached.token_b_mint, cached.token_a_mint]
  : [cached.token_a_mint, cached.token_b_mint];
 
await ensureFromBaseBalances({
  executor, userId: userId.toString(), wallet: solAddr, creds, privySigner,
  tokenA: mintForA, tokenB: mintForB,
  amountARaw: plan.amountARaw, amountBRaw: plan.amountBRaw,
  baseMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC
  supabase: storage,
  headroomBps: 300, // +3%
  minBaseLeftRaw: 0n
});

In practice it prints either:

ensureFromBaseBalances: already funded for both sides.

or performs 1–2 small USDC→{X,Y} swaps.

Sweeping to a sink (USDC)

I also consolidated a function to settle leftovers to the base mint (called sink here) in sweepLeftoversTo:

export async function sweepLeftoversTo({...}) {
  const prices = await getPricesUsd([sinkMint, ...mints]);
  const decCache = new Map<string, number>();
 
  for (const mint of mints) {
    if (mint === sinkMint) continue;
 
    const bal = await getTokenBalanceRaw(
      executor.readConnection,
      new PublicKey(wallet),
      new PublicKey(mint)
    );
    if (bal === 0n) continue;
 
    let dec = decCache.get(mint);
    if (dec == null) {
      dec = await getMintDecimals(executor.readConnection, new PublicKey(mint));
      decCache.set(mint, dec);
    }
 
    const ui = Number(bal) / 10 ** dec;
    const px = prices[normalize(mint)] ?? 0;
    const usd = ui * px;
    if (usd < minUsd) continue;
 
    await performJupiterSwapExactIn({
      inputMint: mint,
      outputMint: sinkMint,
      amountInRaw: bal,
      userId,
      solAddr: wallet,
      creds,
      executor,
      privySigner,
      supabase,
    });
  }
}

This will come in handy to automate what it happens to pool's mints after we close or rebalance positions:

  1. In rebalanceSinglePosition, after we call removeAndRecordLiquidity, we will check if we compound the fees:
const addFeeA = compoundFees ? xFee : 0;
const addFeeB = compoundFees ? yFee : 0;
const amountAui = (Number(xRemoved) + addFeeA) / 10 ** decA;
const amountBui = (Number(yRemoved) + addFeeB) / 10 ** decB;

And pass these amounts to openFreshPosition. After opening the position, if we didn't use the fees to compound liquidity in the next position, we will settle funds to USDC:

if (!compoundFees) {
    await sweepLeftoversTo({
        executor,
        privySigner,
        creds,
        supabase: storage,
        wallet: solAddr,
        sinkMint: BASE_MINT,
        mints: [cached.token_a_mint, cached.token_b_mint],
        userId,
        minUsd: 2,
    });
}

We can choose the client level whether we compound collected fees in the next position we create in the rebalance, or we collect them and convert to USDC.

Note: In jupiter swaps we pass-through in RPC mode. We keep JUP tx augmentation off in pure RPC mode:

[augmentJupiterTx] RPC/no-tip passthrough. ixs=3 hasComputeBudget=true

This preserves Jupiter’s compute budget and avoids accidental instruction reordering. (For Jito/NextBlock we still have a safe tip-injector.)

The PnL fix that mattered

PnL = (liquidity + unclaimed fees @ spot) + realised fees − cost basis. Cost basis = Σ(DEPOSIT USD) − Σ(WITHDRAWAL USD).

Two critical rules:

  • Flows must be pool-oriented: token_a_mint = tokenX, token_b_mint = tokenY; amounts are raw X/Y.
  • Every DEPOSIT/WITHDRAWAL writes price_a/price_b (spot at the event).

Where it went wrong before

We were sometimes recording A/B in caller order (CLI), then pricing with A/B that didn’t match tokenX/tokenY. That inflated/deflated cost basis. On a new open it looked like +200% PnL. 🙃

What I changed

After create, I fetch the pool, resolve X/Y mints and prices, then upsert position + write a DEPOSIT flow in pool orientation:

const pool = await meteora.getPool(poolAddress);
const mintX = pool.tokenX.mint.address.toBase58();
const mintY = pool.tokenY.mint.address.toBase58();
 
const px = await getPricesUsd([mintX, mintY]);
const priceX = px[normalize(mintX)] ?? 0;
const priceY = px[normalize(mintY)] ?? 0;
 
// on-chain read (or plan fallback)
const amountX = onChain ? parseInt(onChain.data.totalXAmount) : Number(plan.amountARaw);
const amountY = onChain ? parseInt(onChain.data.totalYAmount) : Number(plan.amountBRaw);
 
// position row (pool-oriented, includes strategy)
await storage.positions.upsertPosition({
  user_id: userId,
  pool_address: poolAddress,
  position_pubkey: position,
  token_a_mint: mintX,  amount_a: amountX,
  token_b_mint: mintY,  amount_b: amountY,
  strategy: Number(strategy),
  min_bin_id: plan.minId, max_bin_id: plan.maxId, interval: plan.interval,
  last_rebalanced_at: new Date().toISOString(),
});
 
// DEPOSIT flow (pool-oriented)
await storage.flow.insertFlow({
  user_id: userId,
  position_pubkey: position,
  pool_address: poolAddress,
  token_a_mint: mintX, amount_a: amountX, price_a: priceX,
  token_b_mint: mintY, amount_b: amountY, price_b: priceY,
  event_type: "DEPOSIT",
});

Result: PnL on a fresh open is ~0%, then moves sanely with price and fees.

The monitor grew up

  • Create cooldown: default 10 minutes after created_at—no alerts/actions. Log sample:
[monitor] cooldown after create (pos=...) ~2m left
  • Decision rule:

    • If in range ⇒ HOLD.
    • Else if PnL ≤ −SL ⇒ STOP_LOSS.
    • Else if PnL ≥ TP ⇒ TAKE_PROFIT.
    • Else ⇒ REBALANCE.
  • Errors: caught and sent to Telegram; the process doesn’t exit.

Field notes (real runs)

The new scripts

Manual close (script):

bun run scripts/meteora/closePosition.ts \
  --user 668...476 \
  --wallet nor...qdl \
  --solAddr 2yh...Q5b \
  --pool 5SH...pjf
[closePosition] going to claim fees: tokenX: 17337, tokenY: 96453
[closePosition] removing liquidity before closing position.
[confirm] meteora ... confirmed in 2631ms
[closePosition] done: https://solscan.io/tx/2ZALWL...
[closePosition] withdrawn: 8.091412|0 ... fee: 0.017337|0.096453

Fresh open

bun run scripts/meteora/openPosition.ts \
  --user 668...476 \
  --wallet nor...qdl \
  --solAddr 2yh...Q5b \
  --pool 5cu...rSU \
  --tokenA EPjF...Dt1v \ # USDC
  --tokenB 27G8...idD4 \ # JLP
  --amountA 24 \
  --amountB 4 \
  --strategy 2 \
  --placement 2 \
  --interval 6
ensureFromBaseBalances: already funded for both sides.
[openFreshPosition] create params: { amountARaw:"4020001", amountBRaw:"24120001", ... }
[confirm] meteora ... confirmed in 2799ms
[openPosition] new position 6dHoDizP... — https://solscan.io/tx/3ErzoPD...

In this section, I was using the openPosition script to spin off a position and then run the bot to test the monitoring of that single one.

Pool Liquidity

Supabase flow (DEPOSIT) — pool-oriented, priced

I also fixed the position supabase executions, plus trading records for PNL calculations later. We added the recordings in all critical parts (open, close, remove liquidity, swaps).

Example flow deposit record:

{
  "user_id": 668...476,
  "position_pubkey": "6dH...ZnF",
  "pool_address": "5cu...rSU",
  "token_a_mint": "27G...dD4",
  "token_b_mint": "EPj...t1v",
  "amount_a": "4151527",
  "amount_b": "23348252",
  "price_a": 5.862657146164453,
  "price_b": 0.9997099659588243,
  "event_type": "DEPOSIT",
  "timestamp": "2025-10-07 14:39:48.182+00"
}

Units check: amount_* are raw token units. For JLP (6 d.p.), 4,151,5274.151527 JLP. For USDC (6 d.p.), 23,348,25223.348252 USDC.

Monitor output

We finally test the automatic monitor for a single position, running our bot with config:

{
  "poolAddr": "5cu...rSU",
  "userId": "668...476",
  "walletId": "nor...qdl",
  "account": "2yh...5bb",
  "enabled": true,
  "dryRun": false,
  "takeProfitPct": 10,
  "stopLossPct": 5,
  "placement": 2,
  "cooldownMin": 5
}

After some tests and fixes, we finally get a good-looking monitoring:

$ bun run src/bot/index.ts
[16:43:33.955] INFO (161112): 🤖 Bot: starting scheduler (every 1800s)
[16:43:34.617] INFO (161112): [lp::monitor::tick] Positions found:
  {
    "user_id": 668...476,
    "pool_address": "5cu...rSU",
    "position_pubkey": "9HR...Pkc",
    "token_a_mint": "27G...dD4",
    "token_b_mint": "EPj...t1v",
    "strategy": 2,
    "min_bin_id": 2202,
    "max_bin_id": 2208,
    "interval": 6,
    "created_at": "2025-10-07T15:56:08.597709+00:00",
    "updated_at": "2025-10-07T15:56:08.597709+00:00",
    "amount_a": 8219373,
    "amount_b": 0,
    "last_rebalanced_at": "2025-10-07T15:56:08.414+00:00"
  }
]
[16:43:34.619] INFO (161112): [monitor] cooldown after create (pos=6dHoDizPdHJrNWMPYgeDhaWX5Gt4VoMUC9vBTdd4RZnF) ~2m left
[17:17:42.644] INFO (162277): [monitorOnce] sendMessage at time 1759850262644
[17:17:53.535] INFO (162277): [openFreshPosition] new position 3RK...h4U – https://solscan.io/tx/...

In our telegram bot's chat, we are receiving now every half an hour a monitoring message:

AIGEXbot, [7/10/25 17:56]
📊 JLP-USDC
      pool: 5cuy...BrSU | pos: 3RKE...fh4U
      8.178 JLP | 0.000 USDC
 
―――――――――――――――――――――――――
Price: $5.82
$5.84 │🔺▬▬▬▬▬▬▬▬▬▬│ $5.87
range: 2207..2213
active=2203 🔻 +4 bins (0.32%)
―――――――――――――――――――――――――
 
value: $47.49
rewards: 0.00 JLP + 0.00000 USDC = $0.00
PnL: -$0.07  (-0.15%)
 
→ REBALANCE — out-of-range (below)
AIGEXbot, [7/10/25 17:56]
🔄 Rebalanced JLP-USDC
tx: https://solscan.io/tx/2uP...yqS
pool: 5cuy...BrSU | old: 3RKE...fh4U → new: 9HRp...XPkc
range: 2202..2208 (w=6) · placement=BID ASK
inventory: 8.219 JLP | 0.000 USDC
value: $47.73

There's a lot to work on and improve, but that is our first bot's live test! And it is looking quite good.

Telegram Lite Bot's messaging

We can now keep track of our meteora positions and act on them immediately and automatically:

Meteora Position

Philosophy checkpoints

  • Simple beats clever for funding: one base (USDC) → ExactIn → proportional spend. You can always iterate on top.
  • Orientation is law: everything written to the ledger is pool-oriented (X/Y). This keeps decimals, prices, and accounting coherent.
  • A boring monitor is a good monitor: cooldowns, in-range hold, and clear TP/SL thresholds make behavior predictable.
  • Pass-through first: don’t mutate third-party txs unless you must (e.g., block-producer tips). It eliminates a class of non-deterministic failures.

Appendix: a few helpful snippets

Monitor cooldown & decision

// after loading `p`
const createdAtMs = Date.parse(p.created_at ?? "");
if (!Number.isNaN(createdAtMs) && Date.now() - createdAtMs < (this.cfg.minAgeMin ?? 10) * 60_000) {
  logger.info(`[monitor] cooldown after create (pos=${p.position_pubkey})`);
  return;
}
 
private decide(pnlPct: number, inRange: boolean, tp: number, sl: number): Decision {
  if (inRange) return "HOLD";
  if (pnlPct <= -Math.abs(sl)) return "STOP_LOSS";
  if (pnlPct >= tp) return "TAKE_PROFIT";
  return "REBALANCE";
}

Error → Telegram, process stays up

try {
  this.inFlight.add(p.position_pubkey);
  this.monitorOnce(p, this.readConn);
} catch (e: any) {
  logger.error(`[monitor] pos=${p.position_pubkey} error: ${e?.message || e}`);
  await TelegramClient.sendMessage(String(p.user_id), `❌ *Monitor error* ...`);
} finally {
  this.inFlight.delete(p.position_pubkey);
}

What’s next

Our big test is getting closer. In the next posts we will be scaling this exponentially.

  • Auto-create positions in the bot (no manual script), seeded from a “base budget” per wallet.
  • Smarter decisioning: incorporate TVL, 24h fees, volume trend, pool skew, and bin shape (e.g., widen into volatility, tighten on calm).
  • Run a multi-pool test (3–4 positions) with end-to-end create → monitor → rebalance/close flows.
  • Perps hedging (we’re studying Drift): light delta-hedge to keep exposure sane; plus limit orders to complement Jupiter pathing where it makes sense.

That’s it for Part 6. We traded the “exciting” chaos for a quiet, reliable pipeline: base-funded opens, correct ledgering, and a monitor that doesn’t panic. On to auto-creation and strategy brains.

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 5: Live Rebalance on Meteora DLMM (RPC Profiles, Clean PnL & One-Sided Liquidity)

I rewired RPC handling with role-based profiles, unified LP strategy controls, fixed PnL accounting, and executed a live one-sided rebalance on a PUMP/USDC DLMM pool over gRPC—end to end with Supabase ledgering.