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,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 VLP 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; // 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/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. 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) andequityFloorBuffer(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 includingBuilderReferral) - Delegate permissions -
delegatePermissions(gatestransferDepositByDelegate) - 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 alsoHasBuilder(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:
- Loads accounts passed in the instruction
- Validates account ownership and PDAs
- Loads and validates oracle price data
- Applies protocol rules from the
Stateaccount - Updates
UserAccount(adds order, updates positions, etc) - Updates market state if needed (AMM, funding, etc)
- 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
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 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:
| Variant | Value |
|---|---|
Initialized | 0 |
Active | 1 |
ReduceOnly | 2 |
Settlement | 3 |
Delisted | 4 |
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.