Skip to Content
DevelopersConceptsAccount Model

Account Model

Velocity is a Solana program (smart contract) that manages user accounts, positions, orders, and markets. Understanding the onchain data model helps you work effectively with the Velocity SDK (TypeScript) and understand how state updates propagate.

Velocity has no Python SDK. A Rust client (velocity-rs) exists in the velocity-v1 monorepo  but is source-only and not published to crates.io: the interfaces below are TypeScript.

Core accounts

Velocity uses several account types, each serving a specific purpose:

State account

Single global account holding protocol-wide configuration:

  • 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 (see below), plus protocol fee treasuries
  • Solvency status - Bitflag gating bankruptcy/deficit-resolution instructions independently of the withdraw-pause flags
  • Feature flags - exchangeStatus, featureBitFlags, lpPoolFeatureBitFlags

The State account is a singleton: there’s only one per deployment. It’s read by almost every instruction to apply protocol-level rules.

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, etc.), each authorizing only the specific 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 }

See StateAccount in packages/sdk/src/types.ts for the full field list.

Market accounts

PerpMarketAccount One per perp market, 1304 bytes on-chain

Manages perpetual futures markets with:

  • AMM state (amm) - Base/quote reserves and liquidity parameters for the constant-product vAMM
  • Oracle fields - oracle, oracleSource live at the top level of PerpMarketAccount (moved off amm.* 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 VLP pool (poolId, status, pausedOperations, exchangeFeeExclusionScalar, feeTransferScalar), replacing the earlier flat lp* 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; // LIQUIDATOR_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; }

The current on-chain size is exactly 1304 bytes, 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 sdk/src/idl/velocity.json. See PerpMarketAccount in types.ts for the full field list.

SpotMarketAccount One per spot market

Manages spot markets and lending pools with:

  • Interest rates - Dynamic deposit/borrow rates based on utilization
  • Insurance fund (insuranceFund) - Now 100% staker-owned: totalFactor/userFactor were replaced by a single ifFeeFactor (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. The SDK methods placeSpotOrder, placeAndTakeSpotOrder, placeAndMakeSpotOrder, and fillSpotOrder still exist, but each is now a stub that always throws client-side before building a transaction. The on-chain instruction discriminants are preserved as deprecated stubs too, and would raise SpotDlobTradingDisabled if ever called directly. Spot markets still exist for collateral/borrow-lend and swaps, just not order-book trading. External fulfillment (Serum/Phoenix/OpenBook v2) is also 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; // IF_FACTOR_PRECISION (1e6) }; 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_BALANCE_PRECISION (1e9) scaled balance borrowBalance: BN; maxTokenDeposits: BN; initialAssetWeight: number; // SPOT_WEIGHT_PRECISION (1e4) maintenanceAssetWeight: number; initialLiabilityWeight: number; maintenanceLiabilityWeight: number; liquidatorFee: number; imfFactor: number; withdrawGuardThreshold: BN; }

Markets are identified by numeric indices (market 0, market 1, etc). The SDK caches market accounts for fast lookups.

User accounts

UserAccount Holds all trading state for a specific subaccount

Each UserAccount stores:

  • 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
  • Equity floor - equityFloor (admin-set minimum cross-margin collateral, 0 = disabled) and equityFloorBuffer (extra headroom required by risk-increasing orders, fills, withdrawals, and transfers)

Velocity removed high leverage mode: there is no marginMode field or MarginMode enum. 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 equityFloor: BN; // QUOTE_PRECISION (1e6); admin-set minimum cross-margin collateral; 0 = disabled equityFloorBuffer: BN; // QUOTE_PRECISION (1e6); extra headroom above equityFloor for risk-increasing actions }

UserStatsAccount Tracks aggregated stats across all subaccounts under one wallet

Maintains lifetime statistics:

  • Fee tracking - fees.totalFeePaid / totalFeeRebate / totalTokenDiscount / totalRefereeDiscount
  • Volume metrics - makerVolume30D / takerVolume30D / fillerVolume30D (rolling 30-day windows)
  • Referral data - referrer, referrerStatus (bitmask, ReferrerStatus, now including BuilderReferral)
  • Delegate permissions - delegatePermissions (gates transferDepositByDelegate)
  • Equity breaker - equityBreakerTripped, set by the permissionless equity-floor breaker across every subaccount of the authority until the warm admin clears it

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; equityBreakerTripped: number; // non-zero once the permissionless equity-floor breaker fires; cleared only by the warm admin }

Users are PDAs derived from seeds ["user", authority, subAccountId as u16 LE]. Each wallet can have multiple subaccounts (0, 1, 2, …) sharing cross-margin.

Order accounting

Orders are stored directly in the UserAccount, not as separate accounts. This reduces transaction overhead and allows up to 32 orders per user.

Each order contains:

  • 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 - bitFlags (OrderBitFlag): post-only, reduce-only, and now also HasBuilder (order attaches a builder fee)
  • Auction settings - JIT auction parameters (auctionStartPrice, auctionEndPrice, auctionDuration)

When an order fills, it’s marked as filled but not immediately removed, allowing order history tracking within the account.

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 on-chain (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 PDA: ["velocity_state"] User PDA: ["user", authority.key(), subAccountId as u16 LE] UserStats PDA: ["user_stats", authority.key()] PerpMarket PDA: ["perp_market", marketIndex as u16 LE] SpotMarket PDA: ["spot_market", marketIndex as u16 LE] SpotMarketVault PDA: ["spot_market_vault", marketIndex as u16 LE] InsuranceFundVault PDA: ["insurance_fund_vault", marketIndex as u16 LE]

The SDK provides helpers to derive these addresses without onchain calls, e.g. getUserAccountPublicKey(), getPerpMarketPublicKey(), getSpotMarketPublicKey() from @velocity-exchange/sdk.

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 instructions modify state

When you call an instruction (e.g. placePerpOrder), the program:

  1. Loads accounts passed in the instruction
  2. Validates account ownership and PDAs
  3. Loads and validates oracle price data
  4. Applies protocol rules from the State account
  5. Updates UserAccount (adds order, updates positions, etc)
  6. Updates market state if needed (AMM, funding, etc)
  7. Emits event logs for offchain indexing

The SDK handles account passing automatically. You rarely need to manually construct the account list.

Remaining accounts pattern

Many instructions use “remaining accounts” to dynamically pass oracle, market, and user accounts. This lets a single instruction handle variable market sets without requiring fixed account slots. The SDK builds this list for you (VelocityClient does it internally, or use VelocityCore.remainingAccounts.getRemainingAccounts() for the stateless/subscription-free path. 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 on-chain 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 AccountExtension hot 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, and Constituent. Anything else fails with InvalidAccountExtension. 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 your 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 1304 bytes, SpotMarket 808, 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 current discriminants are:

VariantValue
Initialized0
Active1
ReduceOnly2
Settlement3
Delisted4

Always decode against sdk/src/idl/velocity.json, not a hardcoded enum. If you’re porting a raw decoder from an earlier program version, see the migration guide for the discriminants it replaced.

Last updated on