Wallet
Outcome
Kei.start() returns the player-side client. The runnable proof below makes one purchase arrive in each possible order, correlates the receiver's block back to the player's send hash, and delivers exactly once through one reconciliation function. It also proves that a payment memo is refused rather than discarded.
Run the payment proof
From a clean clone of this site:
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"}The file below is the file the command and site regression test execute.
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
| Fact | Authority |
|---|---|
| Player key and signed send | The player's wallet. The game cannot sign it. |
| Confirmed payment | The receiver's account chain, read back from the node. |
| Purchase meaning | The game's durable order record, keyed by the payment send hash. |
| Fulfillment | One atomic game-database transaction with a unique send-hash record. |
| Live UI state | A presentation of those facts, never a settlement authority. |
pay() returns the player's send-block hash. onPayment.hash is the receiver's receive-block hash. Resolve that receive block with game.client.node.blockInfo(); its link is the send hash that identifies the purchase.
Payment state transitions
- Record an order by send hash, even if no payment is recorded yet.
- Record a confirmed payment by resolving receive hash to send hash, even if no order is recorded yet.
- Invoke the same reconciliation function after either insert.
- Deliver only when both records agree on sender and amount.
- Insert fulfillment and grant the purchase atomically under a unique send-hash constraint. Replays then observe the existing fulfillment.
The complete server-side sequence is in the integration model.
Properties and methods
kei.address // 'kei_3abc...'
await kei.balance() // number, in Kei
await kei.send(to, amount) // { hash, amount, to }
await kei.faucet() // testnet only; throws on mainnet
kei.seed // export for backup; never log it
await kei.wallet.summary() // { address, kei, tokens, items, pending } // item summaries include image/description/stats metadata when present| Member | Purpose |
|---|---|
address | The account's public Kei address. |
balance() | Read the account's Kei balance. |
send(to, amount) | Sign and send Kei from this account. |
faucet() | Request test funds where a faucet is available. |
seed | Export the wallet credential for backup. Treat it as a secret. |
wallet.summary() | Read the account's Kei, tokens, items, and pending state together. Includes immutable item image/description/stats metadata in each row. |
Receive events
kei.on('received', (transaction) => {
console.log(transaction.from)
console.log(transaction.amount)
console.log(transaction.hash)
})The event reports a transaction after the client observes it. Do not treat an unconfirmed intent or UI action as settlement.
Payments
const receipt = await kei.pay({
to: gameAddress,
amount: 0.05,
})
await attachPayment(order.id, receipt.hash)A payment is signed by the current wallet. There is intentionally no from argument. A memo has no representation in the current state-block wire format; pay({ memo }) fails with no-memo-yet. Use the exact send hash rather than an amount-and-time guess or an application memo.
Failure cases
| Code or condition | Response |
|---|---|
no-memo-yet | Permanent for that request shape. Remove the memo and correlate by hash. |
| Order missing | Keep the confirmed payment record and reconcile after the order arrives. |
| Payment missing | Keep the order record and reconcile after confirmation arrives. |
| Sender or amount mismatch | Refuse fulfillment and investigate; do not coerce the records. |
| Transport failed after a signed write | Refresh account/block state before deciding whether to resubmit. |
See the executable recovery categories for stable-code handling.
What Kei.mock() proves
The proof executes the current kei-transaction@0.8.0 client, two account chains, receive-block lookup, event delivery, stable memo refusal, both event orderings, and idempotent application reconciliation without a network, secret, or prompt. The maps deliberately stand in for durable tables: the mock does not prove database crash safety, public-network uptime, distributed consensus, or production value.