About the exchange instance

This page discusses why the SDK has one constructible object, SomniaMarkets, and what that decision buys and costs. It covers ownership of connections, state, and signers. It does not cover the internals of the live watches; those are in Architecture.

One root, no globals

Many chain SDKs expose a set of free functions and a global configuration step: set the RPC once, then call anything from anywhere. This SDK does not. new SomniaMarkets(config) creates an instance, and every capability hangs off it: the exchange verbs, the engine at exchange.client, the trader at exchange.trader, the SomniaLend namespace at exchange.client.lend. Nothing that needs a connection is reachable without an instance. The root entry exports only the class, types, pure helpers, constants, and ABIs.

The reason is isolation. An instance owns its configuration, its WebSocket, its live store, its watches, its approval cache, its signer, and its local-send queue. Two instances share none of those execution resources. Viem's nonce manager is process-wide and keyed by account and chain, but serialization runs per trader: code that writes concurrently as one account should share one long-lived exchange instead of relying on separate roots to coordinate broadcast acceptance. A bot that trades on two chains constructs two exchanges. A server reuses one long-lived exchange per chain. An indexer-only request may use a short-lived instance because it never opens a socket. A test suite runs cases in parallel with one exchange each. With module-level state, each of those needs a workaround.

The cost is one line of plumbing. A React app constructs the exchange once at module scope and passes exchange.client to the provider. A script constructs it at the top. There is no configure() call to forget and no hidden singleton to reason about.

Sockets opened late

Every instance can talk to the indexer over HTTP and the chain over one WebSocket. A configured price feed is a third, independent backend: its fetches use the feed's GraphQL HTTP endpoint, and watchPrice opens a separate GraphQL WebSocket. The chain WebSocket carries log and head subscriptions for market watches, eth_call reads, and transaction sends. There is no HTTP RPC path and no polling. Concurrent chain reads pipeline on that socket, so a Promise.all of ten reads costs about one round-trip.

The chain socket opens on the first chain touch, not at construction, and the price-feed socket opens on the first price watch. close() stops active price-feed sockets but leaves the viem chain transport open. An instance that only lists markets or reads candles opens neither socket. This is deliberate: server-side code that renders a page from indexer data pays nothing for transports it does not use, and a misconfigured wsRpcUrl surfaces at the first chain call as NotConfiguredError rather than as a failed construction.

You can think of the instance as a small local node for the markets you care about. It hydrates a snapshot of a market from the indexer once, then follows the chain itself. After that the indexer is only asked for history.

The signer belongs to the instance

A signer is part of the configuration: privateKey, account, or walletClient. The instance derives walletAddress from it and uses it for every authenticated verb, read or write. fetchBalance and watchOrders are authenticated reads: they answer for the signer's address without taking an address parameter. That mirrors how exchange APIs behave, and it is why SignerRequiredError is thrown by reads as well as writes.

Browser apps rarely have a signer at boot. setSigner() exists for them: construct the exchange for public reads, bind the wallet when the user connects, and pass {} on disconnect. Watches and market data are unaffected by a signer change; only the trader is rebuilt.

An alternative would have been per-call signers, as in createOrder(symbol, …, { signer }). That design keeps the instance stateless but pushes the approval cache to the caller, and the cache needs to be per-signer to be correct. Holding the signer in the instance keeps it correct by construction. The trade-off is that one instance trades as one account; an app that trades as several accounts constructs several instances or calls setSigner between them.

Lifecycle

close() releases the watches and the streaming channels. The instance stays usable for one-shot reads, and for that reason it does not close the WebSocket to the node. Do not create and discard a chain-touching instance for each server request because each instance retains its socket. Reuse a long-lived instance instead. A Node script that has touched the chain does not exit on its own after close(); it ends with an explicit process.exit(). A server or a browser tab, which never wants to exit, is unaffected. In our view close() should also close the transport, or the SDK should offer a separate call that does. Today it does neither.

Where the design shows through

  • exchange.client is the engine, not a second client. It shares the store and the socket with the exchange, so a live book read through a hook and through watchOrderBook are the same data.
  • exchange.client.getViemClient() returns the underlying viem client for anything the SDK does not cover. It is deliberately undecorated: errors from it are viem's, not the SDK's, and it opens the socket if it is not open yet.