--- # overview/onboarding # Onboarding What you need to do, in order, to go from "have a Solana wallet" to "first authenticated RPC." ## Audience This page assumes you're either: - **Building a market-maker bot.** You'll quote on at least one pair and need a `maker_id` registered against your pubkey. - **Building a read-only integration** (analytics, charts, alerts). You only need to talk to `MarketDataService` and may skip the signing flow. ## 1. Get an endpoint | Environment | Host | | ----------- | ---------------------------------------------- | | Mainnet | `https://api.superis.exchange:443` | | Testnet | `https://testnet.api.superis.exchange:443` | | Localhost | `http://localhost:50051` (`SUPERIS_INSECURE=1` for the SDKs to skip TLS) | Endpoints are unauthenticated for `MarketDataService` and require a session token for everything else. ## 2. Solana wallet You need an ed25519 keypair. The same key: 1. Authenticates with the gRPC server (signs the AuthService challenge). 2. Signs on-chain quoting transactions (`UpdateOracleFair`, `UpdateQuotingParams`). The server only accepts authenticated calls from pubkeys that are registered as **quoting authorities** for some `maker_id`. ```sh # Generate a fresh keypair (mainnet → use a hardware wallet instead). solana-keygen new --no-bip39-passphrase --outfile ~/.config/solana/superis.json solana address -k ~/.config/solana/superis.json ``` ## 3. Register your pubkey as a quoting authority If you don't already have a `maker_id`, the on-chain admin needs to create one for you. Maker creation is a one-time `CreateMaker` instruction signed by the admin; ask the operator that runs your deployment. If you already have a `maker_id` and want to delegate quoting to a hot wallet, send `ManageMaker { AddQuoter }` from the admin wallet: ```rust use sweetspot_client::instruction::manage_maker::build_add_quoter_ix; let ix = build_add_quoter_ix(&program_id, &admin, maker_id, &hot_wallet_pubkey); // Sign + submit via your own RPC (this is admin-only; not part of // the SDK's standard surface). ``` ::: tip Hot vs. cold wallets Use a cold wallet to mint your `maker_id` and set `max_balance` / risk params (the admin path). Use a delegated hot wallet for the high-frequency `UpdateOracleFair` / `UpdateQuotingParams` flow. The SDK's `WalletSigner` doesn't care which one you give it. ::: ## 4. Fund the maker Two on-chain balances gate fills: | Balance | Where | What it gates | | --- | --- | --- | | Per-spot `SpotMakerMicroBook.balance` | one per spot you quote on | Maker's holding of the **base** token. Sets `available_to_sell`. | | Global per-maker quote balance (`GlobalMarket.balances[maker_id]`) | singleton | Maker's quote (USDC) holdings. Sets `available_to_buy` for the base side and is required for SELL fills to land. | ::: warning The two-balance trap The on-chain matcher gates **SELL fills on the global quote-side balance**. With `max_balance=0` on the global quote side, sells silently never match — the order shows in the book but the matcher drops it. Set `max_balance > 0` on both balances before you start quoting. ::: Fund both via `DepositWithdrawQuote` (admin-side) and the standard SPL transfer to your micro-book vault. ## 5. Install an SDK ::: code-group ```sh [Rust] cargo add superis tokio --features tokio/macros,tokio/rt-multi-thread # Or git dep — see `docs/sdks/rust.md`. ``` ```sh [Go] go get github.com/superis/sweetspot-maker-client/go/superis ``` ```sh [TypeScript] npm install @superis/sweetspot-client \ @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf ``` ::: ## 6. First authenticated call ```rust use std::sync::Arc; use superis::auth::AuthFlow; let channel = tonic::transport::Channel::from_static("https://api.superis.exchange:443") .connect() .await?; let auth = AuthFlow::new(channel.clone(), Arc::new(my_signer)); let session = auth.token().await?; println!("authenticated as maker_id={}", session.maker_id); ``` If `AuthFlow::token()` returns `UNAUTHENTICATED: pubkey not registered as a quoting authority`, your pubkey hasn't been added yet — go back to step 3. ## 7. Verify the program id Defense in depth. Confirms the server isn't pointing you at a different on-chain program than you expect. ```rust use superis::config::{refresh, verify_program_id, ConfigCache}; let cache = ConfigCache::new(); let cfg = refresh(&cache, &mut market_client, &mut tx_client, server_program_id).await?; verify_program_id("https://api.mainnet-beta.solana.com", &cfg.program_id).await?; ``` Run this once at boot; if it fails, refuse to quote until it passes. ## 8. Pick your path | Goal | Next page | | --- | --- | | Stream books and fills | [Market data](/sdks/market-data) | | Quote and submit fills | [Quoting](/sdks/quoting) | | Survive network blips | [Resilience](/sdks/resilience) | | Pull historical trades / candles | [Historical queries](/sdks/historical) | | Sign-in flow detail | [Auth flow](/sdks/auth) | --- # overview/exchange # How Sweetspot works You only need to read this if you're quoting. Read-only integrators can skip to [Market data](/sdks/market-data). The bits below are what the on-chain program forces on every quoting client — sequence numbers, the unit system, the two-balance trap, the cross-spread gotcha. Get them wrong and your orders won't match. ## Markets A market is identified by its **pair name** — `"SOL/USDC"`. Each pair is two on-chain `SpotMarket` accounts (one per token) 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 mode-exclusive quoting client. | Strategy | When to use | SDK | | --- | --- | --- | | **OracleOffset** | You have an off-book fair price (oracle, internal mid). Quote a fair anchor + per-side delta vectors. | `OracleOffsetQuotingClient` | | **OrderList** | You want explicit order-by-order control (place / cancel by price + size). | `OrderListQuotingClient` | | **LinearDistribution** | Server interpolates linearly between buy/sell price ranges. Cheapest message size. | `LinearDistributionQuotingClient` | Strategies are documented under [Quoting](/sdks/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](/overview/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 `SystemTime::now().as_micros()` on startup, so a process restart never collides with prior on-chain state. Don't override the seed unless you know what you're doing. ## The two-balance trap A maker has **two separate on-chain balance entries**: 1. `SpotMakerMicroBook.balance` (per-spot, per-micro-book) — base token holdings. Set via `UpdateQuotingParams.max_balance`. Affects `available_to_sell` for the base side. 2. `GlobalMarket.balances[maker_id]` (singleton) — quote (USDC) holdings. Has its own `max_balance`. Set via `DepositWithdrawQuote`. **The matcher gates SELL fills on the global quote-side `available_to_buy()`.** With `max_balance=0` on the global quote side, sells **silently never match** — the order shows in the book but the matcher drops it. Set `max_balance > 0` on both before quoting. ## 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 `spot_id=0` (the global quote). You still need a `cross_spread` entry for `0` even though you don't care about hedging. `Some(0)` is enough to pass the gate without widening. ```rust oc.queue_params_update( "SOL/USDC", ParamsUpdate { enable: true, max_balance: 100_000_000_000, cross_spread: vec![(0, Some(0))], // required for SOL/USDC }, ).await?; ``` ## 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`. The SDK stamps `last_oracle_slot` with the freshest slot it's seen (via `ServerState.current_slot`). For 1 Hz flush cadences, `staleness = 30` is a reasonable default. Lower than that and orders silently expire between flushes. ## L3 doesn't show oracle-offset orders L3 streams (`MarketDataService.Subscribe` with `level: L3`) show `OrderList` entries only — oracle-offset orders are virtual, reconstructed at match time from `OracleFair + offsets`. If you oracle-offset quote and your L3 stream shows zero orders for your maker_id, that's normal. Use `BalanceService` + your own commit log to verify what you submitted. --- # overview/units # Prices, sizes, and units Every numeric field in the API that represents money or quantity is a **decimal string**. This is the load-bearing contract — get it right and everything downstream works. ## Why strings Floating-point arithmetic loses precision for large notionals. Decimal strings preserve the exact representation. Parse with whatever fixed-precision type your language offers: | Language | Library | | --- | --- | | Rust | `rust_decimal::Decimal` | | Go | `shopspring/decimal` | | TypeScript | `bignumber.js` or `decimal.js` | The SDKs hand back the string verbatim — they do not parse for you, because picking the precision strategy is your decision. ## Conventions on the wire | Field | Example | Meaning | | --- | --- | --- | | `price` | `"155.14"` | Quote per base, human-readable | | `size` | `"10.0"` | Base token units (e.g. SOL) | | `volume` | `"42.137"` | Same convention as `size` | | `open` / `high` / `low` / `close` | `"154.97"` | Same as `price` | | `best_bid` / `best_ask` / `mid` | strings | Optional, omitted on empty side | You will **never** see on the wire: - Atom counts (raw on-chain integer base units). - Lot multiples. - Oracle units. The SDK's quoting layer converts the human values you pass to the on-chain integer forms exactly once, at the point of building the instruction. The integer forms are documented in [How Sweetspot works](/overview/exchange#unit-system) for completeness — most integrators never have to think about them. ## Pricing helpers (Rust) For when you need the conversion outside a `queue_*` call: ```rust use superis::pricing::{human_to_oracle, oracle_to_human, human_size_to_lots}; let market = config.pairs.iter().find(|p| p.base_name == "SOL").unwrap(); let oracle = human_to_oracle(155.14, market); // → 155_140 let back = oracle_to_human(oracle, market); // → 155.14 let lots = human_size_to_lots(10.0, market); // → 10_000 ``` Edge cases (saturate, NaN, negative) are documented in the function-level docs. ## Timestamps - `timestamp_us` — Unix microseconds since epoch. Used on `Trade`, `Candle`, every event. - `slot` — Solana slot number. Useful for cross-referencing on-chain data. - `from_sec` / `to_sec` (request) — Unix **seconds**. Values ≥ 10¹¹ are auto-converted from milliseconds (with a `Warning` header). - `server_time_us`, `started_at_us` — Unix microseconds. ## Enums on the wire Connect-JSON serializes enums by their proto variant name: | Field | Examples | | --- | --- | | `source` | `"SOURCE_LIVE"`, `"SOURCE_HISTORICAL"` | | `interval` | `"INTERVAL_5M"`, `"INTERVAL_1H"`, `"INTERVAL_1D"` | | `side` | `"SIDE_BUY"`, `"SIDE_SELL"` | | `state` | `"HEALTH_STATE_HEALTHY"`, `"HEALTH_STATE_DEGRADED"` | Binary protobuf clients see integer variant numbers. The SDKs all expose the typed enum, so you don't have to hardcode either form. --- # overview/errors # Errors Every authenticated RPC can fail. Most failures fall into one of five codes; the SDKs surface the gRPC status verbatim plus an optional domain-specific reason string. ## Codes you'll see | gRPC code | When | Retry? | | --- | --- | --- | | `UNAUTHENTICATED` | Session token missing / expired / revoked. Pubkey not registered as a quoting authority. | Re-authenticate (`AuthFlow.refresh()`) and retry once. | | `INVALID_ARGUMENT` | Bad pair name, malformed pubkey, out-of-range limit, unsupported interval. | No — fix the input. | | `RESOURCE_EXHAUSTED` | Per-IP rate-limit bucket empty. | Yes, with exponential backoff. The bucket replenishes. | | `FAILED_PRECONDITION` | Historical query when the historical archive is disabled on this deployment. | No — feature isn't enabled. | | `NOT_FOUND` | Pair not in catalog, maker_id has no balances. | No — fix the input. | | `UNAVAILABLE` | Transport drop. The server is restarting or unreachable. | Yes, with backoff. The SDK's `ResilientStream` auto-retries; for unary calls you decide the policy. | | `INTERNAL` | A bug. Report it. | Capped retry (1–3 attempts) before surfacing. | The SDKs don't retry by default. Pick your policy explicitly. ## Reading errors ::: code-group ```rust [Rust] use tonic::Code; match client.get_balance(req).await { Ok(res) => /* ... */, Err(status) => match status.code() { Code::Unauthenticated => auth.refresh().await?, Code::ResourceExhausted => sleep_then_retry().await, _ => return Err(status.into()), }, } ``` ```go [Go] import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) if err != nil { s, _ := status.FromError(err) switch s.Code() { case codes.Unauthenticated: _, _ = auth.Refresh(ctx) case codes.ResourceExhausted: time.Sleep(backoff) default: return err } } ``` ```ts [TypeScript] import { ConnectError, Code } from "@connectrpc/connect"; try { await client.getBalance(req); } catch (err) { if (err instanceof ConnectError) { if (err.code === Code.Unauthenticated) await auth.refresh(); if (err.code === Code.ResourceExhausted) await sleep(backoff); } } ``` ::: ## Auth-flow specific failures `AuthService.Authenticate` returns `UNAUTHENTICATED` for any of: - The pubkey has no outstanding nonce (you didn't call `Challenge` first). - The nonce expired (TTL exceeded between `Challenge` and `Authenticate`). - The signature doesn't cover `b"SWEETSPOT-AUTH-V1:" || nonce`. - The pubkey isn't registered as a quoting authority for any maker. The SDKs' `AuthFlow.refresh()` always runs `Challenge → sign → Authenticate` from scratch, so you don't need to think about nonce expiry yourself. ## On-chain failures (quoting) When you submit a tx via the quoting layer, the on-chain program may revert. The SDK surfaces this through `Receipt`: ```rust match receipt.confirmed().await { Ok(()) => /* landed */, Err(CommitError::OnChain { reason }) => { // Reason is whatever the on-chain program returned. // Common: "OracleFairSequenceNotMonotonic", "InsufficientBalance". } Err(CommitError::Timeout) => /* didn't land in time */, Err(CommitError::Disconnected) => /* status stream dropped */, } ``` The most common on-chain reverts and what to do about them: | Reason | Cause | Fix | | --- | --- | --- | | `OracleFairSequenceNotMonotonic` | Two clients sharing a maker_id, or a clock-skew restart. | Don't share maker_ids; the SDK's microsecond seed handles restarts. | | `OrderSequenceNotMonotonic` | Same as above for `UpdateQuotingParams`. | Same fix. | | `OracleFairTooStale` | `current_slot - last_oracle_slot > order.staleness`. | Increase `staleness` or flush more often. | | `InsufficientBalance` | The two-balance trap. | See [How Sweetspot works](/overview/exchange#the-two-balance-trap). | --- # api/index # API Five gRPC services in `sweetspot.api.v1`. One is unauthenticated; the rest require a session token from `AuthService.Authenticate`. | Service | Auth | What you use it for | | --- | --- | --- | | `AuthService` | none | Sign in once with your pubkey, get a bearer token. | | `MarketDataService` | none | Stream books, fills, list pairs, snapshot a single book. | | `BalanceService` | yes | Per-maker balances — current snapshot or live stream. | | `TxService` | yes | Submit signed transactions, stream blockhash + tx status. | | `HistoricalService` | yes | ClickHouse-backed historical trades + candles. | Each is fully wire-compatible with vanilla gRPC, gRPC-Web, and Connect on the same URL. Pick whichever transport fits your stack — the SDKs hide the difference. ## Schema - [`api.proto`](/api.proto) — protobuf source. Drop into your codegen tool. - [`openapi.yaml`](/openapi.yaml) — OpenAPI 3.1 spec. For Postman, Insomnia, Stoplight, or generating clients in languages we don't ship. - [`openapi.json`](/openapi.json) — same as YAML, JSON-encoded. CI fails the build if these files diverge from the upstream proto. ## URL convention ``` POST https:///sweetspot.api.v1./ Content-Type: application/proto (gRPC binary, hot path) application/grpc-web (browser-native) application/json (Connect-JSON, curl-friendly) ``` No URL params, no path-based versioning beyond the package name. ## Authentication `MarketDataService` is open. Every other service expects: ``` authorization: Bearer ``` Mint a token via `AuthService.Challenge → sign → Authenticate`. See the [Auth flow recipe](/sdks/auth) — every SDK ships a one-line helper that handles refresh. --- # api/reference --- aside: false outline: false --- # API reference Driven by [`openapi.json`](/openapi.json) (also available as [`openapi.yaml`](/openapi.yaml)). Each endpoint shows its request schema, response schema, and try-it-now panel. --- # api/schema # Proto schema The canonical schema for `sweetspot.v1.MarketData`. Synced from `sweetspot-server` on every commit; CI fails the build if this file diverges from upstream. - [Download `api.proto`](/api.proto) - [Download `openapi.yaml`](/openapi.yaml) --- # api/transports # Protocols & transports Every RPC is served over four protocols at the same URL. Pick whichever fits your stack. | Protocol | Content-Type | Best for | | --- | --- | --- | | gRPC (binary) | `application/grpc` | Native Rust / Go / Python clients — **hot path**. | | gRPC-Web (binary) | `application/grpc-web` | Browsers using a gRPC-Web library. | | Connect (JSON) | `application/json` | curl, Postman, debugging. | | Connect (binary) | `application/proto` | Browser SDK default; smaller than JSON. | The Superis SDKs default to: | SDK | Default transport | | --- | --- | | Rust | gRPC (binary) over HTTPS | | Go | gRPC (binary) over HTTPS | | TypeScript-web | Connect (binary) over HTTPS | All three let you override. ## curl examples `POST` to any RPC URL with a JSON body: ```sh # Public — no auth needed. curl -sS -X POST \ -H 'Content-Type: application/json' \ -d '{}' \ https://api.superis.exchange/sweetspot.api.v1.MarketDataService/ListPairs | jq # Authenticated — pass the session token. curl -sS -X POST \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ${SUPERIS_TOKEN}" \ -d '{}' \ https://api.superis.exchange/sweetspot.api.v1.BalanceService/Get | jq # Historical candles — note from_sec / to_sec are unix seconds. curl -sS -X POST \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ${SUPERIS_TOKEN}" \ -d '{"pair":{"base":{"id":1},"quote":{"id":0}},"interval":"INTERVAL_5M","from_sec":1735689600,"to_sec":1735776000}' \ https://api.superis.exchange/sweetspot.api.v1.HistoricalService/GetCandles | jq ``` The JSON shape mirrors the proto field names exactly. Enums travel as their variant name (`"INTERVAL_5M"`, not `"5m"` or `1`). ## Connection reuse A single tonic `Channel` (Rust), `*grpc.ClientConn` (Go), or Connect `Transport` (TypeScript) handles every RPC across every service. Build it once at boot, share it across your service clients, and let HTTP/2 multiplexing handle the per-call traffic. ```rust let channel = Channel::from_static("https://api.superis.exchange:443") .connect() .await?; let auth = AuthFlow::new(channel.clone(), Arc::new(my_signer)); let market = MarketDataServiceClient::new(channel.clone()); let balances = BalanceServiceClient::with_interceptor(channel.clone(), auth.interceptor()); let tx = TxServiceClient::with_interceptor(channel.clone(), auth.interceptor()); ``` ## Streaming Long-lived server streams (`MarketDataService.Subscribe`, `BalanceService.Subscribe`, `TxService.SubscribeBlockhash` / `SubscribeSlots` / `SubscribeTxStatus`) are the recommended path for anything event-driven. Wrap them in [`ResilientStream`](/sdks/resilience) to get auto-reconnect + fan-out. --- # sdks/rust # Rust SDK Async, tonic-based Rust client for every service in `sweetspot.api.v1`. Quoting layer (`OracleOffset`, `OrderList`, `LinearDistribution`) behind the default `quoting` feature; turn it off for read-only consumers to drop the on-chain Solana SDK from your build. ## Install ```toml [dependencies] superis = { git = "https://github.com/superis/sweetspot-maker-client", branch = "main" } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } ``` When the SDK is published to crates.io: ```toml superis = "0.1" ``` Read-only (no quoting): ```toml superis = { version = "0.1", default-features = false, features = ["tls"] } ``` ## Quickstart ```rust use std::sync::Arc; use superis::auth::AuthFlow; use superis::proto::{ market_data_service_client::MarketDataServiceClient, ListPairsRequest, }; #[tokio::main] async fn main() -> anyhow::Result<()> { let channel = tonic::transport::Channel::from_static("https://api.superis.exchange:443") .connect() .await?; // Public RPC — no auth required. let mut market = MarketDataServiceClient::new(channel.clone()); let pairs = market.list_pairs(ListPairsRequest {}).await?.into_inner(); for pair in &pairs.pairs { println!("{:?} / {:?}", pair.base, pair.quote); } // Authenticated path: AuthService.Challenge → sign → Authenticate. let auth = AuthFlow::new(channel.clone(), Arc::new(my_signer)); let session = auth.token().await?; println!("authenticated as maker_id={}", session.maker_id); Ok(()) } ``` ## Where to go from here | You want | Page | | --- | --- | | Boot a maker bot | [Quoting](./quoting) | | Stream books and fills | [Market data](./market-data) | | Survive transient disconnects | [Resilience](./resilience) | | Pull historical trades / candles | [Historical queries](./historical) | | Sign-in flow detail | [Auth flow](./auth) | ## Decimal handling `price`, `size`, and OHLCV fields are wrapped as `Decimal { value: String }` on the wire. Parse with [`rust_decimal`](https://crates.io/crates/rust_decimal): ```rust use rust_decimal::Decimal; use std::str::FromStr; let price = Decimal::from_str(&trade.price.as_ref().unwrap().value)?; let size = Decimal::from_str(&trade.size.as_ref().unwrap().value)?; let notional = price * size; ``` ## Errors The SDK surfaces gRPC `tonic::Status` directly. Branch on `status.code()` for retry decisions — see [Errors](/overview/errors). ## Cargo features | Feature | Default | What it adds | | --- | --- | --- | | `tls` | yes | HTTPS via rustls + native roots. | | `quoting` | yes | Quoting layer (`OracleOffsetQuotingClient`, `OrderListQuotingClient`, `LinearDistributionQuotingClient`, `Receipt`, sequence trackers). Pulls in the on-chain Solana SDK. | ## Source - Crate: [`rust/`](https://github.com/superis/sweetspot-maker-client/tree/main/rust) - Examples: [`examples/rust/`](https://github.com/superis/sweetspot-maker-client/tree/main/examples/rust) --- # sdks/go # Go SDK `grpc-go`-based Go client for every service in `sweetspot.api.v1`. Includes `AuthFlow` for the signed-nonce session-token flow, `ResilientStream` for auto-reconnecting server streams, and `QuotingCore` for tx batching + status tracking. Strategy clients (OracleOffset / OrderList / LinearDistribution) are Rust-only — see [Quoting](./quoting#go) for the Go integration shape. ## Install ```sh go get github.com/superis/sweetspot-maker-client/go/superis ``` ## Quickstart ```go package main import ( "context" "fmt" "log" "google.golang.org/grpc" "google.golang.org/grpc/credentials" "github.com/superis/sweetspot-maker-client/go/superis" pb "github.com/superis/sweetspot-maker-client/go/sweetspot/api/v1" ) func main() { ctx := context.Background() conn, err := grpc.NewClient("api.superis.exchange:443", grpc.WithTransportCredentials(credentials.NewTLS(nil))) if err != nil { log.Fatal(err) } defer conn.Close() // Public RPC — no auth needed. market := pb.NewMarketDataServiceClient(conn) pairs, err := market.ListPairs(ctx, &pb.ListPairsRequest{}) if err != nil { log.Fatal(err) } for _, p := range pairs.Pairs { fmt.Printf("%v / %v\n", p.Base, p.Quote) } // Authenticated path: AuthService.Challenge → sign → Authenticate. auth := superis.NewAuthFlowOnConn(conn, mySigner) session, err := auth.Token(ctx) if err != nil { log.Fatal(err) } fmt.Printf("authenticated as maker_id=%d\n", session.MakerID) } ``` ## Where to go from here | You want | Page | | --- | --- | | Boot a maker bot | [Quoting](./quoting) | | Stream books and fills | [Market data](./market-data) | | Survive transient disconnects | [Resilience](./resilience) | | Pull historical trades / candles | [Historical queries](./historical) | | Sign-in flow detail | [Auth flow](./auth) | ## Decimal handling `price`, `size`, and OHLCV fields come back as `*pb.Decimal{Value: string}`. Parse with [`shopspring/decimal`](https://github.com/shopspring/decimal): ```go import "github.com/shopspring/decimal" price, _ := decimal.NewFromString(trade.Price.GetValue()) size, _ := decimal.NewFromString(trade.Size.GetValue()) notional := price.Mul(size) ``` ## Errors The SDK returns standard `error` values. Branch on the gRPC status code via `google.golang.org/grpc/status` — see [Errors](/overview/errors). ## Source - Module: [`go/`](https://github.com/superis/sweetspot-maker-client/tree/main/go) - Examples: [`examples/go/`](https://github.com/superis/sweetspot-maker-client/tree/main/examples/go) --- # sdks/typescript # TypeScript (web) SDK ESM-only TypeScript SDK built on [`@connectrpc/connect-web`](https://connectrpc.com/docs/web/getting-started). Works in any modern browser and in Node 18.14+. No build-time codegen required by consumers — the package ships pre-generated TypeScript. ::: warning No quoting layer The TS SDK intentionally stops at AuthFlow + read-only client wrappers + `ResilientStream`. Browser trading clients should sign and submit transactions through a wallet adapter (Phantom, Solflare, `@solana/wallet-adapter-base`), not the SDK. For server-side quoting, use the [Rust](./rust) or [Go](./go) SDK. ::: ## Install ```sh npm install @superis/sweetspot-client # peer deps: npm install @connectrpc/connect @connectrpc/connect-web @bufbuild/protobuf ``` ## Quickstart ```ts import { createClient } from "@connectrpc/connect"; import { createConnectTransport } from "@connectrpc/connect-web"; import { AuthFlow, MarketDataService, type WalletSigner, } from "@superis/sweetspot-client"; const transport = createConnectTransport({ baseUrl: "https://api.superis.exchange", useBinaryFormat: true, }); // Public RPC — no auth needed. const market = createClient(MarketDataService, transport); const pairs = await market.listPairs({}); console.log(pairs.pairs); // Authenticated path: AuthService.Challenge → sign → Authenticate. const auth = new AuthFlow({ transport, signer }); const session = await auth.token(); console.log("authenticated as maker_id=", session.makerId.toString()); ``` The `signer` is your `WalletSigner` — typically a thin adapter over `@solana/wallet-adapter-base` or `@noble/ed25519`. See [Auth flow](./auth) for the full contract. ## Where to go from here | You want | Page | | --- | --- | | Stream books and fills | [Market data](./market-data) | | Sign-in flow detail | [Auth flow](./auth) | | Survive transient disconnects | [Resilience](./resilience) | | Pull historical trades / candles | [Historical queries](./historical) | ## Authenticated transport For services other than `MarketDataService`, layer the AuthFlow interceptor on a fresh transport — every call gets a bearer token attached automatically: ```ts const authedTransport = createConnectTransport({ baseUrl: "https://api.superis.exchange", useBinaryFormat: true, interceptors: [auth.interceptor()], }); import { BalanceService } from "@superis/sweetspot-client"; const balances = createClient(BalanceService, authedTransport); const snapshot = await balances.get({ spotIds: [] }); ``` ## Decimal handling `price`, `size`, and OHLCV fields come back as `Decimal { value: string }`. Parse with [`bignumber.js`](https://www.npmjs.com/package/bignumber.js) or [`decimal.js`](https://www.npmjs.com/package/decimal.js): ```ts import BigNumber from "bignumber.js"; const price = new BigNumber(trade.price.value); const size = new BigNumber(trade.size.value); const notional = price.multipliedBy(size); ``` ## Errors ```ts import { ConnectError, Code } from "@connectrpc/connect"; try { await balances.get({ spotIds: [] }); } catch (err) { if (err instanceof ConnectError) { switch (err.code) { case Code.Unauthenticated: await auth.refresh(); break; case Code.ResourceExhausted: await sleep(backoff); break; } } } ``` See [Errors](/overview/errors) for the full code map. ## gRPC-Web The default transport is Connect-binary. For environments that need gRPC-Web specifically, swap `createConnectTransport` for `createGrpcWebTransport` — the rest of the SDK is unchanged. ```ts import { createGrpcWebTransport } from "@connectrpc/connect-web"; const transport = createGrpcWebTransport({ baseUrl: "https://api.superis.exchange", useBinaryFormat: true, }); ``` ## Browser compatibility Modern browsers (Chrome 90+, Firefox 90+, Safari 14+). Requires `fetch` and `ReadableStream` — both baseline in every release-channel browser since 2022. ## Source - Package: [`typescript-web/`](https://github.com/superis/sweetspot-maker-client/tree/main/typescript-web) - Examples: [`examples/typescript-web/`](https://github.com/superis/sweetspot-maker-client/tree/main/examples/typescript-web) --- # sdks/auth # Auth flow `AuthService` issues short-lived **session tokens** bound to a Solana pubkey. Every authenticated RPC (`BalanceService`, `TxService`, `HistoricalService`) accepts the token via `authorization: Bearer ` metadata. `MarketDataService` is public — no token needed. The flow is multi-step and signed: 1. **`Challenge(pubkey)`** — server returns a single-use random `nonce` bound to your pubkey, valid for a short TTL. 2. **Sign** `AUTH_DOMAIN_PREFIX || nonce` (the raw bytes — no extra encoding) with the keypair owning the pubkey. The constant is `b"SWEETSPOT-AUTH-V1:"`. 3. **`Authenticate(pubkey, signature)`** — server verifies the signature, maps the pubkey to a `maker_id` via its registry, and returns a `session_token` plus an `expires_at`. 4. **Use** the token as `authorization: Bearer ` on subsequent RPCs. 5. **Re-auth** before `expires_at`. The SDK helpers cache the token and refresh `skew` ahead of expiry automatically. Each SDK ships an `AuthFlow` helper that drives all five steps — you implement a `WalletSigner` (sign-arbitrary-bytes + return-pubkey) and the helper handles the rest. ## Wallet signer The signer is intentionally narrow: produce a 64-byte ed25519 signature over an arbitrary byte slice, and return the corresponding 32-byte public key. This lets you back the same `AuthFlow` with a keypair file, hardware wallet, remote signer, or browser wallet adapter. ::: code-group ```rust [Rust] use async_trait::async_trait; use superis::auth::{AuthError, WalletSigner}; use ed25519_dalek::{Signer as _, SigningKey}; struct KeypairFile(SigningKey); #[async_trait] impl WalletSigner for KeypairFile { async fn sign(&self, bytes: &[u8]) -> Result, AuthError> { Ok(self.0.sign(bytes).to_bytes().to_vec()) } fn public_key(&self) -> [u8; 32] { self.0.verifying_key().to_bytes() } } ``` ```go [Go] import "crypto/ed25519" type keypairFile struct { priv ed25519.PrivateKey pub [32]byte } func (k *keypairFile) Sign(_ context.Context, bytes []byte) ([]byte, error) { return ed25519.Sign(k.priv, bytes), nil } func (k *keypairFile) PublicKey() [32]byte { return k.pub } ``` ```ts [TypeScript] import * as ed25519 from "@noble/ed25519"; import type { WalletSigner } from "@superis/sweetspot-client"; const priv = ed25519.utils.randomPrivateKey(); const pub = await ed25519.getPublicKeyAsync(priv); const signer: WalletSigner = { async sign(bytes) { return ed25519.signAsync(bytes, priv); }, publicKey() { return pub; }, }; ``` ::: ## Drive the flow ::: code-group ```rust [Rust] use std::sync::Arc; use superis::auth::AuthFlow; use tonic::transport::Channel; let channel = Channel::from_static("https://api.example.com").connect().await?; let auth = AuthFlow::new(channel.clone(), Arc::new(my_signer)); // Run Challenge → sign → Authenticate; cache the bearer + maker_id. let session = auth.token().await?; println!("authenticated as maker_id={}", session.maker_id); // Authenticated client: pipe RPCs through the AuthInterceptor. let mut balances = superis::proto::balance_service_client::BalanceServiceClient::with_interceptor( channel, auth.interceptor(), ); // Optional: keep the session fresh in the background. let _refresher = auth.spawn_refresh_loop(); ``` ```go [Go] auth := superis.NewAuthFlowOnConn(conn, mySigner) session, err := auth.Token(ctx) if err != nil { log.Fatal(err) } fmt.Println("maker_id:", session.MakerID) // Use the interceptors on a per-service connection. The unary + stream // interceptors both attach `authorization: Bearer `. authedConn, _ := grpc.NewClient(addr, grpc.WithTransportCredentials(creds), grpc.WithUnaryInterceptor(auth.UnaryInterceptor()), grpc.WithStreamInterceptor(auth.StreamInterceptor()), ) bal := pb.NewBalanceServiceClient(authedConn) // Optional: refresh in the background. stop := auth.StartRefreshLoop(ctx) defer stop() ``` ```ts [TypeScript] import { createConnectTransport } from "@connectrpc/connect-web"; import { createClient } from "@connectrpc/connect"; import { AuthFlow, BalanceService } from "@superis/sweetspot-client"; const transport = createConnectTransport({ baseUrl: "https://api.example.com", useBinaryFormat: true, }); const auth = new AuthFlow({ transport, signer }); const session = await auth.token(); console.log("maker_id:", session.makerId.toString()); // Authenticated client: layer the AuthFlow interceptor on a fresh transport. const authedTransport = createConnectTransport({ baseUrl: "https://api.example.com", useBinaryFormat: true, interceptors: [auth.interceptor()], }); const balances = createClient(BalanceService, authedTransport); // Optional: refresh in the background. const stop = auth.startRefreshLoop(); window.addEventListener("beforeunload", stop); ``` ::: ## Refresh strategy | SDK | Strategy | | -------------- | ---------------------------------------------------------------- | | Rust | `spawn_refresh_loop()` returns a `tokio::JoinHandle` | | Go | `StartRefreshLoop(ctx)` returns a `func() { cancel }` | | TypeScript | `startRefreshLoop()` returns a `() => void` stop fn | All three default to a 30 s skew before expiry. Override with `with_skew` (Rust) / `WithSkew` (Go) / `skewMs` constructor option (TS). ## Revoke If you need to invalidate a token before its natural expiration (e.g. on logout), call `revoke()`: ::: code-group ```rust [Rust] auth.revoke().await?; ``` ```go [Go] err := auth.Revoke(ctx) ``` ```ts [TypeScript] await auth.revoke(); ``` ::: The cached session is dropped; subsequent calls re-authenticate from scratch. ## Errors `AuthService.Authenticate` returns `UNAUTHENTICATED` when: - The pubkey has no outstanding nonce. - The nonce has expired (TTL exceeded). - The signature is invalid for the nonce. - The pubkey is not registered as a quoting authority. The SDK helpers surface these as language-specific error types (`AuthError::Service` in Rust, returned `error` in Go, thrown `ConnectError` in TS) with the gRPC status preserved so callers can branch on the code. --- # sdks/market-data # Market data Reading the book, fills, and pair catalog. Public — no session token required. ## What's available | RPC | Returns | Use it for | | --- | --- | --- | | `MarketDataService.ListPairs` | Catalog of pairs + per-spot metadata. | Boot — discover what's tradeable. | | `MarketDataService.GetBook` | One-shot L2 or L3 snapshot. | Cold-start reconciliation, periodic resync. | | `MarketDataService.Subscribe` | Stream of L1/L2/L3 book updates + status events for one or more pairs. | Continuous order-book, depth charts, status alerts. | | `MarketDataService.SubscribeFills` | Stream of executed trades. Optional per-pair filter. | Trade tape, volume metrics. | A single `Subscribe` call returns a multiplexed stream — every pair you subscribed to plus cross-cutting `StatusEvent`s arrive on the same channel. ## Pick your level Subscriptions take a `FeedLevel`: | Level | Payload | Use for | | --- | --- | --- | | `FEED_LEVEL_L1` | Best bid + best ask | Tickers, mark prices, sanity checks. | | `FEED_LEVEL_L2` | Aggregated depth, snapshot + deltas | Depth charts, mid calculation, taker sizing. | | `FEED_LEVEL_L3` | Per-maker order list | Maker analytics. **Does not include oracle-offset orders** (those are virtual). | Most integrations want L2. ## Snapshot then stream The canonical pattern for a UI: ::: code-group ```rust [Rust] use std::sync::Arc; use superis::ResilientStream; use superis::proto::{ market_data_service_client::MarketDataServiceClient, GetBookRequest, MarketDataEvent, Pair, SpotId, SubscribeRequest, PairSubscription, FeedLevel, }; let mut market = MarketDataServiceClient::new(channel.clone()); // 1. Cold-start snapshot. let snapshot = market .get_book(GetBookRequest { pair: Some(Pair { base: Some(SpotId { id: 1 }), quote: Some(SpotId { id: 0 }) }), level: FeedLevel::L2.into(), }) .await? .into_inner(); let book = build_local_book(snapshot); // 2. Subscribe to deltas. let pair = Pair { base: Some(SpotId { id: 1 }), quote: Some(SpotId { id: 0 }) }; let sub = PairSubscription { pair: Some(pair), level: FeedLevel::L2.into(), snapshot_only: false, }; let factory: superis::resilience::StreamFactory = { let mut market = market.clone(); let sub = sub.clone(); Arc::new(move || { let mut market = market.clone(); let sub = sub.clone(); Box::pin(async move { let stream = market .subscribe(SubscribeRequest { pairs: vec![sub] }) .await? .into_inner(); Ok(Box::pin(stream) as superis::resilience::BoxStream<_>) }) }) }; let book_stream = ResilientStream::new(factory, None, 256); let mut rx = book_stream.subscribe().await; while let Ok(ev) = rx.recv().await { apply_event(&mut book, ev); } ``` ```go [Go] sub := &pb.PairSubscription{ Pair: &pb.Pair{Base: &pb.SpotId{Id: 1}, Quote: &pb.SpotId{Id: 0}}, Level: pb.FeedLevel_FEED_LEVEL_L2, } // Cold-start snapshot. snap, err := market.GetBook(ctx, &pb.GetBookRequest{ Pair: sub.Pair, Level: pb.FeedLevel_FEED_LEVEL_L2, }) if err != nil { log.Fatal(err) } book := buildLocalBook(snap) // Subscribe to deltas. factory := func(ctx context.Context) (<-chan *pb.MarketDataEvent, <-chan error, error) { stream, err := market.Subscribe(ctx, &pb.SubscribeRequest{Pairs: []*pb.PairSubscription{sub}}) if err != nil { return nil, nil, err } items := make(chan *pb.MarketDataEvent, 256) errs := make(chan error, 1) go func() { defer close(items); defer close(errs) for { ev, err := stream.Recv() if err != nil { errs <- err; return } items <- ev } }() return items, errs, nil } rs := superis.NewResilientStream(factory, nil, 256) rs.Start(ctx) defer rs.Close() for ev := range rs.Subscribe() { applyEvent(book, ev) } ``` ```ts [TypeScript] import { createClient, type Transport } from "@connectrpc/connect"; import { MarketDataService } from "@superis/sweetspot-client"; import { ResilientStream } from "@superis/sweetspot-client"; const market = createClient(MarketDataService, transport); const pair = { base: { id: 1n }, quote: { id: 0n } }; // Cold-start snapshot. const snapshot = await market.getBook({ pair, level: "FEED_LEVEL_L2" }); const book = buildLocalBook(snapshot); // Subscribe to deltas. const stream = new ResilientStream({ factory: async (signal) => market.subscribe({ pairs: [{ pair, level: "FEED_LEVEL_L2", snapshotOnly: false }] }, { signal }), capacity: 256, }); stream.subscribe((ev) => applyEvent(book, ev)); stream.start(); ``` ::: The `ResilientStream` wrapper handles auto-reconnect + per-subscriber fan-out. On reconnect, **call `GetBook` again to resync** — the server restarts the L2 stream from a fresh snapshot but you may have missed deltas in flight. ## Fills Separate stream so book consumers don't pay to deserialize fills. Filter by pair (empty = all pairs). ```rust let fills = market .subscribe_fills(SubscribeFillsRequest { pairs: vec![Pair { base: Some(SpotId { id: 1 }), quote: Some(SpotId { id: 0 }) }], }) .await? .into_inner(); while let Some(fill) = fills.message().await? { println!("{} {} @ {}", fill.side, fill.size.unwrap().value, fill.price.unwrap().value); } ``` ## Discovering pairs at boot ```rust use superis::config::{refresh, ConfigCache}; let cache = ConfigCache::new(); let cfg = refresh(&cache, &mut market, &mut tx, server_program_id).await?; for p in &cfg.pairs { println!("{}/{}: spot {} → {}", p.base_name, p.quote_name, p.base_spot_id, p.quote_spot_id); } ``` `ConfigCache` joins `ListPairs` + `TxService.GetSponsoredPayers` into one cached struct you can pass into the quoting layer. ## Health events `Subscribe` multiplexes `StatusEvent`s onto the same stream. Treat them as advisory — the SDK doesn't gate calls on them. Common values: | State | What it means | What to do | | --- | --- | --- | | `HEALTH_STATE_HEALTHY` | Book is fresh. | Quote and trade normally. | | `HEALTH_STATE_DEGRADED` | Book may be stale. | Widen quotes; consider pausing taker flow. | | `HEALTH_STATE_HALTED` | Don't act on this data. | Pause submissions until you see `HEALTHY` again. | The status can be global (no `pair`) or scoped to one pair. ## Rate limits `MarketDataService` is per-IP rate limited. Bursting will return `RESOURCE_EXHAUSTED`; the bucket replenishes. For high-throughput consumers, prefer the streaming RPCs over polling `GetBook` — streams don't bill against the bucket per event. ## Backoff | Operation | Suggested cadence | | --- | --- | | `ListPairs` | Once at boot, then on schema changes. | | `GetBook` (single pair) | Once at boot, then on stream reconnect. | | `Subscribe` (any level) | Long-lived. Don't tear down + reopen on every event. | | `SubscribeFills` | Long-lived. | Polling `GetBook` >1 Hz means you should be on `Subscribe` instead. --- # sdks/quoting # 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. ::: warning Read [How Sweetspot works](/overview/exchange) 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. | Strategy | When to use | SDK type | | --- | --- | --- | | **OracleOffset** | You have an off-book fair price. Quote a fair anchor + per-side offset vectors. | `OracleOffsetQuotingClient` | | **OrderList** | Explicit order-by-order Place/Cancel. | `OrderListQuotingClient` | | **LinearDistribution** | Server interpolates linearly between buy/sell ranges. Cheapest message size. | `LinearDistributionQuotingClient` | Strategies are **mode-exclusive** — one per maker, per market. Don't mix. ::: warning 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`](#go) (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 { (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. ::: danger 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` 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](/overview/exchange#the-two-balance-trap). 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} 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"] } ``` --- # sdks/resilience # Resilience Production trading clients can't tolerate a 5-second network blip killing the bot. The SDKs ship three primitives — modeled on the `sweetspot-maker-client` patterns — that turn long-lived gRPC streams into something a maker bot can rely on: - **`ResilientStream`** — auto-reconnects with exponential backoff (500 ms → 30 s, capped at 6 attempts), fans out to multiple subscribers, and exposes a `ConnectionState` watch. - **`ServerState`** — holds the latest blockhash, recommended CU price, and current slot. Each field is a watch; consumers can `wait*` until the first value arrives. - **`ConfigCache` + `verifyProgramId`** — caches the server's `MarketDataService.ListPairs` + `TxService.GetSponsoredPayers` responses, and cross-checks the advertised program id against an independent Solana RPC. The shapes are the same in all three SDKs. ## ResilientStream ::: code-group ```rust [Rust] use std::sync::Arc; use superis::{ConnectionState, ResilientStream}; use superis::proto::tx_service_client::TxServiceClient; use superis::proto::SubscribeBlockhashRequest; let mut tx_client = TxServiceClient::with_interceptor(channel.clone(), auth.interceptor()); let factory: superis::resilience::StreamFactory = { let mut tx_client = tx_client.clone(); Arc::new(move || { let mut tx_client = tx_client.clone(); Box::pin(async move { let stream = tx_client .subscribe_blockhash(SubscribeBlockhashRequest {}) .await? .into_inner(); Ok(Box::pin(stream) as superis::resilience::BoxStream<_>) }) }) }; let blockhash_stream = ResilientStream::new(factory, None, 64); let mut state_rx = blockhash_stream.state(); tokio::spawn(async move { while state_rx.changed().await.is_ok() { match *state_rx.borrow() { ConnectionState::Disconnected => tracing::warn!("blockhash feed down — pausing submissions"), ConnectionState::Connected => tracing::info!("blockhash feed back"), ConnectionState::Connecting => {} } } }); let mut bh_rx = blockhash_stream.subscribe().await; ``` ```go [Go] import ( "context" "github.com/superis/sweetspot-maker-client/go/superis" pb "github.com/superis/sweetspot-maker-client/go/sweetspot/api/v1" ) factory := func(ctx context.Context) (<-chan *pb.BlockhashEvent, <-chan error, error) { stream, err := txClient.SubscribeBlockhash(ctx, &pb.SubscribeBlockhashRequest{}) if err != nil { return nil, nil, err } items := make(chan *pb.BlockhashEvent, 64) errs := make(chan error, 1) go func() { defer close(items) defer close(errs) for { ev, err := stream.Recv() if err != nil { errs <- err return } items <- ev } }() return items, errs, nil } rs := superis.NewResilientStream(factory, nil, 64) rs.Start(ctx) defer rs.Close() go func() { for s := range rs.State() { if s == superis.ConnectionDisconnected { log.Warn("blockhash feed down") } } }() for ev := range rs.Subscribe() { bh := ev.GetBlockhash() _ = bh } ``` ```ts [TypeScript] import { ResilientStream } from "@superis/sweetspot-client"; const stream = new ResilientStream({ factory: async (signal) => { return txClient.subscribeBlockhash({}, { signal }); }, capacity: 64, }); stream.onState((s) => { if (s === "disconnected") console.warn("blockhash feed down"); }); const unsub = stream.subscribe((ev) => { console.log(ev.blockhash, ev.recommendedCuPrice); }); stream.start(); ``` ::: The Rust and TS variants pass the abort signal / drop the stream when their owning supervisor cancels; Go uses `ctx` cancellation. ## ServerState Hooks the resilient stream up to a typed holder: ::: code-group ```rust [Rust] use std::sync::Arc; use superis::ServerState; let state = Arc::new(ServerState::new()); let _bh_task = state.run_blockhash(blockhash_stream.clone()); let _slot_task = state.run_slots(slot_stream.clone()); let blockhash = state.wait_blockhash().await; let slot = state.wait_current_slot().await; ``` ```go [Go] state := superis.NewServerState() go func() { for ev := range blockhashStream.Subscribe() { if bh := ev.GetBlockhash(); bh != nil { state.SetBlockhash(base58.Encode(bh.Key), ev.GetRecommendedCuPrice()) } } }() bh, err := state.WaitBlockhash(ctx) slot, _ := state.WaitCurrentSlot(ctx) ``` ```ts [TypeScript] import { ServerState } from "@superis/sweetspot-client"; const state = new ServerState(); blockhashStream.subscribe((ev) => { if (ev.blockhash) state.setBlockhash(toBase58(ev.blockhash.key), ev.recommendedCuPrice); }); slotStream.subscribe((ev) => state.setCurrentSlot(ev.slot)); const blockhash = await state.waitBlockhash(); const slot = await state.waitCurrentSlot(); ``` ::: ## Program-id verification Defense in depth. A compromised server could swap in an attacker program id — the SDK validates against any public Solana JSON-RPC endpoint the caller controls. ::: code-group ```rust [Rust] use superis::config::{refresh, verify_program_id, ConfigCache}; let cache = ConfigCache::new(); let cfg = refresh(&cache, &mut market_client, &mut tx_client, server_program_id).await?; verify_program_id("https://api.mainnet-beta.solana.com", &cfg.program_id).await?; ``` ```go [Go] err := superis.VerifyProgramID( ctx, "https://api.mainnet-beta.solana.com", cfg.ProgramID, ) ``` ```ts [TypeScript] import { verifyProgramId } from "@superis/sweetspot-client"; await verifyProgramId({ rpcUrl: "https://api.mainnet-beta.solana.com", programId: cfg.programId, }); ``` ::: ## Backoff Identical schedule across SDKs: | Attempt | Wait | | --- | --- | | 0 | 500 ms | | 1 | 1 s | | 2 | 2 s | | 3 | 4 s | | 4 | 8 s | | 5 | 16 s | | 6+ | 30 s (capped) | Resets to attempt 0 on every successful reconnect. --- # sdks/polling # Snapshots vs. streams Two ways to read state from the API. Pick the right one or you'll either burn rate-limit or lag the market. | You want | Use | | --- | --- | | One snapshot at app boot | Snapshot RPC (`ListPairs`, `GetBook`, `BalanceService.Get`) | | Continuous live updates | Streaming RPC (`MarketDataService.Subscribe`, `BalanceService.Subscribe`, `TxService.SubscribeBlockhash`/`SubscribeSlots`/`SubscribeTxStatus`) | | Historical backfill | `HistoricalService.GetTrades` / `GetCandles` | Streaming RPCs are long-lived. Open one, fan it out across your code (see [`ResilientStream`](/sdks/resilience)), and let HTTP/2 multiplex the rest of your traffic on the same channel. ## Cadence Snapshots are bucket-counted per IP. Streams are not. Recommended ceilings for snapshot RPCs: | Operation | Suggested cadence | | --- | --- | | `ListPairs` | Once at boot, then on schema changes | | `GetBook` (per pair) | Once at boot + once per reconnect | | `BalanceService.Get` | Once at boot + once per reconnect; otherwise use `Subscribe` | | `HistoricalService.GetTrades` | As needed for backfill | If you find yourself polling above ~1 Hz, switch to a stream. ## The boot pattern For any UI or bot: 1. **`ListPairs`** — discover what's tradeable. 2. **`GetBook`** for each pair you care about — populate local book. 3. **`Subscribe`** to those pairs — apply deltas to the local book. 4. **On reconnect**, call `GetBook` again before re-applying stream events — you may have missed deltas in flight. The same pattern applies to `BalanceService` (snapshot via `Get`, deltas via `Subscribe`) and to `TxService.SubscribeTxStatus` (no snapshot — just open the stream when you start submitting). ## Backoff on rate-limit `RESOURCE_EXHAUSTED` means the per-IP bucket emptied. Honour it; do not retry tighter. Suggested policy: exponential backoff starting at 500 ms, capped at 30 s — same schedule the SDK's `ResilientStream` uses. ## Don't poll the same data over two surfaces Subscribing to `MarketDataService.Subscribe` for a pair AND polling `GetBook` for the same pair just doubles your bandwidth. Subscribe for the live deltas and only call `GetBook` on a fresh boot or after a stream drop. --- # sdks/historical # Historical queries `HistoricalService` returns archived trades and candles for any pair within the last 30 days. Authenticated; pulls from the deployment's historical archive (when configured). ## What's available | RPC | Returns | Cap | | --- | --- | --- | | `GetTrades` | Trades within `[from_sec, to_sec]` for a pair. | 1,000 rows per call | | `GetCandles` | OHLCV candles at one of seven intervals. | 10,000 rows per call | If the deployment doesn't have the historical archive enabled, every RPC returns `FAILED_PRECONDITION`. Read it once at boot to feature-gate the UI. ## Range semantics - `from_sec` and `to_sec` are both **inclusive**. - Units are **unix seconds**. Values ≥ 10¹¹ auto-convert from milliseconds with a `Warning` header. - Historical trades come back **oldest-first**. (Live trades from `MarketDataService` come back newest-first — different paths, different conventions.) - Maximum window is 30 days per call. Larger spans need to be paginated. ## Backfill recipe To paginate a long span (e.g. a year of 1-minute candles), chunk by the row cap: ::: code-group ```rust [Rust] use superis::proto::{Interval, GetCandlesRequest, Pair, SpotId}; let interval_sec = 60u64; let max_rows = 10_000u64; let window_sec = max_rows * interval_sec; let mut from = start_sec; let mut all = Vec::new(); while from < end_sec { let to = (from + window_sec).min(end_sec); let res = historical .get_candles(GetCandlesRequest { pair: Some(Pair { base: Some(SpotId { id: 1 }), quote: Some(SpotId { id: 0 }) }), interval: Interval::Interval5m.into(), from_sec: Some(from), to_sec: Some(to), }) .await? .into_inner(); all.extend(res.candles); from = to + 1; } ``` ```go [Go] const intervalSec = uint64(60) const maxRows = uint64(10_000) windowSec := maxRows * intervalSec from := startSec var all []*pb.Candle for from < endSec { to := from + windowSec if to > endSec { to = endSec } res, err := historical.GetCandles(ctx, &pb.GetCandlesRequest{ Pair: &pb.Pair{Base: &pb.SpotId{Id: 1}, Quote: &pb.SpotId{Id: 0}}, Interval: pb.Interval_INTERVAL_5M, FromSec: &from, ToSec: &to, }) if err != nil { log.Fatal(err) } all = append(all, res.Candles...) from = to + 1 } ``` ```ts [TypeScript] const intervalSec = 60n; const maxRows = 10_000n; const windowSec = maxRows * intervalSec; let from = startSec; const all: Candle[] = []; while (from < endSec) { const to = from + windowSec > endSec ? endSec : from + windowSec; const { candles } = await historical.getCandles({ pair: { base: { id: 1n }, quote: { id: 0n } }, interval: "INTERVAL_5M", fromSec: from, toSec: to, }); all.push(...candles); from = to + 1n; } ``` ::: ## Stitching with live For a chart that shows the last 24h: 1. `GetCandles` with `from_sec = now - 86_400`, `to_sec = now` to pull the historical body. 2. Subscribe to `MarketDataService.Subscribe` for the pair to drive live updates from `now` forward, computing the last candle yourself from the fill stream — or just `GetCandles` again every interval for less aggressive UIs. The historical and live paths align on the interval boundary. ## When to prefer live `HistoricalService.GetTrades` over a recent window is more expensive than just streaming `MarketDataService.SubscribeFills` and ringing your own buffer. Use the historical path for backfill or for ranges older than your in-memory retention; use the live path for anything inside the active session.