How Flint works
Flint is a Solana on-chain DEX that aggregates multiple private market makers into a unified virtual orderbook. Think of it as a multi-entity proprietary AMM: each maker independently quotes prices using pluggable quoting strategies, and when a trader wants to swap, the protocol builds a virtual book on the fly by merging all quoting intents and filling makers pro-rata at each price level.
This design delivers two things that are usually at odds:
- Efficient pricing updates — makers update a fair price plus offsets instead of rewriting full order state, so quote refreshes stay cheap.
- Flexibility — each maker chooses their own quoting strategy, risk parameters, and cross‑market pairs, instead of being locked into one AMM curve or a single operator.
Markets
Flint has one on-chain SpotMarket account per listed token and one GlobalMarket for the quote currency (USDC). There are no per-pair accounts. Pairs are discovered from those spot markets and then joined virtually at match time.
Two swap modes exist:
- Global swap — base token vs. the quote currency (e.g. SOL ↔ USDC). The matcher loads one spot account; quote balances live in the singleton
GlobalMarket. - Cross swap — base vs. base (e.g. SOL ↔ ETH). The matcher loads both spot accounts and bridges them through each maker's per-spot micro-book.
The SDK's ListPairs RPC returns the catalog of discovered pairs along with their per-token metadata (decimals, atoms-per-lot, mints).
Quoting strategies
Pick one per market. Three are supported on-chain; the SDK exposes each as a fluent block on the shared QuoteBuilder.
| Strategy | When to use | Rust builder entry |
|---|---|---|
| OrderList | Explicit order-by-order control (place / cancel by price + size). The CEX-shaped option. | QuoteBuilder::new().order_list("SOL", ...) |
| OracleOffset | You have an off-book fair price (oracle, internal mid). Quote a fair anchor + per-side delta vectors. | QuoteBuilder::new().oracle_offset("SOL", ...) |
| LinearDistribution | Server interpolates linearly between buy/sell price ranges. Cheapest message size. | QuoteBuilder::new().linear("SOL", ...) |
Strategies are documented under Quoting.
Unit system
You as a quoter work in human units (price 155.14, size 10.0 SOL). The SDK converts to the on-chain integer forms at the boundary. You never write atoms or lots in normal flow.
The three on-chain units exist:
| Unit | Meaning |
|---|---|
| Atoms | Smallest token integer. 1 SOL = 10⁹ atoms. |
| Lots | atoms / atoms_per_contract_lots. The matcher works in lot-space. |
| Oracle units | Common reference unit for price comparisons across markets. |
The SDK's pricing helpers (human_to_oracle, human_size_to_lots, oracle_to_human) do every conversion. See Prices, sizes, units.
Sequence numbers
Two monotonic counters per spot market are checked by the matcher. Both must strictly increase across all transactions you ever submit, including across process restarts.
| Counter | Purpose |
|---|---|
oracle_sequence_number | Anti-replay on OracleFair updates. Bump on every fair-price flush. |
order_sequence | Anti-replay on UpdateQuotingParams. Bump on every params/order flush. |
client_order_id | Per-maker monotonic id for OrderList placements. |
The SDK seeds all three counters from microseconds since Unix epoch on startup, so a process restart should not collide with prior on-chain state. Don't override the seed unless you know what you're doing, and don't run two quoting processes for the same maker_id.
Cross-market spread
For any cross-swap market (e.g. SOL ↔ ETH), you must explicitly allow crossing into the counterparty market's spot id by including a cross_spread entry. The matcher looks up book.get_cross_params(SpotId::OF_COUNTERPARTY) and silently filters your maker if absent.
For a global swap (e.g. SOL ↔ USDC), the counterparty is SpotId::GLOBAL, encoded as 0. You still need a cross_spread entry for 0 even though there is no second spot market. Some(0) is enough to pass the gate without widening.
QuoteBuilder::new()
.order_list("SOL", |b| b
.init()
.with_params(ParamsUpdate::new()
.enable(true)
.max_balance(100.0)
.with_cross(0_u16, Some(0.0)))) // counterparty + spread cap
.commit(&mut core).await?;Budgets per leg
Liquidity is allocated dynamically at swap time. When a taker swap touches a maker's quote, the matcher reads the maker's current balance and caps the fill against:
| Side of the fill | Budget read | What's actually needed |
|---|---|---|
| Sell side (token going out) | available_to_sell = balance | Deposited inventory. |
| Buy side (token coming in) | available_to_buy = soft_max_balance − balance (sat.) | A non-zero soft_max_balance with headroom under it. |
Because the check happens at swap time, makers can publish levels that exceed their current inventory — the matcher just fills up to the available budget at the moment of the swap. Quotes are silently filtered only when a side's budget is literally zero (no deposit on the sell side, or no soft_max_balance headroom on the buy side).
A useful consequence: fills recycle. A SOL ask that takes SOL out brings USDC in, which immediately becomes available_to_sell USDC backing any SOL bid the maker also posted. A maker can run a two-sided book on a one-sided initial deposit; inventory cycles through the quotes.
For a global swap (e.g. SOL ↔ USDC) the USDC side lives in the singleton GlobalMarket account (set via DepositWithdrawQuote + set_quote_max_balance). For a cross swap (e.g. SOL ↔ ETH) both sides live in per-spot micro-books. The mechanism is identical in both cases — only the account holding the balance differs.
A common cold-start trap: a fresh maker has soft_max_balance = 0 everywhere, so the buy-side budget reads zero on every direction. A sell-only SOL/USDC maker needs a non-zero USDC soft_max_balance on the global market — without it, the buy side of every sell fill is 0 and asks silently don't match. (Importantly, that's the only cap a sell-only maker needs; no cap is required on the SOL side they're never buying into.)
See Onboarding step 4 for the boot sequence.
OracleFair staleness
Each oracle-offset order carries a max_slot_staleness byte. The matcher computes slot_delay = current_slot - last_oracle_slot and rejects any order where slot_delay > max_slot_staleness.
