Python SDK
The flint-api-client Python package carries the perps SDK under
flint.margin, next to the spot SDK. It is asyncio-based, uses
grpcio for the API and
solders for keys, instructions, and
transactions.
| Module | What it covers |
|---|---|
flint.margin.api | MarginUserClient for the nine public margin-user services, MarginMakerClient and MarginAuthFlow for the authenticated maker API, and a Solana-RPC fallback for TxService. |
flint.margin.onchain | The margin program's instruction builders, account and event decoders, PDAs, and error table, generated from the same Codama IDL as the Rust and TypeScript clients. Adds transaction decoding, quote-book and resting-order readers, and the taker helpers prepare_perp_order and build_perp_close_instruction. |
flint.margin.quoting | QuoteBuilder and QuotingCore, a port of the Rust maker quoting core: sequence allocation, signing, submission with retry and fallback, landing receipts, and spread protection. |
See SDK overview for what each language supports.
Install
Python 3.10 or newer. The package index needs a read-only token from the
Developer tab of the maker dashboard
(mainnet,
devnet), or from the
operator. The examples read it from PYPI_MAKER_TOKEN.
uv init --bare flint-bot && cd flint-bot
cat >> pyproject.toml <<'TOML'
[[tool.uv.index]]
name = "flint"
url = "https://pypi.xantasoft.com/simple/"
explicit = true
[tool.uv.sources]
flint-api-client = { index = "flint" }
TOML
export UV_INDEX_FLINT_USERNAME=__token__
export UV_INDEX_FLINT_PASSWORD="$PYPI_MAKER_TOKEN"
uv add "flint-api-client[quic]>=8.1"
uv run python bot.pypython -m venv .venv
source .venv/bin/activate
python -m pip install \
--index-url "https://__token__:${PYPI_MAKER_TOKEN}@pypi.xantasoft.com/simple/" \
"flint-api-client[quic]>=8.1"With uv, explicit = true and the flint-api-client source pin mean only the
SDK comes from Flint's index; every other dependency resolves from PyPI. uv
reads the credentials from UV_INDEX_FLINT_USERNAME and
UV_INDEX_FLINT_PASSWORD, so the token never lands in pyproject.toml or
uv.lock. Set the same two variables in CI before uv sync. Put the
examples below in bot.py.
Perps support (flint.margin) needs flint-api-client 8.1 or newer. The
quic extra lets QuotingCore send transactions straight to the leader's TPU;
drop it for read-only or taker-only use.
Public data — MarginUserClient
MarginUserClient owns one gRPC channel to the public host and exposes one
generated stub per service: market, account, risk, sponsorship,
funding, perp_market, market_data, order, and notifications. Every
call carries x-flint-sdk: python/<version> and the proto hash and revision.
import asyncio
from flint.margin.api import MarginUserClient
from flint.margin.gen.flint.margin.user.v1 import common_pb2
from flint.margin.gen.flint.margin.user.v1.account import messages_pb2 as account_msg
from flint.margin.gen.flint.margin.user.v1.market_data import messages_pb2 as md_msg
from flint.margin.gen.flint.margin.user.v1.perp_market import messages_pb2 as perp_msg
async def main() -> None:
async with MarginUserClient.devnet() as client:
markets = await client.perp_market.ListPerpMarkets(perp_msg.ListPerpMarketsRequest())
for market in markets.markets:
print(market.perp_id.id, market.symbol, market.tick_size.value)
trader = common_pb2.TraderId(id=42)
account = await client.account.GetAccount(account_msg.GetAccountRequest(trader_id=trader))
print(account.account.health.net_equity.value)
book = client.market_data.SubscribeOrderBook(
md_msg.SubscribeOrderBookRequest(perp_id=markets.markets[0].perp_id, depth=20)
)
async for update in book:
print(update.snapshot, update.sequence, update.order_book.bids[:3])
asyncio.run(main())MarginUserClient() with no endpoint connects to mainnet;
MarginUserClient.devnet(), MarginUserClient.mainnet(), and
MarginUserClient(url) choose explicitly. The endpoint constants
DEVNET_PUBLIC_ENDPOINT, MAINNET_PUBLIC_ENDPOINT, DEVNET_MAKER_ENDPOINT,
and MAINNET_MAKER_ENDPOINT are exported from flint.margin.api.
MarginUserClient.from_channel(channel) wraps a channel you configured.
stream_with_backoff(factory, stop) reopens a server stream on a
500 ms → 30 s schedule and yields StreamEvents of kind connected, item,
and disconnected. Each reconnect starts with a fresh snapshot. Failures are
grpc.aio.AioRpcError; status_code(err), status_message(err), and
response_metadata(err) narrow any exception.
Reading the chain
decode_flint_account_as checks the owner, discriminator, and exact length
before decoding, then returns the generated dataclass. The generated module
flint.margin.onchain.generated holds the account types, PDA finders, and
instruction builders.
from flint.margin.onchain import (
decode_aggregated_quote_book,
decode_flint_account_as,
decode_trader_positions,
encoded_quoting_entries,
read_resting_orders,
)
from flint.margin.onchain import generated as g
trader = decode_flint_account_as(g.TraderAccount, owner, data)
positions = decode_trader_positions(trader)
for perp in positions.linear_perp:
print(perp.linear_perp_id, perp.entry_contract_lots, perp.realised_pnl_ticks)
quoting = decode_flint_account_as(g.LinearPerpQuoting, quoting_owner, quoting_data)
book = decode_aggregated_quote_book(encoded_quoting_entries(quoting), slot)
print(book.bids[:3], book.asks[:3])
order_book = decode_flint_account_as(g.LinearPerpOrderBook, book_owner, book_data)
for order in read_resting_orders(order_book):
print(order.trader_id, order.side, order.price_ticks, order.lots, order.expiry)owner and data are the account's owner Pubkey and raw bytes from
getAccountInfo. PDAs come from the generated finders, for example
g.find_trader_account_pda(trader_id)[0], g.find_quoting_pda(perp_id)[0],
and g.find_order_book_pda(perp_id)[0].
| Helper | Returns |
|---|---|
decode_flint_account(owner, data) | DecodedFlintAccount(kind, account) for any Flint account. |
decode_trader_positions(trader) | DecodedTraderPositions with spot and linear_perp lists, walked from the account's tail. |
decode_aggregated_quote_book(entries, slot) | Maker quote depth live at slot, summed by price. |
decode_quote_entry_side(entry, slot, "bid" | "ask") | One maker's live levels. |
read_resting_orders(book) | RestingBookOrders; is_resting_live(order, slot, unix_seconds) filters expired ones. |
Quote depth is indicative: maker margin caps are enforced by the program.
Placing orders
prepare_perp_order reads the market's quoting account, resting order book,
and the Clock sysvar in one getMultipleAccounts call, picks the makers whose
quotes or resting orders cross your price bound, and returns a
place_perp_order instruction with their trader accounts attached in
ascending ID order. It does not sign or send. Pass any object with an async
request(method, params) JSON-RPC method:
from flint.api.rpc import FailFastRpc
from flint.margin.onchain import FLINT_PROGRAM_ID, PreparePerpOrderInput, prepare_perp_order
rpc = FailFastRpc(rpc_url, timeout=30)
prepared = await prepare_perp_order(
PreparePerpOrderInput(
program_id=FLINT_PROGRAM_ID,
signer=owner.pubkey(),
trader_id=42,
linear_perp_id=0,
minimum_order_size_base="0.001",
minimum_price_increment="0.001",
side="buy",
size_base="0.003",
reference_price_ticks=str(mark_ticks),
max_slippage_bps=100,
time_in_force="IOC",
client_order_id=1,
),
rpc,
on_waiting_for_quotes=lambda: print("waiting for fresh maker quotes"),
)
print(prepared.lots, prepared.limit_price_ticks, prepared.maker_trader_ids, prepared.slot)Add prepared.instruction to a transaction, sign it, and send it with your
own RPC or TxService.
- Sizes and prices are decimal strings;
exact_grid_unitsrejects a size or price that isn't a whole number of lots or ticks. - A market order needs
reference_price_ticksandmax_slippage_bps. A limit order setslimit_priceinstead. - The default time in force is FOK for market orders and GTC for limit orders.
GTD needs a future
expires_at_slot;post_only=Trueneeds GTC or GTD.reduce_only,min_fill_base,rent_payer, andis_delegateare supported. - Defaults: 16 attempts, 300 ms between retries, a 5 s request timeout, and a
16-slot quote expiry buffer (
max_attempts,retry_delay_ms,request_timeout_ms,quote_buffer_slots). It retries only while quotes are unavailable; live quotes outside the bound raisePerpLiquidityErrorat once. The SDK never widens the bound.
A resting limit order:
rest = await prepare_perp_order(
PreparePerpOrderInput(
program_id=FLINT_PROGRAM_ID,
signer=owner.pubkey(),
trader_id=42,
linear_perp_id=0,
minimum_order_size_base="0.001",
minimum_price_increment="0.001",
side="buy",
size_base="0.003",
limit_price="120.5",
time_in_force="GTC",
post_only=True,
client_order_id=2,
),
rpc,
)With chain data you already hold, build_perp_order_instruction takes a
BuildPerpOrderInput with slot, unix_seconds, quoting, and
resting_orders, and select_crossing_makers returns just the crossing
makers. market_limit_ticks, base_for_lots, fractional_close_size_base,
and reverse_size_base are the grid helpers behind them.
Modify and cancel
Modify and cancel use the generated builders. A size of 0 cancels one
order; build_cancel_all_perp_orders cancels every order on a contract.
from solders.system_program import ID as SYSTEM_PROGRAM
modify = g.build_modify_perp_order(
rent_payer=owner.pubkey(),
authority=owner.pubkey(),
trader_account=g.find_trader_account_pda(42)[0],
global_config=g.find_global_config_pda()[0],
spot_product_cache=g.find_spot_product_cache_pda()[0],
oracle_cache=g.find_oracle_cache_pda()[0],
linear_perp_cache=g.find_linear_perp_cache_pda()[0],
linear_perp=g.find_linear_perp_pda(0)[0],
order_book=g.find_order_book_pda(0)[0],
system_program=SYSTEM_PROGRAM,
linear_perp_id=0,
side=g.MakerSide.BUY,
price_ticks=120_500,
lots=g.FixedOptionU32(is_some=True, value=1),
reduce_only=g.FixedOptionBool(is_some=False, value=False),
expires_at_slot=g.FixedOptionU64(is_some=False, value=0),
auth=g.TraderAuthWallet(),
)
cancel_all = g.build_cancel_all_perp_orders(
authority=owner.pubkey(),
trader_account=g.find_trader_account_pda(42)[0],
global_config=g.find_global_config_pda()[0],
order_book=g.find_order_book_pda(0)[0],
linear_perp_id=0,
auth=g.TraderAuthWallet(),
)Close a position
build_perp_close_instruction rounds an integer fraction of the position down
to whole lots and sends it as a reduce-only FOK order:
from flint.margin.onchain import BuildPerpCloseInput, build_perp_close_instruction
close = build_perp_close_instruction(
BuildPerpCloseInput(
program_id=FLINT_PROGRAM_ID,
signer=owner.pubkey(),
trader_id=42,
linear_perp_id=0,
minimum_order_size_base="0.001",
minimum_price_increment="0.001",
position_side="long",
position_size_base="0.003",
close_numerator=1,
close_denominator=2,
reference_price_ticks=str(mark_ticks),
max_slippage_bps=100,
slot=slot,
unix_seconds=unix_seconds,
quoting=encoded_quoting_entries(quoting),
resting_orders=read_resting_orders(order_book),
)
)Pass the chain clock's unix_seconds so timestamp expiries use chain time.
Funding, PnL, deposits, and withdrawals
These are plain generated builders. Funding settles closed funding periods, so refresh the perp cache in the same transaction:
crank = g.build_sync_linear_perp_cache(
linear_perp=g.find_linear_perp_pda(0)[0],
quoting=g.find_quoting_pda(0)[0],
spot_product_cache=g.find_spot_product_cache_pda()[0],
oracle_cache=g.find_oracle_cache_pda()[0],
linear_perp_cache=g.find_linear_perp_cache_pda()[0],
linear_perp_id=0,
)
settle = g.build_settle_funding(
trader_account=g.find_trader_account_pda(42)[0],
linear_perp_cache=g.find_linear_perp_cache_pda()[0],
)build_settle_realised_pnl moves realised PnL into the perp's PnL currency.
build_deposit takes amount=g.DepositAmountAtoms(value=atoms), and
build_withdraw takes amount in atoms; both need the collateral mint's
g.find_spot_market_pda(mint)[0] and its vault, the market's associated token
account. python/examples/perps_e2e.py runs the full sequence on devnet:
deposit, IOC buy, post-only rest, modify, cancel all, settle funding, close,
settle PnL, and withdraw. Set RPC_URL to a devnet RPC endpoint and KEYPAIR
to the owner's keypair file; FUNDER_KEYPAIR tops the owner up when its SOL
balance is short.
Decoding transactions and errors
decode_flint_transaction takes a getTransaction response requested with
"encoding": "json" and returns every Flint instruction, including inner
ones, plus the events they emitted:
from flint.margin.onchain import custom_error_code, decode_flint_transaction, flint_error_name
decoded = decode_flint_transaction(response)
for top in decoded.instructions:
if top.flint is not None:
print(top.index, top.flint.instruction_type.name)
for event in decoded.events:
if event.kind == "LinearPerpTrade":
print(event.maker_id, event.taker_id, event.price_ticks, event.lots)
err = (response.get("meta") or {}).get("err")
code = custom_error_code(err) if err else None
if code:
print(flint_error_name(code[1]))decoded.complete is false when an instruction carried bytes this client
does not know. decode_flint_event_batch(data) and is_flint_event_batch(data)
decode a raw event-batch instruction, and FLINT_EVENT_KINDS lists every
event kind. flint_error_name(code) and flint_error_message(code) map a
custom program error code to its name and message. See
Errors.
Maker client — MarginMakerClient
MarginMakerClient authenticates a key that owns a trader account or is an
active TRADE- or QUOTE-scoped delegate on one. MarginAuthFlow signs
FLINT-MARGIN-AUTH-V1: followed by the server's nonce, caches the session,
refreshes it before expiry, and shares one handshake across callers.
from solders.keypair import Keypair
from flint.margin.api import MarginMakerClient
from flint.margin.gen.flint.margin.maker.v1.maker import messages_pb2 as maker_msg
async with MarginMakerClient.devnet(keypair, trader_id=7) as client:
session = await client.authenticate()
print(session.trader_id, session.trader_ids)
stats = await client.maker.GetStats(maker_msg.GetMakerStatsRequest())authenticate()mints a session, starts the refresh loop, and holds theSessionServiceheartbeat open unlessauto_session_heartbeat=False.- Stubs:
maker,tx,session,spread_protect,escalation, andnode.client.publicis aMarginUserClienton the matching public host. api_key=withtrader_id=authenticates with an organization API key instead of a keypair.TxService,SessionService, andSpreadProtectServicestill need a keypair session.client.tx_with_fallback(rpc_url)returns aTxServicewrapper whoseSubmitTx,SubscribeBlockhash, andSubscribeSlotsfall back to plain Solana RPC when the server is unreachable (should_fallback(err)decides).poll_tx_status(rpc, signature, last_valid_block_height)is the opt-in per-signature substitute forSubscribeTxStatus.
Quoting — QuotingCore and QuoteBuilder
QuotingCore.start(client, config) checks that the node serves
QuotingConfig.program_id, authenticates, starts the blockhash, slot, and
leader streams, loads your quoting entries, and follows the spread-protection
feed. QuoteBuilder builds update_quoting instructions, allocates sequence
numbers above what the chain has seen, groups updates by market, signs, and
submits:
from flint.margin.api import MarginMakerClient
from flint.margin.onchain.generated import DensityFair, OrderListLevel
from flint.margin.quoting import QuoteBuilder, QuotingConfig, QuotingCore
client = MarginMakerClient.devnet(keypair, trader_id=7)
core = await QuotingCore.start(client, QuotingConfig())
try:
slot = core.server_state.get_current_slot() or 0
result = await (
QuoteBuilder()
.order_list(
0,
[OrderListLevel(price=120_000, quantity=5, order_id=1, slot_expiry=slot + 30)],
[OrderListLevel(price=120_100, quantity=5, order_id=2, slot_expiry=slot + 30)],
)
.commit(core)
)
for failed in result.failed:
print(failed.transaction.signatures[0], failed.error)
outcome = await result.receipt.settled_within(10.0)
print(outcome.landed, outcome.failed, outcome.unknown, outcome.is_complete())
finally:
await core.close()
await client.close()- Builder methods:
update,reset,enable,density_fair(DensityFair(slot, bid_fair, ask_fair)),density_bands,relative_density_bands, andorder_list.build(core)returns the instruction groups without sending. commitretries transport failures with the same signed bytes;commit_with_retry(core, RetryConfig(max_attempts, delay))changes the defaults (3 attempts, 0.1 s). Rejected submissions land inresult.failed.- Receipts report processed observations.
settled_withinraisesCommitTimeout,CommitLagged, orCommitDisconnectedwhen it can't tell;receipt.outcome()keeps the partial picture. QuotingConfigdefaults match Rust: 200,000 compute units, the server's priority-fee recommendation, a 10 s readiness timeout, a 15 s maximum blockhash age, and v1 transactions. Setdelegate_trader_accountwhen the key isn't the owner.- Call
refresh_quoting_state()after admission changes.core.marketsholds each admitted market's entry index and sequences. core.spread_mult()andcore.spread_protect_window()report spread protection. They don't touch your quotes.with_rpcs([...])also broadcasts each transaction through your RPCs,with_tx_fallback(rpc)falls back to RPC whenTxServiceis unreachable, andwith_quic(sender)sends to the next leaders over QUIC.
To sign elsewhere, start with
QuotingCore.start_with_signer(client, config, QuotingSigner.external(pubkey))
and deliver through QuoteBuilder.commit_externally(core, submitter); report
outcomes with core.tx_outcome_publisher(). This is the same model as the
Rust client.
python/examples/perps_maker_smoke.py starts a core, refreshes quoting state,
commits an order list, and waits for its receipt on devnet.
Decimal handling
Prices, sizes, balances, and rates arrive as Decimal { value: str }. Parse
them with decimal.Decimal, never float. The on-chain helpers take decimal
strings and return exact integers in ticks and lots.
