Integration model
Outcome
Build a purchase path where the player and issuer remain separate signers, orders and confirmed payments may arrive in either order, and one send hash can fulfill at most once. The executable proof runs both orderings through the same reconciliation function.
Run the integration proof
bun install --frozen-lockfile
bun run docs/playgrounds/payment-reconciliation.ts
# {"kind":"payment-reconciliation","scenarios":[{"ordering":"order-first","linkMatches":true,"deliveries":1},{"ordering":"payment-first","linkMatches":true,"deliveries":1}],"memoRefusal":"no-memo-yet"}import { strict as assert } from 'node:assert'
import { Kei, KeiError, type PaymentEvent } from 'kei-transaction'
type Ordering = 'order-first' | 'payment-first'
interface Order {
sendHash: string
from: string
amount: number
}
interface ConfirmedPayment {
sendHash: string
receiveHash: string
from: string
amount: number
}
async function scenario(ordering: Ordering) {
const node = await Kei.mock()
const game = await Kei.server({ seed: 'C'.repeat(64), node })
const player = await Kei.start({ seed: 'D'.repeat(64), node })
await player.faucet(1)
// These maps stand in for durable tables. In production, inserting the
// fulfillment row and granting the purchase belong in one transaction with a
// unique constraint on sendHash.
const orders = new Map<string, Order>()
const payments = new Map<string, ConfirmedPayment>()
const fulfilled = new Set<string>()
let deliveries = 0
const reconcile = (sendHash: string) => {
const order = orders.get(sendHash)
const payment = payments.get(sendHash)
if (!order || !payment || fulfilled.has(sendHash)) return
assert.equal(payment.from, order.from)
assert.equal(payment.amount, order.amount)
fulfilled.add(sendHash)
deliveries += 1
}
const recordOrder = (order: Order) => {
orders.set(order.sendHash, order)
reconcile(order.sendHash)
}
let releasePayment!: () => void
const paymentGate = new Promise<void>((resolve) => { releasePayment = resolve })
let observed!: (payment: PaymentEvent) => void
const observedPayment = new Promise<PaymentEvent>((resolve) => { observed = resolve })
const stop = game.onPayment(async (event) => {
if (ordering === 'order-first') await paymentGate
const receive = await game.client.node.blockInfo(event.hash)
assert.ok(receive && receive.type === 'state')
assert.ok(receive.subtype === 'open' || receive.subtype === 'receive')
const confirmed: ConfirmedPayment = {
sendHash: receive.link,
receiveHash: event.hash,
from: event.from,
amount: event.amount,
}
payments.set(confirmed.sendHash, confirmed)
reconcile(confirmed.sendHash)
observed(event)
})
const receipt = await player.pay({ to: game.address, amount: 0.05 })
if (ordering === 'order-first') {
recordOrder({ sendHash: receipt.hash, from: player.address, amount: 0.05 })
releasePayment()
}
const event = await observedPayment
const receive = await game.client.node.blockInfo(event.hash)
assert.ok(receive && receive.type === 'state')
assert.notEqual(event.hash, receipt.hash)
assert.equal(receive.link, receipt.hash)
if (ordering === 'payment-first') {
assert.equal(deliveries, 0)
recordOrder({ sendHash: receipt.hash, from: player.address, amount: 0.05 })
}
// Any retry, replayed webhook, or repeated worker pass reaches the same path.
reconcile(receipt.hash)
reconcile(receipt.hash)
assert.equal(deliveries, 1)
assert.equal(fulfilled.size, 1)
stop()
game.close()
player.close()
return { ordering, linkMatches: true, deliveries }
}
const orderFirst = await scenario('order-first')
const paymentFirst = await scenario('payment-first')
const memoNode = await Kei.mock()
const memoGame = await Kei.server({ seed: 'E'.repeat(64), node: memoNode })
const memoPlayer = await Kei.start({ seed: 'F'.repeat(64), node: memoNode })
await memoPlayer.faucet(1)
let memoRefusal = ''
try {
await memoPlayer.pay({ to: memoGame.address, amount: 0.05, memo: 'order-123' })
} catch (error) {
assert.ok(error instanceof KeiError)
memoRefusal = error.code
}
assert.equal(memoRefusal, 'no-memo-yet')
memoGame.close()
memoPlayer.close()
console.log(JSON.stringify({
kind: 'payment-reconciliation',
scenarios: [orderFirst, paymentFirst],
memoRefusal,
}))Authority and trust boundary
| Concern | Authority and limit |
|---|---|
| Player key | The player's wallet. The game may request a signature, never copy or use the key. |
| Issuer key | Server-side secret storage used only by Kei.server(). It cannot debit a player. |
| Balances and ownership | Accepted account-chain state. Application tables may cache, not replace it. |
| Realtime game state | The game server or simulation authority: position, combat, presence, cooldowns, matchmaking. |
| Market discovery/indexing | An application directory names bounded account chains to read. It cannot move assets or make a listing authoritative. |
| Purchase meaning | The application's order table, joined to confirmed chain state by send hash. |
| Recovery | Player-controlled seed backup or an explicitly chosen custody design. Kei has no automatic account-recovery service. |
Player: browser
import { Kei } from 'kei-transaction'
const kei = await Kei.start()
const payment = await kei.pay({ to: gameAddress, amount: 0.05 })
await attachPayment(order.id, payment.hash)The player's seed stays with the player. A game asks the wallet to make a payment; it does not debit the player itself.
Issuer: server
import { Kei } from 'kei-transaction'
const game = await Kei.server({ seed: process.env.KEI_SEED! })
const gems = await game.token.issue({
name: 'Gems',
symbol: 'GEM',
decimals: 0,
transfer: 'open',
})The issuer can issue and deliver assets. It cannot authorize a payment from a player's account. Follow the security rules before placing either signer in a process.
Purchase state transitions
- The player signs and publishes a payment.
- The browser persists the returned send-block hash with the order over the game's normal authenticated channel.
- The game observes a confirmed receive block. It resolves
onPayment.hash, then reads that block'slinkas the player's send hash. - Order arrival and payment arrival each invoke the same reconciliation path.
- That path verifies recipient, sender, amount, and purchase context.
- One database transaction inserts a unique fulfillment keyed by send hash and records the delivery request.
- The issuer signs delivery; the player later observes the asset in their own account.
A payment can arrive before its order. An order can arrive before receiver processing. Neither is an error and neither justifies a one-shot event handler. A payment has no memo field in the current wire format; no-memo-yet forces the exact send hash to remain the identifier.
What remains off-chain
Kei replaces the ledger, not the game server. Keep position, presence, combat, matchmaking, cooldowns, and other fast-changing instance state in the system that owns the real-time loop. Keep account directories and catalogue search in bounded application indexes. Neither kind of application state may invent a balance or sign for a player.
Failure cases
- A mismatched sender, recipient, amount, or order context is a refusal, not a best-effort fulfillment.
- A repeated callback or worker replay must encounter the existing unique fulfillment record.
- A lost reply after a signed write requires chain reconciliation before any resubmission.
- Lost player keys are not repaired by an issuer database. Make custody and backup status visible before the wallet holds anything.
- A global market or item browse view needs an explicit bounded index; do not imply that one account-chain read discovers the network.
Use the stable error recovery categories for control flow, and check where each code comes from before branching on one: a refusal raised by the node arrives as node-error, not as the granular code Kei.mock() returns for the same attempt.
What Kei.mock() proves
The proof runs the released player and issuer clients, creates real mock-chain send and receive blocks, resolves their hash link, exercises both application event orderings, refuses a memo, and proves exactly-once behavior in its sample store. It does not prove public-network consensus, database durability, wallet recovery, application authentication, or production readiness.
For the parts that only a real node can settle — a rooted claim, an atomic swap, and the refusal codes the node actually sends — run the public testnet proof.