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
[]
= "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:
[]
= { = "https://github.com/Fabric-Labs/polyester-sdk-rust", = "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:
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 ;
use ListMarketOverviewOptions;
async
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 | Profile → Account 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 ;
let client = new?;
If your deployment stores secrets in environment variables, read them in your
application and pass them to Client::new:
use env;
use ;
let client = new?;
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 = 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 = 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 ;
use OrdersService;
use ;
client.wait_for_catalogs.await?;
let mut params = create_params;
params.time_in_force = Some;
params.post_only = Some;
let result = client.orders.create.await?;
println!;
client
.orders
.cancel_by_client_order_id
.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
.expect;
params.quantity = None;
params.max_quote_debit_scaled = Some;
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 ;
use OrdersService;
use ;
let params = create_params;
let _ = client.orders.create.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 withCatalogs::quote_quantity_scale_for_symbol[_id]and construct anOrderQuoteQuantity.
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 viaPolyesterSmartAccountwith a caller-supplied owner EOA key (SDK derives the Polyester Safe; no UI-exported owner key). - Funding → external: on-chain
FundingAccount.withdrawToChain(samepolyester::chain); quote fees withquote_zipper_feefirst. - Whitelist: FundingAccount allowlist + GuardRegistry signer encoders under
polyester::chain(encode_add_allowed_external_destinations, …). - Funding → another user's funding wallet: on-chain
FundingAccount.UAssetTransfervia 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 U256;
use ;
use Duration;
let owner_private_key = "0x...";
let u_asset_id = "0x...";
let account = new?;
let call = encode_trading_gateway_deposit?;
let result = account.send_calls.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 GetBalancesRequest;
let balances = client.balances.list.await?;
for bal in balances.balances
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 ;
use ;
async
async
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 ;
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
.await?;
let current: = client
.market_data
.get_current_candle
.await?;
let trades = client.market_data.get_trades.await?;
let _ = ;
client.wait_for_catalogs.await?;
let mut sub = client.market_data.subscribe_trades.await?;
sub.set_on_error;
if let Some = sub.recv_result.await?
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 MarketOverviewCreateSubscriptionOptions;
let mut sub = client
.market_overview
.create_subscription
.await?;
sub.set_on_error;
if let Some = sub.updates.recv.await
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 CreateSubscriptionOptions;
let mut orders = client.orders.subscribe.await?;
if let Some = orders.recv_result.await?
let mut api_policies = client.policies.subscribe_api_policies.await?;
if let Some = api_policies.recv_result.await?
let mut book = client
.orderbook
.create_subscription
.await?;
book.set_on_error;
if let Some = book.updates.recv.await
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
CI requires every public Connect RPC in gen to be wrapped or listed in
sdk-coverage.toml. Contributors:
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:
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.