Appearance
Python SDK
Use the Python SDK when your maker bot signs and submits Flint quote updates directly from Python. It gives you one Client for market data, maker authentication, balances, chain tips, transaction submission, and receipts, plus a QuoteBuilder for the three on-chain quoting modes:
| Strategy | Use it when |
|---|---|
| Linear distribution | You quote a ladder around a fair price. |
| Oracle offset | You quote offsets from an external oracle/fair. |
| Order list | You want CEX-style explicit bid and ask orders. |
Your bot still owns fair-value inputs, risk checks, inventory targets, persistence, and reconciliation. The SDK handles the Flint-specific work needed to publish those decisions: decimal-safe unit conversion, quote instruction construction, transaction packing/signing, and submit-status tracking.
Before you start
This guide continues from Onboarding — the canonical, SDK-agnostic setup. To quote against a real maker, finish Onboarding steps 1–2 first:
- a provisioned
maker_id; - your hot quoting keypair delegated as a quoting authority;
- the markets you'll quote enabled, with
soft_max_balancecaps on both sides; and - inventory deposited.
Onboarding also covers your gRPC endpoints and the trusted, config-pinned program id you pass below. A quote can be accepted by the API and still never fill if the maker has no sell-side deposit or no buy-side cap headroom — see Onboarding's funding section.
The Python examples below assume:
- base spot
1quotes against quote spot0; - your quoter keypair is registered as a quoting authority;
fair_slotis the Solana slot associated with your current fair/oracle input;FLINT_PROGRAM_IDandSOLANA_RPC_URLcome from trusted bot config;stateis your own durable state object or database row when using the low-level path.
Install
Install into a virtualenv — recent Pythons refuse to install into a system interpreter (error: externally-managed-environment):
sh
python -m venv .venv && source .venv/bin/activateThe index needs a read-only token. Grab yours from the Developer tab of the dashboard — mainnet or devnet — and substitute it for {TOKEN}:
sh
pip install --index-url https://__token__:{TOKEN}@pypi.xantasoft.com/simple/ flint-api-clientInstall flint-api-client; import it as flint in Python.
The SDK uses solders for Solana keys, pubkeys, blockhashes, instructions, and transaction signing.
Connect
Read-only processes default to the mainnet endpoint:
python
from flint import Client
client = Client()Maker bots just need a keypair — the authed endpoint defaults to mainnet too:
python
from solders.keypair import Keypair
from flint import Client
keypair = Keypair.from_json(open("/path/to/id.json").read())
client = Client(keypair=keypair)To target another network or a custom host, see Overriding the endpoint. Endpoints(..., insecure=True) and SUPERIS_INSECURE=1 force plaintext even for HTTPS-looking URLs, so never set them in production.
Always close long-lived clients on shutdown:
python
await client.close()Endpoint discovery
A Sweetspot hostname resolves to several nodes. By default the client races a probe across every DNS record behind the public hostname (median of three NodeService.GetNodeInfo calls each) and pins both channels to the fastest healthy node — each deployment node serves api and makerapi from the same address, so the authed channel follows the public winner. A background task re-probes every 10 s:
- If the pinned node stops answering (or reports degraded health), the client switches to the best alternative immediately.
- A merely faster node must beat the pinned one by ≥10 % and ≥5 ms, in two consecutive rounds (so ~20 s), before a switch — a transient network blip never cuts live streams.
Because the client's constructor is synchronous, the first race runs in the background: channels start out dialling the hostname exactly as an undiscovered client would, and get pinned to the winner shortly after the first RPC. Every switch reconnects the client's resilient streams onto the new node — swapping the channel alone only affects new calls, since gRPC keeps serving streams already in flight on the old connection.
python
client = Client(
endpoint_discovery=False, # opt out entirely, or
discovery_interval=60.0, # slow the re-probe cadence
)
# Observability: the node both channels are pinned to right now
# (None until the first race lands).
pinned = client.discovered_endpoint()
# Saw a disconnect yourself? Trigger a re-probe immediately.
client.reprobe_endpoints()Discovery is skipped for IP-literal endpoints, which always connect directly. This matches the Rust SDK's behaviour and thresholds; the two are pinned against each other in tests/test_cross_sdk.py.
Discover markets
Call list_markets() once on startup and whenever you need to refresh the catalog. It returns Python-friendly MarketInfo objects keyed by (base_spot_id, quote_spot_id).
python
markets = await client.list_markets()
market = markets[(1, 0)]
print(market.name)
print(market.program_id)
print(market.last_price)Each MarketInfo includes the mint, program id, and unit metadata the quoting builder needs to convert human prices and sizes into on-chain integers.
Authenticate
Authenticate once after startup. The client caches and refreshes the session when later maker or transaction calls need bearer metadata.
python
session = await client.authenticate()
print("maker id", session.maker_id)
balances = await client.maker_balance()
stats = await client.maker_stats()Revoke when you need best-effort early token invalidation, such as logout or key rotation:
python
await client.revoke()If revoke fails during shutdown, the server-side token may remain live until expiry. Do not treat revoke as a substitute for short token lifetimes and key rotation controls.
Logging
The Python SDK uses the standard library logging module. With a logger configured at DEBUG or TRACE, the SDK can emit connection, auth, RPC, and stream lifecycle logs. At DEBUG, client construction logs the SDK version, proto hash, and proto revision stamped on outbound requests.
To enable full protobuf message payload logs:
python
from flint import Client
client = Client(
keypair=keypair,
log_messages=True,
max_message_chars=50_000,
)You can also enable AuthService payload logging directly on AuthFlow if you are using it without Client.
Notes:
- Message payload logs are opt-in.
- Payload logging covers
Clientconvenience methods andAuthFlow. - Payloads are emitted at the custom
TRACElevel exposed asflint.api.TRACE_LEVEL. max_message_charscaps each payload in characters. Use0to disable truncation.- Auth fields such as
nonce,signature,code, andsession_tokenare redacted. - Bearer headers and API key headers are not logged.
Initialize bot state
Flint enforces strictly increasing per-maker/per-market sequence counters. The Python builder consumes counters when it builds instructions, before anything is submitted.
For a brand-new durable state row, seed counters from wall-clock nanoseconds:
python
import time
seed = time.time_ns()
state.next_oracle_sequence = seed
state.next_order_sequence = seed
state.next_client_order_id = seed
state.save()The high-level quoting path seeds in-memory counters from time.time_ns() and reserves consumed values before any submit attempt. Gaps are safe; reuse is not. Run one quoting process per maker_id. If you need durable counter storage, use the low-level escape hatch below.
Submit one quote tick
A quote tick usually builds one QuoteBuilder, commits it through QuotingCore, then waits for the receipt:
python
import os
from solders.pubkey import Pubkey
from flint.onchain import LinearParams, ParamsUpdate, RiskParams
trusted_program_id = Pubkey.from_string(os.environ["FLINT_PROGRAM_ID"])
core = await client.start_quoting_core(program_id=trusted_program_id)
market = core.market("WSOL/USDC")
builder = core.builder()
builder.linear(market).fair(
last_oracle_slot=fair_slot,
buy_start_price="155.10",
buy_end_price="155.00",
sell_start_price="155.20",
sell_end_price="155.30",
).linear_params(
LinearParams(
bid_size_per_level="0.10",
ask_size_per_level="0.10",
bid_post_only=True,
ask_post_only=True,
)
).risk(
RiskParams(per_slot_decay_factor="0.99") # percents in [0, 1].
).params(
ParamsUpdate(enable=True) # generic per-market params (enable, cross_spread).
)
receipt = await core.commit(builder)
await receipt.landed_within(5.0)QuoteBuilder is one-shot. Create a new builder for each tick. core.commit(...) packs, signs, submits, reserves counters before submit, and returns one receipt covering every transaction emitted by the builder.
core.market(...) accepts a full pair name such as "WSOL/USDC", a unique base symbol such as "WSOL", a (base_spot_id, quote_spot_id) tuple, a MarketInfo, or a QuoteMarket. Base-only names must be unique; otherwise use the full pair name.
A complete script
The whole path — keypair, auth, quoting core, one oracle-offset quote, receipt — in one runnable file. Pass the quoter keypair path as the first argument and your trusted, config-pinned program id as the second.
python
import asyncio
import sys
from solders.keypair import Keypair
from solders.pubkey import Pubkey
from flint import Client
from flint.onchain import OffsetSpec, RiskParams
async def main() -> None:
keypair = Keypair.from_json(open(sys.argv[1]).read())
client = Client(keypair=keypair)
session = await client.authenticate()
print("authenticated as maker_id", session.maker_id)
trusted_program_id = Pubkey.from_string(sys.argv[2])
core = await client.start_quoting_core(program_id=trusted_program_id)
await client.wait_ready() # warms blockhash + slot streams
# One level per side: quote 0.25 (quote-asset units) off the fair,
# 1.0 base unit in size.
level = OffsetSpec(price_offset="0.25", size="1.0", staleness=50)
market = core.market("WSOL/USDC")
builder = core.builder()
builder.oracle_offset(market).risk(RiskParams()).spread(
[level], [level] # (bids, asks)
).fair(
buy_price="100.0",
sell_price="100.0",
)
receipt = await core.commit(builder)
await receipt.landed_within(20.0)
print("quote landed")
await client.close()
if __name__ == "__main__":
asyncio.run(main())Keep chain tips warm
chain_tip() and current_slot() are self-bootstrapping: the first call starts the reconnecting SubscribeBlockhash / SubscribeSlots stream and blocks until its first event, then returns the latest cached value (continuously refreshed by the background stream). A maker loop just calls them — no manual setup:
python
await client.wait_ready() # warm both streams concurrently up front
while True:
tip = await client.chain_tip() # latest blockhash + recommended_cu_price
slot = client.cached_current_slot() # latest observed slot (or: await current_slot())
# build, sign, and submit this tick with tip.blockhash and slotstart_chain_tip() starts both streams, while wait_ready() blocks until the first blockhash and slot arrive. The accessors above still start what they need on first use, but call wait_ready() once before a quote loop when you omit last_oracle_slot from .fair(...). All three require a keypair/auth and raise immediately on a public-only client. For a single fetch with no background stream, use refresh_chain_tip(). Call await client.close() on shutdown to stop the streams.
If the status or chain-tip stream disconnects, reconcile before replacing orders that might already have landed.
The Client exposes the current slot via current_slot() / cached_current_slot() (backed by TxService.SubscribeSlots). A fair's last_oracle_slot is the reference point the matcher uses to widen your spread as the fair ages, so it should reflect how fresh the quote is. Builders created by core.builder() are connected to the client, so omitting last_oracle_slot defaults it to the latest observed slot after wait_ready() or current_slot() has populated the slot cache — the same convention as the Rust SDK. Pass an explicit last_oracle_slot only when your price source itself lags and you want the on-chain staleness widening to start from the older observation slot.
Strategy helpers
Use one strategy helper per market, matching the strategy you want live on-chain:
.params(ParamsUpdate(...)) is the generic per-market update (enable, soft max balance, cross-spread) on all three strategies. Linear's strategy-specific knobs live on .linear_params(LinearParams(...)).
| Strategy | Fair helper | Params/helper updates |
|---|---|---|
| Linear distribution | builder.linear(market).fair(...) | .linear_params(LinearParams(...)), .risk(RiskParams(...)), .params(ParamsUpdate(...)) |
| Oracle offset | builder.oracle_offset(market).fair(...) | .spread([OffsetSpec(...)], [OffsetSpec(...)]), .risk(RiskParams(...)), .params(ParamsUpdate(...)) |
| Order list | None | .submit(...), .submit_post_only(...), .submit_and_pop(...), .cancel_by_id(...), .cancel_all(...), .params(ParamsUpdate(...)) |
ParamsUpdate.cross_spread is the one field these helpers do not lower for you: it carries pre-lowered CrossSpreadUpdate values whose spread is a raw u32 in this market's oracle units — the market you pass to .params() / the strategy builder, not the peer. On-chain the spread is stored on this market's book and applied to this market's own fair (fair ± spread) when quoting against the peer named by spot_index, so lower the spread yourself with human_to_oracle_for_quoting against this market before constructing the CrossSpreadUpdate; spot_index only identifies the peer the cap targets. (This matches the Rust SDK, which lowers cross-spread against the current market.)
Params updates overwrite enable
Every params instruction writes the market's enable on-chain — it is not a "leave unchanged" field. Any tick that emits a params update resets it to ParamsUpdate's default (enable=True) unless you set it. That includes a tick whose only change is a strategy knob (.linear_params(...), .spread(...), .risk(...), or .init()): the builder synthesizes a default ParamsUpdate for you, so a tick that merely resizes the ladder silently re-enables the market. (This matches the Rust SDK and the on-chain handler.)
soft_max_balance is not touched by params updates: it is admin-gated and set via the deposit/withdraw path, so a params tick no longer resets an inventory cap.
To stop quoting a market without disturbing its strategy, call builder.disable(market) — a params-only update (enable=False) that leaves the installed strategy and its orders intact on-chain. Do not pause by passing enable=False to a per-strategy helper: that re-applies the strategy (and, if the market currently runs a different strategy, switches it and zeroes its state). To re-enable, send the strategy helper again with enable=True.
Oracle-offset OffsetSpec, order-list placements, and linear params all take human-unit decimal strings and lower them for you. Floats are rejected at the protocol boundary.
Use one strategy family per market per builder. Repeated calls to the same family update that family's pending quote. Mixing linear, oracle_offset, and order_list for the same market raises ValueError.
Order-list cancels are emitted before placements in the same batch, so a replace can free slots before placing new orders:
python
builder.order_list(market).cancel_by_id("bid", 1201).submit_post_only(
"bid",
price="155.12",
size="0.25",
client_order_id=1202,
)Low-level escape hatch
Use the lower-level methods when you need durable counters, custom packing, or raw instruction control:
python
from flint.onchain import QuoteBuilder, QuoteMarket, tx
quote_market = QuoteMarket.from_market_info(market_info)
builder = QuoteBuilder(
trusted_program_id,
keypair.pubkey(),
maker_id,
oracle_sequence=state.next_oracle_sequence,
order_sequence=state.next_order_sequence,
client_order_id=state.next_client_order_id,
client=client,
)
builder.linear_fair(
quote_market,
last_oracle_slot=fair_slot,
buy_start_price="155.10",
buy_end_price="155.00",
sell_start_price="155.20",
sell_end_price="155.30",
)
builder.linear_params(
quote_market,
bid_size_per_level="0.10",
ask_size_per_level="0.10",
)
tip = await client.chain_tip()
groups = builder.pack(keypair.pubkey(), tip.blockhash, tip.recommended_cu_price)
state.next_oracle_sequence = builder.next_oracle_sequence
state.next_order_sequence = builder.next_order_sequence
state.next_client_order_id = builder.next_client_order_id
state.save()
for group in groups:
signed = tx.sign_transaction(
group.instructions,
keypair.pubkey(),
[keypair],
tip.blockhash,
)
receipt = await client.submit_transaction_receipt(bytes(signed))
await receipt.landed_within(5.0)Receipts
submit_transaction_receipt() subscribes to transaction status before it submits, so the bot does not miss an early ack event.
python
receipt = await client.submit_transaction_receipt(raw_transaction)
await receipt.accepted()
await receipt.landed_within(5.0)
status = receipt.status()
print(status.accepted, status.landed, status.total)Treat these receipt errors as operational signals:
| Error | Meaning |
|---|---|
OnChainFailure | The chain reported a failed transaction status. |
CommitTimeout | Confirmation did not arrive before your timeout; the transaction may still later confirm or fail. |
CommitDisconnected | The status stream disconnected before the outcome was known. |
CommitLagged | The subscriber lagged and may have dropped status events. |
Treat timeout, disconnect, lag, and submit errors after signing as unknown outcomes. Reconcile before submitting replacement orders. If submit_transaction_receipt() raises CommitDisconnected before submit, the helper did not submit the transaction.
If you abandon a receipt before confirmation, cancel any waiters and then close it. close() unsubscribes from future status events; it is not a way to make existing accepted() or landed() calls return.
python
receipt.close()Finish, cancel, or close outstanding receipts before await client.close(). Closing the client tears down the status stream, so active receipt waiters can observe CommitDisconnected.
Committing a whole tick with QuotingCore
builder.pack() + submit_transaction_receipt() is the low-level path. For a full tick, QuotingCore.commit() packs, signs, submits every transaction under a single status subscription, and returns one Receipt covering them all (matching the Rust SDK). It also serializes a strategy switch — when a market in the tick changes its quoting strategy and carries a fair update, the params transaction is submitted and awaited on-chain before the fair is sent — and reserves the builder's sequence counters before any submit can partially succeed.
python
from flint.api import RetryConfig
from flint.onchain import OffsetSpec
core = await client.start_quoting_core(program_id=trusted_program_id)
market = core.market("WSOL/USDC")
builder = core.builder() # seeded with the core's current counters
builder.oracle_offset(market).spread(
[OffsetSpec(price_offset="0.02", size="0.10", staleness=25)],
[OffsetSpec(price_offset="0.02", size="0.10", staleness=25)],
).fair(
last_oracle_slot=fair_slot,
buy_price="155.10",
sell_price="155.20",
)
receipt = await core.commit(builder) # one Receipt for the whole tick
await receipt.accepted()commit() returns a single Receipt, not a list. blockhash / compute_unit_price default to the latest chain tip. A strategy switch whose params transaction never reaches a submission path, or fails to land, raises StrategySwitch and the fair is never sent.
Partial outcomes: settled()
landed() is all-or-nothing — it resolves only when every transaction lands, and raises on the first failure. When a tick updates several markets, one can revert while the rest land. settled() waits until every transaction reaches a terminal state, then reports the split per intent:
python
receipt = await core.commit(builder)
outcome = await receipt.settled()
# SettledOutcome(landed_intents=[...], failed_intents=[...])
for i in outcome.failed_intents:
intent = receipt.intents()[i] # resolve the index → (market, kind)
print("re-push", intent.kind, "for", intent.name, "(spot", intent.spot_id, ")")An intent is one (market, kind) — a single on-chain operation. kind is IntentKind.PARAMS (the UpdateQuotingParams instruction, which carries the oracle offset ladder or the order list in its payload) or IntentKind.FAIR (a fair-value push). So a market that updates both its params and its fair produces two intents; an order-list market produces just one (PARAMS). They're numbered flat across the whole commit in a canonical order — by (spot_id, kind), PARAMS before FAIR — so the same logical tick gets the same indices regardless of the order you called the builder methods (and identically in the Rust and Python SDKs).
Each intent is one instruction in exactly one transaction — never split. So there's no half-applied ambiguity within an intent: a market whose params land but fair reverts shows up as FAIR failed and PARAMS landed — two separate, atomic outcomes.
Resolve an index with receipt.intents() — element i is the Intent(spot_id, name, kind) for index i. (Empty for direct, non-builder submissions.)
An intent is in landed_intents only when its transaction landed; it's in failed_intents if its transaction reverted on-chain or never reached the chain. Unlike landed(), settled() does not short-circuit on the first failure, so the full breakdown is always returned. Stream-level errors (CommitDisconnected / CommitLagged) still surface as exceptions, while a transaction-granularity on-chain failure does not poison settled() — it keeps draining.
SettledOutcome.is_complete is true when nothing failed; is_partial is true when at least one intent landed and at least one failed.
Retrying failed submissions
commit() automatically retries transactions that failed before the server accepted them (a dropped gRPC call, a disconnect before ack) — 1 retry with a 100 ms backoff by default. Transactions the server accepted are never retried, and on-chain reverts are never retried: re-sending a quote whose pricing is now stale is the wrong move. Each retry re-selects a fresh blockhash.
Tune the policy with RetryConfig (backoff is in seconds):
python
# More aggressive retry for a flaky link.
receipt = await core.commit(
builder, retry=RetryConfig(max_retries=3, backoff=0.05)
)
# Opt out entirely.
receipt = await core.commit(builder, retry=RetryConfig(max_retries=0))An intent whose packed group exhausts every retry without reaching a submission path is reported in failed_intents by settled().
Balances, stats, and history
Use authenticated maker calls for inventory and per-maker activity:
python
from flint.gen.flint.spot.v1.stats import messages_pb2 as stats
balances = await client.maker_balance()
maker_stats = await client.maker_stats()
volume = await client.maker_volume_breakdown()
nav = await client.maker_nav_history(stats.STATS_WINDOW_30D)
fills = await client.maker_fills(limit=100)
activity = await client.maker_activity(limit=100)
for balance in balances.balances:
print(balance.spot_id.id, balance.balance.value)
for point in nav.points:
print(point.ts.micros, point.nav_usd.value)
# On-chain account actions (deposits, withdrawals, market joins, config
# changes) — newest-first. Trading fills come from maker_fills, not here.
for act in activity.activities:
print(act.ts.micros, act.signature, act.WhichOneof("action"))These helpers return protobuf response messages. Historical fills and candles also use request messages from the API schema:
python
import time
from flint.gen.flint.spot.v1 import common_pb2
from flint.gen.flint.spot.v1.historical import messages_pb2 as hist
now = int(time.time() * 1_000_000)
pair = common_pb2.Pair(
base_id=1,
quote_id=0,
label="WSOL/USDC",
)
fills = await client.historical_fills(
hist.GetFillsRequest(
pair=pair,
start=common_pb2.Timestamp(micros=now - 60 * 60 * 1_000_000),
end=common_pb2.Timestamp(micros=now),
limit=100,
)
)See Historical queries for pagination and live-plus-historical stitching.
Decimals and units
All wire money values are decimal strings. Parse response fields with decimal.Decimal, not float:
python
from decimal import Decimal
notional = Decimal(trade.price.value) * Decimal(trade.size.value)All human money inputs accepted by the quoting helpers should be decimal strings, Decimal, or integers. Floats are rejected because binary floating point can silently change prices and sizes.
Price conversion
Use human_to_oracle_for_quoting only when you are manually building instruction payloads or nonzero CrossSpreadUpdate.spread values. Normal QuoteBuilder price and size parameters already call the correct conversion helper. See Prices, sizes, units.
Advanced RPC access
Client exposes public generated service clients for RPCs that do not yet have a handwritten helper:
python
from flint.gen.flint.spot.v1 import common_pb2
from flint.gen.flint.spot.v1.market_data import messages_pb2 as md
pair = common_pb2.Pair(
base_id=1,
quote_id=0,
label="WSOL/USDC",
)
response = await client.market_data.GetBook(
md.GetBookRequest(pair=pair, level=common_pb2.FEED_LEVEL_L2)
)Prefer the high-level helpers when they exist; they keep endpoint selection and auth metadata consistent. For authenticated RPCs without a helper, wire the generated stub with AuthFlow directly so every call gets fresh bearer metadata.
Production checklist
- Use a public-only
Clientfor read-only processes. - Pin the program id from trusted config and call
verify_program_id()before building or submitting maker transactions. - Call
wait_ready()once before quoting if builders omitlast_oracle_slot; otherwise pass an explicit slot from your own fair/oracle input. - Use
core.commit(...)for in-memory counter reservation, or reserve/persistnext_oracle_sequence,next_order_sequence, andnext_client_order_idyourself before submitting packed low-level transactions. - Use decimal strings or
Decimalat money boundaries. - Treat
CommitTimeout,CommitDisconnected, andCommitLaggedas unknown outcomes and reconcile before submitting replacement orders. - Close the client on shutdown.
Where to go next
| You want | Page |
|---|---|
| Create and fund a maker | Onboarding |
| Understand quote models and sequence safety | Quoting |
| Stream books and fills | Market data |
| Query historical fills and candles | Historical queries |
| Inspect a runnable script | examples/python/ in the SDK distribution |
