Building a Bank
This page is a practical companion to the protocol contract. It walks through the decisions you need to make when building a barter.game bank from scratch.
1. Choose your stack
The protocol only requires:
- ed25519 sign/verify
- SHA-256
- RFC 8785 canonical JSON
- An HTTP server that can hold a private key
- Storage that enforces two invariants: sum-to-zero and one-active-hold-per-account
Everything else is up to you.
2. Implement canonical JSON first
This is the most dangerous piece to get wrong. If your canonicalizer produces different bytes than the reference implementation, every signature becomes unverifiable across implementations.
Golden rule: Sort object keys by Unicode code-unit order. Serialize numbers via ECMAScript ToString(Number). Escape ", \, and control characters. Drop undefined keys.
Test: Canonicalize the same document under your runtime and under Bun/Deno. Assert the hashes match. The reference repo has golden vectors you can copy.
3. Set up your key model
A bank is an ed25519 keypair. The private key must be:
- Loaded at startup.
- Never exposed to clients.
- Used to sign all RPC responses and
/<name>/barter-bank.json.
The pubkey is the bank’s identity. Every RPC envelope has a to field that must match this pubkey; reject if it doesn’t.
4. Build the RPC envelope
All requests are POST /<name>/rpc with this shape:
{
"jsonrpc": "2.0",
"id": "<ulid>",
"method": "<name>",
"params": { ... },
"pubkey": "<sender>",
"to": "<your-bank-pubkey>",
"sig": "<base58-sig>"
}Before dispatching to a method handler:
- Verify the envelope is well-formed.
- Verify
tomatches your bank pubkey. - Verify
sigis a valid ed25519 signature overcanonical(envelope minus sig). - Verify
idhas not been seen before (replay window). - Only then, call the method handler.
5. Implement the method handlers
Start with read-only methods (get_voucher, get_account_balance, list_accounts, get_record_signatures) — they’re simple and let you test your envelope.
One rule that applies everywhere: there is no open_account. Accounts arrive as signed docs through submit_docs, and the bank stores each one by hash on first sight after verifying the holder’s signature (a Voucher must reference this bank; an Account’s voucher must be issued here). Account names stay private to the holder, and balances are private by default.
Then implement the trade path in order:
submit_docs
- One intake method for every holder-signed doc. Route by
type: Vouchers must havepubkey == bank; Accounts must reference a known Voucher and be signed by the holder; Orders must reference known Accounts owned by the same holder and carry a valid, positiverate; Addresses update the address directory if newer by ULID; Posts per bank policy. - Optionally derive and publish a discovery Offer for any Order hash listed in
publish_offers— the Offer copies the Order’s terms while hiding the holder’s identity and account hashes. - Return the hashes of stored docs and any derived Offers.
- There is no
mint. Issuers begin trading by submitting an Order that debits their own issuer account — the issuer is the one holder allowed to go negative.
create_records
- Coordinator-only: this is the sole way records come into existence.
- Params:
giverandreceiver— hashes of holder-signed Orders already stored viasubmit_docs— plusamount,counter_amount, and adeal_id. - Mint exactly one debit/credit record pair for this bank’s voucher, with your own ULIDs;
pairlinks the two halves. Sealdeal_idand the sender’s pubkey (the coordinator) into each record’sRecordDetails— only that coordinator’s Mandate can advance them later. - Validate: both Orders resolve and name this bank’s voucher on the right sides;
amountsits inside both Orders’min/max; the caller’scounter_amountis bank-asserted against both Orders’ foreign-side windows and rates — never trusted. - Idempotent on
(deal_id, giver, receiver); same key with different amounts is rejected. State:created. Return the bodies.
submit_mandate
- Params: one coordinator-signed
Mandate— the unit of work for one (Order, bank) — plus all the record bodies it lists (every record satisfying that Order across all banks). - Verify the coordinator’s signature and
mandate.bank == you. Resolvemandate.orderto a stored Order. - Local records must have been created for
mandate.deal_idwithdetails.coordinator == mandate.pubkeyandRecord.order == mandate.order. Foreign bodies must hash to their listed values and be signed by their minting banks. - Verify local completeness (every record you minted for this deal+order is listed), then validate the Order’s conditions — per-record bounds, cumulative and account limits, and the rate over the full local+foreign set.
- Reject duplicate
(deal_id, order)Mandates. Only after a valid Mandate may records leavecreated.
subscribe
- Optional. Store the Subscription doc (subscriber pubkey = sender) plus one watch row per key (
record,holder, orvoucher). - On every matching signature the bank creates or receives, POST a bank-signed
notify_signaturesenvelope to the subscriber’s URL. Fire-and-forget: a lost push is unstuck by relay. Banks never rely on subscriptions to settle.
notify_signatures
- Verify each pushed signature (known pubkey, valid sig), store the valid ones, then run the advance engine for every deal they touch. Invalid entries are skipped, not fatal.
- This single method serves both topologies: direct bank-to-bank delivery (the reference default — banks find each other via
Order.bankand the Address registry) and manual relay by any party.
The advance engine (not an RPC)
Banks self-advance — there is no client hold or settle call. After every submit_docs, submit_mandate, and notify_signatures, re-evaluate the affected records:
created→approvedonce every owned record has a valid Order bound and the Mandate for its Order has arrived. Issue areadysignature per record after checking free balance (issuers may go negative on their own Orders); fan out.approved→held. Lead (the authorizing Order haslead: true): hold once every record in the deal isready. Follow: hold only after verifying the lead’sholdsignature, whoseseenmust contain yourreadyhashes. Acquire holds on owned debit accounts, signhold, fan out. On hold conflict, leave state unchanged; the next event retries.held→settled. Lead: settle once every record in the deal is held and the follow holds cite your hashes. Follow: settle once the lead’ssettlesignature arrives, citing yourholdhashes inSignature.seen.- Settle = apply deltas (enforcing sum-to-zero), release holds, sign
settle, fan out. Idempotent. - Reject: banks — never holders or coordinators — issue a
rejectsignature on a record whose precondition failure is permanent (side/account mismatch, amount out of bounds, uncovered debit, limit violation). A reject cascades through the deal’s pre-settled records, releases holds, and fans out. Settled records stay settled. A bank MAY also reject a stalled deal on its own timeout to free locked accounts.
6. Enforce the invariants
Sum-to-zero
On every settle, after applying deltas, the sum of balance across all accounts for the settled Voucher must equal zero. If it doesn’t, your implementation has a bug.
One active hold per account
When the advance engine acquires holds, check for another active hold on the same account by a different deal. On conflict, leave the record state unchanged — the next incoming signature retries. Release holds on settle, reject, and sweeper cleanup.
7. Expose discovery
Implement GET /<name>/barter-bank.json:
{
"pubkey": "<base58>",
"url": "<canonical-rpc-url>",
"name": "my-bank",
"protocol_version": "barter.game/v1"
}Sign this response with your bank key so clients can verify it.
8. Add an address directory (optional but recommended)
The reference server exposes one plain REST endpoint for reads:
| Method | Path | Purpose |
|---|---|---|
GET | /<name>/address/<pubkey> | Look up the newest stored Address doc |
Address docs map a pubkey to a human-readable name and a callable URL; peer banks use them to call each other directly. Writes go through the standard submit_docs RPC — an Address is just another signed doc, updated when a newer ULID arrives. Only the read lives outside RPC, so anyone can resolve a pubkey with a bare GET.
9. Write a client and test end-to-end
You need a client that can:
- Register a keypair and submit signed Voucher, Account, and Order docs via
submit_docs(an issuer’s first Order debits the issuer account negative — that’s how vouchers come into existence; there is no mint call). - Produce and consume
barter://invite strings and discover counterparty Offers vialist_offers. - Act as coordinator: read the Order hashes from the Offers, call
create_recordson each participating bank, then send a signedMandateper Order per bank viasubmit_mandate. - Watch the banks self-advance (
list_account_records/get_record_signatures) and relay missing signatures by hand —get_record_signatureson one bank,notify_signatureson the other — when a direct bank-to-bank push got lost.
Test against the reference banks (the browser SPA each bank serves at /:bank/ui is the reference client): publish Orders → create records → mandate → watch the banks settle. If your client can trade with bank-alice and bank-bob, your implementation is interoperable.
10. Production considerations
- Backup your bank private key. Lose it and every Voucher issued by that bank becomes unverifiable.
- Rate-limit your RPC endpoint. Signed RPCs are cheap to verify but expensive to handle.
- Monitor the sum invariant. Alert if it ever drifts — it should be impossible, but bugs happen.
- Decide on a sweeper. Stuck holds happen. A cron job that releases holds older than N hours is pragmatic.
Reference file map
| Concern | Path in reference repo |
|---|---|
| Canonical JSON, crypto, doc types + validators | packages/protocol/src/index.ts (single file) |
| Deno entrypoint | apps/bank/main.ts |
| Env var key loader | apps/bank/env.ts |
| RPC envelope handler | apps/bank/rpc.ts |
| Bank method registry | apps/bank/registry.ts |
| Per-method handlers | apps/bank/handlers/*.ts |
| Deno KV database layer | apps/bank/db.ts |
| Advance engine | apps/bank/advance.ts |
| Web UI serving | apps/bank/ui.ts |
| Browser client | apps/web/app.js |