Using Ledger

In-process is the primary interface

The ledger is a server-authoritative primitive — every call below is atomic, idempotent and overdraft-safe by database constraint, and the reference has the signatures. Your own code — a game model, a reward handler, a trusted route — calls it directly against the DB binding.

import { openLedger } from "@pithy-sh/ledger/src/ledger";

const ledger = openLedger(env.DB);

await ledger.credit("alice", "chips", 1000, "signup-bonus:alice");
await ledger.debit("alice", "chips", 50, "buyin:table-7:hand-3");
await ledger.transfer("alice", "bob", "chips", 30, "tip:xyz");

const { balance, held, available } = await ledger.balance("alice", "chips");

A credit opens the account if it does not exist. There is no separate create step — an account is (userId, currency), and the first credit upserts it.

Pass a unique ref for every operation so retries are safe. See idempotency refs — this is the one thing to get right.

Wagering

Three calls and three numbers: holds and settlement is the model, and an in-app economy is it applied to a game.

await ledger.hold("alice", "chips", 100, "bet:hand-9:alice");
await ledger.capture("bet:hand-9:alice");   // she lost
// or
await ledger.release("bet:hand-9:alice");   // pushed

The HTTP surface

Balance reads are for players. Moving funds over HTTP is server-authoritative.

RouteVerified by
GET /ledger/:currencyuser
GET /ledger/:currency/transactionsuser
POST /ledger/:currency/credituser + the admin scope
POST /ledger/:currency/debituser + the admin scope

Add the auth capability. Without it every route is denied.

The management surface is read-only, permanently

RouteScope
GET /ledger/admin/accountsledger:accounts:read
GET /ledger/admin/accounts/:userIdledger:accounts:read
GET /ledger/admin/accounts/:userId/:currency/transactionsledger:transactions:read

There is no adjustment route, and that is deliberate — see regulatory concerns.

These routes are always mounted. Without the control plane composed, each denies with controlplane/not_connected — a management surface that appears only when something else is installed is a surface nobody can discover.

Paths follow your basePath: mount the ledger at /wallet and the manifest advertises /wallet/admin/accounts.

What to do when a debit fails

ledger/insufficient_funds means the constraint refused it — the balance genuinely could not cover it, at the moment of writing.

Do not retry it hoping. A retry with the same ref is a no-op if the first one landed and another refusal if it did not. The recovery is upstream: tell the player, or credit them first.

Composing with a game

// at the wager
await ledger.hold(player, "chips", stake, `bet:${sessionId}:${player}`);

// at the result
for (const loser of losers) await ledger.capture(`bet:${sessionId}:${loser}`);
for (const winner of winners) {
  await ledger.release(`bet:${sessionId}:${winner}`);
  await ledger.credit(winner, "chips", payout, `payout:${sessionId}:${winner}`);
}

Every ref names the session and the player, so the whole settlement is replayable and a retried result settles once.

ESC