Keelchain
Docs / Building a client

Building a client on Keel

A client is any service that signs actions for its users or holds a role on the chain: an exchange, a marketplace, a payments desk, a bot. This guide is the integration contract: how a client runs its own ledger and custody next to Keel, turns Keel on one network at a time, and reconciles everything by transaction id. models.md explains the two models (custodial client with its own vaults, P2P client on Keel Wallet); this page is the mechanics, and it is the same for both.

What is stable

https://testnet.keelchain.com/rpc/v1 (node) and /api/v1 (indexer) are the contract. Within v1 routes and fields are only added, never removed or renamed; a deprecation is announced thirty days ahead in the changelog and kept working meanwhile. The reference pages are rpc, api and ws under keelchain.com/docs, and @keelchain/sdk on npm tracks them.

Accounts and roles

The per-network switch

Turn Keel on one external network at a time. The switch lives in the client: when bitcoin is on, new deposit addresses, new withdrawals and new escrow for Bitcoin go through Keel; everything that started before stays on the old custody path until it completes; nothing is moved between the two. The client's ledger stays the client's; Keel's balances are the Keel side of it.

Before turning a network on, and continuously afterwards, read

GET /rpc/v1/ready/{BTC|ETH|TRON}

It answers ready: true only when the network's vault is registered, the light-client checkpoint is fresh (Bitcoin), nothing is halted, and it lists the reasons otherwise, plus the last deposit credited, the pending outbounds and the fee rate. A client flips its switch off when ready goes false and back on when it recovers; no funds are at risk in between, only new operations wait.

Checklist for one network:

  1. ready is true.
  2. The operator account has budget (locked or bought KEEL).
  3. One deposit address issued for a test account (RequestDepositAddress), and a small deposit credited (deposits:<chain>:<addr> on the socket, or GET /v1/vaults/deposits?owner=).
  4. One small withdrawal to an address the client controls, confirmed (WithdrawalQueued โ†’ OutboundBatched โ†’ OutboundConfirmed).
  5. For a P2P client: one escrow round trip (StartTrade, MarkPaid, ReleaseTrade).
  6. The reconciliation job below has run clean for a day.

Custodial clients: your own vault

A client that keeps its own keys (docs/models.md, Model A) uses the custody actions instead of the network vault: RegisterCustodyVault once per chain (public key, chain code, signer URL), RequestCustodyAddress per account, WithdrawCustody per payout. The observers attest deposits on the client's addresses like any other; the client's signer signs its batches after the same policy check the network signers run. Balances land in the custody account type, separate from network-backed balances. GET /v1/custody/{addr} is the client's reserve versus liabilities per asset; the per-network switch applies the same way (enable a network once its custody vault is registered and /v1/ready/{chain} is green for deposits).

To let a custody balance trade on the chain's book or escrow, swap it against your own floats with SwapCustody (to: "deposit"), and swap it back (to: "custody") when the user is done: one posting, no external transaction, both reserve checks untouched (models.md, "The float swap"). Keep a deposit float of your own for the way in and a custody float (index-0 top-ups) for the way out; GET /v1/custody/{addr} reports both as floats[], and the CustodySwapped event on account:<addr> is what your reconciliation ingests. Rebalance the floats with an ordinary WithdrawCustody to your own network deposit address, or a Withdraw to your vault's index-0 address.

To move your platform to a fresh vault (a labelled pool, a rebrand), start a second signer for a new key and RotateCustodyVault (models.md, "Rotating to a fresh pool"). Nothing on your side changes except the addresses you show: fetch a user's deposit address from GET /v1/custody/{you}/{chain}/addresses?owner= at deposit time, never from a cache older than the session, and subscribe to PoolRotated on account:<you> to refresh what is on screen. Old addresses keep crediting during the grace, so a user mid-deposit is safe; a reverse lookup of any live generation's address is GET /v1/custody/{you}/{chain}/addresses/lookup?address=. Keep the old signer reachable until GET /v1/custody/{you} shows that generation Retired.

Naming your dispute team

Disputes on your users' trades are ruled by whoever the offer names. Bond your dispute team's address as an arbitrator (Bond { role: Arbitrator }, at least min_arbitrator_bond; no election needed), then have every offer your site creates name it with SetOfferArbitrators (one address binds at StartTrade; several let the taker choose with ChooseArbitrator before MarkPaid, else the first applies). Takers accept the arbitrator by taking the offer, so show it next to the price. When your users take other makers' offers, filter on arbitrators[] of GET /v1/offers to decide which arbitrators you accept; an offer that names nobody is ruled by the chain's elected set. Your team rules with RuleDispute on GET /v1/trades/{id} whose arbitrator is its address; the TradeArbitratorBound event on account:<arbitrator> tells it which trades it holds. Governance can ban an arbitrator chain-wide; its bond is what it has to lose.

Idempotency and reconciliation

Every signed action carries the signer's nonce; the chain applies each nonce once and in order, so the nonce is the idempotency key. A client keeps a table

client_id -> (signer, nonce, tx_id, status)

and computes tx_id locally before submitting (txId(signedAction) in the SDK, the hash of the signed envelope), so a crashed process can look the receipt up (GET /v1/receipts/{tx_id}, or the indexer's /v1/txs/{tx_id} for anything older than the node's recent window) instead of signing again. A duplicate submit of the same signed bytes is rejected as a nonce reuse and is harmless. Free-text memo fields on transfers are for people, not for matching.

Reconciliation: once a day, for every Keel account the client manages, compare GET /v1/accounts/{addr} balances (deposit, escrow, sendout escrow) with the client's ledger, and every outbound of the day with the indexer's /v1/vaults/outbounds. The explorer shows the same numbers to the client's users.

Streams

Subscribe rather than poll for anything time-sensitive: account:<addr> for a user's receipts, deposits:<chain>:<addr> for credits, pair: and book: for markets. Every frame has a sequence number and the server tells a client when it fell behind (gap); the SDK's KeelSocket reconnects and replays from the last height. Details in the ws reference.

Signing

Pricing

Everything Keel charges is paid in KEEL, bought on the chain's own order book with KUSD, USDT, BTC or TRX; there is no invoice.

Testnet policy

The testnet resets when the genesis has to change (a validator set change, a schema change that cannot migrate). Resets are announced in the changelog and re-onboard every client listed with the workflow; balances are testnet balances. GET /rpc/v1/status carries the chain id and the state hash a client can pin.

Getting help

[email protected] for roles, API access and partnerships; [email protected] for everything else.