SDK Internals
The Velocity SDK handles onchain interactions, account subscriptions, and transaction construction. Understanding its internals helps you optimize performance, debug issues, and use advanced features.
Core architecture
The SDK has three main components:
VelocityClient - Main interface for protocol interactions
- Constructs transactions
- Manages account subscriptions
- Provides helper methods for orders, deposits, etc.
- Caches market and state data
User - Represents a single user account
- Subscribes to user account updates
- Calculates positions, PnL, health
- Provides convenience methods for account queries
AccountSubscriber - Handles real-time account updates
- Polls or streams account data from RPC
- Notifies clients when data changes
- Caches account data for fast access
Account subscription patterns
The SDK supports multiple subscription strategies, each with different performance characteristics:
Polling subscription
How it works: Periodically calls connection.getAccountInfo() for each account
import { VelocityClient, BulkAccountLoader } from "@velocity-exchange/sdk";
const accountLoader = new BulkAccountLoader(connection, "confirmed", 1000);
const velocityClient = new VelocityClient({
connection,
wallet,
accountSubscription: {
type: "polling",
accountLoader,
},
});Example Polling subscriptionReference ↗
Example Polling subscriptionReference ↗Polling subscription.Pros:
- Simple and reliable
- Works with any RPC endpoint
- Predictable resource usage
Cons:
- Higher latency (poll interval delay)
- More RPC calls
- Not real-time
Best for: Development, low-frequency trading, simple bots
WebSocket subscription
How it works: Uses Solana’s onAccountChange WebSocket notifications
import { VelocityClient } from "@velocity-exchange/sdk";
const velocityClient = new VelocityClient({
connection,
wallet,
accountSubscription: {
type: "websocket",
},
});Example WebSocket subscriptionReference ↗
Example WebSocket subscriptionReference ↗WebSocket subscription.Pros:
- Lower latency than polling
- Fewer RPC calls
- Real-time updates
Cons:
- WebSocket can disconnect (needs reconnection handling)
- Some RPC endpoints have connection limits
- Slightly more complex error handling
Best for: Market makers, latency-sensitive bots, production trading
gRPC subscription (fastest)
How it works: Uses Yellowstone gRPC plugin for Solana validators
import { VelocityClient } from "@velocity-exchange/sdk";
const velocityClient = new VelocityClient({
connection,
wallet,
accountSubscription: {
type: "grpc",
grpcConfigs: {
endpoint: "https://grpc.mainnet.jito.wtf",
token: "YOUR_GRPC_TOKEN",
},
},
});Example gRPC subscriptionReference ↗
Example gRPC subscriptionReference ↗gRPC subscription.Pros:
- Lowest latency (sub-second updates)
- Most efficient bandwidth usage
- Best for high-frequency trading
Cons:
- Requires gRPC-enabled RPC (e.g., Jito, Triton)
- More complex setup
- May require authentication/payment
Best for: HFT bots, JIT market makers, competitive filling
BulkAccountLoader
For loading many accounts efficiently, the SDK provides BulkAccountLoader:
import { VelocityClient, BulkAccountLoader } from "@velocity-exchange/sdk";
// Constructor: (connection, commitment, pollingFrequencyMs)
const bulkAccountLoader = new BulkAccountLoader(connection, "confirmed", 1000);
// Typically you don't call addAccount directly. Instead, pass the loader
// to VelocityClient and it registers the accounts it needs automatically.
const velocityClient = new VelocityClient({
connection,
wallet,
accountSubscription: { type: "polling", accountLoader: bulkAccountLoader },
});Example BulkAccountLoaderReference ↗
Example BulkAccountLoaderReference ↗Batches many accounts behind a single periodic `getMultipleAccounts` RPC poll instead of one WebSocket subscription per account. Multiple independent callbacks (e.g. from different `PollingUserAccountSubscriber`/`PollingVelocityClientAccountSubscriber` instances) can register against the same `publicKey`; polling starts automatically on the first `addAccount` and stops when the last callback for the last account is removed. A buffer is only re-delivered to callbacks when its slot is not older than the last-seen slot for that account and its bytes actually changed, so a callback never observes a stale or duplicate update. `load()` calls are coalesced: concurrent callers share one in-flight RPC batch rather than issuing redundant requests.
| Property | Type | Required |
|---|---|---|
connection | Connection | Yes |
commitment | Commitment | Yes |
pollingFrequency | number | Yes |
accountsToLoad | Map<string, AccountToLoad> | Yes |
bufferAndSlotMap | Map<string, BufferAndSlot> | Yes |
errorCallbacks | Map<string, (e: Error) => void> | Yes |
intervalId | Timeout | No |
loadPromise | Promise<void> | No |
loadPromiseResolver | any | Yes |
lastTimeLoadingPromiseCleared | number | Yes |
mostRecentSlot | number | Yes |
addAccount | (publicKey: PublicKey, callback: (buffer: Buffer, slot: number) => void) => Promise<string>Registers a callback to be invoked whenever `publicKey`'s account data changes on a poll.
Multiple callbacks may be registered for the same account. Starts polling automatically if
this is the loader's first account. Awaits any in-flight `load()` before returning so a
caller can immediately follow with its own `load()` without racing the poll interval. | Yes |
removeAccount | (publicKey: PublicKey, callbackId: string | undefined) => voidUnregisters a callback previously returned by `addAccount`. Once an account has no
remaining callbacks, its cached buffer/slot is dropped and it stops being polled; if no
accounts remain at all, polling stops entirely. A no-op if `callbackId` is undefined. | Yes |
addErrorCallbacks | (callback: (error: Error) => void) => stringRegisters a callback invoked whenever a `load()` batch throws (e.g. RPC failure or timeout).
The loader continues polling afterward; errors do not stop the interval. | Yes |
removeErrorCallbacks | (callbackId: string | undefined) => voidUnregisters an error callback previously returned by `addErrorCallbacks`. A no-op if `callbackId` is undefined. | Yes |
chunks | <T>(array: readonly T[], size: number) => T[][] | Yes |
load | () => Promise<void>Fetches every registered account in one or more chunked, concurrent `getMultipleAccounts`
batches (via `loadChunk`) and dispatches changed accounts to their callbacks. Concurrent
calls while a load is already in flight share that same in-flight promise rather than
issuing a duplicate RPC batch, unless the previous load has been running for over a minute
(treated as stuck and restarted). On failure, invokes every registered error callback
instead of throwing. | Yes |
loadChunk | (accountsToLoadChunks: AccountToLoad[][]) => Promise<void> | Yes |
handleAccountCallbacks | (accountToLoad: AccountToLoad, buffer: Buffer | undefined, slot: number) => void | Yes |
getBufferAndSlot | (publicKey: PublicKey) => BufferAndSlot | undefinedReturns the last-fetched raw buffer/slot for `publicKey`, or undefined if it has never been loaded. | Yes |
getSlot | () => numberReturns the highest slot number observed across any account fetched by this loader so far. | Yes |
startPolling | () => voidStarts the polling interval if not already running and `pollingFrequency !== 0`. Called automatically by `addAccount`. | Yes |
stopPolling | () => voidStops the polling interval, if running. Called automatically by `removeAccount` once no accounts remain. | Yes |
log | (msg: string) => void | Yes |
updatePollingFrequency | (pollingFrequency: number) => voidRestarts polling at a new interval (ms), preserving all registered accounts and callbacks. | Yes |
The loader batches multiple accounts into single getMultipleAccounts RPC calls for efficiency.
Transaction construction
The SDK builds transactions in several layers:
// 1. Get instruction
const ix = await velocityClient.getPlacePerpOrderIx(orderParams);
// 2. Build transaction
// getVersionedTransaction(ixs, lookupTableAccounts, additionalSigners?, opts?, blockhash?)
// — the fee payer/signer is `velocityClient`'s own wallet, not a positional arg.
const tx = await velocityClient.txSender.getVersionedTransaction(
[ix],
[], // lookup tables
);
// 3. Send transaction
const { txSig } = await velocityClient.txSender.sendVersionedTransaction(
tx,
[],
velocityClient.opts
);Example Transaction construction layersReference ↗
Example Transaction construction layersReference ↗Transaction construction layers.Higher-level methods like placePerpOrder() do all three steps automatically.
Remaining accounts pattern
Many Velocity instructions need dynamic account lists (oracles, markets, positions). The SDK’s getRemainingAccounts() method builds this list:
const remainingAccounts = velocityClient.getRemainingAccounts({
userAccounts: [user.getUserAccount()],
writableSpotMarketIndexes: [0], // USDC market
});
// These accounts get passed to the instruction
const ix = await velocityClient.program.methods
.placePerpOrder(params)
.accounts({
user: userAccountPubkey,
// ... other fixed accounts
})
.remainingAccounts(remainingAccounts)
.instruction();Example Remaining accountsReference ↗
Example Remaining accountsReference ↗Remaining accounts.This handles oracle accounts, market accounts, and cross-position accounts automatically.
Event subscriptions
The SDK can subscribe to onchain program events. See Events for the full event catalog (including the fee-sweep/revenue-share/LP-borrow-lend records) and query helpers:
import { EventSubscriber, isVariant } from "@velocity-exchange/sdk";
const eventSubscriber = new EventSubscriber(connection, velocityClient.program, {
commitment: "confirmed",
logProviderConfig: { type: "websocket" },
});
await eventSubscriber.subscribe();
// All events come through "newEvent", filter by eventType
eventSubscriber.eventEmitter.on("newEvent", (event) => {
if (event.eventType === "OrderActionRecord" && isVariant(event.action, "fill")) {
console.log("Order filled:", event);
console.log(" Market:", event.marketIndex);
}
});Example Event subscriptionsReference ↗
Example Event subscriptionsReference ↗Event subscriptions.Common event types:
OrderActionRecord(with action:fill,place,cancel, etc.) - Order lifecycle eventsDepositRecord- Deposits and withdrawalsFundingPaymentRecord- Funding paymentsLiquidationRecord- Liquidations
Caching and performance
// Market account caching:
// First call after subscribe: data from subscription cache (no extra RPC)
const market = velocityClient.getPerpMarketAccount(0);
// Subsequent calls: same cached data, updates via subscription
const market2 = velocityClient.getPerpMarketAccount(0);
// Oracle price caching:
const oracle = velocityClient.getOracleDataForPerpMarket(0);
// Price is cached from account subscription
// User account caching:
const user = velocityClient.getUser();
const position = user.getPerpPosition(0);
// No RPC call, data from subscriptionExample Caching examplesReference ↗
Example Caching examplesReference ↗Caching examples.This makes queries fast (microseconds vs milliseconds for RPC).
UserMap for multiple users
To track many user accounts efficiently:
import { UserMap } from "@velocity-exchange/sdk";
const userMap = new UserMap({
velocityClient,
subscriptionConfig: {
type: "websocket",
},
});
await userMap.subscribe();
// Add a specific user account to the map
await userMap.addPubkey(userAccountPubkey);
// Get user data
const user = userMap.get(userAccountPubkey.toString());
const position = user.getPerpPosition(0);Example UserMapReference ↗
Example UserMapReference ↗In-memory cache of every `User` account on the program, keyed by the `User` account's own public key.
Sync/subscription filtering (see `getFilters`) is built from memcmp filters: the base `getUserFilter()` (matches the `User` account discriminator — required, or every account on chain would match) is always present; `getNonIdleUserFilter()` is added unless `includeIdle` is set (idle accounts are excluded by default to reduce subscription volume); `getUsersWithPoolId(filterByPoolId)` is added when `filterByPoolId` is set; and any `additionalFilters` from the config are appended last. The same filter set is used for the initial `getProgramAccounts` sync and for the live websocket/gRPC subscription, so what you sync is what you keep getting updates for.
Automatically does a full `sync()` whenever the program's `StateAccount.numberOfSubAccounts` changes (new/deleted user accounts), unless `disableSyncOnTotalAccountsChange` is set.
| Property | Type | Required |
|---|---|---|
userMap | any | Yes |
velocityClient | VelocityClient | Yes |
eventEmitter | StrictEventEmitter<EventEmitter, UserEvents> | Yes |
connection | any | Yes |
commitment | any | Yes |
includeIdle | any | Yes |
filterByPoolId | any | No |
additionalFilters | any | No |
disableSyncOnTotalAccountsChange | any | Yes |
lastNumberOfSubAccounts | any | No |
subscription | any | Yes |
stateAccountUpdateCallback | any | Yes |
decode | any | Yes |
mostRecentSlot | any | Yes |
syncConfig | any | Yes |
syncPromise | any | No |
syncPromiseResolver | any | Yes |
throwOnFailedSync | any | Yes |
subscribe | () => Promise<void>Populates the map with a full initial `sync()` (no-op if already
populated) and starts the configured live subscription
(`'websocket'`/`'polling'`/`'grpc'`), plus (unless
`disableSyncOnTotalAccountsChange`) a listener that triggers a full
re-sync whenever `StateAccount.numberOfSubAccounts` changes. | Yes |
addPubkey | (userAccountPublicKey: PublicKey, userAccount?: UserAccount | undefined, slot?: number | undefined, accountSubscription?: UserSubscriptionConfig | undefined) => Promise<...>Adds `userAccountPublicKey` to the map, creating a `User` for it.
By default (`accountSubscription` omitted), subscribes it with a
`OneShotUserAccountSubscriber` seeded from `userAccount`/`slot` rather
than a live per-account websocket subscription — the map already gets
live updates in bulk via its own subscription (`WebsocketSubscription`/
`PollingSubscription`/`grpcSubscription`), so per-`User` subscriptions
here would needlessly multiply RPC load. | Yes |
has | (key: string) => booleanReturns true if a `User` account keyed by `key` (the `User` account pubkey, base58) is cached in the map. | Yes |
get | (key: string) => User | undefinedgets the User for a particular userAccountPublicKey, if no User exists, undefined is returned | Yes |
getWithSlot | (key: string) => DataAndSlot<User> | undefinedLike `get`, but also returns the slot at which the `User` account was last observed. | Yes |
mustGet | (key: string, accountSubscription?: UserSubscriptionConfig | undefined) => Promise<User>gets the User for a particular userAccountPublicKey, if no User exists, new one is created | Yes |
mustGetWithSlot | (key: string, accountSubscription?: UserSubscriptionConfig | undefined) => Promise<DataAndSlot<User>>Like `mustGet`, but also returns the slot at which the `User` account was observed. | Yes |
mustGetUserAccount | (key: string) => Promise<UserAccount>Like `mustGet`, but returns the underlying `UserAccount` data directly (throws if the `User`'s account is not loaded). | Yes |
getUserAuthority | (key: string) => PublicKey | undefinedgets the Authority for a particular userAccountPublicKey, if no User exists, undefined is returned | Yes |
getDLOB | (slot: number) => Promise<DLOB>Implements the `DLOBSource` interface: builds a `DLOB` from every
subscribed user's open orders. | Yes |
updateWithOrderRecord | (record: OrderRecord) => Promise<void>Ensures an entry exists in the map for `record.user`, adding it via `addPubkey` if not already present. | Yes |
updateWithEventRecord | (record: any) => Promise<void>Incrementally updates the map in response to a single program event,
ensuring an entry exists for every `User` account the event references
(deposit/funding/liquidation/order/order-action/settle-pnl/new-user
records). Unrecognized event types are silently ignored. | Yes |
values | () => IterableIterator<User>Iterates all cached `User` instances. | Yes |
valuesWithSlot | () => IterableIterator<DataAndSlot<User>>Like `values`, but paired with the slot each `User` was last observed at. | Yes |
entries | () => IterableIterator<[string, User]>Iterates all `[userAccountPublicKey, User]` pairs in the map. | Yes |
entriesWithSlot | () => IterableIterator<[string, DataAndSlot<User>]>Like `entries`, but paired with the slot each `User` was last observed at. | Yes |
size | () => numberNumber of `User` accounts currently cached in the map. | Yes |
getUniqueAuthorities | (filterCriteria?: UserAccountFilterCriteria | undefined) => PublicKey[]Returns a unique list of authorities for all users in the UserMap that meet the filter criteria | Yes |
sync | () => Promise<void>Runs a full sync using the strategy configured in `UserMapConfig.syncConfig` (`'default'` or `'paginated'` — see `SyncConfig`). | Yes |
getFilters | anyBuilds the memcmp filter set for both the initial sync and the live
subscription: always the `User`-account discriminator filter; plus a
non-idle filter unless `includeIdle`; plus a pool-id filter if
`filterByPoolId` is set; plus any caller-supplied `additionalFilters`. | Yes |
defaultSync | anySyncs the UserMap using the default sync method (single getProgramAccounts call with filters).
This method may fail when velocity has too many users. (nodejs response size limits) | Yes |
paginatedSync | anySyncs the UserMap using the paginated sync method (multiple getMultipleAccounts calls with filters).
This method is more reliable when velocity has many users. | Yes |
unsubscribe | () => Promise<void>Tears down the live subscription, unsubscribes and removes every cached
`User`, and (if registered) removes the `stateAccountUpdate` listener
that triggers auto-resync on `numberOfSubAccounts` changes. | Yes |
updateUserAccount | (key: string, userAccount: UserAccount, slot: number) => Promise<void>Applies a fresh `userAccount` observation for `key` at `slot`. If the
user is already cached, updates in place only if `slot` is at least as
new as the cached slot (stale/out-of-order updates are dropped) and
emits `'userUpdate'`. If not cached yet, adds it via `addPubkey`.
Also advances `getSlot()`'s tracked most-recent slot. | Yes |
updateLatestSlot | (slot: number) => voidAdvances the map's tracked most-recent slot to `slot` if it's newer. | Yes |
getSlot | () => numberReturns the most recent slot at which any account update has been observed. | Yes |
UserMap handles subscription lifecycle for all users automatically.
Common patterns
import { VelocityClient } from "@velocity-exchange/sdk";
import { ComputeBudgetProgram } from "@solana/web3.js";
// Initialize and subscribe
const velocityClient = new VelocityClient({
connection,
wallet,
env: "mainnet-beta",
});
await velocityClient.subscribe();
const user = velocityClient.getUser();
await user.subscribe();
// Transaction with priority fee
const ix = await velocityClient.getPlacePerpOrderIx(orderParams);
const tx = await velocityClient.txSender.getVersionedTransaction([
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 50000 }),
ix,
], []); // lookup tables
const { txSig } = await velocityClient.txSender.sendVersionedTransaction(
tx, [], velocityClient.opts
);
// Switch subaccounts
await velocityClient.switchActiveUser(1); // Switch to subaccount 1
const user1 = velocityClient.getUser(); // Now returns subaccount 1
// Batch operations: place multiple orders in one transaction
await velocityClient.placeOrders([order1, order2, order3]);Example Common patternsReference ↗
Example Common patternsReference ↗Common patterns.Error handling
Common error scenarios:
// Insufficient collateral
try {
await velocityClient.placePerpOrder(params);
} catch (e) {
if (e.message.includes("InsufficientCollateral")) {
console.log("Need to deposit more collateral");
}
}
// Account subscription errors
velocityClient.eventEmitter.on("error", (e) => {
console.error("Subscription error:", e);
// Reconnect logic here
});Example Error handlingReference ↗
Example Error handlingReference ↗Error handling.Performance tips
Use appropriate commitment:
processed- Fastest, some risk of reorgsconfirmed- Balanced (recommended)finalized- Slowest, most secure
// Precompute values: convert once, reuse
const size = velocityClient.convertToPerpPrecision(1);
const price = velocityClient.convertToPricePrecision(100);
// Use lookup tables (ALTs) to reduce transaction size
const lookupTables = [/* your AddressLookupTableAccount objects */];
const tx = await velocityClient.txSender.getVersionedTransaction(
instructions,
lookupTables
);Example Performance tipsReference ↗
Example Performance tipsReference ↗Performance tips.Related
- SDK Setup - Getting started with the SDK
- Bot Architecture - Production bot patterns
- Program Structure - onchain accounts the SDK interacts with