Skip to content

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

sh
bun install --frozen-lockfile
bun run docs/playgrounds/testnet-live.ts

This 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:

json
{"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.

ts
/**
 * 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:

AttemptKei.mock() codehttps://testnet.keicoin.org/rpc
Claim twice from one rootalready-claimednode-error"This account has already claimed from that root"
Claim from a closed rootroot-closednode-error"That commit root is closed and accepts no further claims"
Claim with a tampered amountbad-proofnode-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 leafbad-proofnode-errorthe 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

FactAuthority
Whether a block was acceptedThe node's reply to process, read back from the account chain.
Why a block was refusedKeiError.message for a node-error; KeiError.code only for client-side refusals.
Root publication and closurecommitInfo(root) on the node, not the SDK's local batch object.
Item custody during an offerThe ledger: items.owner() is null while the offer stands.
Offer discoveryThe accounts you name. There is no global order book and no indexer.
The testnet itselfOne rate-limited best-effort dev node with published dev keys, no uptime promise, and no monetary value.

Live state transitions

  1. Kei.server() against the node URL reports network: 'testnet' and faucets.
  2. token.commit(entries) publishes one root; commitInfo(root) on the node echoes issuer, asset, count, total and closed: false.
  3. The player's own key writes the claim block. The balance moves.
  4. A second claim is refused by the node as node-error.
  5. token.close(root) flips closed to true on the node; later claims are refused as node-error.
  6. market.sell() locks the item at the ledger — items.owner() becomes null, for everyone, including the seller.
  7. market.accept() moves both legs in one settlement: the item to the buyer and the Kei to the seller. The offer's state becomes accepted.
  8. A second accept is refused client-side as offer-taken.

Failure cases

FailureWhat 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 outDo not resubmit. Re-read the account chain; the node may have accepted the block before the reply was lost.
node-error on a claimRe-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 liveCheck whether it is a mock ledger code. The table above lists the ones that differ.
The summary values moveOnly 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.

The package is the source of truth for the API.