Skip to content

Quoting

You're building a maker bot. This page is the integrator-facing reference for the SDK's quoting layer — how to wire it up, how to keep it submitting, and the on-chain gotchas that will silently drop your fills if you skip them.

Read How Sweetspot works first

The on-chain matcher enforces sequence-number monotonicity, the two-balance trap, and the cross-spread requirement. The SDK handles the first; you have to handle the other two.

Three strategies

Pick one per market.

StrategyWhen to useSDK type
OracleOffsetYou have an off-book fair price. Quote a fair anchor + per-side offset vectors.OracleOffsetQuotingClient
OrderListExplicit order-by-order Place/Cancel.OrderListQuotingClient
LinearDistributionServer interpolates linearly between buy/sell ranges. Cheapest message size.LinearDistributionQuotingClient

Strategies are mode-exclusive — one per maker, per market. Don't mix.

Rust + Go only

Quoting is gated on the on-chain Rust SDK (encoding the UpdateOracleFair / UpdateQuotingParams instructions). The TS SDK intentionally stops at AuthFlow + read-only client wrappers — browser trading clients should sign and submit transactions through a wallet adapter, not the SDK.

The Go SDK ships QuotingCore (Receipt, sequence tracking, status fan-out, packing scaffolding) but not the strategy clients. Build instructions in Rust and ship the bytes through Go's SubmitTx, or use a Go Solana library to encode against the on-chain program directly.

Bot loop, end-to-end (Rust)

The minimal shape of a quoting bot:

rust
use std::sync::Arc;
use superis::auth::AuthFlow;
use superis::quoting::{
    OffsetOrderSpec, OracleOffsetQuotingClient, ParamsUpdate, QuotingCore, RiskParams,
};
use superis::{ConfigCache, ResilientStream, ServerState};
use tonic::transport::Channel;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 1. Connect once + share the channel everywhere.
    let channel = Channel::from_static("https://api.superis.exchange:443")
        .connect()
        .await?;

    // 2. Authenticate.
    let auth = AuthFlow::new(channel.clone(), Arc::new(my_signer));
    let session = auth.token().await?;
    let _refresher = auth.spawn_refresh_loop();

    // 3. Cache config + verify program id.
    let mut market = MarketDataServiceClient::new(channel.clone());
    let mut tx = TxServiceClient::with_interceptor(channel.clone(), auth.interceptor());
    let cache = ConfigCache::new();
    let cfg = superis::config::refresh(&cache, &mut market, &mut tx, server_program_id).await?;
    superis::config::verify_program_id(
        "https://api.mainnet-beta.solana.com",
        &cfg.program_id,
    ).await?;

    // 4. Hook server state to blockhash + slot streams (resilience recipe).
    let server = Arc::new(ServerState::new());
    wire_blockhash_and_slots(&server, channel.clone(), auth.interceptor()).await?;

    // 5. Spin up the quoting core.
    let core = QuotingCore::start(
        channel.clone(),
        auth.interceptor(),
        keypair.clone(),
        session.maker_id as u16,
        Arc::new(cfg),
        server.clone(),
    ).await?;
    let oc = OracleOffsetQuotingClient::new(core.clone());

    // 6. One-time params (enable + balance + cross_spread).
    oc.queue_params_update(
        "SOL/USDC",
        ParamsUpdate {
            enable: true,
            max_balance: 100_000_000_000,        // 100 SOL atoms
            cross_spread: vec![(0, Some(0))],    // required even for SOL/USDC
        },
    ).await?;
    oc.queue_risk_params_update(
        "SOL/USDC",
        RiskParams { per_slot_decay_factor: Some(0.999), ..Default::default() },
    ).await?;
    oc.commit().await?.confirmed().await?;

    // 7. Quoting loop. Fair price comes from your strategy.
    loop {
        let fair = my_oracle.fetch().await;          // your decision
        oc.queue_fair_price("SOL/USDC", fair - 0.05, fair + 0.05).await?;
        oc.queue_spread(
            "SOL/USDC",
            buy_ladder(fair),
            sell_ladder(fair),
        ).await?;

        let mut receipt = oc.commit().await?;
        if let Err(e) = receipt.confirmed_within(std::time::Duration::from_secs(5)).await {
            tracing::warn!(?e, "quote commit failed");
        }
        tokio::time::sleep(std::time::Duration::from_secs(1)).await;
    }
}

fn buy_ladder(fair: f64) -> Vec<OffsetOrderSpec> {
    (1..=5).map(|i| OffsetOrderSpec {
        price_offset_delta: 50 * i,
        lots: 100,
        staleness: 30,
        client_order_id: None,
    }).collect()
}

The same shape works for OrderListQuotingClient (replace queue_fair_price + queue_spread with queue_order(...)) and LinearDistributionQuotingClient (replace with queue_linear_params(...)).

OracleOffset queue → commit

rust
oc.queue_fair_price("SOL/USDC", 155.10, 155.20).await?;
oc.queue_spread(
    "SOL/USDC",
    vec![
        OffsetOrderSpec { price_offset_delta: 50,  lots: 100, staleness: 30, client_order_id: None },
        OffsetOrderSpec { price_offset_delta: 100, lots: 200, staleness: 30, client_order_id: None },
    ],
    vec![
        OffsetOrderSpec { price_offset_delta: 50,  lots: 100, staleness: 30, client_order_id: None },
    ],
).await?;
oc.queue_params_update("SOL/USDC", ParamsUpdate {
    enable: true,
    max_balance: 100_000_000_000,
    cross_spread: vec![(0, Some(0))],
}).await?;

let mut receipt = oc.commit().await?;
receipt.confirmed_within(std::time::Duration::from_secs(30)).await?;

commit() packs every queued change into the smallest set of transactions that fit, partial-signs each with your quoting key, submits via TxService.SubmitTx, and returns a Receipt you await.

OrderList queue → commit

rust
use superis::quoting::OrderAction;
use sweetspot_sdk::core::side::Side;

let ol = OrderListQuotingClient::new(core.clone());

ol.queue_order("SOL/USDC", OrderAction::Submit, Side::Buy,  154.90, 10.0).await?;
ol.queue_order("SOL/USDC", OrderAction::Submit, Side::Sell, 155.30, 10.0).await?;
ol.commit().await?.confirmed().await?;

The local tracker remembers (price, size) → client_order_id so a follow-up Cancel at the same price/size resolves the right id without you wiring it.

LinearDistribution queue → commit

rust
let ld = LinearDistributionQuotingClient::new(core.clone());

ld.queue_linear_params(
    "SOL/USDC",
    LinearParamsUpdate {
        buy_start_price: 155.00,
        buy_end_price: 154.50,
        sell_start_price: 155.30,
        sell_end_price: 155.80,
        spread_backoff_per_slot: Some(2),
        bid_post_only: Some(true),
        ask_post_only: Some(true),
        client_order_id: None,
    },
).await?;
ld.commit().await?.confirmed_within(std::time::Duration::from_secs(30)).await?;

Receipts

Receipt resolves when every submission in a commit() has acked or confirmed. Cheap to drop — the underlying submissions continue server-side regardless. The mechanism is fed by TxService.SubscribeTxStatus, which the quoting core opens and auto-reconnects in the background.

rust
use superis::quoting::{CommitError, ReceiptStatus};

// Block until acked.
receipt.accepted().await?;

// Block until confirmed, with timeout.
match receipt.confirmed_within(std::time::Duration::from_secs(10)).await {
    Ok(()) => println!("confirmed"),
    Err(CommitError::OnChain { reason }) => eprintln!("reverted: {reason}"),
    Err(CommitError::Timeout) => eprintln!("timeout"),
    Err(CommitError::Disconnected) => eprintln!("status stream dropped"),
}

// Or 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}"),
}

Re-quoting on fills

Subscribe to fills (your maker_id is scoped automatically by the session) and re-quote when your inventory shifts:

rust
use superis::proto::{
    market_data_service_client::MarketDataServiceClient, SubscribeFillsRequest,
};

let mut market = MarketDataServiceClient::new(channel.clone());
let mut fills = market
    .subscribe_fills(SubscribeFillsRequest { pairs: vec![] })
    .await?
    .into_inner();

while let Some(fill) = fills.message().await? {
    if fill.maker_id.as_ref().map(|m| m.id) == Some(session.maker_id) {
        // Your fill — refresh fair, requeue, commit.
        let fair = my_oracle.fetch().await;
        oc.queue_fair_price("SOL/USDC", fair - 0.05, fair + 0.05).await?;
        oc.commit().await?.accepted().await?;
    }
}

Sequence-number invariants

The matcher rejects any oracle_sequence_number or order_sequence that isn't strictly increasing per spot. The SDK seeds both counters from SystemTime::now().as_micros() at startup so a process restart never collides with prior on-chain state.

client_order_id is allocated from a single global counter, also seeded from microseconds-since-epoch. If you supply your own ids (OffsetOrderSpec.client_order_id = Some(n)), it's your job to keep them monotonic — the SDK does not check.

Don't share a maker_id across processes

Two processes using the same maker_id will burn each other's sequence numbers and cause OracleFairSequenceNotMonotonic reverts. Run one quoting process per maker_id.

Tx batching

Every commit() produces a flat Vec<Instruction> that the SDK packs greedily into as few 1232-byte transactions as fit. A single instruction that overflows on its own is still emitted (the server will reject it); keep individual instructions reasonable.

Each tx is partial-signed with the quoting keypair. The fee-payer signature slot is left empty for the server to fill via its sponsored-payer wallet (you select one from TxService.GetSponsoredPayers, which superis::config::refresh() caches for you).

Capacity planning

Three knobs control whether your fills land:

  1. ParamsUpdate.max_balance — base-side balance ceiling, per spot. Set above your expected inventory.
  2. Global quote balance — set via DepositWithdrawQuote (admin path). Required for sells to land — see the two-balance trap in How Sweetspot works.
  3. ParamsUpdate.cross_spread — must include an entry for the counterparty spot_id (typically 0 for global swaps). Without it the matcher silently filters your maker.

If your orders show up in the L3 stream but never fill, walk through those three first.

Go

Go ships the transport layer — Receipt, sequence tracking, status fan-out, packing scaffolding — without the strategy clients (encoding the on-chain instructions is Rust-only):

go
import (
    "github.com/superis/sweetspot-maker-client/go/superis"
    pb "github.com/superis/sweetspot-maker-client/go/sweetspot/api/v1"
)

txClient := pb.NewTxServiceClient(authedConn)
state := superis.NewServerState()
seq := superis.NewSequenceTracker()

core := superis.NewQuotingCore(txClient, state, seq)
stop := core.Start(ctx)
defer stop()

// Build + sign your tx with your preferred Solana Go library, then:
receipt, err := core.SubmitTx(ctx, txBytes, []string{signature})
if err := receipt.ConfirmedWithin(ctx, 30*time.Second); err != nil {
    log.Fatal(err)
}

SequenceTracker exposes NextOracleSeq(spotID), NextOrderSeq(spotID), and AllocClientOrderID(). Match the seeding strategy by calling them when building each on-chain instruction.

Disabling the quoting layer (Rust)

The quoting Cargo feature is on by default. Read-only consumers can turn it off to drop the on-chain SDK + Solana deps from the build:

toml
superis = { version = "0.1", default-features = false, features = ["tls"] }

Apache 2.0