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.
import { closeAllRpcClients } from "wative-core";
// … your process makes RPC calls through the library …
await closeAllRpcClients(); // call once, when the process is done with RPCWhy 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)Connectionper 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/v1andhttps://rpc.x.com/v1share one client; - elides a default port —
https://x.com:443/pcollapses tohttps://x.com/p; - resolves
..segments and supplies a canonical trailing slash for an empty path; - strips the fragment —
/v1#primaryand/v1#backupare 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.
The cache key is the whole canonical URL — including the path, and any userinfo. That path may carry a provider API key, and the userinfo may carry basic-auth credentials (https://user:pass@host/…). The cached entry keeps that credential-bearing URL in memory for as long as the process lives. This is why an explicit eviction exists: closeAllRpcClients() clears both maps so those URLs are dropped.
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
Connectionmap, releasing the retained (possibly credential-bearing) endpoint URLs; - returns a
Promise<void>—awaitit 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.
What actually accumulates is one client and one Connection per distinct endpoint, retained for the process lifetime. In this library every Solana call is plain HTTP, so no websocket or timer is left running — the reason to evict is credential retention in the cache key, not a leaked socket or handle.
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:
| Scope | What tears it down |
|---|---|
| One workspace's in-memory state (keys, unlocked containers) | Workspace.lock() / Workspace.close() |
| The process-global RPC client caches | closeAllRpcClients() |
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.
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)
| Method | Chain | Returns |
|---|---|---|
readBalance(owner, token?, opts?) | both | Promise<bigint> — native (token omitted / null) or token balance, raw base units |
getBlockNumber(opts?) | both | Promise<number> — EVM block number / SVM slot height |
getGasPrice(opts?) | EVM | Promise<bigint> — wei |
getTransactionCount(address, block?, opts?) | EVM | Promise<number> — nonce ("latest" / "pending") |
getCode(address, opts?) | EVM | Promise<string> — "0x" for an EOA; a 0xef0100… designator for a 7702 EOA |
readContract(req, opts?) | EVM | Promise<T> — decoded eth_call view read; integers as bigint (see below) |
getTransactionReceipt(hash, opts?) | EVM | receipt, or null until mined |
getRecentBlockhash(opts?) | SVM | Promise<{ blockhash, lastValidBlockHeight }> |
getAccountInfo(address, opts?) | SVM | parsed account info, or null if absent |
getSignatureStatus(signature, opts?) | SVM | status, or null if unknown |
resolveAddressLookupTables(addresses, opts?) | SVM | Promise<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.
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 shapeContract.decodeproduces. A single-output function is unwrapped to a scalar, a genuine multi-output function resolves an array, and a function with no outputs resolvesundefined. - Throws
PROVIDER_IOwhen 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 (aRawTxor its serialized string) and resolve aSimulationResult(it never throws for a failed simulation). EVM requiresopts.from(a simulation runs as an account); SVM ignores it.checkDestination(to, opts?)— the keyless, network-level destination guard; returns aDestinationReport(only a malformed address throws). UnlikeAddress.checkDestinationit cannot resolve a{ symbol }asset ref (no asset registry), so a token transfer passesopts.tokenAddressdirectly.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/rpcUrlonly choose which single endpoint it targets. EVM takes a0x-hex string; SVM takes raw bytes or a base64 string.