Metadata-Version: 2.5
Name: polyester-sdk
Version: 0.1.0a42
Summary: Official Python SDK for Polyester APIs.
Project-URL: Homepage, https://github.com/Fabric-Labs/polyester-sdk-python
Project-URL: Documentation, https://polyester.ai/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:** Alpha (`0.1.0a42`). 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/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).
<!-- 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.1.0a42"
```

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]"
```

## 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

async def main() -> None:
    async with AsyncPolyester(
        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:
            ticks = market.last_price.ticks if market.last_price is not None else None
            print(market.symbol, ticks)

        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** (e.g. `RLxqJGUDg92`) | `default_account_id` |

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).

`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`.

## Authentication patterns

**Recommended: explicit parameters**

```python
from polyester import AsyncPolyester

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

**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`,
and `POLYESTER_ACCOUNT_ID` 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="RLxqJGUDg92",
    default_sub_account_id="",  # main account; omit subaccount scoping
) as client:
    result = await client.orders.create(
        symbol="BNB-USDT",
        side="buy",
        order_type="limit",
        tif="gtc",
        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 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, cancel, and replace accept at most 20 items. 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 `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("BNB-USDT")
assert quote_scale is not None  # await client.wait_for_catalogs() first
result = await client.orders.create(
    symbol="BNB-USDT",
    side="buy",
    order_type="market",
    max_quote_debit=Quantity.from_quote_decimal_str(
        "25.00", quote_scale, symbol="BNB-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.ai/developer-docs/shared-concepts/market-order-price-protection)
before overriding market slippage.

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 1e6). After catalog hydration, order and trigger writes preflight the
advertised tick size, step size, minimum base quantity, and minimum quote
notional when both quantity and execution price are available. These
deterministic checks do not replace `orders.preview_order(...)`, which remains
the authority for balances, reservations, liquidity, and other stateful rules.

Inspect the immutable rules with:

```python
rules = client.catalogs.pair_constraints_for_symbol("BTC-USDT")
assert rules is not None
print(rules.tick_size, rules.step_size, rules.min_qty_base, rules.min_notional_quote)
```

### For bots (scaled integers)

Stay in integer space; no string round-trip:

```python
from polyester import Price, Quantity

result = await client.orders.create(
    symbol="BNB-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),            # 100.000000 at 1e6
    post_only=True,
)
# Reads expose the same types: order.price.ticks, order.orig_qty.scaled
```

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`.


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.

## 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`. Raw
symbol filters in market overview, order cleanup/history, and trigger list
paths are checked against the hydrated spot catalog; unknown non-empty filters
fail locally instead of broadening the request.

Unknown values raise `ValueError` (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). `Order` also
exposes `post_only`.

`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).

## 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_TESTNET_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_TESTNET_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_TESTNET_ENVIRONMENT.contracts.zipper_endpoint_address,
)
withdraw = encode_funding_withdraw_to_chain(
    funding_account=POLYESTER_TESTNET_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="BNB-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
```

Explicit list and heatmap limits must be integers from 1 through 1000. APIs
whose limit is optional preserve the server default when it is omitted.
Response fields named `ts_ns` are epoch nanoseconds; millisecond-shaped values
are rejected with `PolyesterResponseContractError`.

`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. 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.

## 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="RLxqJGUDg92",
) 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

# 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`.
