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

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

10/1/20257 min • defi
SolanaTelegramSupabasePrivyJupiterMeteoraDLMMBotsPnLTypeScript

TL;DR — We’re about to test the monitor on a live position. To make PnL and triggers reliable, I refactored defi_server (the same infra powering our Telegram Lite bot) to standardize decimals, prices, symbols, balances, and deltas. Also dropped Birdeye, updated to Jupiter’s lite endpoints, unified BigInt math, removed the legacy WebApp proxy (direct Supabase instead), added trade recording, and cleaned up Redis so tests and scripts exit cleanly.

This builds on Part 1–3 where we shipped the DLMM scanner, a dry-run monitor, and the Telegram Lite surface.

Why this refactor now?

The monitor makes decisions (TP/SL, rebalance) from PnL and range status. If your decimals or prices wobble, your PnL lies — and the bot misfires. So I paused the “fun stuff” and made the data paths boring and correct.

We use the same defi_server that drives the Telegram Lite UX (from Part 3). One set of helpers feeds both the bot and the monitor. Less drift, fewer surprises.

What changed (and why)

  1. utils/amount.ts — one decimals source of truth
  • detectTokenProgramId(conn, mint) — handles token-2021 vs token-2022.
  • getMintDecimals(conn, mint) — on-chain via getMint(...) with the right program.
  • fetchAtaBalanceOrZero(...), parseAmountToRaw(...), toSafeNumber(...) stay here.
  • Removed SDK-coupled decimals (e.g., @meteora-ag/dlmm): infra shouldn’t depend on a protocol’s helpers.

Why: all protocol code (Meteora, swaps, etc.) reads decimals the same way, including Token-2022 mints.

  1. utils/balances.ts — one function, all balances
  • getTokenBalanceRaw(conn, owner, mint) → bigint
    • SOL (NATIVE_MINT) → lamports as bigint
    • SPL → ATA amount as bigint (0 if ATA doesn’t exist)
  • listSplTokens(conn, owner) — parsed RPC for UI lists.

Why: callers pass a Connection (no hidden globals), get raw units, and decide later how to format.

  1. utils/deltas.ts — deterministic swap deltas
  • getTokenAndSolDeltas(conn, signature, wallet, mint) waits for meta, computes:
    • tokenDelta from pre/post token balances (UI amount)
    • solDelta from pre/post lamports
  • Used by trade recording and PnL.

Why: consistent deltas across bot + server, a stable base for PnL math.

  1. utils/prices.ts — Jupiter lite Price API v3 (batched)
  • Endpoints updated to lite:
    • https://lite-api.jup.ag/price/v3?ids=... (≤50 per call)
  • getPricesUsd(mints[]) chunks and caches for 15s.
  • getPriceUsd(mint) wraps getPricesUsd([mint]).
  • SOL normalization: we map SOL→WSOL mint under the hood.

Why: fast, free, and matches the Telegram Lite portfolio code. One short cache for bots, not a database.

Usage tip: whenever you already have two mints, prefer batching:

const p = await getPricesUsd([mintA, mintB]);
const priceA = p[mintA] ?? 0;
const priceB = p[mintB] ?? 0;
  1. utils/symbols.ts — Jupiter lite Token API v2 (search)
  • Old token list endpoints are deprecated; we now call:
    • GET https://lite-api.jup.ag/tokens/v2/search?query=<comma-separated-mints>
    • ≤100 mints per request, cached for 1h.
  • Optional on-chain fallback via Metaplex Token Metadata (UMI) behind a flag (slower; off by default).

Why: symbols are UX sugar; use the light web API first, fall back on-chain only if you must.

  1. protocols/solana/ata.ts — ATA creation matches our executor pipeline
  • ensureOrCreateTokenAccount(owner, mint, signer, creds, executor)
    • Detects token program (2021/2022), builds V0 tx, runs through processTx.
    • Mirrors closeAtaIfEmpty style, no direct sendAndConfirmTransaction.

Why: consistent send/confirm path, better logging/metrics, and future Jito/priority-fee hooks.

  1. utils/trade.ts — record trades with explicit connection
  • recordTrade(conn, webApp, {...})
    • Uses getTokenAndSolDeltas(conn, ...)
    • Prices SOL via getPriceUsd(SOL), derives token price from cost/qty
    • Writes to Supabase via webApp.trading.addTradingRecord(...)

Why: executors already carry a Connection; making it explicit avoids spooky singletons.

  1. BigInt everywhere (the bps fix)

We killed accidental number math on raw amounts. Example:

// Percent-of-position sell (bps → BigInt)
const bps = BigInt(Math.max(0, Math.min(10_000, percentageBps | 0)));
const amountInRaw = (balance * bps) / 10_000n; // bigint floor by definition
// If a downstream lib needs number:
const amountInNum = toSafeNumber(amountInRaw);

Why: no silent rounding or overflow — JS number can’t safely represent large raw amounts.

  1. Removed Birdeye entirely
  • No more BIRDEYE_API_KEY
  • No more Birdeye balance/price/symbol fallbacks
  • Everything is RPC + Jupiter lite (+ optional on-chain metadata)

Why: fewer vendors, clearer limits (50/100 per call), and aligns with execution paths.

  1. Killed WebAppClient — direct Supabase
  • SupabaseClient.user.getPrivyId(userId) reads from user_info with (user_id, source=2, bot_id).
  • SupabaseClient.trading.addTradingRecord(...) mirrors the Python helper (SOL→USD priced when available).

Why: fewer moving parts, same source of truth.

  1. Redis cleanup + clean exits
  • Merged redis wrappers into RedisClient singleton with close().
  • Tests/scripts call await redis.close() → no more hanging processes.
  1. Connection injection for protocol clients
  • new MeteoraClient(connection) — no hidden RPCs.quicknode.

Why: executors own the Connection; helpers don’t guess.

  1. Monitor ergonomics (single-user, crisp messages)
  • Single-user config:
    • { userId, account, walletId, poolAddr?, takeProfitPct, stopLossPct, placement?, cooldownMin?, dryRun }
  • Dry-run path doesn’t resolve signers.
  • Range bar + in/out status in Telegram.
  • Cooldown + last-decision latch → fewer pings.

Compact test suite

I kept each script tiny and single‑purpose. Run with bun or ts-node.

Redis delegated address

export TEST_USER_ID="<tg uid>"
bun run scripts/tests/redis_delegated_addr.ts

If it hangs, you forgot await redis.close().

Insert a trading row directly

bun run scripts/tests/trading_insert.ts

Assert flows exist for a known position

export TEST_POOL_ADDR=4onkJw4hLYPjkcnYgGsgV3eakXJcHLmshUbMugZBfXho \
    && export TEST_POSITION_PUBKEY=GSQgiSshyKz6kZC8PSkHX9qvD3Hm5xfRc85ETrjchLWB \
    && bun run scripts/tests/assert_get_position_flow.ts

Bugs caught & fixes

  • Undefined operation in recordTrade — operation is now required by default. If omitted, we optionally infer:
    • (tokenΔ>0 ∧ solΔ<0) → BUY, (tokenΔ<0 ∧ solΔ>0) → SELL.
  • Hanging tests — Redis socket kept the loop alive. Added RedisClient.close() and call it in scripts.
  • Numbers vs BigInt vs BN — we convert UI numbers to bigint with decimalToAtomic(), pass new BN(bigint.toString()) only at the SDK boundary.

How this supports the monitor

The monitor decides: HOLD / TAKE_PROFIT / STOP_LOSS / REBALANCE. It needs:

  • Decimals (accurate UI amounts) → getMintDecimals
  • Prices (USD marks) → getPricesUsd / getPriceUsd
  • Balances & deltas (what changed) → getTokenBalanceRaw, getTokenAndSolDeltas
  • Symbols (nice messages) → getTokenSymbols (lite) with an optional on-chain fallback

Together they produce trustworthy PnL that the strategy can use to notify (or act).

What I’ll test next (live)

  1. Manually open a position via gRPC (server on :50051).

    • Verify user_lp_positions upserted with correct min/max bins.
    • Verify a DEPOSIT row in user_lp_position_flows with raw amounts + prices.
  2. Run the monitor (start with dryRun=true).

    • Telegram message includes: uPnL, total PnL %, unclaimed fees, range bar, decision.
    • No signer resolution in dry-run; message shows Would REBALANCE… or Would CLOSE….
  3. Verify:

    • Prices from Jupiter lite (SOL normalized to WSOL)
    • Decimals via on-chain getMint(...)
    • Clean deltas in logs
    • Symbols resolved via /tokens/v2/search
  4. Flip dryRun=false for my wallet only, validate one action path end-to-end (close or rebalance) and Supabase records.

    • On REBALANCE: tx hash returned, cooldown set, and (if there’s withdraw/add legs) flows recorded.
    • On TP/SL: position closed, flow written (WITHDRAWAL + optional FEE_CLAIM), and positions table updated/removed.

Sanity checks: cost basis > 0; total_pnl_pct not absurd; range bar matches active bin.

Endpoint update cheat-sheet (Jupiter)

  • QUOTE → https://lite-api.jup.ag/swap/v1/*
  • TOKENS → https://lite-api.jup.ag/tokens/v2/*
  • PRICE → https://lite-api.jup.ag/price/v3/*

Limits: price ≤50 ids per call; tokens search ≤100 mints per query. Illiquid or flagged tokens may return no price; we treat that as 0 and skip PnL contribution for that token in UI.

Notes, gotchas, and future-proofing

  • Pass Connection explicitly to helpers. Executors own it; helpers shouldn’t.
  • Caching is intentionally short (15s prices, 1h symbols). It’s a bot, not a data warehouse.
  • On-chain metadata is slow; only enable fallback for singletons where UX really needs a name.
  • WSOL normalization is everywhere — same mint for price + symbol lookups.
  • We did not change orchestrator or monitor policy in this pass. This was all plumbing for correctness.

What’s next

  • Turn the monitor live on a controlled position.
  • Persist monitor cooldowns + “last action” in Supabase (so it survives restarts).
  • Optional: multi-RPC round-robin + latency metrics for balance/metadata reads.
  • Add preflight sizing for LP-copy (bin step vs range width guardrails).
  • If needed, cache token metadata in Supabase nightly to reduce web calls.

Small refactors, big confidence. Now the monitor can actually be judged on strategy — not on whether a price endpoint decided to vibe that minute.

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 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.

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.