// viem public client builder. Each createClient() builds one WebSocket client // (lazily, on first chain I/O) and shares it across its tail subscriptions and // every on-chain read/write. WebSocket is the ONLY transport — subscriptions // (newHeads, logs) push, reads pipeline concurrently on the socket, and writes // confirm server-side via realtime_sendRawTransaction. Nothing polls and // nothing falls back to HTTP: a broken socket surfaces as an ERROR (bounded by // the request timeout below), which the live tail answers by reconnecting with // backoff — it is never papered over by a slower transport. // // No request batching is set: viem's webSocket transport has no batch option // (only http does), and it isn't needed — the persistent socket pipelines // concurrent requests (the `Promise.all` reads in getMarketOnchain / // getBinaryOrderBook overlap on the wire rather than serializing). import { ChainDoesNotSupportContract, createPublicClient, hexToBigInt, type Hex, isHex, webSocket, type Chain, type PublicClient, type WebSocketTransport, } from "viem"; import * as Revert from "./revert.js"; // Bounded WebSocket request timeout. viem's default (10s) lets a stalled socket // sit for a long time, and a socket that connects but never answers would hang // a request/response read forever. A short timeout turns that stall into an // error that propagates to the caller (and trips the tail's reconnect). const WS_REQUEST_TIMEOUT_MS = 4_000; /** Chain client over the WebSocket transport, so `transport.subscribe` is typed. * {@link makePublicClient} builds no other transport. */ export type SocketClient = PublicClient; /** * The two views of one chain connection: `decorated` for the SDK's own reads, * `raw` for a caller who wants viem's semantics. * * **Details** * * Both ride ONE WebSocket. `decorated` is `{ ...raw, readContract, call }` — a * spread copy — so it shares `raw`'s `transport` and `uid` and issues requests * down the same socket. Handing out `raw` therefore costs no extra connection. * * **Gotchas** * * Which one a caller holds decides what a reverting read throws: `decorated` * gives a typed {@link ContractRevertError}, `raw` gives viem's own error * untouched. Every read INSIDE the SDK must go through `decorated` — see * `createClient`'s `getClient()`, which is the only internal door. */ export interface ChainClients { /** Undecorated viem client — viem's error contract, verbatim. */ readonly raw: SocketClient; /** The SDK's client: `readContract` / `call` rethrow as typed SDK errors. */ readonly decorated: PublicClient; } /** * Build the WebSocket chain connection for a chain + RPC, in both views. * * **Details** * * `readContract` and `call` are wrapped on the `decorated` view so a failing * chain read reaches callers as one of OUR typed errors — a decoded * {@link ContractRevertError} when the contract rejected the call, an * {@link RpcError} when the request never got an answer. This is the read-side * counterpart of the write path's funnel in trade.ts, and doing it at the ONE * place the client is constructed covers every chain read in the SDK (the * concept modules' point reads, oracleHub.ts, system.ts, operatorReads.ts — * ~83 call sites) without a wrap at each one. * * Returns {@link ChainClients} rather than one client because the SDK owes * callers an undecorated escape hatch (`client.getViemClient()`) that does not * open a second socket. `raw` is that client; it is NOT for internal use. * * **Gotchas** * * Only the error's TYPE changes here, never the control flow. Chain reads keep * throwing rather than returning `null` — see CONVENTIONS.md "Return + error * contract". Modules that deliberately swallow a read (the `getErc20Decimals` * fallback, for one) still catch and still fall back: a typed error is caught * by the same `catch`. */ export function makePublicClient(chain: Chain, wsRpcUrl: string): ChainClients { const raw = createPublicClient({ chain, transport: webSocket(wsRpcUrl, { timeout: WS_REQUEST_TIMEOUT_MS }), }); return { raw, decorated: withTypedReadErrors(raw) }; } /** * Decorates a public client so contract reads reject with SDK errors. * * **When to use** * * Use to give a caller-supplied `config.publicClient` the same typed-error * treatment {@link makePublicClient} builds in. Kept separate for exactly that * case. * * **Details** * * Only the two methods that execute contract calls are touched; everything else * on the client is untouched, so this stays a thin boundary rather than a * re-implementation. * * Returns a NEW client and never mutates the argument. That matters because * `createTrader({ publicClient })` passes an object the CALLER owns and may use * elsewhere (a shared app-wide viem client, say) — decorating it in place would * reach outside this SDK and change how that caller's own reads throw. A viem * client is a plain `Object.prototype` object of data properties, so the spread * copy is faithful; there are no accessors or symbol keys to lose. * * **Gotchas** * * Because it returns a new client, the return value is the decorated one — a * caller that ignores it and keeps using the argument gets raw viem errors. * Applying this twice is harmless: {@link Revert.toSdkError} returns an * already-SDK error untouched, so a re-decorated client does not nest errors. */ export function withTypedReadErrors(client: PublicClient): PublicClient { // Wrap only the methods this client actually has. A caller can pass any // viem-compatible client — and the SDK's own tests pass deliberately minimal // stubs — so assuming a full PublicClient here would turn a partial client // from "works for the reads it supports" into a TypeError at construction. const decorated: Record = {}; if (typeof client.readContract === "function") { const readContract = client.readContract.bind(client); decorated.readContract = async (args: Parameters[0]) => { try { return await readContract(args); } catch (e) { const { address, functionName } = args as { address?: string; functionName?: string }; throw Revert.toSdkError(e, `readContract ${functionName ?? "?"}`, { address, functionName }); } }; } if (typeof client.call === "function") { const call = client.call.bind(client); decorated.call = async (args: Parameters[0]) => { try { return await call(args); } catch (e) { throw Revert.toSdkError(e, "eth_call", { address: (args as { to?: string }).to }); } }; } // `multicall` needs its own wrapper rather than inheriting the one above. Viem binds // client.multicall to the RAW client when the client is created, so the aggregate3 it // issues internally resolves the raw readContract — the decorated one here is never // consulted. Without this, a revert read through multicall escapes as a bare viem // error while the identical read through readContract throws a typed SDK error. if (typeof client.multicall === "function") { const multicall = client.multicall.bind(client); decorated.multicall = async (args: Parameters[0]) => { try { return await multicall(args); } catch (e) { // A chain that cannot serve Multicall3 (none declared, or declared at a block // newer than the one being read) is a CAPABILITY answer, not a failed RPC. // Callers switch on it to fall back to individual reads, so it must keep its // class — flattening it into RpcError here would make that check unreachable. if (e instanceof ChainDoesNotSupportContract) throw e; throw Revert.toSdkError(e, "multicall", { address: (args as { contracts?: { address?: string }[] }).contracts?.[0]?.address, }); } }; } return { ...client, ...decorated }; } // ---- chain heads: the newHeads subscription ---- // // This lives at the transport boundary, not in the live plane that consumes it // (SDK-IO-001). `liveTail` receives {@link subscribeChainHeads} already bound to // its client and never sees a transport: the socket, the frame shape, the // normalization and the release lifecycle are all owned here. /** The height and time the tail takes from a chain head. */ export interface ChainHead { number: bigint; timestamp: bigint; } /** True for a hex string that actually carries a quantity. `isHex` alone is not * enough: it accepts the empty quantity `"0x"`, which `hexToBigInt` throws on. */ function isHexQuantity(v: unknown): v is Hex { return isHex(v) && v.length > 2; } /** Height and time from a `newHeads` frame — null unless BOTH arrive as hex * quantities. All-or-nothing: a frame missing either field, or carrying one that * is not hex or is the empty quantity `"0x"`, is rejected whole rather than * half-read, because a head with only one of the two is no use to the tail. * Total: a malformed frame is contained here, never thrown at the subscription * callback. * @internal Exported for tests. */ export function headOfFrame(result: unknown): ChainHead | null { if (typeof result !== "object" || result === null) return null; const height = Reflect.get(result, "number"); const timestamp = Reflect.get(result, "timestamp"); if (!isHexQuantity(height) || !isHexQuantity(timestamp)) return null; return { number: hexToBigInt(height), timestamp: hexToBigInt(timestamp) }; } /** Starts a `newHeads` stream and returns its release. See {@link subscribeChainHeads}. */ export type ChainHeadSubscriber = (onHead: (head: ChainHead | undefined) => void, onError: () => void) => () => void; /** * Subscribe to `newHeads`, delivering each pushed header already parsed. The * returned function releases the stream. * * **Details** * * The head comes from the pushed frame itself. viem's `watchBlocks` answers a * frame by re-fetching the very block it was handed, which costs a round-trip * per block and returns nothing the frame did not already carry. * * `onHead` receives `undefined` for a frame that could not be read. That is * deliberately still a delivery: an unreadable frame proves the stream is alive, * which is what the caller's stall detection needs to know. * * **Gotchas** * * The socket opens asynchronously, so a release can land before the subscription * exists; it is remembered and applied on arrival. Every continuation is gated on * that release (SDK-OWN-004) — a released stream never calls back, so a frame * already queued cannot revive a stopped caller and a late rejection cannot fire * at whatever replaced it. * * `onError` fires AT MOST ONCE. A failed `eth_subscribe` reaches both the * transport callback and the rejected promise, which is one failure arriving * twice, not two failures; a caller that reconnects or notifies subscribers on * it must not do so twice for the same dead stream. */ export function subscribeChainHeads( client: SocketClient, onHead: (head: ChainHead | undefined) => void, onError: () => void, ): () => void { let active = true; let failed = false; let release: (() => void) | null = null; // The one door for both failure paths: released streams stay silent, and one // dead stream reports once however many ways viem tells us it died. const fail = (): void => { if (!active || failed) return; failed = true; onError(); }; void client.transport .subscribe({ params: ["newHeads"], onData: (data) => { if (!active) return; onHead(headOfFrame(data.result) ?? undefined); }, onError: fail, }) .then(({ unsubscribe }) => { if (!active) { void unsubscribe(); return; } release = () => void unsubscribe(); }) .catch(fail); return () => { active = false; release?.(); release = null; }; }