Appearance
Quoting
You're running a market-maker. You publish bids and asks; takers hit them; you earn the spread. On Flint the shape in which you publish those quotes is configurable — pick whichever quoting model matches how your pricing engine already works.
This page is the practical guide:
- the three quoting models, when each one fits, and how to call them;
- the anatomy of a quoting tick (queue → commit → confirm);
- the maker-account setup you have to do once before any quotes fill;
- the few on-chain invariants the SDK doesn't hide from you.
You don't have to learn Solana to quote
The SDK takes human prices and sizes (155.14, 10.0 SOL), batches your updates into the right number of on-chain messages, signs them, and submits fully signed transactions through the gateway. You don't compose transactions or touch RPC nodes, but your quoting key is the fee payer and must be able to pay Solana transaction fees. The few Solana-specific concepts that do leak through are called out inline — start here if you want the glossary first.
TypeScript does not build quoting transactions
The Rust and Python SDKs both expose a QuoteBuilder + QuotingCore commit loop, instruction packing, chain-tip streaming, and transaction receipts. See Python SDK for the Python API shape. The TypeScript SDK stops at gRPC-Web market data, stats, auth, maker RPCs, and generated descriptors — browser quoting clients should sign through a wallet adapter or use the Rust/Python SDK.
The three quoting models
You pick one model per market. You can run different models on different markets, but you can't mix two models on the same market.
| Model | Mental shortcut | What you publish per tick | Best for |
|---|---|---|---|
| OrderList | CEX-style. "Place this order, cancel that one." | A list of submit / cancel ops. | Porting an existing CEX market-maker with minimal logic changes. Strategies with sparse, intentional order placement. |
| OracleOffset | "Here's my fair price; quote ±N around it." | A buy-side fair, a sell-side fair, and offset ladders. | Continuous quoting driven by an external oracle or your own mid. Cheapest fair refresh. |
| LinearDistribution | "Quote evenly between A and B on each side." | Four anchor prices — best/worst bid, best/worst ask. | Passive liquidity bands. Smallest possible message. |
The columns map directly to how often you'll send messages and how much you'll send per message — pick the row that matches the shape of the pricing decisions your bot is already making.
Each model publishes the same two-sided quote in a different shape. For a visual comparison of all three — the discrete OrderList ladder, the OracleOffset fair + cumulative offsets, and the LinearDistribution band — see How Flint works → Quoting strategies.
Choosing between them
A loose decision tree:
- Do you already think in "place / cancel" operations? Use OrderList. You'll feel at home.
- Do you have a single fair price (oracle, internal mid) and just want a ladder around it? Use OracleOffset. Per-tick you only re-send the fair; the ladder stays installed.
- Do you want the cheapest possible passive liquidity, sized uniformly across a band? Use LinearDistribution. Four numbers per refresh.
If you're not sure, OracleOffset is a good default.
Try it: the book in motion
The shapes above are static; a live book isn't. Two forces move it between your ticks — incoming fills (a taker consuming your liquidity and shifting your inventory) and slot progression (Solana slots advancing, which ages your fair, decays your risk accumulator, and backs your spread off the mid).
The interactive quoting simulator lets you set each model's parameters independently and watch both forces play out on a live book — the clearest way to build intuition before you wire up a bot. Every parameter it exposes is documented in the sections below.
Anatomy of a quoting tick
Every quoting model shares the same flow.
rust
use flint_api_client::api::client::Client;
use flint_api_client::quoting::QuoteBuilder;
// 1. Build the client + authenticate (covered in Onboarding). Endpoints
// default to mainnet — see Onboarding to target another network.
let client = Client::builder()
.wallet(keypair.clone())
.build()
.await?;
client.authenticate().await?;
// 2. Start the quoting "core" — it owns the catalog, sequence
// counters, blockhash/slot streams, and the tx-status stream,
// and auto-spawns the blockhash + slot streams it needs.
let mut core = client.start_quoting_core(keypair.clone()).await?;
// 3. Per tick: describe what you want, commit, await confirmation.
let mut receipt = QuoteBuilder::new()
.order_list("SOL", |b| b
.submit(Side::Buy, 154.90, 10.0)
.submit(Side::Sell, 155.30, 10.0))
.commit(&mut core)
.await?;
receipt.landed_within(std::time::Duration::from_secs(5)).await?;python
import os
from solders.pubkey import Pubkey
from flint import Client
# 1. Build the client + authenticate (covered in Onboarding). Endpoints
# default to mainnet — see Onboarding to target another network.
client = Client(keypair=keypair)
await client.authenticate()
# 2. Start the quoting "core" — it owns the catalog, sequence
# counters, and transaction submission state.
# Pass the program id from trusted config (see Onboarding).
trusted_program_id = Pubkey.from_string(os.environ["FLINT_PROGRAM_ID"])
core = await client.start_quoting_core(program_id=trusted_program_id)
await client.wait_ready() # warms blockhash + slot streams before fair updates
# 3. Per tick: describe what you want, commit, await confirmation.
# Prices and sizes are human-unit decimal strings — floats are rejected.
builder = core.builder()
builder.order_list(core.market("SOL")).submit(
"buy", price="154.90", size="10"
).submit(
"sell", price="155.30", size="10"
)
receipt = await core.commit(builder)
await receipt.landed_within(5.0)A few things to notice:
"SOL"is the base-token name, not"SOL/USDC". Flint has a single global quote (USDC), so naming the base is enough.QuoteBuilderis a one-shot builder. Construct it, attach per-market updates, callcommit()core.commit(builder). Next tick — new builder.commit()core.commit(builder)returns aReceiptyou can await on (accepted,landed,landed_within(timeout)), break down per-intent withsettled(), or sample non-blockingly viareceipt.status(). Dropping it is fine — the submission keeps going server-side.commit()core.commit(builder)retries pre-accept failures by default (1 retry, 100 ms backoff). Only transactions that never reached the server are retried; on-chain reverts are not. Tune it withcommit_with_retry(RetryConfig)core.commit(builder, retry=RetryConfig(...)).
Controlling transaction lifetime
By default, commit()core.commit(builder) signs with the latest blockhash streamed by TxService.SubscribeBlockhash. That gives the transaction the normal recent-blockhash lifetime.
For latency-sensitive quote updates, use commit_with_duration(duration) to ask the SDK for a shorter remaining lifetimepass blockhash=... to core.commit(...) when you need explicit blockhash selection:
rust
use std::time::Duration;
use flint_api_client::quoting::{QuoteBuilder, Side};
let receipt = QuoteBuilder::new()
.order_list("SOL", |b| b
.submit(Side::Buy, 154.90, 10.0)
.submit(Side::Sell, 155.30, 10.0))
.commit_with_duration(&mut core, Duration::from_secs(12))
.await?;python
builder = core.builder()
builder.order_list(core.market("SOL")).submit(
"buy", price="154.90", size="10.0"
).submit(
"sell", price="155.30", size="10.0"
)
tip = await client.refresh_chain_tip()
receipt = await core.commit(builder, blockhash=tip.blockhash)The SDK maintains a small in-memory history of recent blockhashes from the chain-tip stream. Each BlockhashEvent includes Solana's last_valid_block_height, so the SDK can choose the oldest retained blockhash whose estimated remaining validity is still at least the duration you requested. The selected blockhash is then used for transaction packing, signing, and submission.The Python SDK uses the blockhash you pass. If you need an age or lifetime policy, keep that policy in your process and pass the selected blockhash into core.commit(...).
Use this when late-arriving quote updates are riskier than dropped updates. For example, if your bot refreshes fair value every few seconds, controlling which blockhash signs a quote can reduce the chance that an older update lands after the market has moved.
This is a best-effort control, not an exact wall-clock expiry. If the SDK has not retained enough history yet, or if the server reports last_valid_block_height = 0 during startup, it falls back to the newest valid blockhash and may leave the transaction valid longer than requested. The history is process-local and resets when your bot restarts.This is still a best-effort control, not an exact wall-clock expiry. If you have not retained enough chain-tip history yet, fall back to the newest valid blockhash.
OrderList — CEX-style
If you've integrated against a centralized exchange, this is the model that maps to your existing code. Each tick you describe a list of operations:
- submit — place an order on a side at a price + size, optionally with an
expire_slot(see Expiring orders on a slot). - cancel_by_id — remove a specific order you placed earlier.
- cancel_all — wipe all live orders (one side or both).
rust
use flint_api_client::quoting::{QuoteBuilder, Side};
QuoteBuilder::new()
.order_list("SOL", |b| b
// Two-sided quote at the level you choose.
.submit(Side::Buy, 154.90, 10.0)
.submit(Side::Sell, 155.30, 10.0))
.commit(&mut core).await?
.landed().await?;python
market = core.market("SOL")
await client.wait_ready()
builder = core.builder()
# Two-sided quote at the level you choose.
builder.order_list(market).submit("buy", price="154.90", size="10").submit(
"sell", price="155.30", size="10"
)
receipt = await core.commit(builder)
await receipt.landed()Tracking your orders for cancels
When you call .submit(...) the SDK auto-assigns a client_order_id under the hood. If you plan to cancel a specific order later, pin the id yourself:
rust
QuoteBuilder::new()
.order_list("SOL", |b| b
.submit_with_id(Side::Buy, 154.90, 10.0, /* id */ 1001)
.submit_with_id(Side::Sell, 155.30, 10.0, /* id */ 1002))
.commit(&mut core).await?;
// …later, when your mid moves:
QuoteBuilder::new()
.order_list("SOL", |b| b
.cancel_by_id(Side::Buy, 1001)
.cancel_by_id(Side::Sell, 1002)
.submit_with_id(Side::Buy, 154.50, 10.0, 1003)
.submit_with_id(Side::Sell, 155.50, 10.0, 1004))
.commit(&mut core).await?;python
market = core.market("SOL")
builder = core.builder()
builder.order_list(market).submit(
"buy", price="154.90", size="10", client_order_id=1001
).submit(
"sell", price="155.30", size="10", client_order_id=1002
)
await core.commit(builder)
# …later, when your mid moves:
builder = core.builder()
builder.order_list(market).cancel_by_id("buy", 1001).cancel_by_id(
"sell", 1002
).submit(
"buy", price="154.50", size="10", client_order_id=1003
).submit(
"sell", price="155.50", size="10", client_order_id=1004
)
await core.commit(builder)client_order_id is u64a Python int encoded as an on-chain u64. The SDK seeds its auto-counter to a value derived from the current time, so even auto-allocated ids won't collide with what's already on the book — but if you supply your own, keep them strictly increasing.
Expiring orders on a slot
Each order carries an expire_slot. The on-chain matcher stops matching the entry once expire_slot <= current_slot, so an order can stop quoting on its own if your bot stalls or a tick never arrives. By default orders never expire.
expire_slot is an absolute Solana slot, not a duration — anchor it on the current slot the SDK already streams:
rust
use flint_api_client::quoting::{QuoteBuilder, Side, SubmitOrder};
// ~150 slots ≈ 60s at 400ms/slot.
let slot = core.current_slot().expect("slot stream warm");
QuoteBuilder::new()
.order_list("SOL", |b| b
.submit_order(SubmitOrder::new(Side::Buy, 154.90, 10.0)
.with_expire_slot(slot + 150))
.submit_order(SubmitOrder::new(Side::Sell, 155.30, 10.0)
.with_expire_slot(slot + 150)))
.commit(&mut core).await?;python
market = core.market("SOL")
# ~150 slots ≈ 60s at 400ms/slot.
slot = await client.current_slot()
builder = core.builder()
builder.order_list(market).submit(
"buy", price="154.90", size="10", expire_slot=slot + 150
).submit(
"sell", price="155.30", size="10", expire_slot=slot + 150
)
await core.commit(builder)SubmitOrder is the full-control placement form — it also carries with_post_only(..) and with_client_order_id(..), so it's how you combine expiry with a pinned id. The positional submit / submit_post_only / submit_with_id helpers all lower to it with expire_slot = NEVER_EXPIRES.
Two things to keep in mind:
- Expiry is not a cancel. An expired entry stops matching but still occupies its order-list slot until you
cancel_by_id/cancel_allit, or push it out with a place-and-pop. Treat expiry as a safety net against stale quotes, not as slot reclamation. - Slots are not wall-clock. Solana slot times drift; a slot budget is an upper bound on how long a quote can sit, not a precise timer.
Wipe and re-quote
For the common "every tick, replace the whole quote" pattern:
rust
QuoteBuilder::new()
.order_list("SOL", |b| b
.cancel_all()
.submit(Side::Buy, fair - 0.05, 10.0)
.submit(Side::Sell, fair + 0.05, 10.0))
.commit(&mut core).await?;python
from decimal import Decimal
builder = core.builder()
builder.order_list(core.market("SOL")).cancel_all().submit(
"buy",
price=str(fair - Decimal("0.05")),
size="10",
).submit(
"sell",
price=str(fair + Decimal("0.05")),
size="10",
)
await core.commit(builder)Cancels are processed before placements in the same batch, so the on-chain slots free up before the new orders need them.
Post-only (subtler than on a CEX)
For OrderList specifically, use .submit_post_only(...) instead of .submit(...):
rust
b.submit_post_only(Side::Buy, 154.90, 10.0)python
b.submit_post_only("buy", price="154.90", size="10.0")The semantics aren't the CEX semantics you may be used to — read Post-only on Flint below before you set the flag.
When OrderList costs more
Every submit and every cancel is a discrete op in the on-chain message. Replacing a 10-level ladder = 10 cancels + 10 submits = 20 ops. That's still fine — the SDK splits the work across multiple transactions as needed — but it's heavier than the other two models, which re-quote by changing a single number. If your bot ticks fast and re-quotes the entire book each tick, look at OracleOffset next.
OracleOffset — fair + per-side offsets
This is the model purpose-built for continuous quoting against an external price source. You publish two things:
- a fair price per side (buy fair, sell fair — usually the same number, or split if you want asymmetry);
- a ladder of offsets describing how far from fair each level sits, and how large it is.
rust
use flint_api_client::quoting::{OffsetSpec, QuoteBuilder, RiskParams};
// Tick 1 — install the strategy with the ladder + fair.
let ladder = vec![
OffsetSpec { price_offset: 0.05, size: 5.0, staleness: 5, client_order_id: None, post_only: false },
OffsetSpec { price_offset: 0.10, size: 10.0, staleness: 5, client_order_id: None, post_only: false },
OffsetSpec { price_offset: 0.20, size: 20.0, staleness: 5, client_order_id: None, post_only: false },
];
QuoteBuilder::new()
.oracle_offset("SOL", |b| b
.with_fair((mid, mid))
.with_spread(ladder.clone(), ladder)
.with_risk(RiskParams {
per_slot_decay_factor: Some(0.99),
..Default::default()
}))
.commit(&mut core).await?
.landed().await?;
// Tick 2..N — only the fair changes. Cheap.
QuoteBuilder::new()
.oracle_offset("SOL", |b| b.with_fair((new_mid, new_mid)))
.commit(&mut core).await?;python
from flint.onchain import OffsetSpec, RiskParams
# Tick 1 — install the strategy with the ladder + fair.
ladder = [
OffsetSpec(price_offset="0.05", size="5", staleness=5),
OffsetSpec(price_offset="0.10", size="10", staleness=5),
OffsetSpec(price_offset="0.20", size="20", staleness=5),
]
market = core.market("SOL")
builder = core.builder()
builder.oracle_offset(market).spread(ladder, ladder).risk(
RiskParams(per_slot_decay_factor="0.99") # percents in [0, 1].
).fair(buy_price=mid, sell_price=mid)
receipt = await core.commit(builder)
await receipt.landed()
# Tick 2..N — only the fair changes. Cheap.
builder = core.builder()
builder.oracle_offset(market).fair(buy_price=new_mid, sell_price=new_mid)
await core.commit(builder)How offsets stack
OffsetSpec.price_offset is cumulative, not absolute. Level 0 is measured from fair, level 1 is measured from level 0, and so on. So the ladder above quotes at mid - 0.05, mid - 0.15, mid - 0.35 on the bid side (and symmetrically on the ask).
Asymmetric fair
Pass (buy_fair, sell_fair) as a tuple, or build an OffsetFair explicitly if you want to leave one side unchanged for this tick. Both sides write atomically on-chain; if you only set one side the SDK skips the fair update entirely rather than half-writing it.Pass buy_price=... and sell_price=... to .fair(...); omit .fair(...) for this tick if you want the existing fair left unchanged. When present, the fair update writes both sides atomically on-chain.
staleness
Each level carries a staleness byte — how many Solana slots old the fair is allowed to be before the matcher refuses to fill that level. Lower = safer (you never fill on a stale price) at the cost of needing to re-publish more often. A few slots' worth is typical; 5 (≈ 2 seconds) is the value the bundled example uses.
Risk dampening
RiskParams lets you tell the matcher to back off your quotes after you've filled a certain notional volume. Useful guard rails for adversarial flow:
| Field | What it does |
|---|---|
per_slot_decay_factor | Per-slot multiplier on accumulated fill volume. Closer to 1 = slower decay (you carry "saturated" status longer). |
risk_reduce_factor | Multiplier applied to ladder sizes once you're saturated. |
maker_volume_to_saturation | Notional fill volume that flips you into the saturated regime. |
maker_volume_backoff_at_saturation | Price backoff applied when saturated. |
Install risk once at startup; re-installing it every tick resets the accumulator on-chain.
When OracleOffset is the right pick
You already produce a mid from an oracle, a partner CEX, or your own internal pricing — and the only question is what spread do I quote around it. Re-quoting a fair is one number; the ladder stays installed across ticks. Cheap and natural.
LinearDistribution — server-side interpolation
The cheapest tick. You publish four prices, and the matcher fills buyers anywhere between buy_start_price (best, highest bid) and buy_end_price (worst, lowest bid), and symmetrically for sells.
rust
use flint_api_client::quoting::{LinearFair, LinearParams, QuoteBuilder};
QuoteBuilder::new()
.linear("SOL", |b| b
.with_fair(LinearFair {
buy_start_price: 155.00,
buy_end_price: 154.50,
sell_start_price: 155.30,
sell_end_price: 155.80,
})
.with_linear_params(LinearParams {
spread_backoff_per_slot: Some(0.001),
bid_size_per_level: Some(1.0),
ask_size_per_level: Some(1.0),
bid_post_only: Some(true),
ask_post_only: Some(true),
client_order_id: None,
}))
.commit(&mut core).await?
.landed().await?;python
from flint.onchain import LinearParams
await client.wait_ready()
builder = core.builder()
builder.linear(core.market("SOL")).fair(
buy_start_price="155.00",
buy_end_price="154.50",
sell_start_price="155.30",
sell_end_price="155.80",
).linear_params(
LinearParams(
spread_backoff_per_slot="0.001",
bid_size_per_level="1.0",
ask_size_per_level="1.0",
bid_post_only=True,
ask_post_only=True,
)
)
await (await core.commit(builder)).landed()Sizes are human token units (e.g. 1.0 SOL), lowered to on-chain lots at build time — same as OffsetSpec.size in oracle-offset mode. Use None to leave an existing side unchanged, or Some(0.0)"0" to stop quoting that side. You don't write a ladder — the strategy still interpolates over the band, with spread_backoff_per_slot controlling how the spread widens over time if you go silent.
This is the right model when you want passive participation across a range and don't have strong views on shape — think of it as "AMM-ish" liquidity provisioning with explicit boundaries.
Estimating compute units
The Rust and Python SDKs size set_compute_unit_limit for you
If you're building transactions by hand (or just want to know where the cost comes from), every quoting instruction breaks down into a fixed per-instruction intercept plus a per-market cost that depends on the model:
- Fair updates (
update_oracle_fair,update_linear_distribution_fair) — piecewise-linear in the number of fairs bundled into the instruction. Up to 32 fairs use an unrolled code path; above that a steeper per-fair slope kicks in, so batching past 32 costs more per fair, not less. OrderList has no fair instruction — it re-quotes by submitting/cancelling orders directly. - UpdateQuotingParams — cost depends on which model each bundled market is running:
- OracleOffset — a per-market base cost, plus a per-order cost for every buy/sell order you supply, plus a fixed cost for each side you touch at all. A side you pull to empty (
Some([])) still bills that fixed cost, because the program zero-fills all 16 slots of any supplied side — leaving a side untouched (None) is the only way to skip it. - OrderList — a per-market base cost, plus a linear cost per submitted order, plus a quadratic cost per side (sorted linked-list insertion gets more expensive the more orders are already resident). Cancels are cheaper than the model assumes — it's fit on
Placeupdates, the costliest op — so cancel-heavy batches are safely over-estimated. - LinearDistribution — a flat per-market cost. No per-order or per-side terms — this is the cheapest params update of the three.
- OracleOffset — a per-market base cost, plus a per-order cost for every buy/sell order you supply, plus a fixed cost for each side you touch at all. A side you pull to empty (
Quoting instruction CU calculator
Estimates a safe set_compute_unit_limit for the quoting instructions, including the model's safety margin. The Rust and Python SDKs size this automatically when they pack your instructions — use this if you're building transactions by hand or just want to see where the cost comes from.
Cost is piecewise: up to 32 fairs use an unrolled table; above that a steeper dynamic-loop fallback kicks in. Crossing the breakpoint costs noticeably more than one extra fair below it.
Raw estimate289
Suggested instruction budget +3.5% margin300
Tx
set_compute_unit_limit+3×150 CU for the ComputeBudget prefix750Post-only on Flint
Flint post-only ≠ CEX post-only
On a CEX, post_only is checked at order entry: if your order would cross the book on arrival, it's rejected. Flint doesn't work that way. The on-chain placement path does not look at any book — yours or anyone else's. Your quote is recorded as a quoting intent and that's it.
Two consequences fall out of this:
- You can post a crossing quote and nothing will stop you. If your bid is 158 and another maker's ask is 154, both quotes coexist on-chain. Until something resolves them, the book is simply crossed.
- Taker swaps don't resolve the cross either. A taker is matched along its own side of the consolidated book; it never triggers a maker-to-maker fill.
The only path that resolves crossed maker quotes is a separate on-chain instruction, CrossOrCancelMaker, which anyone can invoke against a spot. It walks the book and, for every crossing pair (bid_maker, ask_maker):
- If either side carries
post_only→ that order is cancelled. - If neither side is
post_only→ the two makers fill each other at the midpoint(bid_price + ask_price) / 2. Both makers get price improvement vs. what they posted.
So post_only is really a flag about what you want to happen during a CrossOrCancelMaker sweep, not at submission time.
How to think about it
You set post_only | What it means in practice |
|---|---|
false (default) | Fine with crossing another maker at midpoint. Best when your fair is robust and you'd rather take the trade than be cancelled. |
true | Refuse maker-vs-maker fills. Your order is cancelled if it ever ends up crossed against another maker. |
There's no in-between. There's also no atomic "post if non-crossing, else reject" — the check happens later, not at submission.
Cost note
CrossOrCancelMaker walks the entire crossed region of the book and emits one event per cancel or fill. It's CU-expensive — nobody runs it on every block. In practice it's invoked by operators, by adversarial counterparties looking to extract the midpoint trade against a stale quote, or by the sweepers that maintain book health.
If you publish a tight, accurate fair, your quotes generally won't cross other makers, so post_only rarely fires either way. If you expect to lag the market or publish defensive (wide) quotes against adversarial flow, post_only is the safer default.
Where to set it
The flag exists in all three models, but the field shape is different:
rust
// OrderList — per-order.
b.submit_post_only(Side::Buy, 154.90, 10.0);
// OracleOffset — per ladder level.
OffsetSpec { price_offset: 0.05, size: 5.0, staleness: 5,
client_order_id: None, post_only: true }
// LinearDistribution — per side, in LinearParams.
LinearParams { bid_post_only: Some(true), ask_post_only: Some(true), .. }python
# OrderList — per-order.
b.submit_post_only("buy", price="154.90", size="10.0")
# OracleOffset — per ladder level.
OffsetSpec(price_offset="0.05", size="5.0", staleness=5, post_only=True)
# LinearDistribution — per side, in LinearParams.
LinearParams(bid_post_only=True, ask_post_only=True)Initial setup, once per market
Before your first quote fills, the maker's account on this market needs to be enabled and balance-capped. You do that on the first commit, alongside the strategy install:
rust
use flint_api_client::quoting::{ParamsUpdate, QuoteBuilder};
QuoteBuilder::new()
.order_list("SOL", |b| b
// Force the params message to be emitted even though
// no orders are queued in this tick yet.
.init()
.with_params(ParamsUpdate::new()
.enable(true)
.with_cross(0_u16, Some(0.0)))) // see "Cross-spread" below
.commit(&mut core).await?
.landed().await?;python
from flint.onchain import CrossSpreadUpdate, ParamsUpdate
market = core.market("SOL")
builder = core.builder()
builder.order_list(market).init().params( # force the params message even with no orders queued
ParamsUpdate(
enable=True,
cross_spread=[CrossSpreadUpdate(0, 0)], # see "Cross-spread" below
)
)
receipt = await core.commit(builder)
await receipt.landed()ParamsUpdate is the shared per-market params concept across all three models, but the call shape is SDK-specific. Build it fluently with ParamsUpdate::new().enable(...).with_cross(...).Construct it as data with ParamsUpdate(enable=..., cross_spread=[...]). Defaults if you skip it: enable=true, no cross-spread entries. soft_max_balance is not a params field: it is admin-gated and set via the deposit/withdraw path (see Budgets per leg), not update_quoting_params.
Liquidity is allocated at swap time
The matcher computes your fill budget when a taker arrives, not when you publish. Each fill is capped at min(available_to_sell on the side you're selling, available_to_buy on the side you're buying), and silently filtered only when one of those reads literally zero.
You can publish quote sizes larger than your current inventory — the matcher fills up to the budget that exists at that moment, and a fill on one side feeds the budget on the other (a SOL ask that takes SOL out brings USDC in, which immediately backs any SOL bid you also posted).
For a fresh sell-only SOL/USDC maker that means: deposit SOL and set a non-zero USDC soft_max_balance on the global market (via the quote deposit/withdraw path or the dashboard, not a params update) — no SOL cap needed. Full mechanics in Budgets per leg.
Cross-spread
Flint supports two kinds of swaps:
- Global swap — base ↔ USDC. The "normal" case.
- Cross swap — base ↔ base (e.g. SOL ↔ ETH). The matcher bridges through both makers' books.
Either way, the matcher reads book.get_cross_params(counterparty)CrossSpreadUpdate(spot_index=counterparty, ...) on your micro-book; if the entry is missing, your maker is silently filtered. Counterparty is the other base token for cross swaps, or spot 0 (SpotId::GLOBALspot_index=0) for global swaps.
In practice the cross_params array is configured once at maker setup (enable_all_zeroedCrossSpreadUpdate(..., 0) blanket-enables every counterparty with zero added spread). The bundled bot example skips with_cross(...)cross_spread_update=... because the maker it talks to was set up that way. If you're cold-booting a maker or quoting against a cross-swap counterparty you've never touched, add the entry explicitly:
rust
.with_params(ParamsUpdate::new()
.enable(true)
.with_cross(0_u16, Some(0.0))) // global swap: counterparty = spot 0python
.params(ParamsUpdate(
enable=True,
# `spread` is a pre-lowered oracle u32 in THIS market's units, not
# lowered for you. A 0 spread lowers to 0; for a nonzero cap use
# human_to_oracle_for_quoting(spread_human, market). spot_index=0
# = global swap (counterparty = spot 0 / GLOBAL).
cross_spread=[CrossSpreadUpdate(spot_index=0, spread=0)],
))Cross-market quoting is covered in detail under Cross-market spread.
Receipts: knowing your quote landed
commit()core.commit(builder) returns a Receipt. Four things to do with it:
rust
use flint_api_client::quoting::{CommitError, ReceiptStatus};
use std::time::Duration;
// (a) await the server's accept.
receipt.accepted().await?;
// (b) await full on-chain confirmation, with a deadline.
match receipt.landed_within(Duration::from_secs(10)).await {
Ok(()) => println!("landed"),
Err(CommitError::OnChain { reason }) => eprintln!("reverted: {reason}"),
Err(CommitError::Timeout) => eprintln!("timed out"),
Err(CommitError::Disconnected) => eprintln!("status stream dropped"),
Err(CommitError::Lagged { skipped }) => eprintln!("missed {skipped} events"),
}
// (c) non-blocking snapshot.
match receipt.status() {
ReceiptStatus::InFlight { accepted, landed, total } =>
println!("{accepted}/{total} acked, {landed}/{total} landed"),
ReceiptStatus::Landed => println!("done"),
ReceiptStatus::Failed(e) => eprintln!("failed: {e}"),
}python
from flint import (
CommitDisconnected,
CommitLagged,
CommitTimeout,
OnChainFailure,
)
# (a) await the server's accept.
await receipt.accepted()
# (b) await full on-chain confirmation, with a deadline (seconds).
try:
await receipt.landed_within(10.0)
print("landed")
except OnChainFailure as e:
print("reverted:", e.reason)
except CommitTimeout:
print("timed out")
except CommitDisconnected:
print("status stream dropped")
except CommitLagged as e:
print("missed", e.skipped, "events")
# (c) non-blocking snapshot.
status = receipt.status()
if status.error is not None or status.failed is not None:
print("failed:", status.error or status.failed)
elif status.landed == status.total:
print("done")
else:
print(f"{status.accepted}/{status.total} acked, "
f"{status.landed}/{status.total} landed")Dropping the Receipt is fine. The submission continues server-side; you just stop awaiting.
Partial outcomes: settled()
landed() is all-or-nothing: it succeeds only when every transaction lands, and surfaces the first failure otherwise. When a tick updates several markets, one can revert while the rest land. settled() waits until every transaction reaches a terminal state and then reports the split, per intent:
rust
let receipt = builder.commit(&mut core).await?;
let outcome = receipt.settled().await?;
// SettledOutcome { landed_intents: Vec<usize>, failed_intents: Vec<usize> }
for &i in &outcome.failed_intents {
let intent = &receipt.intents()[i]; // resolve the index → (market, kind)
eprintln!("re-push {:?} for {} (spot {})", intent.kind, intent.name, intent.spot_id);
}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. The kind is either a params intent (UpdateQuotingParams, which carries the oracle offset ladder or the order list in its payload) or a fair intent (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 intent. 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 attached markets (and identically in the Rust and Python SDKs).
Crucially, each intent is carried by exactly one submitted transaction — it's never split. So there's no half-applied ambiguity within an intent: a market whose params land but fair reverts simply shows up as the fair intent failed and the params intent landed — two separate, atomic outcomes.
Resolve an index to what it was with receipt.intents() — element i is the Intent { spot_id, name, kind }Intent(spot_id=..., name=..., kind=...) for index i. (For direct, non-builder submissions the table is empty.)
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 (Disconnected / LaggedCommitDisconnected / CommitLagged) still surface as Errexceptions.
Retrying failed submissions
commit()core.commit(builder) 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 that the server accepted are never retried, and on-chain reverts (TxFailedEvent) are never retried — re-sending a quote whose pricing is now stale is the wrong move.
Tune the policy with RetryConfig:
rust
use flint_api_client::quoting::RetryConfig;
use std::time::Duration;
// More aggressive retry for a flaky link.
let receipt = QuoteBuilder::new()
.oracle_offset("SOL", |b| b.with_fair((mid, mid)))
.commit_with_retry(&mut core, RetryConfig { max_retries: 3, backoff: Duration::from_millis(50) })
.await?;
// Opt out entirely.
let receipt = QuoteBuilder::new()
.oracle_offset("SOL", |b| b.with_fair((mid, mid)))
.commit_with_retry(&mut core, RetryConfig { max_retries: 0, ..Default::default() })
.await?;python
from flint.api import RetryConfig
await client.wait_ready()
# More aggressive retry for a flaky link.
builder = core.builder()
builder.oracle_offset(core.market("SOL")).fair(buy_price=mid, sell_price=mid)
receipt = await core.commit(
builder, retry=RetryConfig(max_retries=3, backoff=0.05)
)
# Opt out entirely.
builder = core.builder()
builder.oracle_offset(core.market("SOL")).fair(buy_price=mid, sell_price=mid)
receipt = await core.commit(builder, retry=RetryConfig(max_retries=0))commit_with_duration_and_retry combines a shorter blockhash lifetime with an explicit retry policy.core.commit(builder, retry=RetryConfig(...)) sets an explicit retry policy; pass blockhash=... when you need to override the default chain-tip selection. An intent whose group exhausts every retry without reaching a submission path is reported in failed_intents by settled().
Re-quoting on your own fills
A maker bot usually re-quotes when its inventory changes. The public fills feed doesn't tell you which fills are yours, so subscribe to the maker-scoped balance stream:
rust
use flint_api_client::api::proto::SubscribeBalanceRequest;
let bus = client.subscribe_maker_balance(SubscribeBalanceRequest { spot_ids: vec![] })?;
let mut rx = bus.subscribe().await;
while let Ok(ev) = rx.recv().await {
tracing::debug!(spot_id = ?ev.spot_id, balance = ?ev.balance, "inventory changed");
let mid = my_oracle.fetch().await;
QuoteBuilder::new()
.oracle_offset("SOL", |b| b.with_fair((mid, mid)))
.commit(&mut core).await?
.accepted().await?;
}python
import asyncio
seen = {}
await client.wait_ready()
while True:
balances = await client.maker_balance()
snapshot = {
balance.spot_id.id: balance.balance.value
for balance in balances.balances
}
if snapshot != seen:
seen = snapshot
mid = await my_oracle.fetch()
builder = core.builder()
builder.oracle_offset(core.market("SOL")).fair(
buy_price=mid, sell_price=mid
)
await (await core.commit(builder)).accepted()
await asyncio.sleep(1.0)subscribe_maker_balance is the resilient wrapper — auto-reconnects and replays the request. Symmetric wrappers exist for subscribe_maker_fills, subscribe_maker_market_snapshots, and the public streams.Use the generated MakerServiceStub.SubscribeBalance stream for live inventory changes, or Client.maker_balance() for a snapshot. The same generated stub exposes maker fills and market snapshots.
SpreadProtect: advisory spread widening
Some Solana slots are riskier to quote into than others. When an upcoming slot is led by a validator with poor network topology, your repricing transactions are likelier to land late, which is exactly when an informed taker can pick you off at a stale quote. SpreadProtect is an advisory signal that tells you those slots are coming, a couple of slots ahead, so you can widen (or pull) before the risky window opens.
Advisory, never enforced
SpreadProtect is an optimization layer, not a safety mechanism. The on-chain price band and decay are the real floor: if SpreadProtect is down, or you ignore it, the matcher's staleness rules still protect you. So the signal is safe to apply, scale, or ignore. Nothing is auto-applied to your quotes; you decide what to do with it.
The signal is a multiplier on your own spread (≥ 1.0). Because your spread already encodes how volatile the pair is, multiplying it scales protection by volatility for free and stays pair-agnostic. A 1.5 during a risky window means "quote 50% wider than you otherwise would," whatever "wide" means for that market.
Reading the signal
start_quoting_core subscribes for you automatically, so there's nothing to wire up. Just read the multiplier each tick:Use Client.subscribe_spread_protect() and keep the current multiplier in your own state.
rust
let mut core = client.start_quoting_core(keypair.clone()).await?;
// In your quoting tick, multiply your half-spread by the advisory.
// Returns the current baseline multiplier whenever no risky window is active,
// so this line is safe to leave in unconditionally.
let mult = core.spread_mult();
let offset_human = base_offset * mult;
QuoteBuilder::new()
.oracle_offset("SOL", |b| b
.with_spread(ladder_with_offset(offset_human), ladder_with_offset(offset_human))
.with_fair((mid, mid)))
.commit(&mut core).await?;python
from decimal import Decimal
current_mult = Decimal("1")
await client.wait_ready()
stream = await client.subscribe_spread_protect()
async for event in stream:
if event.HasField("reset_state"):
current_mult = Decimal(event.reset_state.default_spread_mult.value or "1")
elif event.HasField("widen_window"):
window = event.widen_window
current_mult = Decimal(window.spread_mult.value)
offset_human = base_offset * current_mult
builder = core.builder()
builder.oracle_offset(core.market("SOL")).spread(
ladder_with_offset(offset_human),
ladder_with_offset(offset_human),
).fair(buy_price=mid, sell_price=mid)
await core.commit(builder)spread_mult() resolves against the current slot: it returns the highest active window multiplier when you're inside a risky window, and the current baseline multiplier otherwise.Apply ResetState.default_spread_mult and WidenWindow.spread_mult from the stream to compute the current multiplier for your latest slot. On reconnect the server sends a reset event followed by any currently relevant windows, so a freshly started or restarted bot is never blind.
Acting on the raw window
If you want to do more than scale your spread (pull quotes entirely, log attribution, or pre-stage a widened ladder before the window opens), read the raw window instead:If you want to do more than scale your spread, handle WidenWindow events directly from SubscribeSpreadProtect.
rust
if let Some(w) = core.spread_protect_window() {
// w.start_slot ..= w.end_slot is the advised range;
// w.spread_mult is the exact Decimal string.
tracing::info!(
start = w.start_slot, end = w.end_slot,
"widen window active",
);
}python
async for event in stream:
if not event.HasField("widen_window"):
continue
window = event.widen_window
if window.start_slot <= current_slot <= window.end_slot:
print("widen window active", window.start_slot, window.end_slot)spread_protect_window() returns the active window for the current slot (the highest-multiplier one if windows overlap), or None when none is active.WidenWindow carries start_slot, end_slot, and spread_mult; choose the active highest-multiplier window for the current slot in your own state.
Applying it per model
The multiplier is just a number; where it lands depends on your model:
- OracleOffset: scale each level's
price_offsetbyspread_mult()the current multiplier. Re-send the ladder when the multiplier changes (it stays installed otherwise, so you only pay for the widen on entry/exit of a window). - LinearDistribution: widen the gap between
*_start_priceand*_end_price, or push both bands away from the mid by the multiplier. - OrderList: multiply your own offset-from-mid before computing each
submitprice.
You still re-send to widen
Applying the multiplier means publishing wider quotes; it's an input to the prices you commit, not a server-side toggle. If your bot only re-sends a fair per tick (the cheap OracleOffset path), you need to re-send the ladder on the tick where spread_mult()the current multiplier changes for the widen to take effect.
Requirements
SpreadProtect rides the authed listener, so it needs the same wallet + session as quoting. When you build a QuotingCore it's always available; if you only want the signal without quoting, open it directly:Python exposes Client.subscribe_spread_protect(); it attaches the same bearer metadata you use for maker and tx RPCs.
rust
let stream = client.subscribe_spread_protect()?; // Arc<ResilientStream<SpreadProtectEvent>>
let mut rx = stream.subscribe().await;
while let Ok(event) = rx.recv().await {
// ResetState or WidenWindow event
}python
stream = await client.subscribe_spread_protect()
async for event in stream:
# ResetState or WidenWindow event
...Sequence safety across process restarts
Flint's matcher enforces strict monotonicity on a couple of per-market counters. The SDK handles this for you by seeding each counter from the current Unix time (in nanoseconds) at startup, so a process restart never collides with what's already on-chain.
The one rule you have to obey yourself:
Don't run two quoting processes under the same maker
Two processes sharing a maker_id will burn each other's sequence numbers and start writing updates that the matcher silently drops. One process per maker, always.
If you supply client_order_ids manually in OrderList mode, keep those strictly increasing too — the SDK doesn't double-check user-supplied ids.
What actually lands on-chain
You can stop reading here if you just want to quote. This section explains the few Solana-specific concepts that do leak through, so you know what you're seeing when something fails.
- Instruction — an on-chain program call. Each
commit()core.commit(builder)produces params payloads for updated markets (your enable/balance/strategy bundle) plus, for OracleOffset and LinearDistribution, separate fair payloads. - Transaction — a bundle of instructions, signed and submitted as one unit. The SDK packs instructions greedily into the smallest number of transactions that fit Solana's 1232-byte cap. Fair and params payloads go in separate transaction groups (the on-chain order matters).
- Fee payer — the wallet that pays the SOL fee. The quoting keypair is the fee payer, and
TxService.SubmitTxforwards the fully signed transaction bytes unchanged. Keep enough SOL on that key for transaction fees. - Blockhash / slot — Solana's notion of "recent". The SDK streams both in the background (
spawn_chain_tipstart_chain_tip()) and refuses to build a transaction without them. - Landed — tx observed on-chain at Processed commitment (not Solana's "confirmed" or "finalized").
Receipt::landed()Receipt.landed()resolves at that point. Most quoting flows await ack (cheap, sub-second) and let landing happen in the background.
If Receipt reports CommitError::OnChain { reason }OnChainFailure.reason, the reason string comes straight from the on-chain program — common values are covered in Errors.
Disabling the quoting layer
The quoting Cargo feature is on by default. If you're only consuming market data, turn it off to drop the Solana dependencies from your build:The Python package includes quoting support by default. If you're only consuming public market data, construct Client() without a keypair.
toml
flint-api-client = { version = "0.1", default-features = false, features = ["tls"] }python
from flint import Client
client = Client()Where to go next
| You want | Page |
|---|---|
| Widen ahead of risky slots | SpreadProtect |
| Read books, fills, the pair catalog | Market data |
| Cold-start a fresh maker account | Onboarding |
| Understand the matcher and unit system | How Flint works |
| Backtest against historical data | Historical queries |
| The end-to-end runnable example | examples/rust/src/quote.rs in the SDK distribution |
