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:
| Language | Package | Version line |
|---|---|---|
| TypeScript | @superis-labs/flint-idl — a @solana/kit client | Its own (0.11.0); tracks the deployed program. |
| Rust | flint-margin-onchain — borsh builders and decoders | The 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::Decodein Rust,FlintDecodeErrorcodeaccount-sizein 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::parseused 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 asUnknownand 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
| Cluster | Margin program | Oracle program |
|---|---|---|
| Built-in reference | 4MxEr7W5f9xtajcTTpTTd5wfdBmwrF72j2NMg6YzXgMo | fnt2ARonSBGhBY1RTxVSvZ3p8JSYi9iAE9Hkq5jXcmf |
| Other network | Use the operator's tested program address and SDK combination | Use 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" }# Requires GitHub Packages access; see the TypeScript SDK page.
npm install @superis-labs/flint-idl@0.11.0 @solana/kitPDAs
| Account | Seeds | Rust | TypeScript |
|---|---|---|---|
| Trader account | "trader_account", trader_id: u64 | TraderAccount::find_pda(trader_id) | findTraderAccountPda({ traderId }) |
| Delegate | "delegate_trader", trader_id: u64, delegate: Pubkey | DelegateTraderAccount::find_pda(trader_id, &delegate) | findDelegateTraderAccountPda({ traderId, delegate }) |
| Collateral market | "spot_market", mint | SpotMarket::find_pda(&mint) | findSpotMarketPda({ mint }) |
| Perp contract | "linear_perp", linear_perp_id: u32 | LinearPerp::find_pda(id) | findLinearPerpPda({ linearPerpId }) |
| Perp quoting account | "quoting", linear_perp_id: u32 | LinearPerpQuoting::find_pda(id) | findQuotingPda({ linearPerpId }) |
| Perp order book | "order_book", linear_perp_id: u32 | LinearPerpOrderBook::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 isFLINT_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.
| Account | Rust live accessor | TypeScript |
|---|---|---|
ProductCatalog | live_products() | data.products.slice(0, data.length) |
SpotProductCache | live_entries() | data.entries.slice(0, data.length) |
LinearPerpCache | live_entries() | data.entries.slice(0, data.length) |
LinearPerpQuoting | live_entries() — one maker slot each | data.entries.slice(0, data.length) |
OracleCache | live_entries() — the index is the dense oracle id | data.entries.slice(0, data.length) |
TraderAccount | live_tail(), or spot_positions() / linear_perp_positions() | decodeTraderPositions(data) |
LinearPerpOrderBook | live_tail() — raw arena bytes | readRestingOrders(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);
}use flint_margin_onchain::events::{FlintEvent, decode_event_batch, is_event_batch};
if invoker == flint_margin_onchain::FLINT_ID
&& inner_program == flint_margin_onchain::FLINT_ID
&& is_event_batch(&inner_data)
{
for event in decode_event_batch(&inner_data)? {
if let FlintEvent::LinearPerpFill(fill) = event {
println!("{} {} {}", fill.trader_id, fill.lots, fill.ticks);
}
}
}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_authorityPDA for you. - Rust:
XxxBuilder::new()…instruction()returns asolana_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):
placePerpOrderandliquidatePerp— the writable maker trader accounts follow the event accounts, strictly ascending by trader id (otherwisePerpOrderMakersUnordered).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.syncOracleSourcesalso 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 atlimituntil cancelled.limitis 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 belowminFillLots.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,4294967295is 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
| Who | Instructions |
|---|---|
| Traders | createTraderAccount, deposit, withdraw, lend, redeemLend, borrow, repayBorrow, placePerpOrder, modifyPerpOrder, cancelAllPerpOrders, setAccountLeverageCap, startFlashloan / endFlashloan |
| Delegates | createDelegateTraderAccount, revokeDelegateTraderAccount, registerPasskeyDelegate, revokePasskeyDelegate |
| Makers | openQuotingEntry, releaseQuotingEntry, updateQuoting, updateOrderLists, updateDensityFairs, resetQuotingSequences |
| Keepers | liquidateSpot, liquidatePerp, backstop, settleFunding, settleRealisedPnl, prunePerpOrders, forceCancelPerpOrders, syncSpotProductCache, syncLinearPerpCache, syncOracleSources, syncLazerReport, publishOracles, applySpotMarketUpdate, applyLinearPerpUpdate, collectSpotBorrowLendFees, collectLinearPerpFees |
| Admin | initializeProgram, 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: putGetSponsorshipPubkey'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.SubmitTxfor 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
}