Velocity ProtocolDevelopers
Velocity SDK

Transactions

Every write the SDK performs goes through two injectable pieces:

TxHandler: builds and signs. It resolves the blockhash, injects the compute-budget instructions, compiles address lookup tables, and calls the wallet.

TxSender: broadcasts and confirms. Four implementations ship with the SDK, each with a different retry and confirmation model.

VelocityClient creates both, and either can be replaced through its config:

import { TxHandler, VelocityClient, WhileValidTxSender } from "@velocity-exchange/sdk";

const txHandler = new TxHandler({
  connection,
  wallet,
  confirmationOptions: { commitment: "confirmed", preflightCommitment: "confirmed" },
});

const velocityClient = new VelocityClient({
  connection,
  wallet,
  env: "mainnet-beta",
  txHandler,
  txSender: new WhileValidTxSender({ connection, wallet, txHandler }),
  txParams: { computeUnits: 600_000, computeUnitsPrice: 0 },
});

With neither passed, VelocityClient builds a TxHandler from its own connection and wallet, then wraps it in a RetryTxSender. Default txParams are computeUnits: 600_000 and computeUnitsPrice: 0, so the default pays no priority fee.

Choosing a tx sender

All four senders share the same TxSender interface (send, sendVersionedTransaction, getVersionedTransaction, sendRawTransaction, simulateTransaction, plus getTimeoutCount, getTxLandRate, getSuggestedPriorityFeeMultiplier). They differ in how the transaction reaches the cluster and how long they keep trying.

SenderBroadcast behaviorDefault retrySleepDefault timeoutPick it when
RetryTxSenderSends once, then resends the same signed bytes to connection and every additionalConnections entry on a fixed interval2,000 ms35,000 msGeneral purpose. This is the default VelocityClient builds.
WhileValidTxSenderSame resend loop as RetryTxSender, and additionally records each in-flight signature's blockhash and lastValidBlockHeight in its untilValid map2,000 ms35,000 ms (inherited from BaseTxSender; not a constructor option)Per-transaction blockhash validity bookkeeping has to be available to the calling code, for example a UI that reports "expired" separately from "dropped".
FastSingleTxSenderSends exactly once and relies on the RPC node's own forwarding. Can skip confirmation, or confirm in the background so the call returns right after the sendnot applicable (no resend loop)35,000 msLatency-sensitive keepers that would rather fire again with fresh inputs than pay for a resend loop.
ForwardOnlyTxSenderNever calls sendRawTransaction on connection. Hands the base58 transaction to additionalTxSenderCallbacks and re-invokes them on an interval. connection is used only to confirm5,000 ms35,000 msSubmission is delegated to a relay or bundler (for example a Jito bundle submitter) while the SDK still handles confirmation and land-rate tracking.

Shared constructor options:

OptionMeaningDefault
optsConfirmOptions used for sends and confirmationsDEFAULT_CONFIRMATION_OPTS (commitment and preflightCommitment both confirmed) with maxRetries: 0, so the RPC node does not retry on top of the sender's own loop
confirmationStrategyConfirmationStrategy.WebSocket (an onSignature subscription), Polling (getSignatureStatuses), or Combo (race both, resolve on whichever lands first)Combo
additionalConnectionsExtra connections to also broadcast to[] (and always [] for ForwardOnlyTxSender, which does not accept the option)
additionalTxSenderCallbacksCallbacks invoked with each sent transaction's base58 encoding[]
trackTxLandRateRecord sent signatures so getTxLandRate() and getSuggestedPriorityFeeMultiplier() return real numbersoff
txLandRateLookbackWindowMinutesRolling window for the land-rate calculation10 minutes
landRateToFeeFuncMaps a land rate to a priority-fee multiplierBuilt-in: returns 1 while the land rate is at or above 0.9 or fewer than 3 samples exist, otherwise grows logarithmically, capped at 10
throwOnTimeoutErrorWhether a confirmation timeout throws TxSendErrortrue

FastSingleTxSender adds skipConfirmation (return immediately after sending), confirmInBackground (confirm without awaiting, so the returned slot is undefined), blockhashRefreshInterval (default 10,000 ms, 0 disables the loop), and blockhashCommitment (default finalized). WhileValidTxSender adds throwOnTransactionError (default true).

TxSendError carries a numeric code. A confirmation timeout uses NOT_CONFIRMED_ERROR_CODE (-1001), which is an SDK-internal code meaning "we never saw a confirmation or a definite failure", not a Solana or program error. Treat it as unknown outcome and re-check the signature before resubmitting.

import { FastSingleTxSender, VelocityClient } from "@velocity-exchange/sdk";

// Fire and forget: return as soon as the send lands, confirm in the background.
const txSender = new FastSingleTxSender({
  connection,
  wallet,
  confirmInBackground: true,
  blockhashRefreshInterval: 5_000,
  trackTxLandRate: true,
});

const velocityClient = new VelocityClient({
  connection,
  wallet,
  env: "mainnet-beta",
  txSender,
});

import { ForwardOnlyTxSender } from "@velocity-exchange/sdk";

// Submission is delegated: the SDK only confirms.
const txSender = new ForwardOnlyTxSender({
  connection,
  wallet,
  retrySleep: 5_000,
  additionalTxSenderCallbacks: [
    (base58EncodedTx) => myRelay.submit(base58EncodedTx),
  ],
});

ForwardOnlyTxSender defaults additionalTxSenderCallbacks to an empty array. With no callbacks it never broadcasts the transaction anywhere, so it will always time out.

Blockhash handling in TxHandler

TxHandler owns blockhash resolution. By default it uses a caching fetcher that reuses a recent blockhash for 2,000 ms, which collapses per-build getLatestBlockhash calls during bursts. Blockhashes stay valid onchain far longer than that window, so this is safe.

txHandlerConfig fieldMeaningDefault
blockhashCachingEnabledfalse forces a fresh RPC fetch on every buildtrue
blockhashCachingConfig.retryCountFetch retries3
blockhashCachingConfig.retrySleepTimeMsSleep between retries200 ms
blockhashCachingConfig.staleCacheTimeMsHow long a cached blockhash is reused2,000 ms

The commitment used for blockhash fetches comes from confirmationOptions.preflightCommitment, falling back to connection.commitment, then confirmed.

import { TxHandler } from "@velocity-exchange/sdk";

const txHandler = new TxHandler({
  connection,
  wallet,
  confirmationOptions: { commitment: "confirmed", preflightCommitment: "confirmed" },
  config: {
    blockhashCachingEnabled: true,
    blockhashCachingConfig: { staleCacheTimeMs: 2_000 },
  },
});

// Build a v0 transaction from raw instructions.
const tx = await txHandler.buildTransaction({
  instructions: [ix],
  txVersion: 0,
  connection,
  fetchAllMarketLookupTableAccounts: () => velocityClient.fetchAllLookupTableAccounts(),
  txParams: { computeUnits: 400_000, computeUnitsPrice: 25_000 },
});

Compute units and priority fee params

txParams is a BaseTxParams plus a ProcessingTxParams. The base half is what ends up in the compute-budget instructions; the processing half tells the SDK how to derive those numbers.

FieldMeaning
computeUnitsCompute-unit limit to request. VelocityClient defaults to 600,000.
computeUnitsPricePriority fee in micro-lamports per compute unit. VelocityClient defaults to 0.
useSimulatedComputeUnitsSimulate the transaction and use the measured usage as the limit instead of the static value.
computeUnitsBufferMultiplierMultiplier applied to the simulated usage. Defaults to 1.2 (20% headroom).
useSimulatedComputeUnitsForCUPriceCalculationAlso derive the price from the simulated unit count. Requires useSimulatedComputeUnits and getCUPriceFromComputeUnits.
getCUPriceFromComputeUnitsFunction mapping a compute-unit count to a price in micro-lamports.
lowerBoundCuFloor applied to the derived limit.

Solana charges the priority fee on the requested limit, not on what the transaction consumes. A 600,000-unit request at 25,000 micro-lamports per unit costs the same whether the instruction burns 40,000 units or 400,000. Right-sizing computeUnits (statically or by simulation) is the cheapest priority-fee optimization available.

TransactionParamProcessor

TransactionParamProcessor.process() runs the simulation pipeline. It rebuilds the transaction with the limit forced to 1,400,000 units so simulation is not constrained by an undersized budget, simulates it with replaceRecentBlockhash: true (which avoids spurious blockHashNotFound failures from an already-stale blockhash), then multiplies the measured usage by the buffer and clamps the result to 1,400,000.

Simulation failure is not fatal. getTxSimComputeUnits catches every error and returns { success: false }, and process() then leaves the original computeUnits in place. The only cases that throw are misconfiguration: useSimulatedComputeUnitsForCUPriceCalculation without useSimulatedComputeUnits, without getCUPriceFromComputeUnits, or when the simulated unit count is unavailable.

import { TransactionParamProcessor, VelocityClient } from "@velocity-exchange/sdk";

// Size the limit from a simulation and derive the price from the result.
// `priorityFeeSubscriber` is a subscribed PriorityFeeSubscriber; see below.
const velocityClient = new VelocityClient({
  connection,
  wallet,
  env: "mainnet-beta",
  txParams: {
    computeUnits: 600_000, // fallback if the simulation fails
    useSimulatedComputeUnits: true,
    computeUnitsBufferMultiplier: 1.2,
    lowerBoundCu: 100_000,
    useSimulatedComputeUnitsForCUPriceCalculation: true,
    getCUPriceFromComputeUnits: () =>
      Math.floor(priorityFeeSubscriber.getCustomStrategyResult()),
  },
});

// Or size one transaction directly.
const { success, computeUnits } = await TransactionParamProcessor.getTxSimComputeUnits(
  tx,
  connection,
  1.2,    // buffer multiplier, mandatory
  100_000 // optional lower bound
);

PriorityFeeCalculator

PriorityFeeCalculator is a smaller, reactive helper: it watches a sender's timeout count and turns the priority fee on only after transactions start timing out, then latches it on for priorityFeeLatchDurationMs (default 10,000 ms). Its generateComputeBudgetWithPriorityFeeIx() converts a target total fee in micro-lamports into a per-unit price by dividing by the compute-unit limit.

import { PriorityFeeCalculator } from "@velocity-exchange/sdk";

const calculator = new PriorityFeeCalculator(Date.now());

const usePriorityFee = calculator.updatePriorityFee(
  Date.now(),
  velocityClient.txSender.getTimeoutCount()
);

const computeBudgetIxs = calculator.generateComputeBudgetWithPriorityFeeIx(
  600_000,        // compute unit limit
  usePriorityFee,
  1_000_000       // additional fee to pay, in micro-lamports
);

Priority fee subscribers

For a live fee estimate rather than a reactive switch, use the priorityFee module. PriorityFeeSubscriber polls one fee source on an interval (default 10,000 ms) and exposes the latest aggregate. Every value it returns is in micro-lamports per compute unit, the unit ComputeBudgetProgram.setComputeUnitPrice expects.

Fee sources

priorityFeeMethod selects the upstream API. It defaults to PriorityFeeMethod.SOLANA.

MethodUpstream callRequired configNotes
SOLANAgetRecentPrioritizationFees on the configured RPCconnectionSamples are filtered to the most recent slotsToCheck slots (default 50) relative to the newest returned slot, then sorted descending by slot. Override the call itself with fetchSolanaPriorityFee to route through a local cache.
HELIUSHelius getPriorityFeeEstimate with includeAllPriorityFeeLevels: trueheliusRpcUrl, or a connection whose endpoint contains heliusReturns percentile buckets: min, low (25th), medium (50th), high (75th), veryHigh (95th), unsafeMax (100th). Read them with getHeliusPriorityFeeLevel(level), which defaults to MEDIUM.
VELOCITYVelocity's hosted /batchPriorityFees endpointvelocityPriorityFeeEndpoint, velocityMarketsPer-market fee levels, same percentile buckets. See /batchPriorityFees.

The constructor throws when HELIUS is selected without a resolvable Helius URL, or SOLANA without a connection.

addresses scopes SOLANA and HELIUS sampling to the accounts the transaction will write-lock, so it measures congestion on the markets being traded rather than on the whole cluster.

Strategies

customStrategy is any object implementing PriorityFeeStrategy.calculate(samples). Five implementations ship with the SDK.

StrategyResultEmpty sample set
AverageOverSlotsStrategyMean fee across the lookback window. This is the default customStrategy.0
AverageStrategyMean fee across all samples, without the empty guardNaN
MaxOverSlotsStrategyHighest fee in the lookback window0
MaxStrategyHighest fee across all samples, without the empty guard-Infinity
EwmaStrategyExponentially weighted moving average over slots, so recent samples dominate and larger slot gaps decay older samples faster. Half life defaults to 25 slots (roughly 10 seconds)0

Prefer the ...OverSlots variants unless the caller handles the empty case.

Every getter (getCustomStrategyResult, getAvgStrategyResult, getMaxStrategyResult) multiplies by priorityFeeMultiplier (default 1.0) and then clamps to maxFeeMicroLamports when one is set. All three return 0 before the first successful load(). getHeliusPriorityFeeLevel clamps but does not apply the multiplier. Both the multiplier and the clamp can be changed at runtime with updatePriorityFeeMultiplier() and updateMaxPriorityFee().

import {
  EwmaStrategy,
  PriorityFeeMethod,
  PriorityFeeSubscriber,
} from "@velocity-exchange/sdk";

const priorityFeeSubscriber = new PriorityFeeSubscriber({
  connection,
  frequencyMs: 5_000,
  priorityFeeMethod: PriorityFeeMethod.SOLANA,
  slotsToCheck: 50,
  addresses: [perpMarketPublicKey, userAccountPublicKey],
  customStrategy: new EwmaStrategy(25),
  priorityFeeMultiplier: 1.5,
  maxFeeMicroLamports: 1_000_000,
});

await priorityFeeSubscriber.subscribe();

// Micro-lamports per compute unit, multiplied and clamped.
const computeUnitsPrice = Math.floor(priorityFeeSubscriber.getCustomStrategyResult());

await priorityFeeSubscriber.unsubscribe();

load() swallows errors and logs them, so a transient RPC failure leaves the previous values in place until the next poll. Call updateAddresses() or updateMarketTypeAndIndex() to change what is sampled; both take effect on the next poll.

PriorityFeeSubscriberMap

PriorityFeeSubscriberMap fetches per-market fee levels for many markets in one request and keeps them addressable individually, rather than collapsing them into a single aggregate. It only supports the Velocity endpoint.

import { PriorityFeeSubscriberMap } from "@velocity-exchange/sdk";

const subscriberMap = new PriorityFeeSubscriberMap({
  velocityPriorityFeeEndpoint: "<DLOB_ENDPOINT>", // e.g. https://dlob.velocity.exchange
  frequencyMs: 5_000,
  velocityMarkets: [
    { marketType: "perp", marketIndex: 0 },
    { marketType: "perp", marketIndex: 1 },
    { marketType: "spot", marketIndex: 2 },
  ],
});

await subscriberMap.subscribe();

// undefined until the first successful load
const fees = subscriberMap.getPriorityFees("perp", 0);
console.log(fees?.medium, fees?.veryHigh);

Reacting to poor landing

With trackTxLandRate: true, the sender records each signature and computes a rolling land rate. Feed the suggested multiplier into the compute-unit price to back off automatically under congestion:

import { RetryTxSender } from "@velocity-exchange/sdk";

const txSender = new RetryTxSender({
  connection,
  wallet,
  trackTxLandRate: true,
  txLandRateLookbackWindowMinutes: 10,
});

const landRate = txSender.getTxLandRate();                    // 0 to 1
const multiplier = txSender.getSuggestedPriorityFeeMultiplier(); // 1 to 10
const timeouts = txSender.getTimeoutCount();

const computeUnitsPrice = Math.floor(
  priorityFeeSubscriber.getCustomStrategyResult() * multiplier
);