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 SDK has the full QuoteBuilder + QuotingCore commit loop. The Python SDK exposes a maker-ready QuoteBuilder, instruction packing, chain-tip streaming, and transaction receipts; see Python SDK for its 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.
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 — it's the model the bundled example bot uses.
Anatomy of a quoting tick
Every quoting model shares the same flow.
use sweetspot_api_client::api::client::Client;
use sweetspot_api_client::quoting::QuoteBuilder;
// 1. Build the client + authenticate (covered in Onboarding).
let client = Client::builder()
.public_endpoint("https://api.superis.exchange:443")
.auth_endpoint("https://auth.api.superis.exchange:443")
.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.confirmed_within(std::time::Duration::from_secs(5)).await?;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(). Next tick — new builder.commit()returns aReceiptyou can await on (accepted,confirmed,confirmed_within(timeout)) or sample non-blockingly viareceipt.status(). Dropping it is fine — the submission keeps going server-side.
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.
- cancel_by_id — remove a specific order you placed earlier.
- cancel_all — wipe all live orders (one side or both).
use sweetspot_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?
.confirmed().await?;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:
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?;client_order_id is just a 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.
Wipe and re-quote
For the common "every tick, replace the whole quote" pattern:
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?;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(...):
b.submit_post_only(Side::Buy, 154.90, 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.
use sweetspot_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?
.confirmed().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?;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.
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.
use sweetspot_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?
.confirmed().await?;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) 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.
Post-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:
// 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), .. }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:
use sweetspot_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)
.max_balance(100.0) // 100 SOL on the base side
.with_cross(0_u16, Some(0.0)))) // see "Cross-spread" below
.commit(&mut core).await?
.confirmed().await?;ParamsUpdate is shared across all three models — same shape, same semantics. Defaults if you skip it: enable=true, no balance cap, no cross-spread entries.
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 — 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) 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::GLOBAL) for global swaps.
In practice the cross_params array is configured once at maker setup (enable_all_zeroed blanket-enables every counterparty with zero added spread). The bundled bot example skips with_cross(...) 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:
.with_params(ParamsUpdate::new()
.enable(true)
.max_balance(100.0)
.with_cross(0_u16, Some(0.0))) // global swap: counterparty = spot 0Cross-market quoting is covered in detail under Cross-market spread.
Receipts: knowing your quote landed
commit() returns a Receipt. Three things to do with it:
use sweetspot_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.confirmed_within(Duration::from_secs(10)).await {
Ok(()) => println!("confirmed"),
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, confirmed, total } =>
println!("{accepted}/{total} acked, {confirmed}/{total} confirmed"),
ReceiptStatus::Confirmed => println!("done"),
ReceiptStatus::Failed(e) => eprintln!("failed: {e}"),
}Dropping the Receipt is fine. The submission continues server-side; you just stop awaiting.
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:
use sweetspot_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?;
}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.
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 — one logical operation. Each
commit()produces one params instruction per market (your enable/balance/strategy bundle) plus, for OracleOffset and LinearDistribution, a separate fair instruction. - 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 instructions go in separate transactions (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_tip) and refuses to build a transaction without them. - Confirmed — Solana cluster consensus on your transaction.
Receipt::confirmed()resolves at that point. Most quoting flows await ack (cheap, sub-second) and let confirmation happen in the background.
If Receipt reports CommitError::OnChain { 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:
sweetspot-api-client = { version = "0.1", default-features = false, features = ["tls"] }Where to go next
| You want | Page |
|---|---|
| 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 |
| Run or fork a Python maker bot | sweetspot-maker-example |
| The end-to-end runnable example | examples/rust/src/quote.rs |
