Public testnet proof
Outcome
Every other playground on this site runs against Kei.mock(), and every one of them says so under its own "what the mock proves" heading. This page is the one that uses the network. It publishes real blocks to https://testnet.keicoin.org/rpc and asserts what that node returns — which on the claims refusal paths is not what the mock returns.
Read this page before you write control flow against KeiError.code.
Run the live proof
bun install --frozen-lockfile
bun run docs/playgrounds/testnet-live.tsThis playground needs the network. It is deliberately not in the site's bun test set, which stays offline; every other playground here runs with the network unplugged.
Captured from a real run on 4 August 2026, against https://testnet.keicoin.org/rpc (node_vendor Banano V25.1, networkdev, build_info a0c91e1f, reported by the node's own version action), on kei-transaction@0.8.0:
{"kind":"testnet-live","node":"https://testnet.keicoin.org/rpc","network":"testnet","claimRoot":{"published":true,"count":2,"claimed":7,"closed":true},"refusals":{"duplicateClaim":"node-error","closedRoot":"node-error","secondAccept":"offer-taken","paymentMemo":"no-memo-yet"},"swap":{"price":3,"buyerOwnsItem":true,"buyerKei":7,"sellerKei":8}}The accounts, root, item and offer are new on every run, so the hashes differ and the summary above does not. The run exits nonzero on any failed assertion.
/**
* The one playground on this site that uses the network.
*
* Every other file in this directory runs against `Kei.mock()` and proves the
* protocol shape in one process. None of them can prove that the public node
* behaves the same way — and on one point it does not, which is why this file
* exists and why its assertions are written against what the node actually
* returns rather than what the mock returns.
*
* bun run docs/playgrounds/testnet-live.ts
*
* It creates fresh throwaway accounts, faucets them on the dev network, and
* publishes real blocks to https://testnet.keicoin.org/rpc. That node is one
* rate-limited best-effort dev node with published dev keys and no monetary
* value, so the units below are worth nothing and the run may fail for reasons
* that are not your integration's fault. It exits nonzero on any failed
* assertion.
*/
import { strict as assert } from 'node:assert'
import { Kei, KeiError, randomSeed } from 'kei-transaction'
const NODE = 'https://testnet.keicoin.org/rpc'
async function refusal(work: () => Promise<unknown>): Promise<KeiError> {
let refused: unknown
try {
await work()
} catch (error) {
refused = error
}
assert.ok(refused instanceof KeiError, 'expected a KeiError refusal')
return refused
}
const game = await Kei.server({ seed: randomSeed(), node: NODE })
assert.equal(game.network, 'testnet')
await game.faucet(50)
// ---------------------------------------------------------------------------
// A rooted claim batch, over the public URL.
// ---------------------------------------------------------------------------
const gems = await game.token.issue({
name: 'Live Gems',
symbol: 'LGEM',
decimals: 0,
maxSupply: 100,
transfer: 'open',
swap: 'off',
})
const alice = await Kei.start({ seed: randomSeed(), node: NODE })
const bob = await Kei.start({ seed: randomSeed(), node: NODE, autoClaim: false })
const drop = await gems.commit([
{ to: alice.address, amount: 7 },
{ to: bob.address, amount: 5 },
])
// The node, not the SDK, is echoing the published root back.
const published = await game.client.node.commitInfo(drop.root)
assert.equal(published?.issuer, game.address)
assert.equal(published?.count, 2)
assert.equal(published?.total, '12')
assert.equal(published?.closed, false)
const [claim] = await alice.claims.add(drop.proofFor(alice.address))
assert.equal(claim?.amount, 7)
assert.equal(await (await alice.token('LGEM', game.address)).balance(), 7)
// A second claim is refused. Note the code: the public node refuses the write
// with `node-error` and puts the reason in the message. `Kei.mock()` returns
// the granular `already-claimed` for the same attempt. Branch on `node-error`
// plus a fresh read of ledger state, never on the granular code alone.
const duplicate = await refusal(() => alice.claims.claim(drop.proofFor(alice.address)))
assert.equal(duplicate.code, 'node-error')
assert.match(duplicate.message, /already claimed from that root/)
await gems.close(drop.root)
assert.equal((await game.client.node.commitInfo(drop.root))?.closed, true)
const closed = await refusal(() => bob.claims.claim(drop.proofFor(bob.address)))
assert.equal(closed.code, 'node-error')
assert.match(closed.message, /closed and accepts no further claims/)
// ---------------------------------------------------------------------------
// One atomic swap, over the public URL.
// ---------------------------------------------------------------------------
const seller = await Kei.start({ seed: randomSeed(), node: NODE, autoCancelExpired: false })
const buyer = await Kei.start({ seed: randomSeed(), node: NODE, autoCancelExpired: false })
const bystander = await Kei.start({ seed: randomSeed(), node: NODE, autoCancelExpired: false })
await game.send(seller.address, 5)
await game.send(buyer.address, 10)
await Promise.all([seller.sync(), buyer.sync()])
const sword = await game.items.create({ name: 'Sword of Live Testing' })
await game.items.mint(sword.id, seller.address)
await seller.sync()
assert.equal(await game.items.owner(sword.id), seller.address)
const offer = await seller.market.sell({ asset: sword, price: 3 })
// The ledger holds the item while the offer stands. The seller cannot sell it
// twice and does not have to be trusted not to.
assert.equal(await game.items.owner(sword.id), null)
// Discovery is account-scoped. There is no global order book and no indexer:
// `offers()` reads the chains you name, and this one names the seller.
const listed = await buyer.market.offers({ from: seller.address })
assert.equal(listed.length, 1)
assert.equal(listed[0]?.hash, offer.hash)
const settlement = await buyer.market.accept(offer)
await Promise.all([buyer.sync(), seller.sync()])
assert.equal(settlement.price, 3)
assert.equal(await buyer.items.owner(sword.id), buyer.address) // one leg
assert.equal(await buyer.balance(), 7) // the other leg, same accept
assert.equal(await seller.balance(), 8)
assert.equal((await seller.market.get(offer.hash))?.state, 'accepted')
// This one is a client-side refusal from a fresh read of the offer, so it is
// the same stable code the mock gives.
const taken = await refusal(() => bystander.market.accept(offer))
assert.equal(taken.code, 'offer-taken')
// So is the memo refusal: `pay({ memo })` never reaches the wire.
const memo = await refusal(() => buyer.pay({ to: game.address, amount: 0.001, memo: 'order-1' }))
assert.equal(memo.code, 'no-memo-yet')
game.close()
alice.close()
bob.close()
seller.close()
buyer.close()
bystander.close()
console.log(JSON.stringify({
kind: 'testnet-live',
node: NODE,
network: game.network,
claimRoot: { published: true, count: published?.count, claimed: claim?.amount, closed: true },
refusals: {
duplicateClaim: duplicate.code,
closedRoot: closed.code,
secondAccept: taken.code,
paymentMemo: memo.code,
},
swap: { price: settlement.price, buyerOwnsItem: true, buyerKei: 7, sellerKei: 8 },
}))The one thing the mock gets wrong
Kei.mock() refuses a bad ledger write with a granular code — already-claimed, root-closed, bad-proof, insufficient-balance. Those codes are produced by the in-process mock ledger in @keicoin/core. The public node does not send them. A write the node refuses comes back as node-error, and the distinction is in KeiError.message, in the node's own words:
| Attempt | Kei.mock() code | https://testnet.keicoin.org/rpc |
|---|---|---|
| Claim twice from one root | already-claimed | node-error — "This account has already claimed from that root" |
| Claim from a closed root | root-closed | node-error — "That commit root is closed and accepts no further claims" |
| Claim with a tampered amount | bad-proof | node-error — "That proof does not lead from this account, asset and amount to the committed root" |
| Claim on a bundle re-pointed at an account with no leaf | bad-proof | node-error — the same sentence. The node does not distinguish a wrong proof from a wrong account. |
So if (error.code === 'already-claimed') is a branch that runs in your tests and never runs in production. Handle a refused signed write as node-error, then re-read authoritative state — commitInfo(root), the account chain, the holding — and decide from what the ledger says rather than from the code.
Refusals raised client-side, before a block reaches the wire, are stable everywhere. The proof above asserts two of them against the live node: offer-taken (from a fresh read of the offer, in @keicoin/market) and no-memo-yet (from @keicoin/core's client, which never puts a memo on the wire because the wire format has no field for one).
Authority and trust boundary
| Fact | Authority |
|---|---|
| Whether a block was accepted | The node's reply to process, read back from the account chain. |
| Why a block was refused | KeiError.message for a node-error; KeiError.code only for client-side refusals. |
| Root publication and closure | commitInfo(root) on the node, not the SDK's local batch object. |
| Item custody during an offer | The ledger: items.owner() is null while the offer stands. |
| Offer discovery | The accounts you name. There is no global order book and no indexer. |
| The testnet itself | One rate-limited best-effort dev node with published dev keys, no uptime promise, and no monetary value. |
Live state transitions
Kei.server()against the node URL reportsnetwork: 'testnet'and faucets.token.commit(entries)publishes one root;commitInfo(root)on the node echoes issuer, asset, count, total andclosed: false.- The player's own key writes the claim block. The balance moves.
- A second claim is refused by the node as
node-error. token.close(root)flipsclosedtotrueon the node; later claims are refused asnode-error.market.sell()locks the item at the ledger —items.owner()becomesnull, for everyone, including the seller.market.accept()moves both legs in one settlement: the item to the buyer and the Kei to the seller. The offer's state becomesaccepted.- A second accept is refused client-side as
offer-taken.
Failure cases
| Failure | What it means and what to do |
|---|---|
The run fails at faucet() | The dev node is rate-limited and best-effort. This is the network, not your integration. Retry later. |
| A signed write times out | Do not resubmit. Re-read the account chain; the node may have accepted the block before the reply was lost. |
node-error on a claim | Re-read commitInfo(root) and the account's holding. The message names the cause; the code does not. |
| A code you assert in tests never appears live | Check whether it is a mock ledger code. The table above lists the ones that differ. |
| The summary values move | Only the assertions matter. Hashes and addresses are new every run by design. |
Screenshot evidence
None. Nothing on this page is a visible UI state; a capture would be decorative rather than evidence. The reproducible artifact is the command, its exit status, and the JSON above, recorded with the node version string it ran against.
What the public testnet proves
It proves that the released SDK publishes accepted blocks to a real node over the public URL, that a rooted claim and an atomic swap both settle there, and that the refusal codes differ from the mock's in the way tabled above.
It does not prove production readiness. One node accepting blocks is a working API, not distributed consensus. There is no mainnet. Nothing on Kei holds value today, this network has published dev keys and no uptime promise, and its state may be discarded without notice.
Pre-release network
The public testnet is one rate-limited, best-effort dev node with weak consensus, no uptime promise, published dev keys, and no monetary value. Do not ship a production economy on it.