A production-grade Solana transaction infrastructure stack. STFU streams live slot data from a Geyser node, submits transaction bundles through Jito, tracks every bundle from submission to finality, and uses an AI agent to decide optimal tip amounts based on real-time network fee pressure.
Mainnet only. The Jito block engine does not operate on devnet. The Yellowstone slot stream and lifecycle tracker work on any cluster, but bundle submission requires mainnet. See Devnet for partial testing options.
Data flow:
SlotStreamopens a Yellowstone gRPC subscription and emits typed slot events- Each processed slot feeds
currentSlotinto theLifecycleTracker(drop detection) and theTipAgent(timing context) - Before submitting a bundle,
TipAgentcallsgetRecentPrioritizationFeesto measure real fee pressure, then calls Claude Haiku for a reasoned tip decision - One
confirmedblockhash is fetched and shared by both the user tx and the tip tx, so they expire at the same slot BundleSubmitterappends a tip transaction and callssendBundleon the Jito block engineLifecycleTrackeropens WebSocket signature subscriptions (primary) and pollsgetSignatureStatusesevery 5 seconds (fallback), advancing each transaction through its state machine and logging real slot numbers + timestamps at every transition
Maintains a persistent gRPC subscription to a Geyser node. Emits SlotUpdate events for every processed, confirmed, and finalized slot.
Reconnection: Exponential backoff (1 s → 30 s cap). On each reconnect, the gRPC client is fully recreated — reusing a stuck channel is a common source of silent failures. The "connected" event fires only after the subscription write is acknowledged, not before.
const stream = new SlotStream(endpoint, token);
stream.on("slot", (update: SlotUpdate) => { /* slot, status, timestamp */ });
stream.on("reconnecting", (attempt) => { /* ... */ });
await stream.start();State machine that tracks every submitted signature through its full confirmation lifecycle.
Primary path: Three connection.onSignature() WebSocket subscriptions per tx (processed / confirmed / finalized). Events arrive within milliseconds of the on-chain state change.
Fallback: getSignatureStatuses RPC poll every 5 seconds — catches events missed during WebSocket disconnections and drives drop detection.
States: submitted → processed → confirmed → finalized (or failed / dropped)
Each state transition records a real slot number and Unix timestamp. Failures are classified by type — see Failure Cases below.
const tracker = new LifecycleTracker(connection);
tracker.track(signature, submittedSlot);
tracker.on("confirmed", (tx) => { /* tx.confirmedSlot, tx.confirmedAt */ });
tracker.on("finalized", (tx) => { /* tx.finalizedSlot */ });
tracker.on("dropped", (tx) => { /* no confirmation after 150 slots */ });
tracker.on("failed", (tx) => { /* tx.failureType, tx.error */ });Builds and submits versioned transaction bundles to the Jito block engine. Automatically:
- Fetches live tip accounts from the block engine
- Selects one at random (distributes tip load)
- Uses the shared blockhash passed by the caller — the same blockhash used to sign the user tx, ensuring both expire at the same slot
- Subscribes to the block engine's result stream for accepted/rejected callbacks
const submitter = new BundleSubmitter(connection, payer, blockEngineUrl);
const submission = await submitter.submit(transactions, tipLamports, currentSlot, blockhash);
submitter.on("accepted", (result) => { /* result.uuid */ });
submitter.on("rejected", (result) => { /* result.reason */ });The only component that makes autonomous decisions. Before each bundle submission, the agent:
- Calls
getRecentPrioritizationFees()to sample network-wide fee pressure over the last ~150 slots - Takes the P75 value as a congestion signal (
low/medium/high) - Calculates slots until the next Jito leader window
- Calls Claude Haiku (
claude-haiku-4-5-20251001) via a system+user message split with a 4-second timeout - Validates the JSON response at runtime (type, NaN, range, enum) before using it
- Enforces the configured tip floor
The model's decision rationale is logged on every submission. Floor enforcement applies after validation.
const agent = new TipAgent(connection, anthropicApiKey, tipFloorLamports);
const context = await agent.gatherContext(currentSlot, nextLeaderSlot);
const decision = await agent.decide(context);
// decision.tipLamports, decision.reasoning, decision.confidenceParses raw Solana TransactionError objects into typed FailureType values:
FailureType |
Solana error |
|---|---|
blockhash_expired |
BlockhashNotFound |
fee_too_low |
InsufficientFundsForFee |
compute_exceeded |
InstructionError[*, ComputationalBudgetExceeded] |
bundle_rejected |
block engine rejection |
dropped |
no confirmation in 150 slots |
pnpm install
cp .env.example .env
# Fill in .env — see steps below
pnpm demo # end-to-end test run (~0.002 SOL per attempt)
pnpm dev # continuous monitoring modeThe Yellowstone slot stream and lifecycle tracker work against any Solana cluster. Jito bundle submission requires mainnet — the Jito block engine does not operate on devnet. To test the stream and tracker without spending SOL, point RPC_URL and GEYSER_ENDPOINT at a devnet Geyser provider and comment out the bundle submission block in src/demo.ts.
STFU requires a Yellowstone gRPC endpoint. The public Solana RPC does not provide one.
| Provider | Notes |
|---|---|
| Helius | Business plan and above include gRPC |
| Triton One | Dedicated Yellowstone nodes |
| QuickNode | Yellowstone available as a marketplace add-on |
GEYSER_ENDPOINT=https://your-region.helius-rpc.com/?api-key=YOUR_KEY
GEYSER_TOKEN=YOUR_KEY
RPC_URL=https://your-region.helius-rpc.com/?api-key=YOUR_KEY
You need a mainnet wallet funded with at least 0.003 SOL per run (tip + transaction fee + buffer). Generate one:
node --input-type=module << 'EOF'
import { Keypair } from "@solana/web3.js";
import bs58 from "bs58";
const kp = Keypair.generate();
console.log("Public key :", kp.publicKey.toBase58());
console.log("Private key:", bs58.encode(kp.secretKey));
EOFFund the public key, then add to .env:
WALLET_PRIVATE_KEY=<base58 private key>
JITO_BLOCK_ENGINE_URL=mainnet.block-engine.jito.wtf
ANTHROPIC_API_KEY=<from console.anthropic.com>
TIP_FLOOR_LAMPORTS=1000000
| Variable | Required | Description |
|---|---|---|
GEYSER_ENDPOINT |
✓ | Yellowstone gRPC endpoint |
GEYSER_TOKEN |
✓ | Auth token — required by all hosted Geyser providers |
RPC_URL |
— | Solana RPC URL (defaults to mainnet public) |
JITO_BLOCK_ENGINE_URL |
— | Block engine host (default: mainnet.block-engine.jito.wtf) |
WALLET_PRIVATE_KEY |
✓ | Base58-encoded private key, min 0.003 SOL per run |
ANTHROPIC_API_KEY |
✓ | Used by the AI tip agent to call Claude Haiku |
TIP_FLOOR_LAMPORTS |
— | Minimum tip in lamports (default: 1,000,000 = 0.001 SOL) |
pnpm test # run all tests once
pnpm test:watch # watch mode
pnpm lint # ESLint
pnpm typecheck # TypeScript type check onlyEvery bundle run (successful or failed) is written as a JSON record to logs/lifecycle-<timestamp>.ndjson. Each line is one transaction:
Successful record:
{
"signature": "3xKf...",
"status": "finalized",
"submittedAt": 1748736000000,
"submittedSlot": 327841092,
"processedAt": 1748736001234,
"processedSlot": 327841106,
"confirmedAt": 1748736001891,
"confirmedSlot": 327841108,
"finalizedAt": 1748736014000,
"finalizedSlot": 327841140,
"tipLamports": 1500000,
"agentReasoning": "Medium congestion with leader 13 slots away — 1.5× floor is sufficient",
"_loggedAt": "2025-01-15T10:30:00.000Z"
}Failed record (dropped — low tip):
{
"signature": "7mPq...",
"status": "dropped",
"failureType": "dropped",
"submittedAt": 1748736100000,
"submittedSlot": 327841250,
"tipLamports": 1000,
"agentReasoning": "Parse failure — defaulting to floor tip",
"_loggedAt": "2025-01-15T10:31:45.000Z"
}Failed record (blockhash expired):
{
"signature": "9nRt...",
"status": "failed",
"failureType": "blockhash_expired",
"error": "{\"BlockhashNotFound\":null}",
"submittedAt": 1748736200000,
"submittedSlot": 327841400,
"tipLamports": 1000000,
"agentReasoning": "Low congestion, tipping at floor",
"_loggedAt": "2025-01-15T10:33:20.000Z"
}Slot numbers in real records are verifiable on Solscan and SolanaFM.
| Failure | failureType |
How to reproduce | Time to manifest |
|---|---|---|---|
dropped |
dropped |
Set TIP_FLOOR_LAMPORTS=1000 — below Jito's effective floor |
~60 s (150 slots) |
blockhash_expired |
blockhash_expired |
Add a 90-second sleep before sendBundle in bundle/index.ts |
Immediate from block engine |
bundle_rejected |
bundle_rejected |
Submit a bundle with a duplicate or malformed tx | Immediate |
fee_too_low |
fee_too_low |
Submit a tx with zero computeUnitPrice during high congestion |
Within a few seconds |
The demo retries automatically on dropped and bundle_rejected outcomes (up to MAX_RETRIES = 3), refreshing the blockhash and re-running the tip agent on each attempt.
It measures how long it took for the transaction's block to earn supermajority confirmation — i.e., for validators holding more than two-thirds of total stake to vote on that block.
A short delta (under 1 second) is normal on a healthy network. A long delta means validators are slow to vote, which can indicate heavy network load, a fork being resolved, or stake-weighted vote propagation delays. Watching this metric across many transactions gives you a real-time read on validator health that you can't get from slot times alone.
finalized lags the current tip of the chain by approximately 32 slots — roughly 13 seconds at 400 ms per slot. A blockhash is valid for 150 slots (~60 seconds), so fetching a finalized blockhash is not immediately fatal, but it creates a time pressure problem: by the time you build, sign, and submit the transaction, a meaningful fraction of the blockhash's valid window has already elapsed. Under any latency or retry scenario, you risk submitting a transaction with an expired blockhash.
Use confirmed instead. It is only 1–2 slots behind the processed tip, well within safety margin, and confirmed blocks have already passed the supermajority vote threshold so they will not be rolled back under normal conditions.
Jito bundles are only eligible for inclusion in a specific leader's slot — they are submitted to the block engine with the expectation that the scheduled Jito-connected leader will pick them up. If that leader skips their slot (fails to produce a block), the bundle is silently dropped. The block engine does not requeue it.
STFU detects this via the SlotStream: if the slot assigned to the next Jito leader advances past that leader's window without a corresponding block, the LifecycleTracker marks all associated signatures as dropped (no processed confirmation within 150 slots). The correct recovery is to re-query getNextScheduledLeader, rebuild the bundle against a fresh confirmed blockhash, re-run the tip agent for updated context, and resubmit — which is exactly what the retry loop in src/demo.ts does.
MIT