Skip to content

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:

sh
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.

ts
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

FactAuthority
Player key and signed sendThe player's wallet. The game cannot sign it.
Confirmed paymentThe receiver's account chain, read back from the node.
Purchase meaningThe game's durable order record, keyed by the payment send hash.
FulfillmentOne atomic game-database transaction with a unique send-hash record.
Live UI stateA 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

  1. Record an order by send hash, even if no payment is recorded yet.
  2. Record a confirmed payment by resolving receive hash to send hash, even if no order is recorded yet.
  3. Invoke the same reconciliation function after either insert.
  4. Deliver only when both records agree on sender and amount.
  5. 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

ts
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
MemberPurpose
addressThe 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.
seedExport 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

ts
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

ts
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 conditionResponse
no-memo-yetPermanent for that request shape. Remove the memo and correlate by hash.
Order missingKeep the confirmed payment record and reconcile after the order arrives.
Payment missingKeep the order record and reconcile after confirmation arrives.
Sender or amount mismatchRefuse fulfillment and investigate; do not coerce the records.
Transport failed after a signed writeRefresh 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.

The package is the source of truth for the API.