For Developers
Build your own implementation
The barter.game protocol is intentionally small. You can read the full contract in an afternoon. The reference implementation is TypeScript on AWS (Lambda + DynamoDB + CloudFront), but the protocol does not care what language or stack you use.
The invariant contract
Read the protocol/ spec first. It defines:
- The document types: Voucher, Account, Order, Mandate, Record, Offer, Signature, Address, Post — Records are bank-minted and identified by ULID; posts, reposts, and media refs are specified in
protocol/post-feed.md - RFC 8785 canonical JSON (cross-runtime parity is load-bearing)
- ed25519 signatures over SHA-256 hashes
- The JSON-RPC envelope and replay protection
- Coordinator-orchestrated approval: each holder signs their own Order (
lead/follow), a coordinator creates the records and clears each Order with a signed Mandate, and the banks self-advance with a lead-first settle cascade - The state machine (created → approved → held → settled / rejected)
- The concurrency invariants (sum-to-zero, one active hold per account)
Everything in that file is the contract. Change it and you are no longer speaking barter.game v1.
What you CAN change
| Layer | Reference choice | Your choice |
|---|---|---|
| Language | TypeScript | Rust, Go, Python, Zig, whatever |
| Runtime | Node.js (AWS Lambda) | Bun, Rust Axum, Go net/http, Python FastAPI |
| Database | DynamoDB single-table | SQLite, Postgres, CockroachDB, custom WAL |
| Client | Web UI (browser SPA, apps/web) | CLI, mobile app, Telegram bot, AI agent loop |
| Key storage | Plaintext JSON | Encrypted keystore, hardware wallet, OS keychain |
| Inbox | 10s polling | WebSocket, SSE, push, email |
| Hosting | AWS (Lambda + CloudFront) | VPS, Fly, Cloudflare, home server |
See the reference implementation notes for how the reference team made each choice and what alternatives you might consider.
Quickstart checklist
If you’re building a bank from scratch:
- Implement RFC 8785 canonical JSON. Verify cross-runtime parity.
- Implement ed25519 sign/verify and SHA-256. Use audited libraries.
- Define the doc types and their validators.
- Build the JSON-RPC envelope handler with replay protection.
- Implement
submit_docs,create_records,submit_mandate,notify_signatures,get_record_signatures, and the read methods (get_voucher,get_account_balance,list_accounts,list_offers,list_vouchers,get_offer,get_invoice,get_cheque,list_posts,get_post,get_post_signatures,get_voucher_meta, …). - Implement the advance engine: records self-advance created → approved → held → settled, evaluated on every incoming signature.
- Enforce sum-to-zero on every settle.
- Enforce at most one active hold per account.
- Expose
GET /<name>/barter-bank.json(andGET /<name>/address/<pubkey>if you want an address directory). - Write a client that can run a trade end-to-end (publish Orders → discover Offers → create records → mandate → banks settle), bilateral or N-party.
- Run it against the reference banks to verify interop.
The protocol library
The reference packages/protocol/ is MIT-licensed, dependency-light, and a single source file — src/index.ts. It runs under Bun, Node, and browser. You can import it directly or treat it as the spec to port:
Piece (all in src/index.ts) | What to port |
|---|---|
| Canonicalizer | RFC 8785 canonical JSON. Must be byte-identical. |
| Crypto | ed25519 + SHA-256 + base58. Thin wrappers; easy to replicate. |
| Doc types + validators | Voucher, Account, Order, Mandate, Record, Offer, Signature, Address, Post. Replicate in your type system of choice. |
Building a bank
A practical walkthrough of 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/Node. 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 are validated against their embedded tree and media refs (see §9). - 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.
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. Address reads and media blobs (§9) are the only surfaces outside RPC, so anyone can resolve a pubkey or fetch a blob with a bare GET.
9. Serve the media vault and carry posts
Voucher artwork and post media travel as content-addressed refs of the form <hash>.<ext>, stored in a per-bank media vault (protocol/bank-rpc.md, protocol/post-feed.md). Two REST endpoints carry it:
| Method | Path | Purpose |
|---|---|---|
GET | /<name>/media/<hash>.<ext> | Serve a vault blob. Unauthenticated — whoever knows the ref may fetch. Verify the bytes hash to <hash> before serving; derive Content-Type from the extension; respond with immutable caching, X-Content-Type-Options: nosniff, and a sandboxing Content-Security-Policy so a served SVG renders as an image but can never run script on your origin. |
POST | /<name>/media | Authenticated upload. The upload must resolve to a known image extension (svg, png, jpg, jpeg, webp, gif) — a caller-chosen Content-Type never reaches storage. |
On intake (submit_docs), a Post is a first-class doc: verify the signatures of embedded reply_to/repost docs, enforce that only a voucher’s issuer may attach voucher_meta releases, and refuse any post whose embedded tree references a blob you don’t store — cross-bank reposts must copy their blobs into your vault first. The protocol validators cap a post at 12 media refs and a voucher at 8 images. Serve the feed reads: list_posts, get_post, get_post_signatures, get_voucher_meta. The reference bank additionally rebroadcasts every accepted user post as a bank-signed repost — a policy choice, not a protocol requirement.
10. 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 (
get_account_balance/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.
11. 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) |
| Bank host (wires storage into the shared engine): Lambda adapter + local Node server | apps/bank-aws/src/index.ts, local-server.ts |
Storage seam (KvStore / MediaStore) | packages/bank-core/src/kv.ts, media.ts |
| Env var key loader | packages/bank-core/src/env.ts |
| RPC envelope handler | packages/bank-core/src/rpc.ts |
| Bank method registry | packages/bank-core/src/registry.ts |
| Per-method handlers | packages/bank-core/src/handlers/*.ts |
| Storage layer (KV-shaped) | packages/bank-core/src/db.ts |
| Advance engine | packages/bank-core/src/advance.ts |
| Web UI serving | packages/bank-core/src/ui.ts |
| Browser client | apps/web/app.js |