
DeFi Bots Series — Part 4: Prepping the Monitor — Decimals, Prices, Symbols & Clean Balances
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)
utils/amount.ts— one decimals source of truth
detectTokenProgramId(conn, mint)— handles token-2021 vs token-2022.getMintDecimals(conn, mint)— on-chain viagetMint(...)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.
utils/balances.ts— one function, all balances
getTokenBalanceRaw(conn, owner, mint) → bigint- SOL (NATIVE_MINT) → lamports as
bigint - SPL → ATA
amountasbigint(0 if ATA doesn’t exist)
- SOL (NATIVE_MINT) → lamports as
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.
utils/deltas.ts— deterministic swap deltas
getTokenAndSolDeltas(conn, signature, wallet, mint)waits formeta, computes:tokenDeltafrom pre/post token balances (UI amount)solDeltafrom pre/post lamports
- Used by trade recording and PnL.
Why: consistent deltas across bot + server, a stable base for PnL math.
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)wrapsgetPricesUsd([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;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.
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
closeAtaIfEmptystyle, no directsendAndConfirmTransaction.
- Detects token program (2021/2022), builds V0 tx, runs through
Why: consistent send/confirm path, better logging/metrics, and future Jito/priority-fee hooks.
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(...)
- Uses
Why: executors already carry a Connection; making it explicit avoids spooky singletons.
- 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.
- 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.
- Killed WebAppClient — direct Supabase
SupabaseClient.user.getPrivyId(userId)reads fromuser_infowith(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.
- Redis cleanup + clean exits
- Merged redis wrappers into
RedisClientsingleton withclose(). - Tests/scripts call
await redis.close()→ no more hanging processes.
- Connection injection for protocol clients
new MeteoraClient(connection)— no hiddenRPCs.quicknode.
Why: executors own the Connection; helpers don’t guess.
- 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.tsIf it hangs, you forgot
await redis.close().
Insert a trading row directly
bun run scripts/tests/trading_insert.tsAssert flows exist for a known position
export TEST_POOL_ADDR=4onkJw4hLYPjkcnYgGsgV3eakXJcHLmshUbMugZBfXho \
&& export TEST_POSITION_PUBKEY=GSQgiSshyKz6kZC8PSkHX9qvD3Hm5xfRc85ETrjchLWB \
&& bun run scripts/tests/assert_get_position_flow.tsBugs 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
bigintwithdecimalToAtomic(), passnew 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)
-
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.
-
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…orWould CLOSE….
-
Verify:
- Prices from Jupiter lite (SOL normalized to WSOL)
- Decimals via on-chain
getMint(...) - Clean deltas in logs
- Symbols resolved via
/tokens/v2/search
-
Flip
dryRun=falsefor 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_pctnot 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
Connectionexplicitly 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.