polyester-sdk 0.1.0-alpha.28

Official Rust SDK for Polyester APIs.
Documentation

Polyester Rust SDK

Official Rust SDK for Polyester APIs, built for trading bots, backend services, and automation. Parity with polyester-sdk-go and polyester-sdk-python, built on Connect for Rust (Buffa + Connect 0.8.x) and the checked-in src/gen/ protobuf bundle.

Status: Alpha (0.1.0-alpha.28, git tag v0.1.0a28). Proprietary license (not open source). API-key only; no browser login or JWT flows.

MSRV: Rust 1.88+

Supported surface

Capability Supported
Public market data (spot config, trades, candles) Yes
Order book snapshot + realtime Yes
Market overview (list + subscribe) Yes
Order book heatmap Yes
API-key (Ed25519 signature) auth Yes
Wallet / browser login No
Session MFA enrollment and challenges No
Profile (identity subscribe) Yes
API keys (list/get/subscribe/local keypair generation) Yes
Subaccounts (list/get/members/invites/activity/subscribe) Yes
Address book (list/view/subscribe) Yes
Policies (realtime subscribe) Yes
Guard signer Yes
Balances, holds, equity history Yes
Orders (create, cancel, modify, batch, cancel-all) Yes
User trades Yes
Triggers Yes
Internal transfers Yes
Transfer history Yes
Deposit addresses Yes
Trading / funding withdraws Yes
Zipper deposit-withdraw config Yes
Chain analytics Yes
Lifecycle flows Yes
Polychart / layout / whiteboard Yes
Realtime account and market streams Yes
Reference catalogs + wait-for-ready Yes
Qty / price decimal + scaled-int inputs Yes
Social verification Yes
Account resolve / lookup No

Rows marked No are intentional for API-key SDKs (use the TypeScript browser client for wallet login and session MFA).

Full cross-language comparison: SDK capability matrix.

Rows marked Yes mean that an SDK wrapper exists; deployment authorization still applies. In particular, whiteboard/social-verification and some layout/polychart routes may require a JWT session or may not be mounted. Private streams require an Account ID and the corresponding API-key permission. A successful subscribe call means the token exchange and realtime handshake completed. Treat a structured permission denial as non-transient and update the API-key policy before retrying.

Install

crates.io: https://crates.io/crates/polyester-sdk

[dependencies]
polyester-sdk = "0.1.0-alpha.28"

Realtime (Centrifugo) and on-chain Funding helpers are always included. The optional realtime / chain Cargo features are empty compatibility flags.

For a private git checkout instead of crates.io:

[dependencies]
polyester-sdk = { git = "https://github.com/Fabric-Labs/polyester-sdk-rust", tag = "v0.1.0a28" }

The repository is currently private, so GitHub access and authenticated Git credentials are required for the git pin. If Cargo cannot use your credential helper, run it with CARGO_NET_GIT_FETCH_WITH_CLI=true.

For development from a git checkout:

git clone https://github.com/Fabric-Labs/polyester-sdk-rust.git
cd polyester-sdk-rust
cargo test --lib

Pin the Connect runtime: this crate depends on connectrpc / buffa 0.8.x. Review upstream notes before upgrading.

Quick start

Create an API key in the Polyester app (API in the sidebar). Copy the key id and private key when shown. The private key is only displayed once. Open the key's Permissions, enable Spot trading, select the markets it may trade, and set a maximum order size appropriate for the strategy.

For a subaccount-scoped key, attach an API key policy that grants ledger reads for balance requests and private balance streams. Add the appropriate trading permissions for create, cancel, modify, and other order mutations. This API key policy is separate from the subaccount policy: the subaccount policy constrains the subaccount, but does not grant permissions to the key.

use polyester::{Client, Config, Result};
use polyester::services::ListMarketOverviewOptions;

#[tokio::main]
async fn main() -> Result<()> {
    let client = Client::new(Config {
        api_key_id: Some("ak_...".into()),
        api_private_key: Some("...".into()), // 64-char hex from key creation
        default_account_id: Some("RLxqJGUDg92".into()), // Profile → Account ID
        ..Default::default()
    })?;

    let overview = client
        .market_overview
        .list(ListMarketOverviewOptions {
            limit: Some(5),
            ..Default::default()
        })
        .await?;
    for market in overview.markets {
        let ticks = market
            .last_price
            .as_ref()
            .map(|p| p.as_ticks());
        println!("{} {:?}", market.symbol, ticks);
    }

    let open_orders = client.orders.list_open(None).await?;
    println!("{} open orders", open_orders.orders.len());
    Ok(())
}

Credentials

Value Where to find it Config field
API key id API → create or view key api_key_id
API private key Shown once when the key is created api_private_key
Account ID ProfileAccount ID (e.g. RLxqJGUDg92) default_account_id

Pass credentials on Config / Client::new. The SDK does not implicitly read environment variables unless you call Client::from_env.

api_private_key accepts the 64-character hex Ed25519 secret from key creation.

default_account_id is the Account ID string from your Profile page. Use the value exactly as shown in the app. Do not use an internal numeric id.

default_account_id is optional for public market-data calls. It is required for account-scoped operations such as private realtime channels, bucket transfers, and some ledger writes.

Automatic request signing gives concurrent identical calls distinct authentication tuples. Cloned and independently constructed credentials with the same key id share a process-wide allocator. The signing protocol has no cross-process nonce, so assign one API key per process; sharing a key between processes can produce identical authentication tuples. Timestamps can lead the local clock by at most five seconds; larger async bursts wait through Tokio-aware backpressure instead of blocking executor threads, reusing a signature, or drifting outside the API's 10-second freshness window. If the bounded capacity wait is exhausted, the call returns retryable Error::RateLimit. Direct low-level Credentials::sign_request calls never sleep; use sign_request_async in async applications.

Authentication patterns

Recommended: explicit config

use polyester::{Client, Config};

let client = Client::new(Config {
    api_key_id: Some("ak_...".into()),
    api_private_key: Some("...".into()),
    default_account_id: Some("RLxqJGUDg92".into()),
    ..Default::default()
})?;

If your deployment stores secrets in environment variables, read them in your application and pass them to Client::new:

use std::env;
use polyester::{Client, Config};

let client = Client::new(Config {
    api_key_id: env::var("POLYESTER_API_KEY_ID").ok(),
    api_private_key: env::var("POLYESTER_API_PRIVATE_KEY").ok(),
    default_account_id: env::var("POLYESTER_ACCOUNT_ID").ok(),
    ..Default::default()
})?;

Client::new never implicitly reads the process environment.

Scripts and local tests only: Client::from_env() loads POLYESTER_API_KEY_ID, POLYESTER_API_PRIVATE_KEY, and optionally POLYESTER_ACCOUNT_ID / POLYESTER_API_URL. This is a convenience helper, not the primary integration pattern.

let client = polyester::Client::from_env()?;

Catalog readiness

Config::hydrate_catalogs defaults to true. When constructed inside a Tokio runtime, the client starts spot and zipper catalog hydration in the background. Await readiness before decimal writes that depend on catalog scales:

let client = polyester::Client::from_env()?;
client.wait_for_catalogs().await?;

If a client is constructed before entering a Tokio runtime, wait_for_catalogs() starts hydration on the current runtime. Scaled bot inputs must carry their source scale. AssetAmount::from_scaled(..., None, ...) is accepted for composition only and fails closed on transfer/withdraw encoding unless the request's amount_scale / quantity_scale is explicit. Prefer from_decimal_str / from_decimal, or pass Some(scale) to from_scaled.

Create and cancel orders

use polyester::models::{CreateOrderType, CreateSide, CreateTimeInForce};
use polyester::services::OrdersService;
use polyester::types::{Price, Quantity};

client.wait_for_catalogs().await?;

let mut params = OrdersService::create_params(
    "BNB-USDT",
    CreateSide::Buy,
    CreateOrderType::Limit,
    Quantity::from_decimal_str("0.01", 8, None, None)?,
    Some(Price::from_decimal_str("100", None)?),
    Some("my-bot-001"), // recommended whenever you may retry
);
params.time_in_force = Some(CreateTimeInForce::Gtc);
params.post_only = Some(true);

let result = client.orders.create(params).await?;
println!("{} {}", result.status, result.order_id);

client
    .orders
    .cancel_by_client_order_id("my-bot-001", Some("BNB-USDT"), None)
    .await?;

client_order_id is optional (matches the API). Pass None for one-shot creates. Set a stable non-empty value when you may retry after an ambiguous transport/server failure, and reuse that same id on retry / reconciliation - without it you cannot safely tell whether the first attempt admitted the order. Client order ids accept 1 to 36 ASCII letters, digits, ., _, :, /, and -. Batch create accepts at most 20 orders. Treat a cancel response as an admission acknowledgement and reconcile with list_open before releasing local state.

Create sizing is explicit: set exactly one of base quantity or max_quote_debit_scaled (a typed hard all-in quote-debit budget). Construct the latter with Quantity::from_quote_decimal_str / from_quote_decimal / from_quote_scaled; the SDK validates its OrderQuote domain and scale against Catalogs::quote_quantity_scale_for_symbol.

let quote_scale = client
    .catalogs
    .quote_quantity_scale_for_symbol("BNB-USDT")
    .expect("catalog hydrated with quote scale");
params.quantity = None;
params.max_quote_debit_scaled = Some(Quantity::from_quote_decimal_str(
    "25.00",
    quote_scale,
    Some("BNB-USDT".into()),
    client.catalogs.symbol_id_for_symbol("BNB-USDT"),
)?);

Use OrdersService::preview(PreviewOrderParams { ... }) to check whether an order intent is currently admissible before submitting. Preview sends the same OrderIntent contract as create (sizing, execution, fee asset, STP, optional attached risk). The host runs an admissibility check only: no hold is placed, and client_order_id is accepted but not claimed. The result reports admissible, optional typed rejection (OrderErrorDetail), optional resolved_base_qty, optional protected_price_bound, and evaluated_at_ms. Known rejection codes use stable labels such as BAD_QTY. Preview no longer returns fee/quote estimates. Preview is not deployed on every API host, so handle an unimplemented/not-found response and do not make Preview a prerequisite for order submission. Fee selection is FeeAsset::Quote or, for BUYs only, FeeAsset::Base; the former received fee mode no longer exists.

Standalone trailing-stop triggers remain SELL market-IOC and now encode wire side explicitly. Attached trailing-stop risk may use either side (opposite the parent); trigger reads project trigger_type, side, and parent_order_id from the host response.

Market orders are IOC and enforce a slippage-derived execution boundary. See Market Order Price Protection before overriding market_max_slippage.

Use decimal strings or Decimal for human-facing qty / price inputs. Do not pass floats. Price ticks are Polyester protocol price units (fixed 1e6), not market tick-size alignment (server validates tick size).

For bots (scaled integers)

Stay in integer space; no string round-trip:

use polyester::models::{CreateOrderType, CreateSide};
use polyester::services::OrdersService;
use polyester::types::{Price, Quantity};

let params = OrdersService::create_params(
    "BNB-USDT",
    CreateSide::Buy,
    CreateOrderType::Limit,
    Quantity::from_scaled(1_000_000, Some(8), Default::default(), None, None)?,
    Some(Price::from_ticks(100_000_000, None)?), // 100.000000 at 1e6
    Some("my-scaled-bot-001"),
);
let _ = client.orders.create(params).await?;
// Reads expose the same types: order.price.as_ticks(), order.orig_qty.as_scaled()

Compatible values from fills/books can be passed back into writes when the instrument/domain matches. Transfers and trading withdraws use AssetAmount (not order Quantity).

Your API key needs a policy that allows trading. Spot orders spend trading balance (see below).

Triggers

triggers.list_with(ListTriggersOpts { status: ... }) filters by lifecycle status. Valid values:

created, armed, running, completed, cancelled, failed, paused

Unknown values return a validation error (they do not silently return an empty list). Response status uses the same labels (British spelling cancelled).

orders.get_with(GetOrderOpts { key: OrderKey::OrderId(id), include_attached_risk: true, subaccount_id: None, include_attached_risk_state: false }) returns policy data on Order.attached_risk. Order also exposes post_only. Identify orders with OrderKey::OrderId or OrderKey::ClientOrderId (exclusive oneOf).

Qty / price rules

Public order/trigger write paths take Price / Quantity wrappers only:

Audience Constructor
Humans / demos Price::from_decimal_str / Quantity::from_decimal_str (or from_decimal)
Bots / MMs Price::from_ticks / Quantity::from_scaled
  • Reject floats (f32/f64); they are not accepted on these APIs.
  • Reject bare integers on public order APIs; use the named constructors.
  • Reject excess fractional digits on decimal→scaled conversion (no silent floor).
  • Price ticks are fixed 1e6; qty scale comes from pair base_quantity_scale (catalog).
  • Quote budgets use pair quote_quantity_scale; retrieve it with Catalogs::quote_quantity_scale_for_symbol[_id] and construct an OrderQuote Quantity.

Balances: funding vs trading

Ledger balances have separate funding and trading buckets per asset.

  • An external deposit can stop in funding or continue to trading, depending on its configured route.
  • Spot orders spend trading balance.
  • Move funds funding → trading in the Polyester UI (Funding → Unified Trading) or on-chain via the funding wallet.

SDK notes:

  • Funding → trading: on-chain TradingGateway.deposit (not an API-key RPC). Either encode calldata or submit a UserOp via PolyesterSmartAccount with a caller-supplied owner EOA key (SDK derives the Polyester Safe; no UI-exported owner key).
  • Funding → external: on-chain FundingAccount.withdrawToChain (same polyester::chain); quote fees with quote_zipper_fee first.
  • Whitelist: FundingAccount allowlist + GuardRegistry signer encoders under polyester::chain (encode_add_allowed_external_destinations, …).
  • Funding → another user's funding wallet: on-chain FundingAccount.UAssetTransfer via wallet/smart-account signing in the Polyester app (not an API-key RPC).
  • Trading → funding: client.withdraw.create_to_funding(...) with a signed intent payload.
  • Trading → trading (another account): client.internal_transfers.create(...).
use alloy_primitives::U256;
use polyester::chain::{
    POLYESTER_TESTNET_ENVIRONMENT, PolyesterSmartAccount, encode_trading_gateway_deposit,
};
use std::time::Duration;

let owner_private_key = "0x...";
let u_asset_id = "0x...";
let account = PolyesterSmartAccount::new(owner_private_key, None, 0, Duration::from_secs(60))?;
let call = encode_trading_gateway_deposit(
    POLYESTER_TESTNET_ENVIRONMENT.contracts.trading_gateway_address,
    u_asset_id,
    U256::from(10u64).pow(U256::from(18u64)),
)?;
let result = account.send_calls(&[call], true, Duration::from_secs(60)).await?;

Realtime queues fail with Error::QueueOverflow instead of silently dropping; managed streams rebuild snapshots after reconnect and expose on_reconnect / on_snapshot_refresh hooks. Subscribe methods return only after the initial websocket handshake succeeds; managed create methods also wait for the initial snapshot. After connection, use recv_result().await or set_on_error(...) so delivery failures cannot look like a clean end of stream. Reconnects use capped exponential backoff with per-subscription jitter. Snapshot-recovery buffers also fail closed on overflow.

Pass default_account_id (your Profile Account ID) on the client for bucket transfers and other account-scoped ledger operations.

Balance fields are ledger u128 wire decimal strings (18-scale). Print them directly, or format smaller integers with polyester::codecs::format_ledger_u64 when you already have a u64 quantity.

use polyester::proto::ledger::read::v1::GetBalancesRequest;

let balances = client.balances.list(GetBalancesRequest::default()).await?;
for bal in balances.balances {
    println!("{} funding={} trading={}", bal.asset_id, bal.funding, bal.trading);
}

Errors, retries, and withdrawal identity

Classify failures with Error::is_retryable() and respect Error::retry_after(). For mutations, Error::mutation_outcome_unknown() means the first request may have committed: reconcile state before retrying and reuse the same logical request identity.

Order mutations that take a request_id (modify, batch_create, batch_cancel, batch_replace, cancel_all / cancel_all_with, cancel_all_after) generate one when omitted, matching TypeScript/Go/Python. That is fine for one-shot calls. Do not blind-retry after an ambiguous failure while omitting request_id: each omitted call mints a new id, so the retry is a second logical mutation rather than an idempotent replay. Generate or choose a stable request_id once, persist it with the attempt, and pass the same value on retry (same rule as client_order_id on create). Convenience helpers that do not accept request_id (for example cancel_all) are one-shot oriented - use the *_with / explicit-argument form when you need retry-safe identity.

Batch replace returns admission, not execution finality. After admission, predecessor IDs may be stale: use replacement_order_id from each result. Reuse the same request_id when retrying a logical batch, then poll get_batch_replace_status. Its item phases are admitted, working, rejected, and terminal; is_batch_replace_settled() means every item has left admission processing (working is live, not terminal execution).

use polyester::models::{CreateOrderParams, ModifyOrderParams};
use polyester::{Client, Result};

async fn create_with_reconciliation(client: &Client, params: CreateOrderParams) -> Result<()> {
    match client.orders.create(params.clone()).await {
        Ok(order) => println!("{}", order.order_id),
        Err(error) if error.is_retryable() => {
            if error.mutation_outcome_unknown() {
                // Reconcile by client_order_id before deciding whether to retry.
                // This only works if params.client_order_id was set on the first attempt.
            }
            if let Some(seconds) = error.retry_after() {
                tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await;
            }
            // If reconciliation permits a retry, reuse the same params.client_order_id.
        }
        Err(error) => return Err(error),
    }
    Ok(())
}

async fn modify_with_stable_request_id(client: &Client, mut params: ModifyOrderParams) -> Result<()> {
    // Choose once per logical modify; reuse on every retry of that attempt.
    params.request_id = Some("mod-bot-attempt-42".into());
    let _ = client.orders.modify(params).await?;
    Ok(())
}

Trading withdrawals require a caller-owned non-empty idempotency key and non-zero nonce. Generate each once per logical withdrawal, persist both with the signed payload, and reuse the entire request after an ambiguous timeout:

use polyester::{new_trading_withdraw_idempotency_key, new_trading_withdraw_nonce};

let idempotency_key = new_trading_withdraw_idempotency_key()?;
let nonce = new_trading_withdraw_nonce()?;
// Persist both before signing CreateTradingWithdrawParams.

Never generate either value inside a retry loop.

Public market data

Public endpoints do not require an API key. Authenticated endpoints use the credentials above.

let candles = client
    .market_data
    .get_candles("BTC-USDT", "1m", Some(50))
    .await?;
let current: Option<_> = client
    .market_data
    .get_current_candle("BTC-USDT", "1m")
    .await?;
let trades = client.market_data.get_trades("BTC-USDT", Some(20)).await?;
let _ = (candles, current, trades);

client.wait_for_catalogs().await?;
let mut sub = client.market_data.subscribe_trades("BNB-USDT").await?;
sub.set_on_error(|error| eprintln!("realtime interruption: {error}"));
if let Some(trade) = sub.recv_result().await? {
    println!(
        "{:?} {:?}",
        trade.price.as_ref().map(|p| p.as_ticks()),
        trade.qty.as_ref().map(|q| q.as_scaled())
    );
}

get_candles and get_candles_with return rows newest-first by ts_sec. When incomplete rows are included, the current open candle is prepended.

Merged market overview stream (snapshot + live updates):

use polyester::services::MarketOverviewCreateSubscriptionOptions;

let mut sub = client
    .market_overview
    .create_subscription(MarketOverviewCreateSubscriptionOptions {
        limit: Some(50),
        ..Default::default()
    })
    .await?;
sub.set_on_error(|error| eprintln!("managed overview failed: {error}"));
if let Some(markets) = sub.updates().recv().await {
    println!("{} rows", markets.len());
}

Realtime

Realtime is binary-only. The client negotiates the centrifuge-protobuf WebSocket subprotocol, sends binary length-delimited Centrifugo commands, and decodes protobuf publication payloads from :proto channels. ConnectRPC's optional JSON wire mode does not apply to realtime. Incoming WebSocket messages, frames, and protobuf record lengths are capped at 8 MiB.

Realtime subscription handles stop their background tasks when explicitly closed or dropped. Call close() when prompt shutdown matters; Drop provides the cleanup safety net.

use polyester::services::CreateSubscriptionOptions;

let mut orders = client.orders.subscribe(None).await?;
if let Some(order) = orders.recv_result().await? {
    println!("{} {}", order.order_id, order.status);
}

let mut api_policies = client.policies.subscribe_api_policies(None).await?;
if let Some(policy) = api_policies.recv_result().await? {
    println!("{} {}", policy.policy_id, policy.revision);
}


let mut book = client
    .orderbook
    .create_subscription(CreateSubscriptionOptions {
        symbol: "ETH-USDT".into(),
        depth: Some(50),
        ..Default::default()
    })
    .await?;
book.set_on_error(|error| eprintln!("managed order book failed: {error}"));
if let Some(snapshot) = book.updates().recv().await {
    println!("{} {}", snapshot.book_seq, snapshot.bids.len());
}

Private order protobufs carry scaled quantity integers but may not carry the base quantity scale, so Quantity::scale() can return None. A bot must resolve the scale from the hydrated catalog using Quantity::symbol_id() (or Quantity::symbol()), then pass that catalog scale to Quantity::format. Never trust, guess, or invent a stream quantity scale.

Development

source "$HOME/.cargo/env"   # if cargo is not on PATH yet
cargo check
cargo test --lib
cargo test --test integration -- --test-threads=1
cargo clippy --all-targets -- -D warnings

CI requires every public Connect RPC in gen to be wrapped or listed in sdk-coverage.toml. Contributors:

python3 scripts/check_sdk_coverage.py
python3 scripts/check_sdk_coverage.py --write-capabilities  # refresh JSON + README table

CI refreshes sdk-capabilities.json and the README capability table on pushes to main when they drift (not on pull-request CI).

CI runs unit/lib and compile-fail UI tests only (cargo test --lib --test ui). Live integration tests under tests/integration/ (and a7_strict_live, which shells out to them) are not part of CI and are excluded from the crates.io package. Run them from a git checkout of this repository. They need POLYESTER_API_KEY_ID / POLYESTER_API_PRIVATE_KEY (and usually POLYESTER_ACCOUNT_ID). Without those env vars they soft-skip unless POLYESTER_TEST_STRICT_LIVE=1 is set.

Optional tiers (same gates as Go/Python):

Env Enables
POLYESTER_TEST_MUTATION=1 State-changing tests, including funded mutations
POLYESTER_TEST_FUNDED=1 Balance-changing transfers / fills (also requires the mutation gate)
POLYESTER_TEST_ACCOUNT_WIDE_CLEANUP=1 Legacy cancel_all stress tests; dedicated test accounts only
POLYESTER_TEST_TRADE_E2E=1 + POLYESTER_TEST_MAKER_* Maker+taker fill e2e
POLYESTER_TEST_INTERNAL_TRANSFER_DEST Internal / unified→user transfers
POLYESTER_TEST_STRICT_LIVE=1 Fail the selected live run if any test takes a skip: path

With a local .env, dotenvy loads it automatically (.env is gitignored). Run live mutation/funded tests serially so order lifecycle and balance checks do not race each other. Enable strict live mode for release certification after configuring every gate and credential required by the selected tests.

CI rejects private ledger.write symbols in public gen (same gate as Go/Python).

Layout

Path Role
src/gen/buffa, src/gen/connect Checked-in Buffa + Connect codegen
src/proto, src/connect_gen Module tree mounting gen as crate::proto / crate::connect
src/auth, src/transport Ed25519 API-key signing + Connect HttpClient
src/types, src/codecs Price / Quantity / scalars
src/services, src/client Ergonomic async Client surface
src/realtime WebSocket subscriptions (always included; realtime/chain are empty compat flags)
src/catalogs, src/orderbook Catalog cache + local book helpers

Proto stubs are updated when a new src/gen/ bundle is landed. Day-to-day SDK work does not require local buf generation. After replacing gen files, run:

python3 scripts/gen_module_tree.py

Auth signing

Authenticated Connect calls sign the exact configured wire body bytes (protobuf or JSON) with:

timestamp_ms
METHOD
pathname
canonical_query
hex(sha256(body))

Headers: X-API-KEY-ID, X-API-TIMESTAMP, X-API-SIGNATURE.

Use the high-level Client service methods for authenticated calls. Generated clients under polyester::connect are low-level protocol bindings and do not apply Polyester API-key signatures by themselves.

Examples

Runnable cookbook examples live in the sibling repo polyester-examples-rust (REST market data, realtime streams, decimal + scaled-int order paths, batch create, RSI bot).

Changelog

See CHANGELOG.md.

License

Proprietary. See LICENSE.