@rpc-bastion/sender
v0.4.1
Published
Transaction execution pipeline: state machine, confirmation engine, Jito/MEV routing.
Downloads
536
Readme
@rpc-bastion/sender
The transaction execution pipeline — an explicit lifecycle, a confirmation
engine, signature-idempotent rebroadcast, blockhash-expiry handling, and
Jito/MEV routing with automatic RPC fallback. Built on web3.js v2.0
(@solana/kit); takes a resilient Kit RPC from @rpc-bastion/core.
sendSmart
import { createSender, asSenderRpc } from '@rpc-bastion/sender';
const sender = createSender({
rpc: asSenderRpc(rpc), // resilient Kit RPC from @rpc-bastion/core
rpcSubscriptions, // optional resilient WS subscriptions
bus, // optional event bus
});
const result = await sender.sendSmart(sendable, {
route: 'rpc-only', // 'auto' | 'jito-only' | 'rpc-only'
commitment: 'confirmed',
rebroadcast: { intervalMs: 2_000 },
skipPreflight: true,
});
// { signature, slot, confirmationStatus, route: 'rpc', attempts, timeline }sendable is a SendableTransaction: { wireTransaction, signature, lifetime:
{ lastValidBlockHeight }, encoding }. Build one from a signed Kit transaction
with prepareSendable — sign a message that has a blockhash lifetime, then:
import { prepareSendable } from '@rpc-bastion/sender';
import { signTransactionMessageWithSigners } from '@solana/kit';
const signed = await signTransactionMessageWithSigners(message); // blockhash lifetime
const result = await sender.sendSmart(prepareSendable(signed));prepareSendable extracts the wire bytes, the signature, and the blockhash
lifetime; it throws BASTION_MISSING_LIFETIME for a durable-nonce / lifetime-less
transaction (the sender needs lastValidBlockHeight to detect expiry).
Lifecycle
signed → submitted → (rebroadcasting) → confirmed | expired | failed | aborted- submit over the resilient RPC,
- rebroadcast the identical signed transaction on an interval (signature-idempotent on-chain) until it confirms or expires; the resilient RPC rotates endpoints across sends,
- confirm via the engine —
signatureNotifications(WS) raced againstgetSignatureStatusespolling, so a dead WebSocket never blocks confirmation, - expiry — when block height passes
lastValidBlockHeight, throw a typedBASTION_TX_EXPIREDerror telling you to rebuild with a fresh blockhash. The SDK never holds keys, so it never re-signs.
Every attempt and transition is recorded in result.timeline and emitted on the
event bus (tx.submitted / tx.rebroadcast / tx.confirmed / tx.expired /
tx.failed).
Notes
- Fees apply pre-signing.
createSenderintentionally does not take afeeOracle(an approved deviation from the SPEC's signature):sendSmartoperates on an already-signed transaction, so priority fees are applied before signing via@rpc-bastion/fees(applyFeeToTransactionMessage). prepareSendable(extracting{ wireTransaction, signature, lifetime }from a compiled Kit transaction) lands in the wallet package by GATE 6; until then, construct theSendableTransactionyourself.- Expiry is best-effort if the RPC is unreachable. Before declaring
BASTION_TX_EXPIRED, the engine does a finalgetSignatureStatuses(retried up to 3×). If the RPC is down for all of those, expiry is reported on a best-effort basis. For high-value transfers, re-check the signature once connectivity returns before re-sending a rebuilt transaction.
Jito / MEV routing
import { createSender, createJitoClient, prepareForJito } from '@rpc-bastion/sender';
const sender = createSender({ rpc: asSenderRpc(rpc), jito: { client: createJitoClient({ region: 'global' }) } });
// route 'auto' (default): try Jito, fall back to resilient RPC on any failure
const result = await sender.sendSmart(sendable, { route: 'auto' });
// result.route is 'jito' or 'rpc'; a tx.route-fallback event fires on fallback
// Atomic bundle (1–5 signed txs):
const bundle = await sender.sendBundle([txA, txB]); // { bundleId, status }Tips go in the transaction (prepareForJito / createJitoTipInstruction),
sized from the tip floor (floor-multiplier, clamped to your bounds, ≥1000
lamports), with a random tip account per send. The tip_floor API reports SOL;
createJitoClient().getTipFloor() converts to lamports so all tip math is in
lamports.
Jito submissions are gated by a built-in per-sender rate limiter (default
~1 req/s, shared across the rebroadcast loop and concurrent sends — Jito's free
tier). Tune or disable it via jito: { client, rateLimit: { maxRps, burst } }
(or { enabled: false }). A landed bundle emits tx.confirmed with
slot: null (the Block Engine surfaces no landing slot here) and a real
elapsedMs. See docs/jito-routing.md for the path
split, tip economics, the auto decision tree, rate limiting, and devnet
limitations.
Confirmation engine
createConfirmationEngine({ rpc, rpcSubscriptions }) exposes confirm(signature,
opts), returning a typed outcome (confirmed / failed / expired /
aborted). It is robust to WebSocket flap (resilient resubscribe) and confirms
through the polling fallback regardless.
