Chains and Reads
The Chain adapter, the RPC proxy, and why the browser never talks to a node.
The adapter
Chain.for(chain_id) follows the same seam as Billing: the real JSON-RPC client when an RPC url
is configured, and a deterministic offline stand-in otherwise.
Chain.for(8453).native_balance("0x…") # wei, as an Integer
Chain.for(8453).block_number
Chain.for(8453).eth_call(to: token, data: Onchain::Erc20.balance_of_data(holder))
Six chains ship configured in Chain::REGISTRY: Base Sepolia, Base, Ethereum, OP Mainnet, Arbitrum
One and Polygon. Adding one means copying an entry; that is the whole extension point.
Public RPC endpoints are documented but never defaulted. Each registry entry names one, and
ONCHAIN_USE_PUBLIC_RPC=1 opts in. Two reasons: a fresh clone must make no network calls, which is
what keeps bin/rspec hermetic, and the kit should not point every buyer's traffic at free
infrastructure it does not own.
The offline fake
Chain::Local derives balances deterministically from the address, so the same wallet always shows
the same numbers and a screenshot in your docs stays true between runs. Its spec asserts it makes no
network call rather than trusting that it does not.
It refuses to simulate methods it has no honest answer for. eth_estimateGas raises rather than
inventing a number, because a made-up gas figure next to a real irreversible spend is worse than an
error.
The browser reads through Rails
The React island's viem transport points at /onchain/rpc on your own origin, not at a
provider. Three things follow:
- The provider's API key stays server-side, out of DevTools.
connect-srcstays'self'for reads, so the Content Security Policy keeps meaning something whatever provider a buyer configures.- A visitor's wallet address is never handed to a third party on page load.
That endpoint's method allowlist is its entire security model. It permits reads only, and
eth_sendRawTransaction is explicitly refused: the browser sends its own transactions through the
user's wallet, and a relay that accepts them turns this into a write endpoint. Without the allowlist
it is an open relay anyone can point at your provider account and run up the bill.
The refusal names what is allowed, on purpose. A silent one costs whoever extends this module an afternoon.
Two lists govern that endpoint, and they are different sizes on purpose. Onchain.chain_ids is what
the app offers: the wallet's network switcher, the swap form, every write. Onchain.read_chain_ids
is what the proxy will serve a read for, and it adds exactly two ids, Base and Ethereum, because
identity needs them. A Basename is a contract read on Base and an ENS name is a contract read on
Ethereum, so a wallet sitting on Base Sepolia still has to reach both to show a name. Adding an id
there does not put a chain in the switcher, does not permit a write, and does not widen the method
allowlist.
Each transport carries its chain id as ?chain_id=, because a JSON-RPC body does not name a chain.
Miss that and every read is answered by the default chain -- silently, with a plausible answer from
the wrong one.
Amounts
Every amount is an integer of base units, carried as a decimal string wherever it crosses a boundary.
Onchain::Units.format(1_500_000_000_000_000_000, 18) # => "1.5"
Onchain::Units.parse("1.5", 18) # => 1500000000000000000
format is integer division and a remainder, so it stays exact all the way to 2256 - 1. Nothing
calls to_f. In the browser, nothing calls Number() or parseFloat: a uint256 does not fit in a
JavaScript Number, and a Float loses the low-order digits of a real balance silently, which is
the worst way for a money bug to behave.