RPC Client Cache

The keyless network-scoped ChainClient RPC facade (readBalance / simulate / checkDestination / broadcast, no key), and closeAllRpcClients() — the only way to evict the process-global RPC client caches (which may hold API keys or basic-auth in their URLs), deliberately separate from Workspace.close().

Every outbound RPC call — reading a balance, simulating, broadcasting, polling a tracker — goes through a cached client. The library keeps one client per distinct endpoint URL, process-wide, and reuses it for the life of the process. closeAllRpcClients() is the one function that empties those caches.

shutdown.ts
import { closeAllRpcClients } from "wative-core";

// … your process makes RPC calls through the library …

await closeAllRpcClients(); // call once, when the process is done with RPC

Why the cache exists

Opening a fresh JSON-RPC client for every call would be wasteful, so the library memoizes. Two module-global maps hold the cached clients:

  • an EVM HTTP JSON-RPC client per endpoint, and
  • a Solana (@solana/web3.js) Connection per endpoint.

The maps are populated automatically the first time the library talks to a given network. You never create these clients yourself and there is no per-call setup cost after the first — that is the point of the cache.

What it keys on

The cache key is the canonical form of the network's rpcUrl, not the raw string. Canonicalization runs the URL through new URL(...).toString(), which:

  • lowercases the scheme and host — https://RPC.X.com/v1 and https://rpc.x.com/v1 share one client;
  • elides a default port — https://x.com:443/p collapses to https://x.com/p;
  • resolves .. segments and supplies a canonical trailing slash for an empty path;
  • strips the fragment — /v1#primary and /v1#backup are one endpoint.

It does not collapse a doubled slash inside the path, so https://x.com/a//b stays distinct from https://x.com/a/b. Two networks are considered "the same endpoint" exactly when they canonicalize to the same key and therefore share a cached client.

Errors and logs never echo the raw URL — the library redacts it to scheme://host before surfacing it — but the live cache entry is the unredacted key.

When to call

Call closeAllRpcClients() once, when the process is finished making RPC calls — typically as part of shutdown. It:

  • clears the EVM client map and the Solana Connection map, releasing the retained (possibly credential-bearing) endpoint URLs;
  • returns a Promise<void> — await it so any release completes before exit;
  • releases every cached client of every kind, EVM and Solana alike.

It is safe to call when the caches are already empty (a fresh process that made no calls) — it simply resolves.

Why it is not wired into close()

The caches are process-global and shared by every workspace. closeAllRpcClients() is deliberately not called by Workspace.close() (nor by lock()), because a per-workspace teardown that emptied a shared cache would evict clients another workspace in the same process is still using.

So the two responsibilities are kept separate:

ScopeWhat tears it down
One workspace's in-memory state (keys, unlocked containers)Workspace.lock() / Workspace.close()
The process-global RPC client cachescloseAllRpcClients()

If your process opens and closes many workspaces over its lifetime, keep letting the RPC cache serve all of them, and call closeAllRpcClients() exactly once at the very end.

ChainClient

ChainClient is the keyless counterpart to a key-bound Address: you hold it with only a Network — no account, no key — and it answers what the chain says about an arbitrary address or a pre-signed raw transaction. It composes the same per-chain RPC units the rest of the library uses (reader, estimator, block/nonce reads, broadcasters, the destination guard) and adds no RPC logic of its own; each method dispatches on network.vm, and a method for the wrong VM throws UNSUPPORTED_OP.

chain-client.ts
import { ChainClient, Network } from "wative-core";

const client = new ChainClient(Network.Ethereum);
const bal = await client.readBalance("0x…");                 // native balance, raw wei
const nonce = await client.getTransactionCount("0x…", "pending");

Every method takes a one-time { rpcUrl } override (ChainClientOpts, top precedence).

Reads (idempotent — ride the read-pool failover)

MethodChainReturns
readBalance(owner, token?, opts?)bothPromise<bigint> — native (token omitted / null) or token balance, raw base units
getBlockNumber(opts?)bothPromise<number> — EVM block number / SVM slot height
getGasPrice(opts?)EVMPromise<bigint> — wei
getTransactionCount(address, block?, opts?)EVMPromise<number> — nonce ("latest" / "pending")
getCode(address, opts?)EVMPromise<string> — "0x" for an EOA; a 0xef0100… designator for a 7702 EOA
readContract(req, opts?)EVMPromise<T> — decoded eth_call view read; integers as bigint (see below)
getTransactionReceipt(hash, opts?)EVMreceipt, or null until mined
getRecentBlockhash(opts?)SVMPromise<{ blockhash, lastValidBlockHeight }>
getAccountInfo(address, opts?)SVMparsed account info, or null if absent
getSignatureStatus(signature, opts?)SVMstatus, or null if unknown
resolveAddressLookupTables(addresses, opts?)SVMPromise<SvmAddressLookupTable[]> (for a v0 build)

Decoded view calls — readContract

readContract is the keyless, decoded eth_call: it encodes the function and arguments against the ABI you pass, calls the node (through the read-pool failover), and ABI-decodes the result into native types — the one-call read path the interop matrix maps publicClient.readContract(...) / contract.fn() onto.

read-contract.ts
const client = new ChainClient(Network.Ethereum);

// A single-output view call is unwrapped to a scalar.
const symbol = await client.readContract<string>({
  address: usdc,
  abi: ERC20_ABI,
  functionName: "symbol",
});

// Integers come back as bigint — not the decimal-string shape Contract.decode produces.
const balance = await client.readContract<bigint>({
  address: usdc,
  abi: ERC20_ABI,
  functionName: "balanceOf",
  args: [owner],
});
  • req: ReadContractRequest — { address, abi, functionName, args? }.
  • Returns the function's decoded outputs with native types — integers as bigint, not the decimal-string shape Contract.decode produces. A single-output function is unwrapped to a scalar, a genuine multi-output function resolves an array, and a function with no outputs resolves undefined.
  • Throws PROVIDER_IO when the call returns no data — a revert, no contract code at the address, or an ABI that does not match.
  • EVM only (an SVM network throws UNSUPPORTED_OP), and it takes the same one-time { rpcUrl } override as the reads above.

Simulate, screen, broadcast

  • simulate(input, opts?: ChainClientSimulateOpts) — simulate a foreign serialized transaction (a RawTx or its serialized string) and resolve a SimulationResult (it never throws for a failed simulation). EVM requires opts.from (a simulation runs as an account); SVM ignores it.
  • checkDestination(to, opts?) — the keyless, network-level destination guard; returns a DestinationReport (only a malformed address throws). Unlike Address.checkDestination it cannot resolve a { symbol } asset ref (no asset registry), so a token transfer passes opts.tokenAddress directly.
  • broadcast(raw, opts?: ChainClientBroadcastOpts) — relay a pre-signed raw transaction and return its id/hash. ⚠️ Single-attempt: a broadcast is never retried across endpoints (double-broadcast hazard); route / rpcUrl only choose which single endpoint it targets. EVM takes a 0x-hex string; SVM takes raw bytes or a base64 string.

Last updated on