Skip to content
LogoLogo

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.

ModuleWhat it covers
flint.margin.apiMarginUserClient for the nine public margin-user services, MarginMakerClient and MarginAuthFlow for the authenticated maker API, and a Solana-RPC fallback for TxService.
flint.margin.onchainThe 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.quotingQuoteBuilder 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.py

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].

HelperReturns
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_units rejects a size or price that isn't a whole number of lots or ticks.
  • A market order needs reference_price_ticks and max_slippage_bps. A limit order sets limit_price instead.
  • The default time in force is FOK for market orders and GTC for limit orders. GTD needs a future expires_at_slot; post_only=True needs GTC or GTD. reduce_only, min_fill_base, rent_payer, and is_delegate are 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 raise PerpLiquidityError at 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 the SessionService heartbeat open unless auto_session_heartbeat=False.
  • Stubs: maker, tx, session, spread_protect, escalation, and node. client.public is a MarginUserClient on the matching public host.
  • api_key= with trader_id= authenticates with an organization API key instead of a keypair. TxService, SessionService, and SpreadProtectService still need a keypair session.
  • client.tx_with_fallback(rpc_url) returns a TxService wrapper whose SubmitTx, SubscribeBlockhash, and SubscribeSlots fall 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 for SubscribeTxStatus.

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, and order_list. build(core) returns the instruction groups without sending.
  • commit retries 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 in result.failed.
  • Receipts report processed observations. settled_within raises CommitTimeout, CommitLagged, or CommitDisconnected when it can't tell; receipt.outcome() keeps the partial picture.
  • QuotingConfig defaults 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. Set delegate_trader_account when the key isn't the owner.
  • Call refresh_quoting_state() after admission changes. core.markets holds each admitted market's entry index and sequences.
  • core.spread_mult() and core.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 when TxService is unreachable, and with_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.