Metadata-Version: 2.5
Name: polyester-sdk
Version: 0.2.0
Summary: Official Python SDK for Polyester APIs.
Project-URL: Homepage, https://github.com/Fabric-Labs/polyester-sdk-python
Project-URL: Documentation, https://polyester.com/docs/sdk/python/get-started/overview
Project-URL: Repository, https://github.com/Fabric-Labs/polyester-sdk-python
Project-URL: Issues, https://github.com/Fabric-Labs/polyester-sdk-python/issues
Author: Fabric Blockchain Labs Inc.
License: Proprietary
License-File: LICENSE
Keywords: connectrpc,polyester,protobuf,sdk,trading
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: base58>=2.1.1
Requires-Dist: connectrpc<0.11,>=0.9.0
Requires-Dist: cryptography>=42
Requires-Dist: eth-abi>=5.1
Requires-Dist: eth-account>=0.13
Requires-Dist: eth-hash[pycryptodome]>=0.7
Requires-Dist: eth-utils>=5.0
Requires-Dist: httpx>=0.27
Requires-Dist: msgspec>=0.18
Requires-Dist: protobuf>=7.35.0
Requires-Dist: websockets>=15
Provides-Extra: chain
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.13; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-httpserver>=1.1; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: python-dotenv>=1.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Provides-Extra: realtime
Description-Content-Type: text/markdown

# Polyester Python SDK

Official Python SDK for Polyester APIs, built for trading bots, backend jobs,
research notebooks, and automation.

**Status:** `0.2.0`, pre-1.0: a minor release may include breaking changes (see
[CHANGELOG.md](CHANGELOG.md)). Proprietary license (not open source).
API-key only; no browser login or JWT flows.

Requires **Python 3.11+**.

## Supported surface

<!-- sdk-capabilities:start -->
<!-- Generated by scripts/check_sdk_coverage.py --write-capabilities. Do not edit. -->

| 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/create/update/subscribe) | Yes |
| Policies (realtime subscribe) | Yes |
| Guard signer | Yes |
| VIP tiers + status | Yes |
| Spot fee rates | Yes |
| Trading rate limits | 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.com/docs/developer-docs/getting-started/sdk-capability-matrix).
<!-- sdk-capabilities:end -->

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

PyPI: https://pypi.org/project/polyester-sdk/

```bash
pip install "polyester-sdk==0.2.0"
```

Realtime (Centrifugo) and on-chain Funding helpers are included by default.

The PyPI sdist/wheel do **not** include `tests/`. Full `pytest` (unit, hardening,
live) requires a git checkout of this repository.

For development from a git checkout:

```bash
git clone https://github.com/Fabric-Labs/polyester-sdk-python.git
cd polyester-sdk-python
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```

## Examples

Runnable cookbook examples live in the sibling repo
[`polyester-examples-python`](https://github.com/Fabric-Labs/polyester-examples-python)
(REST market data, realtime streams, decimal + scaled-int order paths, and bots).

## Quickstart

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 balances and private balance streams, plus Spot trading for order
mutations. This is separate from the **subaccount policy**: authorization is the
intersection of both policies, so configuring only the subaccount policy is
insufficient.

```python
import asyncio

from polyester import AsyncPolyester, POLYESTER_DEVNET_ENVIRONMENT

async def main() -> None:
    async with AsyncPolyester(
        environment=POLYESTER_DEVNET_ENVIRONMENT,
        api_key_id="ak_...",           # from API key creation
        api_private_key="...",       # 64-char hex secret from API key creation
        default_account_id="...",    # Profile → Account ID (see below)
    ) as client:
        overview = await client.market_overview.list(limit=5)
        for market in overview.markets:
            last = market.last_price.ticks if market.last_price is not None else None
            index = market.index_price.ticks if market.index_price is not None else None
            print(market.symbol, last, index)

        open_orders = await client.orders.list_open()
        print(f"{len(open_orders.orders)} open orders")

asyncio.run(main())
```

## Credentials

| Value | Where to find it | Constructor parameter |
| --- | --- | --- |
| 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** | `default_account_id` |

Pass an `environment` preset so API, websocket, RPC, and contract pins stay
together. `POLYESTER_DEVNET_ENVIRONMENT` is the constructor default.
`POLYESTER_TESTNET_ENVIRONMENT` targets public testnet (`api.testnet.polyester.com`,
chain `888169`). Custom / VPC endpoints use `create_polyester_environment` or
`environment.with_urls(...)`. `api_url` / `ws_url` remain as transport overrides.

Pass all credentials as **constructor parameters**. The SDK does not read
environment variables unless you pass them in yourself (or use `from_env()` in
scripts; see below). `from_env()` also reads `POLYESTER_ENV=devnet|testnet`.

`api_private_key` accepts the 64-character hex Ed25519 secret from key creation,
or raw 32-byte key material.

`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.
An account username is optional; Account-ID-based authentication and private
channel scoping are valid without one.

`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.
Timestamps can lead the local clock by at most five seconds. The async client applies bounded,
cooperative backpressure without blocking the event loop. The low-level synchronous
`polyester.auth.sign_request` helper instead raises `PolyesterRateLimitError` immediately when
capacity is exhausted; honor `retry_after`. Neither path reuses a signature or drifts outside the
API's 10-second freshness window.

Connect `resource_exhausted` and HTTP 429 responses raise `PolyesterRateLimitError`. When the
server attaches `polyester.ratelimit.v1.RateLimitDetail` (top-level Connect detail or nested under
`orders.v1.ErrorDetail.rate_limit`), inspect `error.detail` for `policy_class`, `scope`,
`operation_id`, and presence-aware quota fields. `retry_after` prefers `detail.retry_after_ms`,
then `Retry-After` / `Retry-After-Ms` / `Grpc-Retry-Pushback-Ms` headers on HTTP paths. Preview
and batch rejections expose the same payload on `OrderErrorDetail.rate_limit` / batch item
`rate_limit`. That error payload is distinct from `client.rate_limits`
(`ratelimit.v1.RateLimitService`), which returns the public trading quota catalog and
authenticated account / API-key limits.

## Authentication patterns

**Recommended: explicit parameters**

```python
from polyester import AsyncPolyester

client = AsyncPolyester(
    api_key_id="ak_...",
    api_private_key="...",
    default_account_id="YOUR_ACCOUNT_ID",
)
```

**If your deployment stores secrets in environment variables**, read them in your
application and pass them to the constructor:

```python
import os

from polyester import AsyncPolyester

client = AsyncPolyester(
    api_key_id=os.environ["POLYESTER_API_KEY_ID"],
    api_private_key=os.environ["POLYESTER_API_PRIVATE_KEY"],
    default_account_id=os.environ["POLYESTER_ACCOUNT_ID"],
)
```

The plain `AsyncPolyester(...)` / `Polyester(...)` constructors never implicitly
read `os.environ`.

**Scripts and local tests only:** `AsyncPolyester.from_env()` and
`Polyester.from_env()` load `POLYESTER_API_KEY_ID`, `POLYESTER_API_PRIVATE_KEY`,
`POLYESTER_ACCOUNT_ID`, and optional `POLYESTER_API_URL` / `POLYESTER_WS_URL`
from the process environment. This is a convenience helper, not the primary
integration pattern.

## Create and cancel orders

```python
from polyester import AsyncPolyester
from polyester.models import ClientOrderId

async with AsyncPolyester(
    api_key_id="ak_...",
    api_private_key="...",
    default_account_id="YOUR_ACCOUNT_ID",
    default_sub_account_id="",  # main account; omit subaccount scoping
) as client:
    result = await client.orders.create(
        symbol="BTC-USDT",
        side="buy",
        order_type="limit",
        tif="gtc",  # gtc, ioc, fok, or gtd (gtd requires expires_at)
        qty="0.01",
        price="100",
        post_only=True,
        client_order_id="my-bot-001",
    )
    print(result.status, result.order_id)

    await client.orders.cancel(key=ClientOrderId("my-bot-001"))
```

`client_order_id` is **optional** (matches the API). Omit it for one-shot
creates. **Set a stable non-empty value when you may need to reconcile** after
an ambiguous transport/server failure. Without it you cannot tell whether the
first attempt admitted the order. After an unknown outcome, look up that id
(`get` / `list_open`) before creating again. A second create with the same id
is rejected (`CONFLICT_DUPLICATE_CLIENT_ORDER_ID`) even when the payload
matches. Only create again with that id if reconciliation shows the first
attempt did not admit.
Client order ids accept 1 to 36 ASCII letters, digits, `.`, `_`, `:`, `/`, and
`-`. Batch create, cancel, and replace accept at most 20 items. `batch_create`
always sends `request_id` (caller value or a generated `batch-create-*` key);
that batch-level id is the idempotency boundary, so per-item `client_order_id`
may be omitted. Treat a cancel
response as an admission acknowledgement and reconcile with `list_open` before
releasing local state.

Attached take-profit / stop-loss use the same friendly keys as the read
model (`trigger_price`, `trigger_price_source`, `order_type`, `limit_price`). Child execution is
`market` (default) or `limit`, not `limit_ioc` / `limit_fok`, and not a
nested `child.execution` wrapper.

```python
result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="limit",
    tif="gtc",
    qty="0.01",
    price="100",
    post_only=True,
    attached_risk={
        "take_profit": {"trigger_price": "140", "order_type": "market"},
        "stop_loss": {"trigger_price": "80", "order_type": "market"},
        "oco": True,
    },
)
detail = await client.orders.get(
    key=ClientOrderId(result.client_order_id),
    include_attached_risk=True,
)
```

Create sizing is explicit: set exactly one of base `qty` or `max_quote_debit`
(a hard all-in quote-debit budget). Decimal/str quote budgets use the pair's
catalog `quote_quantity_scale`. Typed budgets must use
`QuantityDomain.ORDER_QUOTE` and embed that scale; construct with
`Quantity.from_quote_decimal_str` / `from_quote_decimal` / `from_quote_scaled`
and validate against `client.catalogs.quote_quantity_scale_for_symbol`.

```python
quote_scale = client.catalogs.quote_quantity_scale_for_symbol("BTC-USDT")
assert quote_scale is not None  # await client.wait_for_catalogs() first
result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="market",
    max_quote_debit=Quantity.from_quote_decimal_str(
        "25.00", quote_scale, symbol="BTC-USDT"
    ),
)
```

Use `orders.preview_order(...)` to check current order admissibility and, when
available, resolved base quantity plus protected price bound before submitting.
Preview accepts the same public create kwargs and encodes them as an
`OrderIntent` (same wire contract as create). It does not return fee or
quote-debit estimates. `protected_price_bound` is a protective execution
boundary, not an expected fill price. Rejections carry stable labels such as
`BAD_QTY` plus field violations; `evaluated_at_ms` records when admission was
evaluated. 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.

Market orders are IOC and enforce a slippage-derived execution boundary. See
[Market Order Price Protection](https://polyester.com/docs/developer-docs/shared-concepts/market-order-price-protection)
before overriding pair defaults. Set at most one of `max_slippage_bps`
(1–10000) or `max_slippage_ticks`. Omit both to keep the pair default. The
fields are rejected on limit orders and if both are supplied.

```python
result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="market",
    qty="0.01",
    max_slippage_bps=25,  # or max_slippage_ticks=10; not both
)
```

Scaled transfer/withdraw `AssetAmount` inputs must carry their source scale.
`AssetAmount.from_scaled(..., scale=None)` is accepted for composition only and
fails closed on encoding unless the request's `amount_scale` /
`quantity_scale` is explicit.

Use **decimal strings** or `Decimal` for human-facing `qty` / `price` inputs.
Do **not** pass floats. `ticks` on `Price` means Polyester protocol price units
(fixed 1e9), not market tick-size alignment (server validates tick size).
Catalog hydration resolves `symbol` → `symbol_id` and quantity scales for
encoding; pair admission (tick/step/min qty/notional) stays on the API.
`orders.preview_order(...)` remains the authority for balances, reservations,
liquidity, and other stateful rules.

### For bots (scaled integers)

Stay in integer space; no string round-trip:

```python
from polyester import Price, Quantity

result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="limit",
    tif="gtc",
    qty=Quantity.from_scaled(1_000_000, scale=8),  # already wire units
    price=Price.from_ticks(100_000_000_000),        # 100.000000000 at 1e9
    post_only=True,
)
# Reads expose the same types: order.price.ticks, order.orig_qty.scaled
# orig_qty is the current accepted total and changes after a successful modify.
```

Compatible values from fills/books can be passed back into writes when the
instrument/domain matches.

`orders.batch_replace(...)` returns an admission receipt, not final execution.
The predecessor ID can be stale after admission; reconcile the successor IDs
and per-item phases by polling `get_batch_replace_status`. Use
`is_batch_replace_settled(status)` only to determine that all items have moved
to `working`, `rejected`, or `terminal`, not as a finality signal. Retry an
ambiguous batch request with the same `request_id`.

`cancel_all(...)` requests cancellation of every matching order. The result
lists up to 200 of them, oldest first, in `cancelled_orders` (each with
`order_id` and `client_order_id`); `total_cancelled` counts all of them and
`truncated` is true when the list is shorter. Each order's final state comes
from the order stream and reads. A dry run lists none. Retrying with the same
`request_id` within 120 seconds returns the original counts with an empty
`cancelled_orders` and `listing_status="not_retained_read_open_orders"`; the
empty list does not mean nothing was cancelled, so read open orders instead.


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

## User trade fees

`UserTrade` fee fields are fixed **18-decimal** magnitudes of `fee_asset`
(`fee_amount_e18`, `referral_share_amount_e18`), not catalog asset-scaled
integers. Convert e18 → the fee asset's catalog scale before subtracting from a
BUY fill's base quantity.

Magnitudes are unsigned. Treat `fee_amount_e18` as a **debit** unless
`fee_is_rebate` is true (then it is a **credit**). Proto3 omits false, so the
rebate flag is sparse on the wire.

`client.fees.get_spot_fee_rates()` returns the account target's current effective maker/taker
percents per listed symbol (optional `symbol_id` filter). That is the live effective-rate
surface, not a guaranteed quote of every future fill. The completed user-trade record still
owns the exact charge.

`trades.list(after_match_id=...)` is a durable fill-replay cursor and requires
`symbol` or `symbol_id`.

## VIP, spot fees, and trading rate limits

Public catalog reads need no credentials. Authenticated VIP status, effective spot fees, and
account trading limits require an API key. `GetVIPStatus` has no subaccount selector: JWT and
API-key callers receive the owning root group only. USD amounts and fee percents are decimal
strings. Optional qualification metrics, timestamps, and next-tier thresholds stay omitted
when unset. `policy_class` uses full protobuf enum names
(`TRADING_RATE_LIMIT_CLASS_PLACE` / `_CANCEL`). Rate-limit rules expose
`vip_tier` (not `tier`).

```python
tiers = await client.vip.list_vip_tiers()
status = await client.vip.get_vip_status()
fees = await client.fees.get_spot_fee_rates()
catalog = await client.rate_limits.get_rate_limit_config()
limits = await client.rate_limits.get_trading_rate_limits()
```

## Address book and social handles

Address-book reads (`list_books`, `list_entries`, `get_view`) and writes
(`create_entry`, `update_entry`, `delete_entry`, tag CRUD) are wrapped.
`get_view(minimum_view_revision=...)` asks the server for a newer snapshot;
the view and invalidation events expose `view_revision`.
Create and update accept `new_tags` so a tag can be created and attached in
the same request. Update is a durable PATCH: pass `expected_revision` from a
prior read and only the fields you set are selected. When `tag_ids` is also
set, the resulting set is those ids plus `new_tags`; otherwise `new_tags` are
appended to the current tags.

```python
entry = await client.address_book.create_entry(
    label="ops",
    address="0x…",
    polychain_chain_id=1,
    new_tags=[{"name": "hot", "color": "#f00"}],
)
entry = await client.address_book.update_entry(
    address_book_entry_id=entry.address_book_entry_id,
    expected_revision=entry.revision,
    new_tags=[{"name": "vip"}],
)
```

`social_verification.start(..., handle="@alice")` accepts an optional leading
`@` on Twitter handles. The SDK forwards the handle as supplied.

## Triggers

`triggers.list(status=...)` filters by lifecycle status. Valid values:

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

Invalid status and event-type labels raise `PolyesterValidationError`.
Connect requests and responses use numeric `symbol_id`. Public methods still
accept display `symbol` strings and resolve them through the hydrated catalog
after `wait_for_catalogs()`. Unknown symbols fail closed. Only
`get_spot_config` returns both `symbol` and `symbol_id`; other Connect
payloads are ID-only. Display `symbol` on models such as market overview,
fees, triggers, and policy rules is filled from the catalog when available.

Unknown values raise `PolyesterValidationError` (they do not silently return
an empty list). Response `status` uses the same labels (British spelling
`cancelled`).

`orders.get(..., include_attached_risk=True)` returns policy data on
`order.attached_risk` (take-profit / stop-loss / trailing-stop). Independently,
`include_attached_risk_state=True` can return state-only leg wrappers when
policy inclusion is false. Each returned `RiskLeg` / `TrailingStop` exposes
that runtime `AttachedRiskLegState` at `.state` with `status`, nanosecond
armed/terminal timestamps, `trigger_id`, and `child_order_id`; absent policy
fields remain unset. `Order` also exposes `post_only`. Create/modify accept the
same policy shape as a dict or `AttachedRisk`.

A stop-loss or take-profit with `order_type="market"` takes an optional
slippage cap, `max_slippage_ticks` (Q9 ticks) or `max_slippage_bps`, at most
one; without one the pair's default market slippage applies. A limit child
takes no cap (its limit price bounds it). Either side may use either child.
Trigger reads show the cap as `max_slippage` (ticks, as a price delta) or
`max_slippage_bps`; both unset means the pair default.

```python
await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="stop_loss",
    side="sell",
    qty="0.01",
    trigger_price="83000",
    order_type="market",
    max_slippage_bps=50,
)
```

A standalone trailing stop may sell or buy. A buy trailing stop tracks the
lowest price and fires when the price rises the trailing distance from it.
It requires `limit_price`, its maximum price: the most its child pays per
unit (a 9-decimal string or `Decimal`, or `Price.from_ticks` for ticks),
fixed at create. A sell trailing stop takes no `limit_price`.

```python
await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="trailing_stop",
    side="buy",
    qty="0.01",
    trailing_distance_bps=100,
    limit_price="85000",
)
```

Trigger reads show a buy trailing stop with `side="buy"`,
`order_type="limit"`, `time_in_force="ioc"` and its maximum price as
`limit_price`; `details.trailing.trough_price` is the lowest price tracked so
far (`peak_price` for a sell).

Stop-loss, take-profit and trailing-stop triggers, standalone or attached to
an order, watch the last trade price unless `trigger_price_source` names
another: `"last"`, `"index"` or `"mark"`. Reads report the same label
(`Trigger.trigger_price_source`, `RiskLeg.trigger_price_source`,
`TrailingStop.trigger_price_source`), so a leg read from an order can be
passed back to create or modify unchanged. The API rejects a source the pair
does not support.

```python
await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="stop_loss",
    side="sell",
    qty="0.01",
    trigger_price="83000",
    trigger_price_source="index",
)
```

When modifying a trigger, omit `activation_price` / `max_slippage_ticks` /
`max_slippage_bps` to preserve the current values. Send an explicit zero
(`Price.from_ticks(0)` or `0`) to clear an existing activation price or
maximum-slippage cap. Create/modify `max_slippage_bps` must be 1–10000;
modify still accepts `0` to clear. Trailing distance in bps uses the same
1–10000 range; trigger modify accepts `0` to clear.

`orders.list_open(trigger_id=...)` / `orders.list_history(trigger_id=...)`
return only child orders created by that trigger (TWAP/ladder slices).
Trigger-event `fire_price` is `None` for time-scheduled TWAP slice fires
(`fire_price_ticks` is optional on the wire). Canceled and failed triggers
expose typed `cancel_reason` / `failure_reason` labels (empty when unset).

## 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: pass an owner EOA private key to
  `PolyesterSmartAccount` (SDK derives the Polyester Safe; no UI-exported owner key).
- **Funding → external:** on-chain `FundingAccount.withdrawToChain` (same `polyester.chain`).
- **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:** prepare and persist an exact API-key signature with
  `client.trading_withdraws.prepare_api_key_to_funding(...)`, then call
  `submit_prepared(...)`. One-call `create_api_key_to_funding(...)` is also available.
- **Trading → external destination check:**
  `await client.trading_withdraws.validate_destination(destination_chain_id=..., destination_address=...)`
  returns user-safe `valid` / `code` / `message` / `canonical_destination_address`
  without creating a withdraw (create RPCs remain authoritative).
- **Trading → trading (another account):** `client.internal_transfers.create(...)`.

```python
from polyester.chain import (
    POLYESTER_DEVNET_ENVIRONMENT,
    PolyesterSmartAccount,
    encode_trading_gateway_deposit,
    encode_funding_withdraw_to_chain,
    encode_withdraw_destination,
    quote_zipper_fee,
)

account = PolyesterSmartAccount(owner_private_key="0x…")  # caller-supplied EOA

# Funding → Trading
deposit = encode_trading_gateway_deposit(
    trading_gateway=POLYESTER_DEVNET_ENVIRONMENT.contracts.trading_gateway_address,
    u_asset_id="0x…",
    quantity_scaled=10**18,  # 1 USDT at 18 decimals
)
account.send_calls([deposit])

# Funding → external (quote Zipper fee first)
fee = quote_zipper_fee(
    chain_id=6,  # BSC testnet Zipper id
    z_token="0x…",
    zipper_endpoint=POLYESTER_DEVNET_ENVIRONMENT.contracts.zipper_endpoint_address,
)
withdraw = encode_funding_withdraw_to_chain(
    funding_account=POLYESTER_DEVNET_ENVIRONMENT.contracts.funding_account_address,
    chain_id=6,
    z_token="0x…",
    withdraw_destination=encode_withdraw_destination(address="0x…", is_case_sensitive=False),
    z_amount=5 * 10**18,
    max_fee=fee.fee + fee.fee // 10,
)
account.send_calls([withdraw])
```

Whitelist toggles / destination entries / GuardRegistry signer setup are also encoded under
`polyester.chain` (`encode_add_allowed_external_destinations`, …).

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

Format u128 wire amounts with the public helper (18-decimal scale):

```python
from polyester import format_ledger_u128

print(format_ledger_u128(balance.funding), format_ledger_u128(balance.trading))
```

## Public market data

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

```python
candles = await client.market_data.get_candles(symbol="BTC-USDT", timeframe="1m", limit=50)
current = await client.market_data.get_current_candle(symbol="BTC-USDT", timeframe="1m")
if current is not None:
    print(current.close, current.is_closed)
trades = await client.market_data.get_trades(symbol="BTC-USDT", limit=20)

subscription = await client.market_data.subscribe_trades(symbol="BTC-USDT")
subscription.set_on_error(lambda error: print(f"realtime interruption: {error}"))
async with subscription:
    async for trade in subscription:
        print(trade.price.ticks if trade.price else None, trade.qty.scaled if trade.qty else None)
        break
```

List and heatmap `limit` arguments must be integers (booleans and other types
are rejected for wire safety). Optional limits omit when unset so the server
default applies; allowed ranges are enforced by each API, not a global SDK cap.
Response fields named `ts_ns` are treated as epoch nanoseconds per the API
contract.

`get_trades()` lists trades newest first. Narrow it with `start_time` /
`end_time` (inclusive), `side` (`"buy"` or `"sell"` aggressor), and page with
`page_token=result.next_page_token`.

To backfill trades a live stream missed, walk by trade ID instead: oldest
first and gap-free. Start from `after_trade_id=0` (or the last trade ID you
hold) and pass each result's `next_after_trade_id` to the next call. Every
trade up to `complete_through_trade_id` is returned, across trade-ID epoch
ends (`epoch_ends`). When `next_after_trade_id` equals the ID you sent,
nothing newer is available yet; retry later with backoff. `after_trade_id`
cannot be combined with the time range, side or page token.

```python
after = 0
while True:
    page = await client.market_data.get_trades(symbol="BTC-USDT", after_trade_id=after, limit=1000)
    for trade in page.trades:
        handle(trade)
    if page.next_after_trade_id == after:
        break  # caught up; resume later from `after`
    after = page.next_after_trade_id
```

`get_candles()` returns row-oriented candles newest-first (an incomplete open
candle, when requested, is prepended). `get_candles_columns()` normalizes its
column response oldest-first. Sort explicitly by `ts_sec` before feeding a
chronological indicator.

Merged market overview stream (snapshot + live updates). The create call waits
for the WebSocket handshake and initial snapshot:

```python
sub = await client.market_overview.create_subscription(
    on_error=lambda error: print(f"managed overview failed: {error}")
)
async with sub:
    async for markets in sub:
        print(len(markets), "rows")
        break
```

### Realtime delivery contract

- Realtime is binary-only. The client negotiates the `centrifuge-protobuf`
  WebSocket subprotocol and consumes protobuf publications from `:proto`
  channels; live orders use the private WebSocket (see Live orders below).
  ConnectRPC's optional JSON wire mode does not apply to realtime.
  Inbound WebSocket messages are capped at 4 MiB.
- Subscription queues are bounded. If the consumer falls behind, the SDK raises
  `PolyesterRealtimeOverflowError` and faults the subscription; it does **not**
  silently drop updates.
- Reconnects use capped exponential backoff with per-subscription jitter.
  `set_on_error(...)` observes background feed interruptions; async iteration
  still raises terminal failures.
- Orderbook sequence gaps trigger a REST snapshot refresh. Use
  `on_sequence_gap` / `on_reconnect` / `on_snapshot_refresh` on
  `orderbook.create_subscription(...)` for recovery observability.
- Managed snapshot-then-stream subscriptions disable transport auto-reconnect
  so they can rebuild REST state between reconnect attempts. Buffered
  publications survive a failed snapshot retry and are merged exactly once
  after recovery; cancellation closes the replacement socket. Managed create
  methods await the handshake and initial snapshot before returning.
- Private order stream quantities are raw scaled values. Their
  `Quantity.scale` may be `None`; resolve the hydrated catalog scale with
  `client.catalogs.base_quantity_scale_for_symbol_id(order.symbol_id)` (or by
  symbol), then call `quantity.format(scale)`. Never guess or inject scale 8.

### Live orders

`client.orders.subscribe()` streams an account's orders over the private
WebSocket (`/v1/ws/private`, binary protobuf, subprotocol
`polyester.v1.proto`). It authenticates with the same realtime tokens as the
other private streams. One connection carries every account, and each account
has one server subscription shared by all of its consumers.

```python
from polyester.models import (
    PrivateOrdersConnected,
    PrivateOrdersDisconnected,
    PrivateOrdersError,
    PrivateOrdersReady,
    PrivateOrdersSnapshot,
    PrivateOrdersStale,
    PrivateOrdersUnit,
)

orders = {order.order_id: order for order in (await client.orders.list_open()).orders}
subscription = await client.orders.subscribe()  # default_account_id, or account_id=...
async with subscription:
    async for event in subscription:
        if isinstance(event, PrivateOrdersReady):
            # The stream now reflects every order read made before the subscribe.
            print("ready")
        elif isinstance(event, PrivateOrdersSnapshot):
            # The full live view: replace local state.
            orders = {order.order_id: order for order in event.orders}
        elif isinstance(event, PrivateOrdersUnit):
            for order in event.orders:
                orders[order.order_id] = order  # keep the higher version
            for order_id in event.removed_order_ids:
                orders.pop(order_id, None)  # left the live view; not a cancel
            for change in event.events:
                print(change)  # PrivateOrderEvent or PrivateOrderCommandOutcome
        elif isinstance(event, PrivateOrdersStale):
            print("stale" if event.stale else "caught up")
        elif isinstance(event, PrivateOrdersError):
            print(f"recovering from {event.kind}: {event.error}")
```

- `subscribe()` returns once the server confirmed the subscription; the first
  event is `PrivateOrdersConnected`. A failure before that raises (for
  example `PolyesterAuthError` for a refused token, or
  `PolyesterRealtimeError` with `code == "FORBIDDEN"`).
- Orders are the same `Order` model the order reads return. Two copies with
  the same `version` are identical, and a higher `version` is newer. To merge
  with a read: read `list_open()`, subscribe, wait for `PrivateOrdersReady`,
  and keep the higher version of each order.
- `PrivateOrdersReady` means the subscription reached the account's read
  boundary, sampled when the server received the subscribe: from then on it
  reflects every acknowledgement given before the subscribe. Wait for it
  before relying on the stream. It comes once per server subscription, so
  again after a resubscribe or reconnect, and a consumer that joins an
  account that is already ready gets it right after its catch-up snapshot.
- A sequence gap, an expired token or a dropped connection resumes from the
  last applied position. Consumers see `PrivateOrdersDisconnected` and then
  `PrivateOrdersConnected`; a position the server can no longer resume is
  answered with a fresh `PrivateOrdersSnapshot` that replaces local state.
  Outcomes between the two states are not replayed; fills stay readable from
  trade history.
- `PrivateOrdersStale(stale=True)` means the account's live view is
  temporarily behind; it recovers by itself.
- Recoverable failures arrive as `PrivateOrdersError` events and through
  `set_on_error(...)`. A failure that ends the stream (a refused token, a
  revoked permission, close code 4400 or 4403) is raised from iteration, with
  `PolyesterRealtimeError.code` or `.close_code` set.
- Batch-replace progress has no stream: poll
  `client.orders.get_batch_replace_status(...)` and watch each order's
  revisions on this stream.

## Sync client

The sync `Polyester` client exposes the same service tree and constructor
parameters:

```python
from polyester import Polyester

with Polyester(
    api_key_id="ak_...",
    api_private_key="...",
    default_account_id="YOUR_ACCOUNT_ID",
) as client:
    balances = client.balances.list()
```

Realtime subscriptions are available via `subscribe_sync` helpers on the sync
client. Private API-key policy snapshots use
`client.policies.subscribe_api_policies_sync(...)` (async:
`await client.policies.subscribe_api_policies(...)`).

## Testing (contributors)

**CI (no network):**

```bash
python -m pytest tests/unit tests/hardening -q
```

**Live selection (A7):** public smoke vs credentialed suites are selectable via markers.

```bash
# Public smoke (read-only smoke; no mutation/funded)
python -m pytest -m "public_smoke" -q

# Named-environment pins vs live Zipper catalog (no API key)
python -m pytest tests/integration/test_environment_pins.py -q

# Credentialed live follows POLYESTER_ENV=devnet|testnet (and optional URL overrides)
# POLYESTER_ENV=testnet python -m pytest -m "credentialed and not mutation and not funded" -q

# Credentialed live integration (requires API-key env)
python -m pytest -m "credentialed and not mutation and not funded" -q

# Local L2 hardening only (no network)
python -m pytest tests/hardening -q
```

**Live devnet tests** use a local `.env` file in the test harness only. Fixtures
load values from env and pass them as explicit constructor parameters; the same
pattern application code should use.

```bash
cp .env.example .env
# fill in POLYESTER_API_KEY_ID, POLYESTER_API_PRIVATE_KEY, POLYESTER_ACCOUNT_ID

pip install -e ".[dev]"
python -m pytest tests/unit tests/hardening -q
./scripts/test_all.sh   # optional: unit + live tiers
./scripts/smoke_realtime.sh   # realtime unit + live heartbeat before release
```

Use `python -m pytest` (not bare `pytest`) so tests run in the same venv as `pip install`.
Set `POLYESTER_TEST_MUTATION=1` for state-changing tests. Funded mutations
require both `POLYESTER_TEST_MUTATION=1` and `POLYESTER_TEST_FUNDED=1`. For a
release-certification run, set `POLYESTER_TEST_STRICT_LIVE=1`; any skipped test
then fails instead of making an incomplete live run appear green. Missing or
malformed credentials must fail under strict live (not soft-skip as green). The
session printer scopes executed/skipped/failed counts to `@pytest.mark.integration`
tests and enforces `POLYESTER_TEST_MIN_EXECUTED` (default 5) when credentials are
present.

Legacy stress tests that use non-dry-run `cancel_all` require
`POLYESTER_TEST_ACCOUNT_WIDE_CLEANUP=1`. Set it only for a dedicated test
account; those tests may cancel every open order in their selected symbol.

CI requires every public Connect RPC in gen to be wrapped or listed in
`sdk-coverage.toml`. Contributors: `python scripts/check_sdk_coverage.py`.
CI refreshes `sdk-capabilities.json` and the README capability table on the
same branch when they drift (same-repo PRs / pushes to `main`).

**Pre-release checklist (realtime changes):**

```bash
cd polyester-sdk-python
python -m venv /tmp/polyester-pypi-test && source /tmp/polyester-pypi-test/bin/activate
pip install -e ".[dev]"
python -m pytest tests/unit -q
./scripts/smoke_realtime.sh

cd ../polyester-examples-python
pip install -e "../polyester-sdk-python"
python -m pytest -q
python3 examples/04_public_realtime_trades.py
python3 examples/05_public_orderbook_stream.py
```

Then bump the version, update `CHANGELOG.md`, build, and publish to PyPI. Install from the new wheel (not editable) and rerun `smoke_realtime.sh` once to confirm the published artifact.

## Lifecycle transaction lookups

One chain transaction can reference multiple lifecycle flows when operations
are bundled. Transaction lookups therefore return a page, not one flow:

```python
page = await client.lifecycle.list_flows_by_tx(tx_hash=tx_hash, limit=50)
flow_ids = [flow.intent_id for flow in page.flows]

while page.next_page_token:
    page = await client.lifecycle.list_flows_by_tx(
        tx_hash=tx_hash,
        limit=50,
        page_token=page.next_page_token,
    )
    flow_ids.extend(flow.intent_id for flow in page.flows)
```

`get_flow_by_tx(...)` also returns the complete first page. Use
`list_flows_by_tx(...)` when following pagination.

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## Transport

Connect RPC over HTTP via generated clients in `src/polyester/gen/`. Wire format
defaults to **binary protobuf**; pass `wire_format="json"` for debugging.

Some RPCs may return HTTP 404 on devnet. The SDK raises `PolyesterRouteNotFoundError`
with a clearer message than `[unimplemented]: Not Found`.
