TPU-direct transaction submission for @solana/kit apps — send transactions straight to upcoming leaders over QUIC, bypassing RPC rate limits and reducing time-to-land.
solana-tpu-client is a TypeScript library that opens QUIC connections to the next N Solana leader nodes and fans out your signed transaction bytes directly, rather than routing through an RPC sendTransaction call. This mirrors what the Solana CLI and the Rust TpuClient do under the hood.
What it solves:
- RPC
sendTransactionlimits (rate limits, propagation lag, single-node SPF). - Opaque landing failures — you get per-leader attempt results with RTTs and error kinds.
- Firedancer compatibility — ALPN negotiation (
solana-tpu) is handled automatically. - Observability gaps — a stable
TpuEventunion lets you wire Prometheus counters without patching the library.
Built on @solana/kit 3.x primitives (no @solana/web3.js dependency), @matrixai/quic for QUIC transport, and @peculiar/x509 for X.509 cert generation.
| Option | Hosted? | Stake-weighted QoS | Extra fees | Notes |
|---|---|---|---|---|
| solana-tpu-client | No — self-hosted | Yes, via your own staked keypair | None | Pure TS, open source, you control the identity |
Helius sendSmartTransaction |
Yes | Helius handles it | Per-request or subscription | Easiest path if you already use Helius RPC |
| Jito bundle endpoint | Yes | Jito validators only | Tip required | Optimal for MEV / priority ordering within a block |
| Triton Jet | Yes | Triton infrastructure | Subscription | Managed TPU relay with SLA |
Use solana-tpu-client when:
- You need full control over QoS identity and connection lifecycle.
- You want landing telemetry per attempt for SLAs or alerting.
- You are operating your own validator or staking infrastructure and already hold a funded identity keypair.
- You want a pure-TypeScript dependency with no managed-service lock-in.
Requires Node.js ≥ 22.11 (ESM only; native QUIC bindings require a recent Node ABI).
npm install solana-tpu-client @solana/kit @matrixai/quic @peculiar/x509@solana/kit, @matrixai/quic, and @peculiar/x509 are peer dependencies and must appear in your project's dependencies.
CF Workers / Bun / Deno:
@matrixai/quiccompiles a native Node.js addon vianode-gyp. CF Workers cannot load native bindings; the library will not work there. Bun and Deno may work if they expose a Node-compatiblenode:cryptoandnode:netsurface, but this is untested. Lambda is supported — bundle withnode_modulesor use a Lambda Layer containing the native.nodefile.
This example transfers 0.001 SOL from a generated payer to a recipient using the @solana-program/system package.
Note: This is a compilable skeleton — payer must be pre-funded and the recipient must be a real address. See
test/integration/validator.test.tsfor a runnable example onsolana-test-validator.
import { readFileSync } from 'node:fs';
import {
createSolanaRpc,
createSolanaRpcSubscriptions,
generateKeyPairSigner,
address,
lamports,
createTransactionMessage,
pipe,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
appendTransactionMessageInstruction,
signTransactionMessageWithSigners,
getBase64EncodedWireTransaction,
} from '@solana/kit';
import { getTransferSolInstruction } from '@solana-program/system';
import {
createTpuClient,
sendAndConfirmTpuTransactionFactory,
ed25519KeyPairFromSolanaSecret,
} from 'solana-tpu-client';
// 1. Load your staked identity (64-byte solana-keygen JSON array).
const secretBytes = new Uint8Array(
JSON.parse(readFileSync('/path/to/stake-identity.json', 'utf8')) as number[],
);
const identity = await ed25519KeyPairFromSolanaSecret(secretBytes);
// 2. Create RPC clients.
const rpc = createSolanaRpc('https://api.mainnet-beta.solana.com');
const rpcSubscriptions = createSolanaRpcSubscriptions(
'wss://api.mainnet-beta.solana.com',
);
// 3. Create the TPU client. Resolves when leader schedule is primed.
const tpu = await createTpuClient({ rpc, rpcSubscriptions, identity });
// 4. Build and sign a SOL transfer with @solana/kit + @solana-program/system.
const payer = await generateKeyPairSigner(); /* replace with a pre-funded signer */
const RECIPIENT = address('/* replace with a real, funded recipient */');
const { value: blockhash } = await rpc.getLatestBlockhash().send();
const transferIx = getTransferSolInstruction({
source: payer,
destination: RECIPIENT,
amount: lamports(1_000_000n), // 0.001 SOL
});
const signedTx = await signTransactionMessageWithSigners(
pipe(
createTransactionMessage({ version: 0 }),
(m) => setTransactionMessageFeePayerSigner(payer, m),
(m) => setTransactionMessageLifetimeUsingBlockhash(blockhash, m),
(m) => appendTransactionMessageInstruction(transferIx, m),
),
);
const txBytes = new Uint8Array(
Buffer.from(getBase64EncodedWireTransaction(signedTx), 'base64'),
);
// 5. Send and confirm.
const confirm = sendAndConfirmTpuTransactionFactory({ tpu, rpc, rpcSubscriptions });
const ac = new AbortController();
const result = await confirm(txBytes, {
commitment: 'confirmed',
abortSignal: ac.signal,
lastValidBlockHeight: blockhash.lastValidBlockHeight,
});
console.log('landed:', result.signature);
console.log('attempts:', result.attempts);
// 6. Graceful shutdown.
await tpu.close();Solana's stake-weighted QoS (implemented in both the Agave validator and Firedancer) grants QUIC connections more stream-slots proportional to the sender's stake weight. Under leader load, unstaked connections are dropped first.
When you pass identity: CryptoKeyPair to createTpuClient, the library:
- Mints a self-signed Ed25519 X.509 certificate from that keypair.
- Presents it in the QUIC
ClientHelloso the validator can identify the sender and apply stake-weighting.
Loading a solana-keygen keypair:
solana-keygen saves keypairs as a JSON array of 64 bytes: the 32-byte seed followed by the 32-byte public key.
import { readFileSync } from 'node:fs';
import { ed25519KeyPairFromSolanaSecret } from 'solana-tpu-client';
const raw = new Uint8Array(
JSON.parse(readFileSync('/path/to/id.json', 'utf8')) as number[],
);
const identity = await ed25519KeyPairFromSolanaSecret(raw);If you omit identity, an ephemeral (unstaked) keypair is generated and an ephemeral-identity TpuEvent fires. Ephemeral identities are fine for development but will be first-dropped by validators under production load.
QUIC-layer fix — one-line addition recommended in your app's
package.json:
@matrixai/[email protected]has a stream-creation bug that blocks sends against peers that advertiseinitial_max_streams_uni: 0and grant credit viaMAX_STREAMSframes post-handshake — Agave's unstaked-QoS path (~80% of mainnet leaders).This release depends on a patched fork at
github:lmvdz/js-quic#release/tpu-fixvia its directdependenciesentry, which gets installed automatically when younpm install solana-tpu-client. For belt-and-suspenders correctness — in case another package in your tree also depends on@matrixai/quicand npm hoists the registry version — add this to your app's rootpackage.json:{ "overrides": { "@matrixai/quic": "github:lmvdz/js-quic#release/tpu-fix" } }This forces every resolution (transitive and direct) to the patched fork. The fork is
@matrixai/[email protected]with a ~12-line diff todist/QUICStream.js; native binaries still come from upstream's npm packages unchanged. Upstream PR: MatrixAI/js-quic#157 — when that merges + releases, we drop the fork and theoverridesline becomes unnecessary.
Firedancer note: Firedancer (and its hybrid Frankendancer variant) enforce ALPN — connections without
solana-tpuare rejected. This library sets ALPN automatically. Firedancer has been handling approximately 20% of leader slots on mainnet.
Pass onEvent to observe all internal state transitions:
import type { TpuEvent } from 'solana-tpu-client';
const tpu = await createTpuClient({
rpc,
rpcSubscriptions,
identity,
onEvent(e: TpuEvent) {
switch (e.type) {
case 'send':
successCounter.inc({ ok: String(e.attempts.some((a) => a.ok)) });
e.attempts.forEach((a) => {
attemptHistogram.observe({ leader: a.identity, ok: String(a.ok) }, a.rttMs ?? 0);
});
break;
case 'slot-stall':
slotStallGauge.set(e.lastSlotAgeMs);
break;
case 'cert-pin-mismatch':
alertManager.fire(`cert mismatch for leader ${e.identity}`);
break;
case 'error':
errorCounter.inc({ kind: e.error.kind });
break;
}
},
});TpuEvent union — stable schema:
type |
Key fields | Meaning |
|---|---|---|
ready |
— | Slot tracker primed, first snapshot loaded |
slot |
slot, parent, skipped |
New slot notification; skipped > 0 means gaps detected |
slot-stall |
lastSlotAgeMs |
No slot notification for >2 s; falling back to getSlot polling |
leaders-refresh |
startSlot, count, source |
Leader schedule snapshot updated |
cluster-refresh |
nodes |
getClusterNodes re-fetched |
conn-open |
identity |
QUIC connection opened to leader |
conn-close |
identity, reason? |
QUIC connection closed |
conn-evict |
identity, reason |
Connection evicted from pool (idle / non-upcoming / LRU cap) |
cert-pin-mismatch |
identity, expected, got |
Server cert SPKI did not match gossip identity. In default 'observe' mode this is informational only; in 'strict' mode the leader is rejected and quarantined until the next cluster-refresh |
send |
signature, attempts |
Fires once per sendRawTransaction with full attempt detail. LeaderAttempt.ok: true means the QUIC stream write completed successfully — this is not confirmation that the leader accepted or will include the transaction. Use sendAndConfirmTpuTransactionFactory for landing confirmation. |
error |
error: TpuError |
Non-fatal error during background refresh |
SemVer policy: Adding a new TpuEvent variant is a MINOR version bump. If you use an exhaustive switch over e.type, include a default: branch so your code remains forward-compatible.
Alpenglow SIMD-0326, as specified, does not change TPU ingress — transactions continue to be forwarded to leaders over QUIC at the same TPU port. This client will be re-tested at activation. See the SIMD: solana-foundation/solana-improvement-documents#228
Firedancer is Jump Trading's independent validator client. It requires:
- ALPN set to
solana-tpu— handled automatically. - A valid Ed25519 TLS certificate in the
ClientHello— generated from youridentitykeypair.
Frankendancer (Firedancer networking + Agave consensus) has been handling roughly 20% of leader slots on mainnet. Tested against Agave (solana-test-validator for integration, mainnet nodes via the smoke script) and Frankendancer (mainnet smoke script). The nightly CI smoke probe re-verifies. See: https://docs.firedancer.io
Our QUIC client ALWAYS presents the client cert (required for stake-weighted QoS) and ALWAYS performs TLS. Whether we ALSO verify the server's cert pubkey against the gossip-advertised identity is governed by pinMode:
| Mode | Behavior | When to use |
|---|---|---|
'observe' (default) |
Accept the connection regardless of SPKI match; emit cert-pin-mismatch event when they don't match |
The safe default. Works universally on mainnet. |
'strict' |
Reject connection on SPKI mismatch; quarantine the leader until cluster-refresh | Pure-Agave fleets where you have ground truth that every server presents an identity-signed cert. |
'off' |
Do not inspect server cert at all | Benchmarking, or environments where pin-mismatch noise is undesirable. |
Why 'observe' and not 'strict' by default? Empirical probing of mainnet-beta (April 2026, npm run smoke:firedancer) shows that 100% of Frankendancer nodes and a meaningful fraction of Agave nodes present server certs whose SPKI does not equal the gossip identity — likely due to per-connection ephemeral certs, validator-side TLS terminators, or cloud-native load balancers in front of validator machines. Strict pinning would silently drop ~20%+ of the real leader schedule. In 'observe' mode you still get the telemetry signal (the event fires) without the connectivity loss.
If you are migrating from the previous TpuConnection API (which extended @solana/web3.js Connection), see MIGRATION.md.
| Platform | Status | Notes |
|---|---|---|
| AWS Lambda | Supported | Bundle node_modules with native .node file, or use a Lambda Layer |
| Google Cloud Functions | Supported | Same as Lambda — include native bindings in deployment package |
| CF Workers | Not supported | Native addons cannot load in the V8 isolate sandbox |
| Vercel Edge | Not supported | Same V8 isolate restriction as CF Workers |
| Vercel Node.js runtime | Supported | Uses Node.js; bundle native bindings |
All exports from the solana-tpu-client package:
Type documentation: Full JSDoc is emitted to
lib/*.d.tsduring build. Use your IDE's hover/IntelliSense for field-level descriptions.
| Export | Kind | Description |
|---|---|---|
createTpuClient(opts) |
async function |
Factory — resolves to a TpuClient after priming slot tracker and leader cache |
sendAndConfirmTpuTransactionFactory(cfg) |
function |
Returns a sendAndConfirm function that sends via TPU and races block-height expiry vs. signature confirmation |
ed25519KeyPairFromSolanaSecret(bytes) |
async function |
Import a 64-byte solana-keygen JSON file into a CryptoKeyPair |
ed25519KeyPairFromSeed(seed) |
async function |
Import a 32-byte Ed25519 seed into a CryptoKeyPair |
buildIdentity(keypair?) |
async function |
Build a TpuIdentity (keypair + X.509 cert) from a supplied or ephemeral keypair |
noopEmitter |
const |
No-op EventEmitter — useful as a default when no onEvent handler is wired |
TpuSendError |
class |
Thrown by sendRawTransaction on total failure; carries details: TpuError. Note: createTpuClient may throw TypeError for invalid construction options (e.g. fanoutSlots out of range) — those are programming errors, not TpuSendError. |
CreateTpuClientOptions |
interface |
Options for createTpuClient |
TpuClient |
interface |
{ ready, sendRawTransaction, close, getStats } — see below for ready and close semantics |
SendResult |
interface |
{ signature: Signature; attempts: LeaderAttempt[] } |
TpuConfirmFactoryCfg |
interface |
Config for sendAndConfirmTpuTransactionFactory |
TpuConfirmOptions |
interface |
Per-call options: commitment, abortSignal, lastValidBlockHeight |
TpuConfirmResult |
interface |
SendResult & { commitment } |
TpuEvent |
type |
Discriminated union of all observable events |
TpuError |
type |
Backward-compat union of TpuLeaderError | TpuSendFailure | {kind:'slot-subscription'} |
TpuLeaderError |
type |
Per-leader attempt error variants (connect-timeout, write-timeout, etc.) |
TpuSendFailure |
type |
Top-level send failure variants (aborted, no-leaders, all-failed, invalid-tx) |
LeaderAttempt |
type |
Discriminated union on ok: {ok:true; rttMs} or {ok:false; error: TpuLeaderError}. ok:true means QUIC stream write completed — not landing confirmation. |
LeaderInfo |
interface |
{ identity: Address; tpuQuicAddr: string | null; stake?: bigint } |
LeaderDiscoveryProvider |
interface |
Pluggable leader discovery interface |
TpuIdentity |
interface |
{ keyPair, certDer, certPem, privateKeyPem, pubkeyRaw, ephemeral } |
EventEmitter |
type |
(e: TpuEvent) => void |
evaluatePinDecision |
function |
Pure unit-testable cert-pin decision logic; useful for testing custom transport wrappers — see "Custom transport wrappers" below |
ready: Promise<void> — Resolves when the slot tracker is primed and the first leader snapshot has been loaded. May resolve with an empty snapshot after a 5 s internal deadline if the leader cache could not populate; in that case sendRawTransaction throws {kind:'no-leaders'} until a subsequent refresh succeeds.
close(opts?: { timeoutMs?: number }): Promise<void> — Drains in-flight sends up to timeoutMs (default 5000 ms), then aborts internal signal and disposes resources. Idempotent; second call is a no-op.
If you are hand-rolling a custom QUIC transport (e.g. for testing), evaluatePinDecision lets you reuse the cert-pin logic without a live handshake:
import { evaluatePinDecision } from 'solana-tpu-client';
// gossipSpki: the 32-byte Ed25519 pubkey bytes from getClusterNodes
// peerSpki: the SPKI bytes extracted from the peer's X.509 cert DER
// pinMode: 'strict' | 'observe' | 'off'
const decision = evaluatePinDecision({
gossipSpki: myGossipPubkeyBytes,
peerSpki: extractedCertSpki,
pinMode: 'strict',
});
// decision: { action: 'accept' | 'reject' | 'observe'; mismatch: boolean }
if (decision.action === 'reject') {
throw new Error(`cert pin mismatch for ${leaderIdentity}`);
}
if (decision.mismatch) {
onEvent({ type: 'cert-pin-mismatch', identity: leaderIdentity, ... });
}Prerequisites:
solana-test-validatoron yourPATH(install via Solana CLI: https://docs.solana.com/cli/install-solana-cli-tools)- Node.js >= 22.11
Steps:
# Start a local test validator in the background (takes ~5 s to prime).
solana-test-validator &
# Run the integration suite (targets localhost:8899 by default).
npm run test:integrationSet TPU_RPC_URL and TPU_WS_URL to point at a different cluster:
TPU_RPC_URL=https://api.devnet.solana.com \
TPU_WS_URL=wss://api.devnet.solana.com \
npm run test:integrationExpected runtime: ~30–60 s against a local validator; up to 3 min against devnet depending on congestion.
- Node ≥ 22.11 (ESM-only; no CommonJS support).
ulimit -n 4096— pool cap × stream slots needs ample file descriptors.UV_THREADPOOL_SIZE=16— recommended for@peculiar/x509crypto ops under load.- Stage a staked identity keypair; never ship an ephemeral identity in production.
- Consume
onEventinto structured logs or a metrics sink — don't leave it unwired in production. - CF Workers won't work (native QUIC binding); Lambda needs the prebuilt
.nodefile bundled or in a Lambda Layer.
No benchmarks have been captured yet. Architectural targets: up to 5k sendRawTransaction calls/s per client instance on commodity hardware with Node 22, subject to RPC refresh latency and leader QoS class. Real numbers will ship with the first stable release.
To verify the end-to-end path against devnet (airdrop → self-transfer → confirmed), run:
npm run smoke:devnet
# or: DEVNET_SMOKE=1 npx tsx scripts/devnet-smoke.tsThis takes ~30–60 s and prints the confirmed signature, per-leader attempt results, and a live getStats() snapshot. Not run in CI.
MIT