The SDK is a thin typed layer over two wire protocols:
- ConnectRPC over HTTP for requests
- WebSocket for realtime: Centrifugo for most streams, a private protobuf WebSocket for account order streams
Everything else exists to make those two safe to use.
The layers
PolyesterEnvironment frozen endpoint + chain configuration
|
PolyesterClient wires everything, exposes services as lazy getters
├── Transports two ConnectRPC transports: publicApi and authApi
├── RealtimeClient one shared WebSocket multiplexer
├── ClientCatalog reference data: assets, pairs, scales, routes
└── Services orders, marketData, balances, ... (every API domain)Environment: validated, frozen description of where to connect. See Environments.
Transports: publicApi is unauthenticated. authApi attaches auth (JWT header or Ed25519
request signature) through an interceptor. An error-mapping interceptor wraps both, so RPC
failures surface as typed PolyesterErrors. Both send through the global fetch unless the
client config passes its own. The public reads tradingRateLimits.getConfig, vip.listTiers,
and the changelog methods use cacheable ConnectRPC GET; every other method uses POST.
Services: one class per API domain, exposed as getters. Built on first access and memoized, so creating a client is cheap. That matters when a server builds one per request.
RealtimeClient: one shared subscription multiplexer. Channels are refcounted. The WebSocket
engine (~300 KB) loads on the first subscription via dynamic import. orders.subscribe rides a
separate private WebSocket (/v1/ws/private) that the same client manages.
Cores and service accessors
Each client is a core plus a getter per service. PolyesterCore, PolyesterBrowserCore, and PolyesterServerCore wire the environment, transports, realtime, catalog, and auth. Every other
service lives behind an accessor in @polyester/sdk/services/*:
import { PolyesterCore, POLYESTER_TESTNET_ENVIRONMENT } from "@polyester/sdk";
import { ordersService } from "@polyester/sdk/services/orders";
const core = new PolyesterCore({ environment: POLYESTER_TESTNET_ENVIRONMENT, auth });
const { orders } = await ordersService(core).listOpen();A bundle built on a core only includes the services it calls. The full clients return the same
instances as the accessors (client.orders === ordersService(client)), so both styles can share
one client. See Client configuration.
Catalog: reference data every service uses for symbol lookups and decimal scale conversions. See Catalog & precision.
Validation at both edges
Every service method validates both ways with strict schemas.
Inputs are checked before the request is built. Unknown keys, bad enums, and excess decimal
precision throw ValidationError or CatalogConversionError locally. No network round-trip.
Outputs are parsed into plain TypeScript shapes. Scaled bigints, proto enums, and oneofs do not
leak through. You get decimal strings and string literal unions. Unknown numeric proto enum
values on read paths become "unspecified", leaving the rest of the response readable. Input
enums remain strictly validated.
If it typechecks and does not throw, what you sent is what the API received.
The decimal-string surface
On the wire: scaled integers (price ticks, per-asset quantities).
In the SDK: plain decimal strings in both directions.
- Outputs convert exactly. No rounding. Trailing zeros trimmed.
- Inputs convert strictly. Excess precision errors. Never silent rounding.
number is never used for money.
Why three clients
PolyesterClient is the whole machine. The two subclasses add an auth strategy and a subaccount resolver (the hook that defaults service calls to the user's active subaccount):
| Client | Auth strategy | Resolver default |
|---|---|---|
PolyesterClient | Whatever auth you pass (API key / JWT) | none (main account) |
PolyesterBrowserClient | Managed wallet-signer login + tokens | active account from auth state |
PolyesterServerClient | Bearer token from cookies | main, or display-session active account (opt-in) |
Bundle discipline
The root export stays lean. Heavy graphs sit behind subpaths so a market-data-only shell never bundles them:
| Subpath | Isolated weight |
|---|---|
@polyester/sdk/account-signer | viem ABI / typed-data for Safe signature derivation |
@polyester/sdk/smart-account | permissionless + bundler/paymaster clients |
@polyester/sdk/catalogs | snapshot builders (types are free from the root) |
@polyester/sdk/server-session | cookie/session parsing for servers |
@polyester/sdk/errors | error classes without the rest of the client graph |
@polyester/sdk/services/* | one service each, for bundles built on a core |
Realtime follows the same idea at runtime with dynamic import. Structured error-detail decoders
and API-key signing also load on demand. SDK transports load the decoders before mapping an
error; call loadErrorDetailDecoders(error) before synchronously mapping a raw ConnectError from your own transport, or loadErrorDetailDecoders() to preload all of them.
Extension points
interceptors: ConnectRPC interceptors on every request (logging, tracing, mock headers)wireFormat:"binary"(default) or"json"for readable debuggingfetch: send SDK HTTP requests through a custom fetch implementationrealtime: override WebSocket auth header derivationcatalog/catalogSnapshot/catalogCell: control reference-data storage, hydration, and reactivity