Skip to content

naruto11eth/solana-pnl

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

21 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

solana-pnl

Lowest-latency SOL balance-over-time solver using Helius getTransactionsForAddress. Returns the full balance curve for a wallet — every pre/post balance change from every transaction that touched it — with no indexing, only RPC.

Zero runtime dependencies. TypeScript. Works in Node 20+.

Algorithm: Bidirectional Pincer + Fabricated Slot Tokens + Recursive Subdivision

The challenge is that you don't know in advance how many transactions a wallet has. Sparse wallets should finish in one round-trip; busy wallets need massive parallelism. The algorithm adapts to both without a probe phase.

RTT 1 — Bidirectional probe (2 parallel calls). Fire asc and desc getTransactionsForAddress simultaneously, each asking for the first 100 transactions. This discovers both boundaries of the wallet's history in parallel and, for any wallet with ≤ 200 transactions or any wallet whose activity is tightly clustered, finishes the whole job in the same RTT.

Short-circuit conditions (all handled in RTT 1):

  • Either side returns < 100 txs → we've seen everything in that direction.
  • asc[-1].slot >= desc[-1].slot → the two ends overlap, nothing in between.

RTT 2 — Segmented gap fill (N=128 parallel calls). If there's a gap between asc[-1].slot and desc[-1].slot, split it into 128 equal slot-width segments. For each segment, fire getTransactionsForAddress with a fabricated paginationToken of "<segmentStartSlot>:0" — this lets us start the query at an arbitrary slot without a prior probe.

The paginationToken format ("slot:position") is exposed by Helius but not heavily documented; the trick is that you can fabricate one with position=0 to enter at the start of any slot. 128 was chosen to exactly match the shared HTTPS Agent's maxSockets, so the very first wave saturates the connection pool — no ramp-up through recursive subdivision needed for typical wallets.

RTT 3+ — Recursive subdivision of dense segments. If a segment returned a full 100-tx page and its last-seen slot is still inside the segment's range, it's dense. Instead of linear pagination (which takes O(txs/100) RTTs), we binary-split the remaining range and fetch both halves in parallel. Each subdivision doubles parallelism while halving per-segment load, giving O(log(txs)) RTTs for dense segments.

Base case: below SUBDIVISION_MIN_WIDTH slots (default 10,000 ≈ 66 min of wall time), we fall back to linear continuation because the overhead of further subdivision outweighs the benefit.

Balance extraction. Every Solana transaction carries meta.preBalances and meta.postBalances — the SOL balance of every involved account before and after the tx. The balance curve is simply the sorted sequence of (blockTime, postBalance[walletIdx]) across all collected transactions, where walletIdx is the wallet's position in the flat account-key list. Importantly, that flat list is:

[ ...message.accountKeys, ...meta.loadedAddresses.writable, ...meta.loadedAddresses.readonly ]

For versioned transactions (v0) that use an Address Lookup Table, the wallet might only appear in the loadedAddresses portion — so just looking at message.accountKeys is a silent correctness bug. This implementation handles both cases.

Project layout

solana-pnl/
├── package.json
├── tsconfig.json
├── .env.example
├── .gitignore
├── README.md
├── docs/
│   └── server-side-directions.md  # Below-the-wire roadmap (see below)
├── src/
│   ├── rpc.ts      # node:https client: keep-alive Agent, gzip/br/deflate, retries, call counter
│   ├── pnl.ts      # core algorithm: bidirectional probe + density-aware split + recursive subdivision
│   ├── cache.ts    # opt-in process-local LRU for repeat queries (off by default)
│   └── cli.ts      # CLI entry
└── bench/
    ├── run.ts          # benchmark harness
    └── probe-gtfa.ts   # one-off probe testing gTFA's parameter shapes for hidden field projection

Usage

# 1. Install dev dependencies (tsx + TypeScript)
npm install

# 2. Set your Helius key
export HELIUS_API_KEY=...
# or: export HELIUS_RPC_URL=https://mainnet.helius-rpc.com/?api-key=...

# 3. Run against a single wallet
npm run pnl -- 9ogeCJhwdQ3hLbhSqTterv5nWeetnu9Z2LxjWuN6krhq

# 4. JSON output (pipe to jq, etc.)
npm run pnl -- <addr> --json --no-timeline

# 5. Run the benchmark
npm run bench
RUNS=5 npm run bench
npm run bench -- <addr1> <addr2> <addr3>

Output

Human-readable mode (real run against an active trader wallet):

──────────────────────────────────────────────────────────────────
  9ogeCJhwdQ3hLbhSqTterv5nWeetnu9Z2LxjWuN6krhq
──────────────────────────────────────────────────────────────────
  Net PnL      +1330.275816705 SOL
  Start        0.0 SOL
  End          1330.275816705 SOL
  Peak         1348.442820927 SOL @ slot 403854700
  Trough       0.074272324 SOL @ slot 340095696
  Txs          120218
  Timeline     120218 points
  Latency      5483ms  (phases: 332ms + 5151ms)
  RPC calls    1713
  Segments     128
──────────────────────────────────────────────────────────────────

JSON mode (--json) returns the full timeline as an array of {slot, blockTime, preSol, postSol, deltaSol, signature} points, plus all summary stats. Use --no-timeline to omit the (potentially large) point array.

Tunables

In src/pnl.ts:

  • PAGE_LIMIT = 100 — max page size for transactionDetails: 'full' queries. Fixed by Helius.
  • INITIAL_SEGMENTS = 128 — how many parallel segments to create in RTT 2. Sized to match maxSockets on the shared HTTPS Agent (src/rpc.ts) so the very first wave saturates the connection pool. Higher values = better busy-wallet latency but more credit burn on medium wallets that didn't short-circuit in RTT 1.
  • MAX_SUBDIVISION_DEPTH = 8 — recursion depth limit for overflow subdivision. 8 gives you up to 256-way parallelism per original segment.
  • SUBDIVISION_MIN_WIDTH = 10_000 — stop subdividing below this slot-range width and fall back to linear continuation.

In src/rpc.ts:

  • Agent.maxSockets = 128 — concurrent in-flight requests to Helius. Pinned so Promise.all over the 128 RTT 2 segments doesn't queue against itself on the dispatcher.

Benchmarks

Ten runs each, on a warm connection pool (the bench prewarms 32 sockets before measuring). Tested with a Helius Developer-tier key from a residential connection.

Wallet Type Txs min p50 max avg
8KfwxV1B…CvFz sparse, RTT 1 short-circuit 60 155 ms 193 ms 487 ms 227 ms
9ogeCJhw…krhq active trader, full RTT 2 fan-out 120,218 4,886 ms 5,483 ms 11,449 ms 6,144 ms

Busy-wallet speedup vs the unoptimized baseline: 16,346 ms → 5,483 ms = ~3×.

Run it yourself: RUNS=10 npm run bench.

The sparse case is what most "wallet PnL" lookups look like in practice — it short-circuits in RTT 1 and finishes inside one round-trip. The 120k-tx case is a deliberate worst-case stress test against an active trader; latency there is dominated by raw response bandwidth (1,713 RPC calls × ~64 KB gzipped × full JSON parsing on the main thread), not by RTTs.

Where the speedup came from

Each line is a real commit on main; benchmarks are the reported p50 on the busy wallet at the time it landed.

Step Busy p50 Δ What it does
Baseline (commit 94a8e8c) 16,346 ms Full-mode gTFA + 128 equal-width segments
+ Accept-Encoding: gzip,br,deflate 5,957 ms −10,389 ms One header on the request, node:zlib decompress in the response stream. Wire payload drops from ~480 KB → ~64 KB per call (7.56× reduction). By far the biggest single client-side win available without breaking the zero-runtime-deps rule.
+ density-aware segment split 5,685 ms −272 ms Replaces equal-width slot windows with equal-mass windows under a linear density model fit from Phase 1's trailing asc/desc samples. Modest win because fetchSegment's recursive subdivision already absorbs most segment-level skew.
+ LRU cache (programmatic API only) 5,483 ms Off by default; doesn't appear in CLI/bench numbers. Useful for embedders that re-query the same wallet within the cache TTL.

Optimizations that didn't pay off and were reverted are documented in docs/server-side-directions.md. The short version: worker-thread JSON parsing loses to its own postMessage structured-clone cost, a Phase 1.5 signatures-mode density probe creates pathological segment widths from probe noise, and embedding a 126-socket prewarm into computePnl competes with Phase 1 for the agent pool. The current operating point is the empirical sweet spot of the things I tried.

Where the next 10× has to come from

Client-side optimization has hit a wall — we're shipping ~140 MB of decoded JSON across the wire to compute a few KB of balance points. The next 10× requires going below the wire: server-side field projection in gTFA itself, server-side aggregation (computeSolPnl), a custom Geyser plugin that maintains a per-address balance index, or a SIMD adding getAccountBalanceHistory to base Solana RPC. These are designed in detail in docs/server-side-directions.md.

Correctness notes

  • Commitment: confirmed is used by default. Historical txs have no reorg risk, and confirmed is slightly faster than finalized on the server side.
  • Failed transactions are included. A failed Solana transaction still pays the fee from the fee-payer's balance, so its pre/post diff is the real fee delta and counts toward PnL. We pass filters.status: 'any' so gTFA returns failed txs alongside successful ones, and the balance extractor does not skip on meta.err. Filtering them out would silently miss every failed tx fee, which on a bot or sandwich-prone wallet can be material.
  • Address Lookup Tables. meta.preBalances / meta.postBalances are indexed by […message.accountKeys, …meta.loadedAddresses.writable, …meta.loadedAddresses.readonly], in that order. For v0 transactions using an ALT, the wallet may only appear in the loaded section — looking only at message.accountKeys is a silent correctness bug. The extractor handles both cases.
  • Dedup: transactions are deduplicated on their first signature using a Set<string> across all phases.
  • Sort key: (slot, transactionIndex) for deterministic ordering when multiple txs land in the same slot.

Limitations

  • Only computes native SOL (lamport) balance. Token balances are not included.
  • Covers the full history by default. A time-range version would add a blockTime filter to RTT 1 and prune the gap accordingly — straightforward extension.
  • Assumes getTransactionsForAddress is available on your Helius plan (Developer tier and above).
  • Avoid pointing this at program IDs (e.g. JUP6Lkb…). Programs accumulate millions of transactions and the in-memory tx buffer will OOM the V8 heap before the run finishes. This tool is for wallets, not programs.

Bonus: Rust port

A line-for-line Rust port lives in a sibling repo at ../solana-pnl-rust/. Same algorithm, same Helius primitives, written on tokio + reqwest + futures::stream::buffer_unordered. It exists as a curiosity / language comparison — the TypeScript implementation in this repo is the canonical submission.

Cross-checked against the TS impl on the same wallets, the Rust port returns identical PnL down to the lamport (+1320.070979325 SOL for the active-trader wallet above, peak/trough match exactly). The Rust port traverses slightly more transactions on busy wallets because it uses linear continuation via the server's own pagination_token instead of recursive slot subdivision — that catches a handful of edge-case slot-boundary txs that the TS recursive subdivision drops, but at the cost of a few extra RTTs per dense segment. Net effect: TS is faster on active traders, Rust is more thorough; both agree on PnL.

License

MIT.

About

Lowest-latency SOL balance-over-time solver using Helius getTransactionsForAddress. Bidirectional pincer + fabricated slot tokens + recursive subdivision.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors