Account Model
Velocity is a Solana program that manages user accounts, positions, orders, and markets. This page is the field-level reference for the accounts it owns: what each one holds, how it is addressed, and where the layout differs from what a reader porting an older integration would expect. The Velocity SDK wraps all of it, so read this when a decoded value does not mean what it appeared to.
Velocity has no Python SDK. A Rust client (velocity-rs) exists in the velocity-v1 monorepo, which is not public, and it is not published to crates.io either. The interfaces below are TypeScript.
Core accounts
Four account types carry essentially all the state an integration reads: one global State, one account per market, one per subaccount, and one per wallet.
State account
There is exactly one State account per deployment, and almost every instruction loads it to apply protocol-level rules. It holds:
- Oracle guards: Stale price thresholds and validity checks (
oracleGuardRails) - Fee structures: Separate default fee tiers for perpetual and spot markets
- Admin controls: A tiered cold / warm / hot admin key model, plus protocol fee treasuries
- Solvency status: Bitflag gating bankruptcy and deficit-resolution instructions independently of the withdraw-pause flags
- Feature flags:
exchangeStatus,featureBitFlags,lpPoolFeatureBitFlags - Slot duration:
slotDurationMs,pendingSlotDurationMs, andslotDurationEffectiveSlot, covered in Slot duration
Velocity uses a tiered key model instead of a single admin key: coldAdmin, warmAdmin, pauseAdmin, and a set of narrowly scoped hot* keys (hotAmmCrank, hotLpCache, hotFeatureFlag, hotFeeWithdraw, hotMmOracleCrank, and others), each authorizing only the instructions it needs rather than one address with full control. There is no state.admin field.
View TypeScript interface (abridged)
interface StateAccount {
coldAdmin: PublicKey;
warmAdmin: PublicKey;
pauseAdmin: PublicKey;
hotFeeWithdraw: PublicKey;
// ...additional narrowly-scoped hot* keys
protocolFeeRecipientPerp: PublicKey;
protocolFeeRecipientSpot: PublicKey;
exchangeStatus: number; // bitmask, see ExchangeStatus
whitelistMint: PublicKey;
discountMint: PublicKey;
oracleGuardRails: OracleGuardRails;
numberOfAuthorities: BN;
numberOfSubAccounts: BN;
numberOfMarkets: number;
numberOfSpotMarkets: number;
minPerpAuctionDuration: number; // legacy 400ms slot-duration units, decode with millisFromStoredUnits
defaultMarketOrderTimeInForce: number; // seconds
defaultSpotAuctionDuration: number; // slots
liquidationMarginBufferRatio: number; // MARGIN_PRECISION (1e4)
settlementDuration: number; // seconds
maxNumberOfSubAccounts: number;
signer: PublicKey;
signerNonce: number;
perpFeeStructure: FeeStructure;
spotFeeStructure: FeeStructure;
initialPctToLiquidate: number; // LIQUIDATION_PCT_PRECISION (1e4)
liquidationDuration: number; // legacy 400ms slot-duration units, decode with millisFromStoredUnits
maxInitializeUserFee: number;
featureBitFlags: number; // bitmask, see FeatureBitFlags
lpPoolFeatureBitFlags: number;
solvencyStatus: number; // bitmask, see SolvencyStatus
slotDurationMs: number; // u16; 0 = unset, resolves to the 400ms baseline
pendingSlotDurationMs: number; // u16; 0 = nothing staged
slotDurationEffectiveSlot: BN; // u64; slot the staged value takes effect
}See StateAccount in the SDK's types.ts for the full field list.
Market accounts
One account per market, in two flavours. Both are addressed by a numeric market index (market 0, market 1, and so on) rather than by name, and the SDK caches them after subscription so a lookup by index costs nothing.
PerpMarketAccount
One per perp market, 1560 bytes onchain. It carries:
- AMM state (
amm): Base/quote reserves and liquidity parameters for the constant-product vAMM - Oracle fields:
oracle,oracleSourcelive at the top level ofPerpMarketAccount(moved offamm.*when the AMM was decoupled) - Market stats (
marketStats): mark/oracle TWAPs, volume, and MM-oracle snapshot, shared across makers - Hedge config (
hedgeConfig): this market's relationship to its hedging Velocity liquidity pool (poolId,status,pausedOperations,exchangeFeeExclusionScalar,feeTransferScalar), replacing the earlier flatlp*LP-share fields - Fee ledger (
feeLedger): consolidated fee-split accounting (pending protocol/IF/AMM carveouts) from the fee redesign - Risk parameters:
marginRatioInitial/marginRatioMaintenance(MARGIN_PRECISION, 1e4),imfFactor,contractTier - Market status:
status: MarketStatus(see the discriminant note below)
Velocity has no vAMM LP shares: PerpPosition.lpShares, lastQuoteAssetAmountPerLp, and perLpBase do not exist, and the five flat LP-pool fields on PerpMarketAccount (lpPoolId, lpStatus, lpPausedOperations, lpFeeTransferScalar, lpExchangeFeeExcluscionScalar) were replaced by the single hedgeConfig object above. High leverage mode, protected maker mode, and fuel tracking were also removed: there is no PerpMarket.highLeverageMarginRatioInitial, protectedMaker*, or fuelBoost*.
View TypeScript interface (abridged)
interface PerpMarketAccount {
status: MarketStatus;
contractType: ContractType;
contractTier: ContractTier;
marketIndex: number;
pubkey: PublicKey;
name: number[];
amm: AMM;
marketStats: MarketStats;
marginRatioInitial: number; // MARGIN_PRECISION (1e4)
marginRatioMaintenance: number; // MARGIN_PRECISION (1e4)
pnlPool: PoolBalance;
protocolFeePool: PoolBalance;
feeLedger: FeeLedger;
liquidatorFee: number; // LIQUIDATION_FEE_PRECISION (1e6)
ifLiquidationFee: number;
protocolLiquidationFee: number;
feePoolBufferTarget: BN; // QUOTE_PRECISION (1e6)
imfFactor: number;
unrealizedPnlImfFactor: number;
unrealizedPnlMaxImbalance: BN;
unrealizedPnlInitialAssetWeight: number;
unrealizedPnlMaintenanceAssetWeight: number;
insuranceClaim: { /* revenue withdraw caps and used insurance */ };
quoteSpotMarketIndex: number;
feeAdjustment: number;
pausedOperations: number; // bitmask, see PerpOperation
poolId: number;
hedgeConfig: {
poolId: number;
status: number;
pausedOperations: number;
exchangeFeeExclusionScalar: number;
feeTransferScalar: number;
};
oracle: PublicKey;
oracleSource: OracleSource;
baseAssetAmountLong: BN; // BASE_PRECISION (1e9)
baseAssetAmountShort: BN;
fundingClampThreshold: number; // BPS_PRECISION (1e4)
fundingRampSlope: number; // PERCENTAGE_PRECISION (1e6)
orderStepSize: BN;
orderTickSize: BN;
}PerpMarket::SIZE is 1560 bytes onchain, having grown across the Anchor 1.0 alignment fix, the AMM decoupling, and the fee redesign. Any custom (non-IDL) decoder must be rebuilt against the SDK's velocity.json IDL. See PerpMarketAccount in types.ts for the full field list.
SpotMarketAccount
One per spot market, 1064 bytes onchain. A spot market is both a collateral type and a lending pool, so the account carries both sets of parameters:
- Interest rates: Dynamic deposit/borrow rates based on utilization
- Insurance fund (
insuranceFund): Now 100% staker-owned:totalFactor/userFactorwere replaced by a singleifFeeFactor(the carveout of deposit-interest gains routed to stakers); there is no protocol-owned IF share anymore - Protocol fee pool (
protocolFeePool): Withdrawable protocol fee claim in this market's token - Oracle integration: Price feeds for the spot asset (Pyth, Pyth Lazer; legacy pull oracles and Switchboard are deprecated, see below)
- Asset/liability weights: Collateral weights for risk calculations
Velocity disabled the spot DLOB. placeSpotOrder, placeAndTakeSpotOrder, placeAndMakeSpotOrder, and fillSpotOrder are still public on the client, but each is now a stub that always throws client-side before building a transaction. There is nothing to call onchain either: the spot order instructions were dropped from the program entirely, so they carry no discriminant in the IDL.
SpotDlobTradingDisabled is still a live error code, raised by the shared order instructions through validate_spot_dlob_trading_enabled_for_market_type when they are handed a MarketType::Spot. Spot markets still exist for collateral, borrow-lend, and swaps, just not orderbook trading. External fulfillment through Serum, Phoenix, and OpenBook v2 is removed entirely.
View TypeScript interface (abridged)
interface SpotMarketAccount {
status: MarketStatus;
assetTier: AssetTier;
name: number[];
marketIndex: number;
pubkey: PublicKey;
mint: PublicKey;
vault: PublicKey;
oracle: PublicKey;
oracleSource: OracleSource;
historicalOracleData: HistoricalOracleData;
historicalIndexData: HistoricalIndexData;
insuranceFund: {
vault: PublicKey;
totalShares: BN;
userShares: BN;
ifFeeFactor: number; // 1e6, the insurance-fund carveout share
};
revenuePool: PoolBalance;
protocolFeePool: PoolBalance;
ifLiquidationFee: number;
protocolLiquidationFee: number;
protocolFeeFactor: number;
decimals: number;
optimalUtilization: number;
optimalBorrowRate: number;
maxBorrowRate: number;
cumulativeDepositInterest: BN;
cumulativeBorrowInterest: BN;
depositBalance: BN; // SPOT_MARKET_BALANCE_PRECISION (1e9) scaled balance
borrowBalance: BN;
maxTokenDeposits: BN;
initialAssetWeight: number; // SPOT_MARKET_WEIGHT_PRECISION (1e4)
maintenanceAssetWeight: number;
initialLiabilityWeight: number;
maintenanceLiabilityWeight: number;
liquidatorFee: number;
imfFactor: number;
withdrawGuardThreshold: BN;
}User accounts
UserAccount
One per subaccount, 4496 bytes onchain, holding all of that subaccount's trading state:
- Perp positions: Market index, base amount, quote entry, last funding index
- Spot positions: Deposits and borrows per market
- Open orders: Up to 32 active orders per user, stored inline
- Special/status bitmasks:
status(UserStatus),specialUserStatus(SpecialUserStatus, e.g.VammHedger) - Permissions: Delegate address and access controls
Velocity removed high leverage mode: the onchain MarginMode enum and the User.marginMode field are gone. The TypeScript MarginMode class is still exported from the SDK, reduced to DEFAULT only, so existing imports do not break. Leverage is governed entirely by each market's marginRatioInitial/marginRatioMaintenance and the account's maxMarginRatio override.
View TypeScript interface
interface UserAccount {
authority: PublicKey;
delegate: PublicKey;
name: number[];
subAccountId: number;
spotPositions: SpotPosition[];
perpPositions: PerpPosition[];
orders: Order[];
status: number; // bitmask, see UserStatus
nextLiquidationId: number;
nextOrderId: number;
maxMarginRatio: number; // MARGIN_PRECISION (1e4); 0 = use market defaults
settledPerpPnl: BN; // QUOTE_PRECISION (1e6)
totalDeposits: BN;
totalWithdraws: BN;
totalSocialLoss: BN;
cumulativePerpFunding: BN;
cumulativeSpotFees: BN;
liquidationMarginFreed: BN;
lastActiveSlot: BN;
isMarginTradingEnabled: boolean;
idle: boolean;
openOrders: number;
hasOpenOrder: boolean;
openAuctions: number;
hasOpenAuction: boolean;
poolId: number;
specialUserStatus: number; // bitmask, see SpecialUserStatus
}UserStatsAccount
One per wallet, aggregating across every subaccount that wallet owns. Fee tiering reads it, so it is the account that decides the tier a wallet trades at:
- Fee tracking:
fees.totalFeePaid/totalFeeRebate/totalTokenDiscount/totalRefereeDiscount - Volume metrics:
makerVolume30D/takerVolume30D/fillerVolume30D(rolling 30-day windows) - Referral data:
referrer,referrerStatus(bitmask,ReferrerStatus, now includingBuilderReferral) - Delegate permissions:
delegatePermissions(gatestransferDepositByDelegate)
Fuel (points/incentives) is gone entirely: there is no fuel field. The gov-token (DRIFT) stake fee discount was also removed: ifStakedGovTokenAmount was replaced by padding and no longer affects fee tiers, which are now determined purely by 30-day volume.
View TypeScript interface
interface UserStatsAccount {
numberOfSubAccounts: number;
numberOfSubAccountsCreated: number;
makerVolume30D: BN; // QUOTE_PRECISION (1e6)
takerVolume30D: BN;
fillerVolume30D: BN;
lastMakerVolume30DTs: BN;
lastTakerVolume30DTs: BN;
lastFillerVolume30DTs: BN;
fees: {
totalFeePaid: BN;
totalFeeRebate: BN;
totalTokenDiscount: BN;
totalRefereeDiscount: BN;
};
referrer: PublicKey;
referrerStatus: number; // bitmask, see ReferrerStatus
disableUpdatePerpBidAskTwap: number;
pausedOperations: number; // bitmask, see UserStatsPausedOperation
authority: PublicKey;
ifStakedQuoteAssetAmount: BN;
delegatePermissions: number;
}Each wallet can hold multiple subaccounts, numbered 0, 1, 2, and so on, each with its own collateral and its own liquidation risk. Cross-margin is shared within one subaccount, never across them; see Cross-margin and subaccounts.
Order accounting
Orders live inside the UserAccount itself rather than in separate accounts, which is why the per-user cap is a fixed 32 and why placing an order does not create rent-paying state. Each order carries:
- Market identification: Market index and type (perp/spot)
- Order parameters: Type (limit, market, oracle, trigger), direction, base amount, price
- Order IDs: System order ID (
orderId) and user-defined order ID (userOrderId) - Flags:
postOnly,reduceOnly, andimmediateOrCancelare their own boolean fields onOrder, not bits.bitFlagsis a separate bitmask holding theOrderBitFlagvalues:SignedMessage(1),OracleTriggerMarket(2),SafeTriggerOrder(4),NewTriggerReduceOnly(8),HasBuilder(16, order attaches a builder fee) - Auction settings: JIT auction parameters (
auctionStartPrice,auctionEndPrice,auctionDuration).auctionDurationis not a slot count: it stores wall-clock units of 400ms, so10is 4 seconds whatever the live slot duration is, andget_auction_durationclamps the sanitized value to between 1 and 180 units (0.4s to 72s). Decode it withmillisFromStoredUnits, never with the live slot duration. See Slot duration.
A fully filled order has its status set to Filled rather than being zeroed, so it stays readable in the array until a later order takes the slot. Only orders with status == Open count against the cap: the program picks the first slot whose status is anything else, so a Filled or Canceled entry is free space. Reading 32 non-empty entries therefore does not mean 32 live orders, and MaxNumberOfOrders is raised only when all 32 are Open.
View TypeScript interface
interface Order {
status: OrderStatus;
orderType: OrderType;
marketType: MarketType;
slot: BN;
orderId: number;
userOrderId: number;
marketIndex: number;
price: BN; // PRICE_PRECISION (1e6)
baseAssetAmount: BN; // BASE_PRECISION (1e9) for perp
baseAssetAmountFilled: BN;
quoteAssetAmountFilled: BN; // QUOTE_PRECISION (1e6)
direction: PositionDirection;
reduceOnly: boolean;
triggerPrice: BN;
triggerCondition: OrderTriggerCondition;
existingPositionDirection: PositionDirection;
postOnly: boolean;
immediateOrCancel: boolean;
oraclePriceOffset: BN; // i64 offset from oracle price
auctionDuration: number;
auctionStartPrice: BN;
auctionEndPrice: BN;
maxTs: BN;
bitFlags: number; // bitmask, see OrderBitFlag
postedSlotTail: number;
}The quoteAssetAmount field is gone. It never existed onchain (the decoder always populated it with 0); read filled quote from quoteAssetAmountFilled.
PDAs (Program Derived Addresses)
Velocity extensively uses PDAs for deterministic address generation, derived from the following seeds:
State: ["velocity_state"]
User: ["user", authority, subAccountId as u16 LE]
UserStats: ["user_stats", authority]
PerpMarket: ["perp_market", marketIndex as u16 LE]
SpotMarket: ["spot_market", marketIndex as u16 LE]
SpotMarketVault: ["spot_market_vault", marketIndex as u16 LE]
InsuranceFundVault: ["insurance_fund_vault", marketIndex as u16 LE]
InsuranceFundStake: ["insurance_fund_stake", authority, marketIndex as u16 LE]
ReferrerName: ["referrer_name", name]Derive these with the SDK rather than by hand: getUserAccountPublicKey(), getPerpMarketPublicKey(), and getSpotMarketPublicKey() from @velocity-exchange/sdk all run offchain and cost no RPC call. A derived address depends on the program ID as well as the seeds, so an address computed against a different deployment is a different account even where the seeds match exactly. See Program and vault addresses.
Account relationships
State (1)
├── PerpMarket[0..N]
├── SpotMarket[0..M]
└── Insurance Fund (100% staker-owned)
Wallet
├── UserStats (1 per wallet)
└── User[0..N] (subaccounts)
├── PerpPosition[0..8]
├── SpotPosition[0..8]
└── Order[0..32]How an instruction touches these accounts
Every instruction follows the same shape. Taking placePerpOrder as the example, the program loads the accounts the transaction passed, validates their ownership and PDA derivation, loads and validates the oracle price, applies the protocol rules from State, writes the change into UserAccount, updates market state where the change requires it (AMM, funding), and emits an event log for offchain indexers.
The account list itself is not fixed. Because a margin check has to price every market the account touches, most instructions take oracle, market, and user accounts through Solana's "remaining accounts" tail rather than through named slots, so one instruction handles an account with one position and an account with eight. Callers rarely build that list by hand: VelocityClient assembles it internally, and VelocityCore.remainingAccounts.getRemainingAccounts() does the same thing on the stateless path that has no subscription. See Reading Data.
Account sizes can grow
Every account above is a fixed-size, zero-copy struct, so new fields normally go into reserved padding: the size and every field offset stay put. When a struct genuinely runs out of padding, it grows, and existing accounts are physically resized onchain by the extend_account instruction.
extend_account reads the target account's discriminator, resolves 8 + size_of::<T>() for that type from the deployed program, transfers the rent shortfall from a payer, and grows the account data. The runtime zero-fills the new tail, so newly added fields read as zero until code writes them. It is:
- Grow-only. The target size is compiled in, so the instruction cannot shrink an account or inflate one to an arbitrary size.
- Idempotent. An account already at or beyond target size is a success no-op, so cranking twice is harmless.
- Permissioned. It requires the
AccountExtensionhot role (or the warm/cold admin). Extension never corrupts contents, but growing accounts costs every reader bandwidth, so the timing is the protocol's decision. - Zero-copy only. Supported types include
User,UserStats,ReferrerName,PerpMarket,SpotMarket,State,InsuranceFundStake,PrelaunchOracle,PythLazerOracle,RevenueShare,LPPool, andConstituent. Anything else fails withInvalidAccountExtension. Borsh accounts (SignedMsgUserOrders,RevenueShareEscrow, the LP-pool mapping accounts) version their layouts or ship dedicated resize instructions instead, and are deliberately rejected.
Decode length-tolerantly. Treat the struct size the client compiled against as the number of bytes to read, never as the buffer length to expect. Concretely: never assert data.length === EXPECTED_SIZE, never derive a slice end from the buffer length, and never filter getProgramAccounts by dataSize. A dataSize filter silently matches nothing the first time a type is extended. Filter by the 8-byte discriminator with a memcmp instead.
The sizes quoted on this page (PerpMarket 1560 bytes, SpotMarket 1064, User 4496) are the sizes the current program compiles in, not permanent constants. The TypeScript SDK is already length-tolerant: Anchor's borsh coder and the custom decodeUser fast path both read start-relative offsets, so an extended account decodes unchanged. See Reading Data for the decode paths.
MarketStatus discriminants
MarketStatus is stored directly in PerpMarket.status / SpotMarket.status. The discriminants are:
| Variant | Value |
|---|---|
Initialized | 0 |
Active | 1 |
ReduceOnly | 2 |
Settlement | 3 |
Delisted | 4 |
Always decode against the SDK's velocity.json IDL rather than a hardcoded enum. When porting a raw decoder from an earlier program version, see the migration guide for the discriminants it replaced.