Docs · Crypto

Extending the Onchain Module

Adding an onchain action without breaking the boundary that makes the rest safe.

The rules, in order

  1. Never handle a private key, a seed phrase, or a signature in Ruby. The browser signs. If a task seems to require a server-held key, stop and say so: it is a different product with a different threat model, and this kit is not it.
  2. Never call an RPC or an aggregator from a controller or a model. Go through Chain.for and Swap.for, so the fake path keeps working and a fresh clone stays hermetic.
  3. All amounts are base-unit integers, serialized as decimal strings. Never a Ruby Float, never a JavaScript Number.
  4. Interactive wallet UI is a React island reading data-* values and a JSON data island, never an inline <script>. The CSP blocks inline script silently.
  5. New model gets account_id, a Pundit policy, and a tenant-isolation spec proving a foreign id 404s.
  6. Mainnet writes need ONCHAIN_ALLOW_MAINNET_WRITE and an explicit confirmation.

Adding a new chain

Copy an entry in Chain::REGISTRY, then update .env.example and Bring Your Own Keys. Touch the CSP only if the browser talks to it directly, which it should not: reads go through /onchain/rpc.

If the chain is a mainnet an aggregator supports, add it to Swap::SUPPORTED_CHAIN_IDS too. A spec asserts that list matches the registry's mainnets, so it will tell you.

Adding an action that sends a transaction

The shape, end to end:

  1. A Rails endpoint that builds the calldata and returns { to, data, value }. It holds whatever key is needed. It never signs.
  2. A function in app/javascript/onchain/api.js. That file is the only place fetch appears, which is what keeps the server contract readable in one screen. A spec enforces it.
  3. An island that calls sendTransaction with exactly what the server returned, then posts the hash to /onchain/transactions and stops.
  4. A row in islands.js. The registry is an explicit map, not a glob: an agent should see every island in one screen.
  5. Status is server-rendered. ConfirmOnchainTransactionJob watches the receipt and the onchain/_transaction partial refreshes itself in a turbo-frame. React never subscribes to anything.
  6. A CSP allowance in app/controllers/concerns/onchain_csp.rb if a new host is involved, and a spec pinning it. CSP failures are silent.

Watch for these

Never auto-fail a transaction still in the mempool. It is slow, not lost. Telling a user their money is gone and then having it arrive is worse than saying nothing. ConfirmOnchainTransactionJob only marks something dropped when the node has never heard of it.

A receipt is not settlement. On a chain that can reorg, a block can be re-mined. The job waits for confirmations, five on a mainnet.

Never fabricate calldata in a fake. Swap::Local returns transaction: nil for exactly this reason. A fake that looks executable will eventually be executed.

The boundary is a test

spec/onchain/no_client_side_chain_logic_spec.rb greps every file in the island for encodeFunctionData, parseUnits, createSiweMessage, Number(, and readContract, each with a message naming the Rails owner of that concern.

It exists because the boundary is invisible in any single file, and because the easy wrong fix for almost every future bug is "just build the message in JS". Two files are allowed to construct a transport, and only because of where they point it; a further spec asserts both read the proxy path and name no RPC provider.

If you find yourself adding to that allowlist, that is the moment to ask whether the work belongs in Ruby instead.