0xJosee.
HomeAboutProjectsBlogContact
0xJosee.

Full-stack developer specializing in Solana DeFi, blockchain engineering, and Web3 application development.

Navigation

HomeAboutProjectsBlogContact

Connect

© 2026 0xJosee. All rights reserved.
DevelopmentFebruary 26, 202612 min read

solana-tx-kit: Production-Grade Transaction Infrastructure for Solana

Why I built an open-source Solana transaction library with retry logic, RPC pooling, priority fees, and Jito bundles — and the architecture decisions behind it.

SolanaTypeScriptOpen SourceTransactionsInfrastructure

In a previous post I described how the hardest part of building trading bots on Solana is not the strategy — it is everything around the strategy. The execution layer: building transactions, managing blockhashes, handling RPC failures, estimating priority fees, confirming results, and retrying when things inevitably go wrong. After building that same infrastructure for the third time across different projects, I decided to extract it into a standalone library. The result is solana-tx-kit, an open-source TypeScript package that handles the entire transaction lifecycle so you can focus on what your application actually does.

This post explains the problems that motivated the library, the architecture decisions behind it, and how each module works. If you just want to use it, pnpm add solana-tx-kit and read the README. If you want to understand why it is built the way it is, read on.

The Problem: Sending Transactions on Solana is Deceptively Hard

If you have only sent a few transactions on devnet, you might think Solana's transaction model is straightforward. Create a transaction, sign it, send it, done. In production, reality is far more adversarial. Every step in that simple flow has failure modes that compound in ways that are difficult to anticipate.

Blockhashes expire. Solana transactions include a recent blockhash that acts as a TTL mechanism — if the transaction is not confirmed before that blockhash becomes invalid (roughly 60 seconds), it is dropped silently. Your application needs to detect this, fetch a fresh blockhash, re-sign the transaction, and try again. If you are not careful about caching and refreshing blockhashes, you either make too many RPC calls or send transactions with stale blockhashes that are dead on arrival.

RPC nodes are unreliable. Public endpoints rate-limit aggressively. Private endpoints go down, fall behind on slot height, or return stale data. A production application needs multiple RPC endpoints with automatic failover, health tracking, and circuit breakers to avoid hammering a node that is already struggling.

Priority fees are a moving target. Since Solana introduced priority fees, getting a transaction landed in a reasonable time requires estimating the current fee market and bidding appropriately. Too low and your transaction sits in the queue. Too high and you overpay. The optimal fee changes with every slot.

Confirmation is non-trivial. Sending a transaction returns a signature, but that does not mean the transaction was confirmed. You need to monitor the signature through WebSocket subscriptions or polling, handle the case where the blockhash expires before confirmation, and distinguish between a transaction that failed on-chain and one that was simply never included.

MEV is a real concern. On mainnet, unprotected transactions are vulnerable to MEV extraction. Jito bundles provide a mechanism to submit transactions that are executed atomically, but integrating with Jito's block engine adds another layer of complexity: tip management, bundle status polling, and handling the different failure modes of bundle submission.

Every Solana project I have worked on needed all of this. And every time, the team either cobbled together a fragile ad-hoc solution or copied boilerplate from a previous project. solana-tx-kit is my attempt to solve this once and make the solution available to everyone.

Architecture: Builder Pattern with a Modular Pipeline

The library is organized around two complementary ideas. First, a builder pattern that lets you compose exactly the features you need into a single TransactionSender instance. Second, a set of standalone modules that can each be used independently for teams that want to integrate specific capabilities without adopting the full pipeline.

The builder API is designed to be readable and self-documenting:

import { TransactionSender } from 'solana-tx-kit'
 
const sender = TransactionSender.builder()
  .rpc('https://api.mainnet-beta.solana.com')
  .signer(keypair)
  .withPriorityFees({ targetPercentile: 75 })
  .withRetry({ maxRetries: 5, baseDelayMs: 500 })
  .withSimulation()
  .withConfirmation({ timeoutMs: 30_000 })
  .withBlockhash({ ttlMs: 60_000 })
  .build()

Every with* method is optional. If you do not call .withPriorityFees(), no priority fee estimation happens. If you do not call .withSimulation(), transactions are sent without pre-flight checks. The builder validates that the two required fields — an RPC endpoint and a signer — are present, and everything else has sensible defaults.

When you call sender.send(transaction), the transaction flows through a seven-step pipeline:

  1. Prepare — clone the transaction and prepend compute budget instructions if priority fees are enabled
  2. Estimate fees — query recent prioritization fees and calculate the target percentile
  3. Fetch blockhash — get a valid recent blockhash from the cache or refresh if stale
  4. Sign — set the fee payer, apply the blockhash, and sign with the configured keypair
  5. Simulate — run a pre-flight simulation to catch errors before hitting the chain
  6. Send — broadcast the raw transaction via the RPC connection pool with automatic failover
  7. Confirm — monitor the signature via WebSocket subscription with polling fallback

If any step fails with a retryable error, the entire pipeline retries from step 3 with a fresh blockhash. The retry logic handles backoff, error classification, and blockhash refresh automatically. The caller gets back a single result object:

const result = await sender.send(transaction)
 
console.log(result.signature) // transaction signature
console.log(result.slot) // confirmation slot
console.log(result.attempts) // how many tries it took
console.log(result.totalLatencyMs) // end-to-end time
console.log(result.priorityFee) // fee paid in microLamports/CU

For multi-endpoint setups, the builder accepts an RPC pool configuration:

const sender = TransactionSender.builder()
  .rpcPool(
    [
      { url: 'https://rpc-1.example.com', weight: 3 },
      { url: 'https://rpc-2.example.com', weight: 1 },
    ],
    { strategy: 'weighted-round-robin' }
  )
  .signer(keypair)
  .withPriorityFees()
  .withRetry({ maxRetries: 3 })
  .build()

Retry with Error Classification

Not all errors are created equal. A blockhash expiration error means you need a fresh blockhash and a new signature. A rate limit error means you should back off and try the same request again. An insufficient funds error means retrying is pointless. The retry module classifies errors into these categories and adjusts its behavior accordingly.

The classification logic examines both the error message and the error type to determine the correct response:

import { classifyError, isBlockhashExpired, isRateLimited } from 'solana-tx-kit'
 
const classification = classifyError(error)
// => { retryable: true, reason: "blockhash-expired", needsResign: true }
// => { retryable: true, reason: "rate-limited", needsResign: false }
// => { retryable: false, reason: "insufficient-funds" }

Retryable errors include network failures (ECONNRESET, ETIMEDOUT), node health issues ("node is behind", "service unavailable"), blockhash expiration, and rate limiting (HTTP 429). Non-retryable errors include insufficient funds, signature verification failures, and program execution errors. This classification drives the retry loop — if an error is classified as non-retryable, the loop exits immediately rather than wasting time on attempts that cannot succeed.

The retry algorithm uses full-jitter exponential backoff. For each retry attempt, the delay is a random value between zero and the calculated exponential ceiling, capped at a maximum:

delay = random(0, min(maxDelay, baseDelay * multiplier ^ attempt))

Full jitter is important in distributed systems because it prevents thundering herd problems. If multiple clients experience the same transient failure simultaneously, deterministic backoff causes them all to retry at the same instant, making the problem worse. Randomized jitter spreads the retries across the delay window.

The retry function is generic and works with any async operation, not just transactions:

import { withRetry } from 'solana-tx-kit'
 
const data = await withRetry(() => fetchFromUnreliableApi(), {
  maxRetries: 3,
  baseDelayMs: 1000,
})

RPC Connection Pool and Circuit Breaker

A single RPC endpoint is a single point of failure. The connection pool manages multiple endpoints with health tracking and automatic failover. Each endpoint has its own circuit breaker that prevents the pool from repeatedly hitting a failing node.

The circuit breaker implements the standard three-state pattern. In the CLOSED state, requests flow through normally and failures are counted within a sliding time window. When the failure count exceeds a configurable threshold, the breaker transitions to OPEN and all requests to that endpoint are rejected immediately. After a reset timeout, the breaker enters HALF_OPEN and allows a single probe request through. If the probe succeeds, the breaker returns to CLOSED. If it fails, the breaker goes back to OPEN.

CLOSED ──[failures >= threshold]──> OPEN
  ^                                   |
  |                            [timeout elapsed]
  |                                   v
  └───[probe succeeds]─── HALF_OPEN
                             |
                      [probe fails]
                             |
                             v
                           OPEN

Each endpoint also has a health tracker that maintains an exponential moving average (EMA) of response latencies and an error rate computed over a sliding window. The pool uses these metrics for endpoint selection — in latency-based mode, it prefers the endpoint with the lowest EMA latency among healthy endpoints. In weighted round-robin mode, it respects the configured weights but skips endpoints whose circuit breakers are open.

When sending a transaction, the pool's withFallback method tries the primary endpoint first and automatically falls back to the next healthy endpoint if the primary fails:

import { ConnectionPool } from 'solana-tx-kit'
 
const pool = new ConnectionPool({
  endpoints: [
    { url: 'https://primary.example.com', weight: 3 },
    { url: 'https://backup.example.com', weight: 1 },
  ],
  strategy: 'latency-based',
  circuitBreaker: { failureThreshold: 5, resetTimeMs: 30_000 },
})
 
// Automatically tries backup if primary fails
const result = await pool.withFallback(async (connection) => {
  return connection.sendRawTransaction(serializedTx)
})
 
// Inspect endpoint health
const report = pool.getHealthReport()
for (const [url, metrics] of report) {
  console.log(
    `${url}: latency=${metrics.latencyEma}ms, errors=${metrics.errorRate}`
  )
}

Priority Fee Estimation

The fee estimator queries recent prioritization fee data from the RPC node and calculates a target fee based on configurable percentile targeting. The idea is simple: look at what other transactions paid recently and bid at a competitive level. Targeting the 75th percentile means your transaction should land faster than roughly three-quarters of recent transactions.

The estimation process fetches recent fee data via getRecentPrioritizationFees, sorts the results, and picks the value at the target percentile. The result is clamped to configurable minimum and maximum bounds to prevent paying unreasonably high fees during fee spikes or submitting with trivially low fees during quiet periods.

import {
  estimatePriorityFee,
  createComputeBudgetInstructions,
} from 'solana-tx-kit'
 
const fee = await estimatePriorityFee(connection, {
  targetPercentile: 75,
  minMicroLamports: 1_000,
  maxMicroLamports: 1_000_000,
})
 
const instructions = createComputeBudgetInstructions({
  computeUnits: 200_000,
  microLamports: fee.microLamports,
})
 
// Prepend to your transaction
for (const ix of instructions) {
  transaction.add(ix)
}

When used through the builder, fee estimation and compute budget instruction injection happen automatically as part of the send pipeline. You configure it once and every transaction gets an appropriate priority fee without any per-transaction code.

Blockhash Management

Solana's blockhash mechanism is one of the most common sources of transaction failures. A blockhash is valid for roughly 60 seconds (150 slots), and if your transaction reaches the network after the blockhash expires, it is silently dropped. The blockhash manager addresses this with a background refresh loop and a TTL-based cache.

The manager maintains a cached blockhash and its associated lastValidBlockHeight. A background interval refreshes the cache every 30 seconds by default, ensuring a reasonably fresh blockhash is always available. When the send pipeline requests a blockhash, the manager checks if the cached value is still within its TTL. If it is, the cached value is returned immediately with no RPC call. If the cache is stale, a fresh blockhash is fetched.

A subtle but important detail is promise coalescing. If multiple concurrent send() calls all hit a stale cache simultaneously, the manager ensures only a single RPC request is made. The first caller triggers the fetch, and subsequent callers receive the same promise. This prevents a burst of redundant getLatestBlockhash calls under load.

Jito Bundle Support

For applications that need MEV protection or atomic multi-transaction execution, the library includes a Jito bundle sender. Jito bundles allow you to submit 1 to 5 transactions as a single unit that is either fully executed or not executed at all. This is essential for arbitrage, liquidation, and any operation where partial execution could leave your application in an inconsistent state.

const sender = TransactionSender.builder()
  .rpc('https://api.mainnet-beta.solana.com')
  .signer(keypair)
  .withJito({
    blockEngineUrl: 'https://mainnet.block-engine.jito.wtf',
    tipPayer: keypair,
    tipLamports: 10_000,
  })
  .build()
 
const result = await sender.sendJitoBundle([tx1, tx2, tx3])
console.log('Bundle ID:', result.bundleId)
console.log('Status:', result.status) // "Landed"

The bundle sender handles tip management automatically. It maintains a list of 8 Jito tip accounts and rotates through them round-robin to distribute tips across validators. The tip instruction is appended to the last transaction in the bundle before signing and submission.

Bundle status is tracked via polling against the Jito block engine API. The sender polls for status updates at a configurable interval and resolves when the bundle either lands, fails, or is dropped. The BundleStatus enum provides clear terminal states: LANDED means success, FAILED means the bundle was rejected, and DROPPED means it was accepted but never included in a block.

For teams that want to use Jito independently from the full pipeline, the bundle sender and tip helpers are available as standalone exports:

import {
  JitoBundleSender,
  createTipInstruction,
  getNextTipAccount,
} from 'solana-tx-kit'
 
const tipIx = createTipInstruction(payer.publicKey, 10_000)
transaction.add(tipIx)
 
const bundleSender = new JitoBundleSender({
  blockEngineUrl: 'https://mainnet.block-engine.jito.wtf',
})
const result = await bundleSender.sendBundle([signedTx])

Event System: Observability Built In

Every significant step in the transaction lifecycle emits a typed event. This provides first-class observability without coupling the library to any specific logging or monitoring framework. You subscribe to the events you care about and handle them however you want — log to console, send to a metrics service, trigger alerts, or update a UI.

import { TxEvent } from 'solana-tx-kit'
 
sender.on(TxEvent.SENDING, ({ transaction, attempt }) => {
  console.log(`Sending transaction (attempt ${attempt + 1})...`)
})
 
sender.on(TxEvent.SIMULATED, ({ unitsConsumed }) => {
  console.log(`Simulation: ${unitsConsumed} compute units`)
})
 
sender.on(TxEvent.CONFIRMED, ({ signature, slot }) => {
  console.log(`Confirmed in slot ${slot}: ${signature}`)
})
 
sender.on(TxEvent.RETRYING, ({ attempt, maxRetries, error, delayMs }) => {
  console.warn(
    `Retry ${attempt}/${maxRetries} in ${delayMs}ms: ${error.message}`
  )
})
 
sender.on(TxEvent.BLOCKHASH_EXPIRED, ({ oldBlockhash, newBlockhash }) => {
  console.warn(
    `Blockhash rotated: ${oldBlockhash.slice(0, 8)}... → ${newBlockhash.slice(0, 8)}...`
  )
})
 
sender.on(TxEvent.FAILED, ({ error }) => {
  console.error('Transaction failed:', error.message)
})

The event emitter is fully typed via TypeScript generics. Each event name maps to a specific payload type, so your event handlers get full autocompletion and type checking. The events cover the complete lifecycle: SENDING, SENT, SIMULATED, CONFIRMING, CONFIRMED, RETRYING, BLOCKHASH_EXPIRED, FAILED, and the Jito-specific BUNDLE_SENT, BUNDLE_CONFIRMED, BUNDLE_FAILED.

If you read my earlier post on trading bot architecture, the event system is a direct implementation of the monitoring principle discussed there. The bot subscribes to transaction events and feeds them into its metrics pipeline, giving operators real-time visibility into execution performance without the transaction library needing to know anything about the monitoring system.

Structured Error Handling

All errors thrown by the library are instances of SolTxError with a typed code field from the SolTxErrorCode enum. This makes programmatic error handling straightforward — you can switch on the error code rather than parsing error messages.

import { SolTxError, SolTxErrorCode } from 'solana-tx-kit'
 
try {
  await sender.send(tx)
} catch (err) {
  if (err instanceof SolTxError) {
    switch (err.code) {
      case SolTxErrorCode.BLOCKHASH_EXPIRED:
        // Transaction expired — rebuild with fresh state
        break
      case SolTxErrorCode.INSUFFICIENT_FUNDS:
        // Account balance too low
        break
      case SolTxErrorCode.SIMULATION_FAILED:
        // Pre-flight check caught an error
        console.error('Simulation logs:', err.context?.logs)
        break
      case SolTxErrorCode.ALL_ENDPOINTS_UNHEALTHY:
        // Every RPC endpoint is in circuit-breaker open state
        break
      case SolTxErrorCode.RETRIES_EXHAUSTED:
        // All retry attempts failed — last error in err.cause
        break
    }
  }
}

The library defines 14 distinct error codes covering every failure category: retry exhaustion, blockhash issues, simulation failures, confirmation timeouts, RPC problems, rate limiting, and Jito-specific errors like bundle drops and low tips. Each SolTxError also carries an optional cause (the original error) and context (additional metadata like simulation logs), so you always have the information you need for debugging.

Standalone Module Usage

While the builder provides the most convenient experience for the common case, every module in the library is exported independently and can be used without the builder. This is useful when you want to integrate a specific capability into an existing codebase without adopting the full pipeline.

import {
  estimatePriorityFee,
  withRetry,
  BlockhashManager,
  ConnectionPool,
  TransactionConfirmer,
  simulateTransaction,
  classifyError,
} from 'solana-tx-kit'
 
// Use the retry utility for any async operation
const data = await withRetry(() => fetchExternalApi(), {
  maxRetries: 3,
  baseDelayMs: 1000,
})
 
// Manage blockhashes independently
const bhManager = new BlockhashManager(connection, { ttlMs: 60_000 })
bhManager.start()
const { blockhash, lastValidBlockHeight } = await bhManager.getBlockhash()
 
// Estimate fees without the builder
const fee = await estimatePriorityFee(connection, { targetPercentile: 90 })
 
// Run simulation standalone
const sim = await simulateTransaction(connection, transaction)
if (!sim.success) {
  console.error('Simulation failed:', sim.error?.message)
}
 
// Classify any error for retry decisions
const classification = classifyError(someError)
if (classification.retryable) {
  // safe to retry
}

This modular design means the library grows with your needs. Start with withRetry for basic resilience, add ConnectionPool when you need multi-endpoint support, bring in priority fee estimation when you are ready, and eventually adopt the full builder when you want the complete pipeline. There is no all-or-nothing commitment.

Engineering Standards

The library is built with strict engineering standards that I apply to all production code. TypeScript is configured in strict mode with noUncheckedIndexedAccess and exactOptionalPropertyTypes enabled, and the any type is banned at the linter level. Every function has explicit types, and the public API surface is fully typed with exported interfaces for all configuration objects and return values.

Test coverage targets are enforced at 90% for statements, functions, and lines, with 85% for branches. The test suite runs on Vitest with mock connections that simulate every RPC behavior the library handles — including failures, timeouts, and edge cases like stale blockhashes. Tests run across Node 18, 20, and 22 in CI to ensure compatibility.

The package ships as both ESM and CommonJS with full type declarations, built via tsup. The only peer dependency is @solana/web3.js ^1.87.0, keeping the dependency footprint minimal. Tree shaking is supported — if you only import withRetry, bundlers will exclude the rest of the library.

Getting Started

Install the library alongside the Solana web3 SDK:

pnpm add solana-tx-kit @solana/web3.js

The simplest possible usage sends a transaction with default retry and confirmation:

import { TransactionSender } from 'solana-tx-kit'
import { Keypair, Transaction, SystemProgram, PublicKey } from '@solana/web3.js'
 
const sender = TransactionSender.builder()
  .rpc('https://api.mainnet-beta.solana.com')
  .signer(keypair)
  .withPriorityFees()
  .withRetry()
  .build()
 
const tx = new Transaction().add(
  SystemProgram.transfer({
    fromPubkey: keypair.publicKey,
    toPubkey: new PublicKey('...'),
    lamports: 1_000_000,
  })
)
 
const result = await sender.send(tx)
console.log('Confirmed:', result.signature)
 
// Clean up when done
sender.destroy()

From there, you can add RPC pooling, Jito bundles, simulation, custom event handlers, and everything else the library offers — one builder method at a time.

The library is MIT licensed and open for contributions. If you are building on Solana and have been writing your own transaction retry loops, give solana-tx-kit a try. The code you delete might be the best code you write.

On this page

  • The Problem: Sending Transactions on Solana is Deceptively Hard
  • Architecture: Builder Pattern with a Modular Pipeline
  • Retry with Error Classification
  • RPC Connection Pool and Circuit Breaker
  • Priority Fee Estimation
  • Blockhash Management
  • Jito Bundle Support
  • Event System: Observability Built In
  • Structured Error Handling
  • Standalone Module Usage
  • Engineering Standards
  • Getting Started
Share:

Previous

Taming Wormhole: Async Generators for Cross-Chain Bridge Transactions

Related Posts

Development
Taming Wormhole: Async Generators for Cross-Chain Bridge Transactions

How I integrated Wormhole bridge transactions that yield an unknown number of sequential steps via async generators. Covers the three-phase bridge lifecycle, calldata regeneration, and dual-lock coordination.

July 1, 2025
WormholeCross-ChainTypeScript
Trading Bots
Building Trading Bots in TypeScript: Lessons from Production

Hard-won lessons from building and running automated trading bots on Solana. Covers architecture patterns, error handling, and the operational concerns nobody talks about.

April 20, 2025
TypeScriptTradingSolana
DeFi & Solana
Getting Started with Solana DeFi: A Developer's Perspective

Practical lessons from building DeFi bots on Solana. Covers the account model, transaction patterns, real-time monitoring via WebSocket, and production pitfalls that documentation does not warn you about.

April 1, 2025
SolanaDeFiTypeScript