Contents

The Economy

Construct an Economy from its ports, then drive it through one submit entry point and read its state through read.

Source src/index.ts#L83createEconomysrc/economy.ts#L100createEconomysrc/contract.ts#L525Economy

An Economy is the object you hold and call. It has a small surface: one submit to change money (with submitBatch for bursts), a read group to look at state, capacity for the growth gauges, and close to shut down.

import { createEconomy } from '@pwngh/economy-lab';

const economy = createEconomy(ports); // build — see Construction
const outcome = await economy.submit(operation); // change money → Outcome
const balance = await economy.read.balance(account); // look, never change
await economy.close(); // release the store

You build it from a set of injected ports (a Store, a Signer, a Processor, a Rates, a FeePolicy, a clock, and an id source) so the core never reaches for a database driver or a wall clock. Pass in-memory ports to run the same economy with no external infrastructure.

Construction

createEconomy(ports) is the one call: hand it a full Ports bag and it assembles the economy, synchronously. memoryPorts builds a batteries-included in-memory bag: the in-memory Store, a dev Signer, a dev Rates table, a flat FeePolicy, and an in-memory Processor, all wired. No environment, no infrastructure:

import { createEconomy, memoryPorts } from '@pwngh/economy-lab';

const economy = createEconomy(memoryPorts({ signingKey: 'dev-signing-key' }));

openPorts(env, init?) resolves the bag from the environment instead: the Store from DATABASE_URL (a postgres:// or mysql:// connection string picks that engine, and an unset value falls back to the in-memory store), and the external ports, config, and secrets from their own keys. Outside production it fills sane defaults for anything absent; in production it requires each external port set or expressly declined, and fails fast with one message naming everything missing. Any field you pass in init wins over the env-derived one:

import { createEconomy, openPorts } from '@pwngh/economy-lab';

const economy = createEconomy(await openPorts(process.env));

// env for everything, but override one port:
const withLiveRates = createEconomy(await openPorts(process.env, { rates: liveRates() }));

A host that wants the whole stack from one call uses boot, which opens the ports, assembles the economy over them, and builds the background worker over the same bag (pass worker: false to skip it):

import { boot } from '@pwngh/economy-lab';

const { ports, economy, worker } = await boot(process.env);

Each driver is imported only when its environment variable selects it, so a deployment installs only what it uses. See configuration for every variable openPorts reads.

The in-memory economy bundles for the browser with no aliases or stubs: every Node API and driver sits behind a runtime guard, and a gate (npm run check:browser) bundles the entry with esbuild’s browser platform on every check to keep it that way. The runnable blocks across this handbook are that property at work.

submit

submit(operation) applies one Operation and resolves to an Outcome. The operation names the action and carries an idempotencyKey, so a retried request runs at most once.

submit(op)request invalidateshape · keyauthorizeactor may?idempotencythe claim gatehandlerby kindcommitledger + events
One pass through submit. Validation and authorization run before any money moves; the idempotency claim makes a retried call replay the first outcome; the handler's ledger writes, emitted events, and the recorded outcome all commit in one transaction.

Here a user tops up their balance and the balance visibly moves; the result tells you whether it committed, repeated an earlier request (duplicate), or was declined (rejected). Run it — then click into the code, change the amount, and run yours:

import { credits, spendable, systemActor, topUp } from '@pwngh/economy-lab';

import type { Economy } from '@pwngh/economy-lab';
import type { SnippetReport } from './context.ts';

// The whole loop in one block: read the balance, submit a top-up, read it again. Edit
// the amount and run again — the outcome and the move both follow your number.
export async function run(economy: Economy): Promise<SnippetReport> {
  const account = spendable('usr_alice');
  const before = await economy.read.balance(account);

  const outcome = await economy.submit(
    topUp({
      idempotencyKey: `ord_${crypto.randomUUID().slice(0, 8)}`,
      actor: systemActor('billing'),
      userId: 'usr_alice',
      amount: credits(50),
      source: 'stripe',
    }),
  );

  const after = await economy.read.balance(account);
  const moved = (after.minor - before.minor) / 100n;

  return {
    lines: [
      `outcome: ${outcome.status}` +
        `${outcome.status === 'committed' ? ` → ${outcome.transaction.id}` : ''}`,
      `usr_alice spendable: ${before.minor / 100n} → ${after.minor / 100n} credits (moved ${moved})`,
    ],
    txnId: outcome.status === 'committed' ? outcome.transaction.id : undefined,
  };
}

A rejected outcome is a normal “no” returned as data, not an error; a genuine fault throws instead. See outcomes and reason codes for the full set.

Every submit is also metered through the injected Meter: one count under economy.submit, tagged by operation kind and how it resolved (committed, duplicate, rejected, or fault), plus its duration under economy.submit.ms — request-path telemetry without wrapping submit yourself.

Caveats

  • A business “no” is a returned rejected outcome, never a throw. A throw means the request itself is broken (OP.MALFORMED, AUTH.UNAUTHORIZED) or infrastructure failed — a caller bug or an outage, not a decline.
  • Amounts are minor units as bigint: toAmount(‘CREDIT’, 12_000n) is 120 credits, or credits(120) with the whole-unit shorthand. There is no float anywhere in the money path.
  • A duplicate outcome carries the original transaction. Treat it as success — the work is done — not as an error.
  • A rejected request leaves its idempotency key unused, so the same key can retry after the condition clears.

Batches and bursts

submitBatch(operations) runs several independent operations through one database commit and returns a BatchOutcome per slot, index-aligned with the input. Each operation keeps its own verdict: a slot can commit, reject, or fault without touching its neighbors — a slot that fails rolls back alone — and two slots carrying the same idempotency key fault the later one rather than double-run it. On a store without batch transactions the call degrades to sequential submits, same results, one commit each. Every batch counts once under the economy.submit.batch meter.

Most hosts never call it directly. createSubmitCoalescer wraps it as a drop-in submit that gathers the concurrent calls of one event-loop turn into a single batch — a burst pays one commit instead of one each, and every caller still receives exactly what a direct submit would have produced:

import { createEconomy, createSubmitCoalescer } from '@pwngh/economy-lab';

const economy = createEconomy(ports);
const lane = createSubmitCoalescer(economy); // maxBatch 16, flushes per microtask

// Concurrent requests share one commit; each still gets its own Outcome.
const outcome = await lane.submit(operation);

A wider window is a host decision: pass defer with a short timer to trade a few milliseconds of latency for more batching. A call carrying per-call CallOptions bypasses the queue and submits directly.

read

read is the look-but-don’t-change surface: balances, statements, a posting or saga by id, entitlement checks, the account, payout, and posting journals, the pause status, and the solvency proof.

Reading one account’s balance is a single call that returns an Amount:

import { spendable } from '@pwngh/economy-lab';

const balance = await economy.read.balance(spendable('usr_a1'));
// → { currency: "CREDIT", minor: 5000n }

See reads for the whole surface and what each method returns.

capacity

capacity() returns a CapacityReport: the live gauges of everything that grows for the life of the economy — total postings (historySize), the re-proof watermark’s age, the latest checkpoint’s age, the accrual backlog, session and reservation footprints, and the secondary-table row counts. A gauge the store cannot measure comes back null — unknown, never zero.

advisories are stated facts against CAPACITY_THRESHOLDS, the documented crossover constants: the system informs, the host decides, and no advisory ever changes behavior. When historySize crosses its threshold, the answer is archival; when a backlog ages past its bound, the answer is the worker cadence.

close

close() releases the store’s resources (a database pool, an open connection) and resolves when shutdown is done.

await economy.close();

The request path

Every submit runs the same path: validate the operation, authorize the caller, then post the money in one transaction that writes both the ledger entries and any outgoing events.

submit(operation) ─▶ validate · authorize · post ─▶ Ledger (append-only · hash-chained)

                                  read · balance ◀────┤──▶ Outbox (same transaction)

Validation rejects a malformed operation before any work begins: a blank idempotencyKey, an ownerless wallet account, an out-of-range amount. Authorization decides whether this actor may run this kind of operation, so a forbidden request stops before money moves.

The post then runs inside one all-or-nothing transaction. It appends the balanced double-entry legs to the ledger and enqueues the matching event into the outbox in that same transaction, so the two commit together or not at all; a rollback can’t leave a stray event in the outbox.

A read.balance returns a stored running total, so it’s a single O(1) read rather than a re-sum of history. The ledger entries stay the source of truth: the prover re-derives each balance and reports drift.

The background sweeps that drain the outbox and do the rest of the deferred work run off this path on their own schedule. See the background worker.

Troubleshooting

My retry charged the buyer twice

The retry minted a fresh idempotencyKey, so the engine saw two different operations. The key is how a retry is recognized: mint it once, from the action’s own identity, and reuse it on every attempt.

const idempotencyKey = `ord_${order.id}`; // from the order, minted once

const first = await economy.submit(spend({ idempotencyKey, ...details }));
const retried = await economy.submit(spend({ idempotencyKey, ...details }));
// retried.status → "duplicate": same transaction, nothing new posted

Idempotency & retries owns the full story — and closes with this exact bug as a challenge.

submit throws instead of rejecting

A throw is not a decline. It means the request itself was broken before any business question was asked: a user actor acting on someone else’s account (AUTH.UNAUTHORIZED), a blank key or malformed payload (OP.MALFORMED), or a store outage. Check the actor against the account being drained first — see rejected versus thrown and actors & authorization.

See also