# 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](https://github.com/connectrpc/connect-rust) (Buffa + Connect
**0.8.x**) and the checked-in `src/gen/` protobuf bundle.
**Status:** Alpha (`0.1.0-alpha.27`, git tag `v0.1.0a27`). Proprietary license
(not open source). API-key only; no browser login or JWT flows.
**MSRV:** Rust 1.88+
## Supported surface
| 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](https://polyester.ai/docs/developer-docs/getting-started/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
```toml
[dependencies]
polyester-sdk = "0.1.0-alpha.27"
```
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:
```toml
[dependencies]
polyester-sdk = { git = "https://github.com/Fabric-Labs/polyester-sdk-rust", tag = "v0.1.0a27" }
```
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:
```bash
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.
```rust,no_run
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
| 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**
```rust,no_run
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`:
```rust,no_run
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.
```rust,no_run
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:
```rust,no_run
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
```rust,no_run
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`.
```rust,no_run
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](https://polyester.ai/developer-docs/shared-concepts/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:
```rust,no_run
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**:
| 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(...)`.
```rust,no_run
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.
```rust,no_run
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).
```rust,no_run
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:
```rust,no_run
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.
```rust,no_run
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?;
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):
```rust,no_run
use polyester::services::MarketOverviewCreateSubscriptionOptions;
let mut sub = client
.market_overview
.create_subscription(MarketOverviewCreateSubscriptionOptions {
limit: Some(50),
..Default::default()
})
.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.
```rust,no_run
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?;
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
```bash
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:
```bash
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):
| `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
| `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:
```bash
python3 scripts/gen_module_tree.py
```
## Auth signing
Authenticated Connect calls sign the **exact configured wire body bytes**
(protobuf or JSON) with:
```text
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`](https://github.com/Fabric-Labs/polyester-examples-rust)
(REST market data, realtime streams, decimal + scaled-int order paths, batch create, RSI bot).
## Changelog
See [CHANGELOG.md](CHANGELOG.md).
## License
Proprietary. See [LICENSE](LICENSE).