Skip to content
LogoLogo

On-chain clients

Changes to trader accounts, orders and quotes require a transaction against the Flint margin program. Two clients, generated from the same hand-authored Codama IDL, build those transactions and decode the program's accounts:

LanguagePackageVersion line
TypeScript@superis-labs/flint-idl — a @solana/kit clientIts own (0.11.0); tracks the deployed program.
Rustflint-margin-onchain — borsh builders and decodersThe SDK suite version (7.0.5).

Python's flint.margin.onchain is generated from the same IDL and tested against the same fixtures; see the Python SDK. The examples on this page show Rust and TypeScript.

The API clients re-export them: flint-margin-user-client and flint-margin-maker-client provide these exports in Rust as the onchain module, in TypeScript from the package root and the /idl subpath. SDK 7.0.5 TypeScript client packages depend on flint-idl ^0.11.0; SDK 7.0.0 through 7.0.4 depend on ^0.10. The downloadable maker examples remain pinned to their tested SDK 7.0.4 / IDL 0.10.2 combination. Use the operator's tested program/SDK combination: matching account layouts alone do not prove that order instruction encodings match a deployment. The raw IDL is downloadable as codama.json for generating clients in other languages.

The IDL is written by hand against the program's Rust ABI (Anchor v2 slab accounts are not supported by Anchor's IDL generator) and pinned to it by golden byte fixtures: every instruction encoding, account size, and discriminator is checked against output produced by the program itself. flint_margin_onchain::IDL_REVISION (Rust) and FLINT_ABI_REVISION (TypeScript) name the program commit the fixtures came from.

Both clients are also tested against bytes the program itself wrote: rust/margin-onchain/idl/scenarios.json holds every account kind and every event kind, produced by running real instructions through that revision of the program, with the expected values computed from the program's own Rust types; tests/fixtures/devnet/ holds accounts and transactions recorded from the devnet deployment.

Program versions

Flint accounts carry no layout version: a layout change ships as a new program deployment. A client release decodes exactly one ABI, the one IDL_REVISION / FLINT_ABI_REVISION names, and refuses bytes that do not fit it instead of misreading them:

  • a fixed-size account whose length differs from the layout, or any account with bytes left over after decoding, fails to parse (ParseError::Decode in Rust, FlintDecodeError code account-size in TypeScript). The two update-proposal accounts are the exception: the program allocates them at their widest variant. In Rust this is new in the release after 7.0.4: FlintAccount::parse used to ignore leftover bytes, so code that parses accounts from a deployment with a different layout now gets an error where it previously got a partial read.
  • an event batch whose format byte this client does not know fails (UnsupportedFormat / unsupported-event-format), as does a known event record whose width moved (RecordLayout / event-size). Event tags are append-only, so a tag newer than the client decodes as Unknown and the rest of the batch still reads.
  • an instruction with bytes after the arguments this client knows is reported as undecoded rather than parsed.

Upgrade the client when the program is redeployed.

Program IDs

ClusterMargin programOracle program
Built-in reference4MxEr7W5f9xtajcTTpTTd5wfdBmwrF72j2NMg6YzXgMofnt2ARonSBGhBY1RTxVSvZ3p8JSYi9iAE9Hkq5jXcmf
Other networkUse the operator's tested program address and SDK combinationUse the operator's address

The margin program address is built in as FLINT_ID (Rust) and FLINT_PROGRAM_ADDRESS (TypeScript), and every generated PDA finder and instruction builder defaults to it. Pass programAddress (TS) to target another deployment.

Install

Through the client packages (recommended), or directly:

[dependencies]
flint-margin-onchain = { version = "=7.0.5", registry = "kellnr" }

PDAs

AccountSeedsRustTypeScript
Trader account"trader_account", trader_id: u64TraderAccount::find_pda(trader_id)findTraderAccountPda({ traderId })
Delegate"delegate_trader", trader_id: u64, delegate: PubkeyDelegateTraderAccount::find_pda(trader_id, &delegate)findDelegateTraderAccountPda({ traderId, delegate })
Collateral market"spot_market", mintSpotMarket::find_pda(&mint)findSpotMarketPda({ mint })
Perp contract"linear_perp", linear_perp_id: u32LinearPerp::find_pda(id)findLinearPerpPda({ linearPerpId })
Perp quoting account"quoting", linear_perp_id: u32LinearPerpQuoting::find_pda(id)findQuotingPda({ linearPerpId })
Perp order book"order_book", linear_perp_id: u32LinearPerpOrderBook::find_pda(id)findOrderBookPda({ linearPerpId })
Global config"global_config"GlobalConfig::find_pda()findGlobalConfigPda()
Product catalog"product_catalog"ProductCatalog::find_pda()findProductCatalogPda()
Spot product cache"spot_product_cache"SpotProductCache::find_pda()findSpotProductCachePda()
Perp cache"linear_perp_cache"LinearPerpCache::find_pda()findLinearPerpCachePda()
Oracle cache"oracle_cache"OracleCache::find_pda()findOracleCachePda()
Insurance fund"cross_margin_if"CrossMarginInsuranceFund::find_pda()findCrossMarginInsuranceFundPda()
Event authority"__event_authority"Pubkey::find_program_address(&[b"__event_authority"], &FLINT_ID)findEventAuthorityPda()

Trader ids are chosen by the account's creator; the API returns them as TraderId, and AccountService.ListAccounts finds every account an authority owns or delegates on.

Decoding accounts

The Rust spot_positions() and TypeScript decodeSpotPositions() helpers in SDK 7.0.4 / IDL 0.10.2 support reserved capacity nodes retained after full collateral withdrawal. Use these versions or newer for account reads throughout the maker lifecycle.

Rust: parse::FlintAccount

Every program-owned account type implements flint_margin_onchain::parse::FlintAccount:

  • parse_account(&account) checks the owner is FLINT_ID, then the discriminator, then decodes. Use it whenever you have the whole account from RPC.
  • parse(&data) checks only the discriminator — for data whose owner you've already verified.
use flint_margin_onchain::accounts::{ProductCatalog, TraderAccount};
use flint_margin_onchain::parse::FlintAccount;
 
let trader = TraderAccount::parse_account(&account)?;
for position in trader.spot_positions()? {
    println!("spot {} at node {}", position.position.spot_id, position.node_index);
}
 
let catalog = ProductCatalog::parse_account(&catalog_account)?;
for product in catalog.live_products()? {
    println!("{product:?}");
}

trader.linear_perp_positions() walks the perp list the same way.

Failures come back as ParseError: WrongOwner, WrongDiscriminator, TooShort, Decode, or TruncatedSlab.

Slab accounts: read only the live range

ProductCatalog, SpotProductCache, LinearPerpCache, LinearPerpQuoting, and OracleCache are over-allocated slabs: the trailing array covers every allocated slot, but only the first length entries are live. TraderAccount's tail is a byte slab holding linked-list position nodes, and its unused capacity can hold stale nodes. LinearPerpOrderBook's tail is a byte arena holding the bid, ask, and per-trader red-black trees; only the first length bytes are live.

AccountRust live accessorTypeScript
ProductCataloglive_products()data.products.slice(0, data.length)
SpotProductCachelive_entries()data.entries.slice(0, data.length)
LinearPerpCachelive_entries()data.entries.slice(0, data.length)
LinearPerpQuotinglive_entries() — one maker slot eachdata.entries.slice(0, data.length)
OracleCachelive_entries() — the index is the dense oracle iddata.entries.slice(0, data.length)
TraderAccountlive_tail(), or spot_positions() / linear_perp_positions()decodeTraderPositions(data)
LinearPerpOrderBooklive_tail() — raw arena bytesreadRestingOrders(data)

Never read the raw trailing field directly. In Rust the accessors return TruncatedSlab if the header claims more entries than the account holds; in TypeScript decodeFlintAccount refuses such an account with code truncated-slab.

The TypeScript client walks the order-book trees with readRestingOrders; in Rust, the header's bids, asks, and traders are RedBlackTreeHeads, and the generated RestingOrder, OrderKey, and TraderOrders types describe what the trees hold; see Prices, sizes, units for how keys encode price.

TypeScript: decodeFlintAccount

decodeFlintAccount takes the EncodedAccount that @solana/kit's fetchEncodedAccount returns, checks its owner, identifies the account by discriminator, and decodes it to its last byte. The result is tagged with its kind; decodeFlintAccountAs asserts one kind.

import { assertAccountExists, fetchEncodedAccount } from "@solana/kit";
import {
  decodeFlintAccount,
  decodeFlintAccountAs,
  decodeTraderPositions,
  findTraderAccountPda,
} from "@superis-labs/flint-idl";
 
const [address] = await findTraderAccountPda({ traderId: 42n });
const encoded = await fetchEncodedAccount(rpc, address);
assertAccountExists(encoded);
 
const trader = decodeFlintAccountAs("TraderAccount", encoded);
const { spot, linearPerp } = decodeTraderPositions(trader);
 
const any = decodeFlintAccount(encoded);
if (any.kind === "LinearPerp") console.log(any.account.linearPerpId);

Every failure is a FlintDecodeError with a code: wrong-owner, unknown-account, account-size, truncated-slab, or invalid-position-list.

Every account module exports its *_DISCRIMINATOR constant. u96 values decode as { low: bigint, high: number }; convert with u96ToBigInt / u96FromBigInt (Rust: trader::u96_to_u128 / u96_from_u128).

Prefer the API for valuations. AccountService.GetAccount returns health, margin, and PnL computed against the program's caches; recomputing them from raw accounts means reimplementing the risk engine.

Decoding events and transactions

Each Flint instruction writes its events as one batch, carried in the data of an inner instruction where Flint invokes itself. Only trust a batch that Flint invoked: the program rejects an event instruction its own event authority did not sign, but the attempt can still show up in a failed transaction's inner instructions, and any program can put the same bytes in its own instruction data.

Event values are the program's raw integers: atoms, lots and ticks as stored, and fixed-point fields (Q0.64 fractions, U64.32 share counts, U32.64 share prices, Q32 funding indices) as their raw bits. Scale them at the edge of your code; u96 values come back as { low, high } (TS) and U96 (Rust).

import { decodeFlintTransaction } from "@superis-labs/flint-idl";
 
const response = await rpc
  .getTransaction(signature, { encoding: "json", maxSupportedTransactionVersion: 0 })
  .send();
const decoded = decodeFlintTransaction(response!);
for (const event of decoded.events) {
  if (event.kind === "LinearPerpFill") console.log(event.traderId, event.lots, event.ticks);
}
for (const instruction of decoded.instructions) {
  console.log(instruction.flint?.instructionType, instruction.undecoded);
}

decodeFlintTransaction parses every top-level and inner Flint instruction, attaches each event batch to the instruction that emitted it, and lists all events in emission order. It works out each inner instruction's invoker from the response's stack heights and takes a batch only when Flint invoked it; a response without stack heights yields no events. failed is true when the transaction failed; the program still writes the events of the instruction that failed, so check it before treating them as settled. complete is false when any Flint instruction or batch could not be decoded (each carries its reason in undecoded), so events is missing something: check it before summing fills or balances. decodeFlintEventBatch (TS) and decode_event_batch (Rust) decode a single batch, and a trade's source names the order-list level, density band, or resting order it matched (decodeLevelSource / LevelSource::decode).

Building instructions

Instruction builders are generated per instruction:

  • TypeScript: getXxxInstruction(input) and, for instructions that emit events, getXxxInstructionAsync(input), which derives the __event_authority PDA for you.
  • Rust: XxxBuilder::new()…instruction() returns a solana_instruction::Instruction. Set each required account explicitly. Optional accounts can be omitted.
import {
  findGlobalConfigPda,
  findSpotMarketPda,
  findTraderAccountPda,
  getDepositInstructionAsync,
} from "@superis-labs/flint-idl";
 
const [globalConfig] = await findGlobalConfigPda();
const [traderAccount] = await findTraderAccountPda({ traderId: 42n });
const [spotMarket] = await findSpotMarketPda({ mint });
 
const ix = await getDepositInstructionAsync({
  globalConfig,
  rentPayer: signer,
  signer,
  traderAccount,
  spotMarket,
  mint,
  from: userTokenAccount,
  vault,
  tokenProgram,
  systemProgram,
  amount: { __kind: "Atoms", fields: [1_000_000n] },
});

Instructions that take a variable account list expose only the fixed prefix; append the rest with withRemainingAccounts(ix, metas) (TS) or add_remaining_accounts (Rust):

  • placePerpOrder and liquidatePerp — the writable maker trader accounts follow the event accounts, strictly ascending by trader id (otherwise PerpOrderMakersUnordered).
  • prunePerpOrders — the writable trader accounts whose expired orders to remove follow the event accounts.
  • updateOrderLists, updateDensityFairs, resetQuotingSequences — one writable quoting account per contract the records name.
  • syncSpotProductCache — the product accounts follow the cache, event authority, and program accounts. syncOracleSources also takes a dynamic tail.

Wallet-signed perp orders pass authority: signer with auth: { __kind: "Wallet" }. Passkey orders use the Instructions sysvar as authority, a writable delegate, and the passkey variant of TraderAuth, with the secp256r1 verification instruction immediately before the order — @superis-labs/flint-margin-user-client builds the pair with buildPasskeyPerpOrderInstructions. That helper builds Ioc orders and accepts optional averagePriceTicks. It has no order expiry; validUntilSlot expires the passkey authorization. It signs the instruction prefix before the trailing clientDataJson length and data: 50 bytes without an average bound, or 54 with one. See Passkeys before building a different order kind.

Perp orders and the book

placePerpOrder takes grid lots, direction, reduceOnly, and kind: PerpOrderKind. Price and expiry belong to the chosen kind:

  • Gtc { limit, postOnly } crosses now and rests the remainder at limit until cancelled. limit is positive UInt32 ticks.
  • Gtd { limit, postOnly, expiresAtSlot } also rests the remainder, until the absolute expiry slot. An expiry already in the past is refused.
  • Ioc { bound, minFillLots } crosses and drops the remainder; it is refused if nothing crosses or the fill is below minFillLots.
  • Fok { bound } fills every requested lot within the bound or refuses the order.

bound: TakerBound { limit, average } sets the worst individual fill price and an optional worst volume-weighted average price, before fees. Both prices are positive UInt32 ticks. An average bound above the individual limit for a long, or below it for a short, is refused (PerpOrderAverageBeyondLimit). On Gtc and Gtd, postOnly: true refuses the order if any of it would cross the maker accounts passed to the instruction. Makers omitted from that list do not prevent it from resting.

The remaining arguments include:

  • clientOrderId — your handle, echoed on the order events.
  • bookScan — OrderBookScan { maxOrders }, how many resting orders to read: zero skips the book, 4294967295 is uncapped.

Every perp order writes the contract's orderBook account. modifyPerpOrder changes or cancels one resting order, addressed by MakerSide and price; its lots, reduceOnly, and expiresAtSlot are FixedOption* values, where isSome: false keeps the current one and lots of zero cancels. cancelAllPerpOrders removes every resting order the trader has on a contract. Anyone can run prunePerpOrders to remove expired orders, and forceCancelPerpOrders to cancel the margin-reserving orders of a trader below initial margin.

Quoting slots

Flint grants a trader quoting permission with the admin setTraderQuotingPermission. The trader then opens its own disabled, empty slot on a perp with openQuotingEntry and can free it with releaseQuotingEntry. updateQuoting takes the owner or a TRADE-scoped delegate (with delegateTraderAccount); the batch instructions updateOrderLists, updateDensityFairs, and resetQuotingSequences also accept a QUOTE-scoped delegate. See Maker onboarding.

Instruction catalog

WhoInstructions
TraderscreateTraderAccount, deposit, withdraw, lend, redeemLend, borrow, repayBorrow, placePerpOrder, modifyPerpOrder, cancelAllPerpOrders, setAccountLeverageCap, startFlashloan / endFlashloan
DelegatescreateDelegateTraderAccount, revokeDelegateTraderAccount, registerPasskeyDelegate, revokePasskeyDelegate
MakersopenQuotingEntry, releaseQuotingEntry, updateQuoting, updateOrderLists, updateDensityFairs, resetQuotingSequences
KeepersliquidateSpot, liquidatePerp, backstop, settleFunding, settleRealisedPnl, prunePerpOrders, forceCancelPerpOrders, syncSpotProductCache, syncLinearPerpCache, syncOracleSources, syncLazerReport, publishOracles, applySpotMarketUpdate, applyLinearPerpUpdate, collectSpotBorrowLendFees, collectLinearPerpFees
AdmininitializeProgram, updateGlobalConfig, claimAdmin, createSpotMarket, createLinearPerp, setTraderQuotingPermission, proposeSpotMarketUpdate / cancelSpotMarketUpdate, proposeLinearPerpUpdate / cancelLinearPerpUpdate, updateLinearPerpConfig, updateInsuranceFundConfig, globalPauseSpotMarket, socializeLoss, initializeOracles, registerOracleEntry, configureOracleSources, setOracleTiers, assignOracleTier

Rust modules and builders use the snake_case / PascalCase forms (deposit → DepositBuilder). syncLazerReport must be top level and immediately follow an ed25519 verification instruction over the signed Lazer message; in TypeScript, syncLazerReportEd25519InstructionData(message, syncInstructionIndex) builds that instruction's data for ED25519_PROGRAM_ADDRESS.

Submitting

Sign the transaction, then select a submission path:

  • Your own Solana RPC (sendTransaction).
  • SponsorshipService.SponsorTransaction, which pays the fee: put GetSponsorshipPubkey's key in the fee-payer slot, sign everything else, and leave the sponsor's signature empty. Only margin, compute-budget, and secp256r1 instructions are allowed.
  • TxService.SubmitTx for makers, authenticated as one of the transaction's signers.

Errors

Both clients carry the program's error enum — errors::FlintError in Rust, FLINT_ERROR__* constants with isFlintError and getFlintErrorMessage in TypeScript. The codes and their meanings are in Errors.

import { FLINT_ERROR__HEALTH_CHECK_FAILED, isFlintError } from "@superis-labs/flint-idl";
 
if (isFlintError(err, transactionMessage, FLINT_ERROR__HEALTH_CHECK_FAILED)) {
  // the order would leave an account below its margin requirement
}