Skip to content
Logo

Rust SDK

Async, tonic-based clients for the Flint perps API, shipped as three crates that release together with the rest of the Flint SDK suite:

CrateLibraryWhat it covers
flint-margin-user-clientflint_margin_user_clientMarket and account services, plus owner-authenticated notifications.
flint-margin-maker-clientflint_margin_maker_clientflint.margin.maker.v1 — a managed Client with sessions, node discovery, reconnecting streams, and chain state; a quote builder with landing receipts; every maker service. Re-exports the user client as public_api.
flint-margin-onchainflint_margin_onchainInstruction builders, account decoders, PDAs, and error codes for the margin program. Re-exported by both clients as onchain. See On-chain clients.

All three are edition 2024 with an MSRV of Rust 1.89, and share transport, auth, and version-stamping code through flint-client-core, which Cargo pulls in for you.

Install

From the Kellnr registry:

[dependencies]
flint-margin-user-client = { version = "=7.0.4", registry = "kellnr" }
flint-margin-maker-client = { version = "=7.0.4", registry = "kellnr" }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Add only the crate you need — a read-only frontend or analytics job uses flint-margin-user-client. Each client crate pins its flint-client-core and flint-margin-onchain — and the maker crate its flint-margin-user-client — to the exact same version, so you never mix layouts. Add features = ["rpc"] to the maker crate for RPC and QUIC fan-out (Cargo features).

The registry is the same one the spot SDK uses. Point Cargo at it in ~/.cargo/config.toml:

[registries.kellnr]
credential-provider = "cargo:token"
index = "sparse+https://kellnr.xantasoft.com/api/v1/crates/"

then store your read-only registry token with cargo login --registry kellnr, or export CARGO_REGISTRIES_KELLNR_TOKEN=<token> in CI. When the operator enables SDK credentials, the Developer tab of the maker dashboard and MakerService.GetSdkCredentials return a read-only registry token and index URL for a trader linked to an organization. Otherwise, obtain package access from the operator. Use the dashboard URL for your network.

From git

Each release is also tagged in the SDK repository (rust-v<version>), and the crates build straight from a checkout — build.rs falls back to the flint-protos submodule when no vendored protos are present. The repository is private, so Cargo needs GitHub credentials that can read it (for example net.git-fetch-with-cli = true plus your own git auth).

[dependencies]
flint-margin-user-client = { git = "https://github.com/superis-labs/flint-api-client", tag = "rust-v7.0.4" }

Public data — flint-margin-user-client

Client owns one tonic channel to the public host and hands out a generated client per service. Every call stamps the SDK identity headers; the public data calls below need no credentials.

use std::str::FromStr;
 
use flint_margin_user_client::proto::{
    GetAccountRequest, ListPerpMarketsRequest, SubscribeOrderBookRequest, TraderId,
};
use flint_margin_user_client::{Client, DEVNET_PUBLIC_ENDPOINT};
use rust_decimal::Decimal;
 
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // This example selects devnet explicitly; use the operator's URL for another network.
    let client = Client::connect(DEVNET_PUBLIC_ENDPOINT).await?;
 
    let markets = client
        .perp_market()
        .list_perp_markets(ListPerpMarketsRequest {
            perp_ids: vec![],
            statuses: vec![],
            page_size: 0,
            page_token: String::new(),
        })
        .await?
        .into_inner()
        .markets;
    for market in &markets {
        println!("{} tick={:?}", market.symbol, market.tick_size);
    }
 
    let account = client
        .account()
        .get_account(GetAccountRequest {
            trader_id: Some(TraderId { id: 42 }),
        })
        .await?
        .into_inner()
        .account;
    if let Some(health) = account.and_then(|a| a.health) {
        let equity = Decimal::from_str(&health.net_equity.unwrap_or_default().value)?;
        println!("net equity {equity} USD");
    }
 
    // Server stream: a snapshot, then sequenced deltas.
    let mut book = client
        .market_data()
        .subscribe_order_book(SubscribeOrderBookRequest {
            perp_id: markets[0].perp_id.clone(),
            depth: 20,
        })
        .await?
        .into_inner();
    while let Some(update) = book.message().await? {
        println!("snapshot={} seq={}", update.snapshot, update.sequence);
    }
    Ok(())
}

Client exposes market(), account(), risk(), sponsorship(), funding(), perp_market(), market_data(), and order(). It is cheap to clone. For full control, build any generated client in flint_margin_user_client::proto (for example proto::account_service_client::AccountServiceClient) over your own tonic::transport::Channel.

Account notifications

Client::notifications() exposes owner-only email settings. Each request needs an authorization: Bearer <margin-session> metadata header, and the channel must connect to the same server endpoint that minted the session. You can also pair the generated proto::notifications::notification_service_client::NotificationServiceClient with a margin-maker AuthFlow::for_trader and its bearer interceptor:

use flint_margin_maker_client::auth::AuthFlow;
use flint_margin_user_client::proto::notifications::{
    RequestEmailVerificationRequest, VerifyEmailRequest,
    notification_service_client::NotificationServiceClient,
};
 
let auth = AuthFlow::for_trader(channel.clone(), owner_signer, trader_id);
auth.token().await?;
let mut notifications = NotificationServiceClient::with_interceptor(channel, auth.interceptor());
let settings = notifications.get_settings(()).await?.into_inner();
let challenge = notifications.request_email_verification(RequestEmailVerificationRequest {
    email: email.to_owned(),
}).await?.into_inner();
// After the user enters the code sent to that address:
auth.token().await?;
notifications.verify_email(VerifyEmailRequest {
    challenge_id: challenge.challenge_id,
    code: code.to_owned(),
}).await?;

Refresh the session with auth.token().await? before subsequent calls; the Rust interceptor cannot refresh it by itself. Delegates cannot access notifications. Requesting a code leaves the current verified destination unchanged. Verifying the single-use code enables email for new fills without a historical backlog. disable_email(()) disables email and cancels pending verification. Public fill history and client-local read/dismiss state remain independent of email delivery. See the TypeScript notification guide for settings and verification response fields.

Reading the chain

flint_margin_onchain (re-exported as onchain by both clients) adds readers for the market's quoting and order-book accounts and for whole transactions:

ModuleHelpers
quotingEncodedPerpQuotingEntry::from(&QuotingEntry), decode_aggregated_quote_book(&entries, slot), decode_quote_entry_side(&entry, slot, QuoteSide::Bid), has_configured_quote_data
restingread_resting_orders(&book), is_resting_live(&order, slot, unix_seconds), order_expiry
transactiondecode_flint_transaction(&RpcJsonTransaction) and decode_flint_transaction_for(.., &program_id)
errorerror_name(code), flint_error(code), custom_error_code(&err)
use flint_margin_user_client::onchain::accounts::{LinearPerpOrderBook, LinearPerpQuoting};
use flint_margin_user_client::onchain::quoting::{
    EncodedPerpQuotingEntry, decode_aggregated_quote_book,
};
use flint_margin_user_client::onchain::resting::read_resting_orders;
 
let quoting = LinearPerpQuoting::parse_account(&rpc.get_account(&LinearPerpQuoting::find_pda(perp_id).0).await?)?;
let entries: Vec<EncodedPerpQuotingEntry> =
    quoting.live_entries()?.iter().map(EncodedPerpQuotingEntry::from).collect();
let book = decode_aggregated_quote_book(&entries, slot);
 
let order_book = LinearPerpOrderBook::parse_account(&rpc.get_account(&LinearPerpOrderBook::find_pda(perp_id).0).await?)?;
let resting = read_resting_orders(&order_book)?;

decode_flint_transaction takes a getTransaction response requested with "encoding": "json", deserialized into RpcJsonTransaction, and returns every Flint instruction (inner ones included) with the events it emitted. complete is false when an instruction carried bytes this client does not know.

use flint_margin_user_client::onchain::error::{custom_error_code, error_name};
use flint_margin_user_client::onchain::transaction::{RpcJsonTransaction, decode_flint_transaction};
 
let response: RpcJsonTransaction = serde_json::from_value(raw)?;
let decoded = decode_flint_transaction(&response)?;
for top in &decoded.instructions {
    if let Some(flint) = &top.flint {
        println!("{} {}", top.index, flint.name());
    }
}
if let Some((_, code)) = response.meta.as_ref().and_then(|m| m.err.as_ref()).and_then(custom_error_code) {
    println!("{:?}", error_name(code));
}

Preparing perpetual orders

onchain::perp_order builds place_perp_order with the crossing makers' trader accounts attached in ascending ID order — the same rules, rounding, and messages as the TypeScript buildPerpOrderInstruction:

  • build_perp_order_instruction(&BuildPerpOrderInput, &PerpBookSnapshot) — market orders through reference_price_ticks plus max_slippage_bps, limit orders through limit_price; GTC, GTD, IOC, and FOK; post-only, reduce-only, minimum fill, and delegates.
  • build_perp_close_instruction(&BuildPerpCloseInput, &PerpBookSnapshot) — rounds an integer fraction of the position down to lots and forces reduce-only FOK.
  • select_crossing_makers and PerpLiquidityError (NoLiquidity, OutsidePriceLimit).
  • Grid math: exact_grid_units, market_limit_ticks, base_for_lots, fractional_close_size_base, reverse_size_base.

With the user client's rpc feature, prepare_perp_order reads the quoting account, order book, and Clock sysvar in one processed getMultipleAccounts and builds the instruction, retrying while maker quotes are unavailable. It accepts a solana_rpc_client nonblocking RpcClient or anything implementing PerpPreparationRpc. It does not sign or send.

flint-margin-user-client = { version = "=8.1.0", registry = "kellnr", features = ["rpc"] }
use flint_margin_user_client::onchain::perp_order::{BuildPerpOrderInput, OrderTimeInForce};
use flint_margin_user_client::onchain::resting::PerpSide;
use flint_margin_user_client::{PerpPreparationOptions, prepare_perp_order};
 
let mut input = BuildPerpOrderInput::new(
    payer.pubkey(),
    trader_id,
    perp_id,
    PerpSide::Buy,
    "0.003",
    "0.001",
    "0.001",
);
input.reference_price_ticks = Some(mark_ticks.to_string());
input.max_slippage_bps = Some(100);
input.time_in_force = Some(OrderTimeInForce::Ioc);
 
let prepared = prepare_perp_order(&input, &rpc, PerpPreparationOptions::default()).await?;
println!("{} lots, limit {}, makers {:?}", prepared.lots, prepared.limit_price_ticks, prepared.maker_trader_ids);
let instruction = prepared.order.instruction;

PerpPreparationOptions defaults to 16 attempts, 300 ms between retries, a 5 s request timeout, and a 16-slot quote expiry buffer; on_waiting_for_quotes fires once when it starts retrying. Live quotes outside the bound fail immediately with PerpPreparationError::Order(PerpOrderError::Liquidity(..)); the SDK never widens the bound. prepared.slot is the RPC read context. rust/margin-user/examples/perps_e2e.rs places, modifies, cancels, and closes orders and settles funding and PnL on devnet. Run it with RPC_URL, KEYPAIR (the owner's keypair file) and TRADER_ID set: cargo run -p flint-margin-user-client --features rpc --example perps_e2e.

Maker client — flint-margin-maker-client

Authentication

The maker API authenticates a Solana key that owns a trader account or is an active TRADE- or QUOTE-scoped delegate on one. AuthFlow runs Challenge → sign → Authenticate, signing b"FLINT-MARGIN-AUTH-V1:" || nonce, caches the bearer token, and shares one in-flight handshake across concurrent callers.

Client combines an AuthFlow, all maker services and a public client. It manages token refresh, session heartbeats and stream reconnection. Configure it with the authenticated maker endpoint; TxService is available only there.

use std::sync::Arc;
 
use flint_margin_maker_client::Client;
use flint_margin_maker_client::proto::{GetQuotingStateRequest, SubscribeFillsRequest};
use solana_sdk::signer::keypair::read_keypair_file;
 
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let keypair = read_keypair_file("/path/to/maker.json").map_err(|e| anyhow::anyhow!("{e}"))?;
    // This example selects devnet explicitly; use the operator's URLs for another network.
    let client = Client::builder()
        .devnet()
        .wallet(Arc::new(keypair))
        .build()
        .await?;
 
    // Mints a session, then refreshes it and answers the session
    // heartbeat in the background until `shutdown()`.
    let session = client.authenticate().await?;
    println!("trader {} (controls {:?})", session.trader_id, session.trader_ids);
 
    let state = client
        .maker()
        .get_quoting_state(GetQuotingStateRequest { perp_ids: vec![] })
        .await?
        .into_inner();
    println!("{:?}", state.entries);
 
    // Reconnecting stream: keep the handle alive while you read.
    let fills = client.subscribe_maker_fills(SubscribeFillsRequest { perp_ids: vec![] });
    let mut events = fills.subscribe().await;
    while let Ok(fill) = events.recv().await {
        println!("{} {:?} @ {:?}", fill.signature, fill.size, fill.price);
    }
    client.shutdown().await;
    Ok(())
}
  • A key that controls several trader accounts gets the lowest id by default. Use .trader_id(id) on the builder, Client::connect_for_trader(endpoint, signer, trader_id), or AuthFlow::for_trader to pick one; the session's trader_ids lists them all.
  • Client::connect(endpoint, signer) is shorthand for the builder with defaults.
  • Wallet is Arc<dyn Signer + Send + Sync>, so any solana_sdk::signer::Signer — keypair, hardware wallet, remote signer — can implement that interface.
  • client.auth() returns the AuthFlow for token(), refresh(), revoke(), or interceptor(). The interceptor only attaches the cached token; authenticate() starts the refresh loop that keeps it fresh.
  • shutdown() stops the refresh, heartbeat, discovery, and chain-tip tasks without revoking the session. They also stop when the last clone of the Client drops.
  • There is no maker balance RPC: read collateral and health with client.account().get_account(...) or client.subscribe_account(...).

To configure a generated service client directly:

use std::sync::Arc;
 
use flint_margin_maker_client::auth::AuthFlow;
use flint_margin_maker_client::proto::maker_service_client::MakerServiceClient;
use flint_margin_maker_client::DEVNET_MAKER_ENDPOINT;
 
let channel = tonic::transport::Channel::from_static(DEVNET_MAKER_ENDPOINT)
    .connect()
    .await?;
let auth = AuthFlow::new(channel.clone(), Arc::new(keypair));
auth.token().await?;
let _refresh = auth.spawn_refresh_loop();
let mut maker = MakerServiceClient::with_interceptor(channel, auth.interceptor());

Channel::from_static doesn't configure TLS or keep-alives; prefer the Client, which does.

Client options

Builder methodDefault
.endpoint(url), .devnet(), .mainnet()Mainnet authed host.
.public_endpoint(url)The devnet public host after .devnet(), otherwise mainnet's.
.channel(ch), .public_channel(ch)Supply your own channels; used unchanged.
.wallet(wallet)Required.
.trader_id(id)The lowest trader id the key controls.
.auth_skew(duration)Refresh 30 s before the session expires.
.auto_reconnect(bool)On: streams and the heartbeat reopen after failures.
.auto_session_heartbeat(bool)On: authenticate() starts the SessionService heartbeat.
.endpoint_discovery(bool), .discovery_interval(duration)On for hostnames, every 10 s. See Node discovery.
.log_messages(bool), .message_log_limit(chars)Off. See Logging.

client.public() is a full flint_margin_user_client::Client on the public host; market_data(), perp_market(), and account() are shortcuts. client.node() reaches NodeService, and client.escalation() pages Flint's on-call team when your trader belongs to an organization:

use flint_margin_maker_client::proto::PageTheTeamRequest;
 
let page = client
    .escalation()
    .page_the_team(PageTheTeamRequest { message: "quotes failing since slot 412000000".into() })
    .await?
    .into_inner();
println!("paged: {}", page.page_id);

Reconnecting streams

The subscribe_* methods return an Arc<ResilientStream<T>>. Call subscribe().await for a tokio::sync::broadcast::Receiver; the RPC opens on the first subscriber, reopens on a 500 ms → 30 s backoff with the original filters, and re-authenticates on UNAUTHENTICATED.

MethodStream
subscribe_maker_market_snapshots, subscribe_maker_fills, subscribe_quotesMakerService
subscribe_tx_status, subscribe_blockhash, subscribe_slots, subscribe_leadersTxService
subscribe_spread_protectSpreadProtectService
subscribe_order_book, subscribe_trades, subscribe_candles, subscribe_market_stats, subscribe_accountPublic, over the public channel

Keep the handle alive while you read the receiver. state() and current_state() report Connecting, Connected, or Disconnected; disconnects() counts drops, so a fast reconnect can't hide a gap. Snapshot streams resend state on reconnect; fill and quote streams don't replay what you missed — reconcile with the matching unary RPC. shutdown() on a stream closes its receivers.

Session heartbeat and chain state

After authenticate(), the client holds SessionService.Session open and answers each server ping, which is what puts your session in ListQuoterSessions with its RTT and clock drift. last_heartbeat() returns the latest ping answered.

spawn_chain_tip()? starts the blockhash, slot, and leader streams once per client and caches them in server_state(): get_blockhash(), blockhash_age(), get_current_slot(), get_last_valid_block_height(), the priority-fee recommendation in recommended_cu_price, and wait_ready() to block until all three have arrived. A disconnected feed clears its value rather than going stale.

Organization API key and external submission

ClientBuilder::api_key(key) plus trader_id(perp_maker_id) builds a client with no wallet: the key is sent as x-api-key, and margin MakerService resolves it to the organization's perps trader. TxService, SessionService, and SpreadProtectService need a keypair session, so such a client can't authenticate() or spawn_chain_tip(). Publish the chain tip yourself with server_state().publish_blockhash(..) and publish_slot(..).

To quote without TxService, start the core with QuotingCore::start_with_signer(client, config, signer) and deliver through QuoteBuilder::commit_externally(&mut core, &submitter). A QuotingSigner::Local(wallet) core hands your submitter signed transactions, and a QuotingSigner::External(pubkey) core hands it unsigned ones to sign. Sign tx.message and send tx.with_signature(signature)?, which verifies the signature against the fee payer and returns the wire bytes. Either way the submitter returns the signature, or Signature::default() when signing happens later elsewhere: that delivery is not retried or reported as failed, and its intents stay unknown in the receipt. Only Err is retried. Report outcomes through core.tx_outcome_publisher(); see the spot auth guide for the full model.

Market catalog and units

refresh_config().await? reads the margin program id from NodeService and pages through every perp market, then caches the result in config_cache(). Each market carries exact MarketUnits for converting decimals to the on-chain grid:

use std::str::FromStr;
 
use rust_decimal::Decimal;
 
let config = client.refresh_config().await?;
let market = config.catalog.perp(perp_id)?; // or .market("SOL-USDC-PERP")?
let bid_ticks = market.units.bid_ticks(Decimal::from_str("150.25")?)?; // rounds down
let ask_ticks = market.units.ask_ticks(Decimal::from_str("150.35")?)?; // rounds up
let lots = market.units.lots(Decimal::from_str("2.5")?)?; // rounds down

Quoting

QuotingCore::start(client, config) checks that the node serves the program in QuotingConfig::program_id, authenticates, starts the chain-tip streams, loads your quoting entries, and follows the spread-protection feed. QuoteBuilder then builds density, relative-density, and order-list updates in ticks and lots, allocates sequence numbers above what the chain has seen, signs, and submits:

use std::time::Duration;
 
use flint_margin_maker_client::onchain::types::DensityFair;
use flint_margin_maker_client::quoting::{QuoteBuilder, QuotingConfig, QuotingCore};
 
let mut core = QuotingCore::start(client, QuotingConfig::default()).await?;
let slot = core.client().server_state().get_current_slot().unwrap_or_default();
 
// Needs a density entry with configured bands on perp 1.
let mut result = QuoteBuilder::new()
    .density_fair(1, DensityFair { slot, bid_fair: 150_000, ask_fair: 150_010 })
    .commit(&mut core)
    .await?;
for failed in &result.failed {
    eprintln!("{}: {}", failed.transaction.signatures[0], failed.error);
}
let outcome = result.receipt.settled_within(Duration::from_secs(10)).await?;
if !outcome.is_complete() {
    // Reconcile outcome.failed and outcome.unknown before rebuilding.
}
  • QuotingConfig defaults: the devnet program FLINT_ID, 200,000 compute units per transaction, the server's priority-fee recommendation, a 10 s readiness timeout, a 15 s maximum blockhash age, and v1 transactions (tx_version; set TxVersion::V0 with lookup_tables, or TxVersion::Legacy). Set delegate_trader_account when the wallet isn't the owner.
  • The builder's standalone UpdateQuoting instructions need the owner or a TRADE delegate. A QUOTE delegate can authenticate but must build the batch instructions itself.
  • Updates to one market stay in one transaction; a group too large for one packet (4,096 bytes as v1, 1,232 as legacy or v0) is rejected rather than split.
  • commit retries transport failures with the same signed bytes (commit_with_retry takes a RetryConfig; default 3 attempts, 100 ms apart). Rejected submissions land in result.failed.
  • Receipts report processed observations, not finality. settled_within groups intent indices into landed, failed, and unknown; it returns CommitError::Timeout, Lagged, or Disconnected when it can't tell, and receipt.outcome() keeps the partial picture. A transport error or timeout leaves execution uncertain — reconcile the signature before rebuilding.
  • Call refresh_quoting_state() after admission or strategy changes.
  • core.spread_mult() is the highest spread-protection multiplier active at the current slot and spread_protect_window() the window behind it. Neither touches your quotes; apply them yourself.

With the rpc feature, with_rpcs(...) also broadcasts each signed transaction through your own Solana RPCs, and with_quic(...) sends it straight to the next two leaders' TPU over QUIC, using the SubscribeLeaders schedule. Both are best effort: a QUIC write is not treated as acceptance.

use std::sync::Arc;
 
use flint_margin_maker_client::quoting::quic::QuicTxClient;
 
let core = core.with_quic(Arc::new(QuicTxClient::new()));

Submitting transactions

To submit transactions you build yourself, sign them and hand the wire bytes to TxService.SubmitTx — or to QuotingCore::submit, which returns the same receipts. The program's instruction builders are in flint_margin_maker_client::onchain. The session's key must be one of the transaction's required signers.

use flint_margin_maker_client::proto::SubmitTxRequest;
 
let ack = client
    .tx()
    .submit_tx(SubmitTxRequest {
        transaction: tx_bytes, // bincode-serialized, fully signed VersionedTransaction
        client_sent_at: None,
    })
    .await?
    .into_inner();
println!("accepted {}", ack.signature);

SubmitTx returns on acceptance. Watch SubscribeTxStatus for landed or failed, and SubscribeBlockhash for a fresh blockhash and priority-fee recommendation.

Decimal handling

Every price, size, balance, and rate is Decimal { value: String }. Parse with rust_decimal — never f64:

use rust_decimal::Decimal;
use std::str::FromStr;
 
let price = Decimal::from_str(&level.price.as_ref().unwrap().value)?;
let quantity = Decimal::from_str(&level.quantity.as_ref().unwrap().value)?;
let notional = price * quantity;

Ids are plain integers: PerpId and SpotId wrap u32, TraderId wraps u64. Timestamps are Timestamp { micros: u64 }.

Errors

Generated service calls return tonic::Status; branch on status.code(). The SDK's own helpers return the crate's Error (flint_client_core::CoreError): Config, Auth, Status, or Transport. Quote receipts add quoting::CommitError for outcomes that couldn't be observed. See Errors.

Transport defaults

Both clients build channels with a 5 s connect timeout, a 10 s request timeout, HTTP/2 keep-alive pings every 30 s (including while idle), and TCP keep-alive every 60 s. https:// endpoints use rustls with the platform's native roots. The user client connects to the endpoint you give it; the maker client probes and pins the fastest healthy node behind each hostname — see Node discovery.

Logging

The crates log through tracing: channel connects and endpoint probes at debug; node switches and refresh-loop, heartbeat, and stream failures at warn. Install a subscriber to see them.

.log_messages(true) on the maker builder also logs the payloads of SDK-managed RPCs and stream items at trace, truncated to .message_log_limit(chars) (zero disables truncation). Auth, credential, and signed-submission payloads are redacted and headers are never logged. Calls you make on the generated clients directly aren't covered.

Cargo features

CrateFeatureDefaultWhat it adds
flint-margin-user-client, flint-margin-maker-clienttlsyesHTTPS via rustls + native roots. Disable only for plaintext local stacks.
flint-margin-user-clientrpcnoprepare_perp_order and the perp_preparation module. Pulls in the Solana RPC client.
flint-margin-maker-clientrpcnoQuotingCore::with_rpcs and with_quic, and the quoting::quic module. Pulls in the Solana RPC and QUIC clients.

Version constants

Each client crate exports SDK_VERSION, PROTO_HASH, and PROTO_REV — the values it stamps on requests as x-flint-sdk, x-flint-proto-hash, and x-flint-proto-rev. Compare PROTO_HASH with the server's x-flint-server-proto-hash response header when debugging a schema mismatch.