Contents

Reads

The read surface: balance, statement, postings, saga, entitlements, status, accounts, payouts, and the solvency proof.

Source src/contract.ts#L376-L436Economy.readsrc/economy.ts#L98-L112readtest/economy.read.test.ts

API Economy

read is the query half of the Economy: “what’s this balance?”, “did this go through?”, “does this user own that item?”. Nothing under read writes a ledger entry, claims an idempotency key, or records a velocity attempt.

Every method lives on the economy.read group, and the whole group is read-only. You call them the same way you’d call submit:

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

A few of these return a single value you await; a few stream many rows you iterate; one (status) is synchronous. Three of them, live — and free to repeat, because reads never move the books:

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

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

// One block, three reads: a balance, an ownership check, and the maintenance status. Nothing
// here writes a ledger entry, claims a key, or counts toward a velocity window — run it
// as often as you like and the books never move.
export async function run(economy: Economy): Promise<SnippetReport> {
  const balance = await economy.read.balance(spendable('usr_alice'));
  const owns = await economy.read.entitled('usr_alice', 'Aurora Avatar');
  const status = economy.read.status();

  return {
    lines: [
      `balance: ${balance.minor / 100n} credits in usr_alice's spendable`,
      `entitled('usr_alice', 'Aurora Avatar') → ${owns} — ownership is a record, not a balance`,
      `status: ${status.maintenanceActive ? 'maintenance window active' : 'open'}`,
    ],
    consolePath: '/wallets',
  };
}

The sections below group them by what you’re asking for.

Balances and history

These two answer “how much, and how did it get there?” for one account at a time.

read.balance(account) returns the account’s current balance as an Amount. It’s a stored running total, so it’s a single O(1) read rather than a re-sum over the account’s whole history. Pass an account reference built from a helper like spendable, earned, or promo. One exception to “single”: with platform sharding on, a bare sharded account reads as the sum over its shard rows — one row per shard, still no history walk:

const balance = await economy.read.balance(earned('usr_seller'));
// → { currency: "CREDIT", minor: 1200n }

read.statement(account, range) returns one page of that account’s entries within a time range. The range is half-open in epoch milliseconds (from included, to not), and the range is the paging model: to walk a long history, tile it with consecutive windows — the windows join losslessly because an entry belongs to exactly one half-open range. The page’s cursor field is reserved: no read accepts one, and every engine returns null today.

const page = await economy.read.statement(spendable('usr_a1'), {
  from: 0,
  to: Date.now(),
});
// → { account, entries: [{ txnId, amount, postedAt }, …], cursor: null }

Each entry names the transaction it came from, the signed amount applied to this account, and when it posted, enough to render a statement row with no follow-up query.

A posting or a payout by id

When you already hold an id and want the one record behind it, these resolve it. Both return null rather than throwing when the id is unknown.

read.posting(txnId) returns one committed posting by its transaction id (all of its legs and metadata) or null if no such transaction exists:

const posting = await economy.read.posting('txn_8821');
// → { txnId: "txn_8821", legs: [{ account, amount }, …], meta: {…} }
// (or null for an unknown id)

This lets a reader resolve a posting through read without reaching past it into the raw Store. Unlike a statement, it isn’t scoped to one account. It returns the whole transaction with every leg it touched.

Two conventions to hold onto when rendering legs. Amounts are debit-positive: the sign is the ledger’s, not the account holder’s, so a top-up shows a negative amount on the wallet it credits. To display a leg the way that account’s owner reads it, convert it with balanceDelta from /store-kit. And a committed outcome’s legs may be empty — lifecycle operations like cancelSubscription post a marker transaction that moves no money — so render from the legs themselves rather than assuming every commit carries lines.

read.saga(id) loads one payout saga by its id: its current state, the provider reference once submitted, the attempt count, and the reason it failed if it did. The row’s key is id; an operation that acts on one, like settlePayout, takes that same value as its sagaId. It returns null for an unknown payout id:

const saga = await economy.read.saga('pay_4410');
// → { id, userId, state: "SUBMITTED", providerRef: "…", … }
// (or null for an unknown id)

The background worker advances a saga through its states, and read.saga reports where one currently sits. For the states themselves and what moves a payout between them, see the payout saga.

Ownership and status

Not every question is about money: read.entitled reports what a user owns, and read.status whether the economy is open for writes.

read.entitled(userId, sku) returns true or false: does this user currently own this SKU (an item or feature)? Ownership is a record, not a balance, so it has its own reader: the readable side of the grantEntitlement and revokeEntitlement operations that a UI gates access on:

const owns = await economy.read.entitled('usr_a1', 'wrld_pass');
// → true

read.status() returns the economy’s pause state right now: whether a maintenance window is in effect, its configured bounds, and when writes resume. It’s the only synchronous read: derived from config plus the clock rather than stored, it always reflects the live window:

const status = economy.read.status();
// → { maintenanceActive: false, pauseStart: null, pauseEnd: null, resumesAt: null }

This lets a UI render a maintenance banner directly, instead of inferring the pause from a declined write. While the window is active, a user principal’s discretionary write is declined with ECONOMY_PAUSED; system and operator writes keep flowing so external money can still settle.

Streaming the whole board

These three stream every record of a kind, newest first, because a real ledger holds far more than you’d want in one array. Each returns an AsyncIterable, so you iterate with for await and stop whenever you’ve seen enough.

read.accounts() streams every account that has a balance row. It’s the prover’s own enumeration, exposed so a reader can list accounts (and derive the users behind them) without tracking minted ids itself:

for await (const account of economy.read.accounts()) {
  // … one AccountRef at a time
}

read.payouts() streams every payout saga, newest first: the whole board, settled and failed payouts included, not only the due ones the worker claims. It lets a UI render payout status without tracking minted payout ids:

for await (const saga of economy.read.payouts()) {
  // … one Saga at a time, newest updatedAt first
}

read.postings() streams every committed posting, newest first: the whole journal. That’s user operations and the worker’s own postings alike, across every account — not only the postings your own service submitted. Each posting carries its full legs, so a row renders and expands without a second lookup:

for await (const posting of economy.read.postings()) {
  // … one Posting at a time, newest commit first
}

The solvency proof

read.health() runs the integrity check and returns a ProveReport: a set of flags, each one a property the ledger is supposed to hold:

const report = await economy.read.health();
// → { conserved: true, backed: true, noOverdraft: true, … }

It walks every posted account once and re-derives each balance from the recorded debit and credit lines, rather than trusting the cached running total. So conserved, backed, noOverdraft, chainIntact, and consistent each report on the books as the entries actually describe them. This is the readable face of solvency and the hash-chain integrity the economy maintains.

read.health is a thorough enough check to deserve its own page. For what each flag means, how shortfall and drift are computed, and how this lighter in-process check relates to the full out-of-band prover, see the proof.

Caveats

  • read.balance is the live total, not the spendable one: money still inside its maturity window counts here but can’t be spent or cashed out yet.
  • On a sharded platform account, the bare id reads as the sum over its shard rows — one logical number whatever PLATFORM_SHARDS is set to.
  • The streaming reads (accounts, payouts, postings, lineage) can be long on a busy ledger. Iterate and stop early; don’t collect them all.
  • Nothing under read claims a key or counts toward a velocity window — reads are free to repeat.

Troubleshooting

The balance says the money is there, but the spend was rejected

read.balance reports the live total; spends and payouts draw only the part that has cleared its settlement wait. A fresh top-up can cover the price and still decline as FUNDS_IMMATURE. The rejection carries when the funds clear:

const outcome = await economy.submit(spend({ ...order }));
if (outcome.status === 'rejected' && outcome.detail.reason === 'FUNDS_IMMATURE') {
  const readyAt = outcome.detail.availableAt; // retry the same key after this
}

Credit maturity owns the mechanism.

read.posting returns null for a transaction I just saw

Only committed postings exist to read. A rejected outcome posted nothing, and a duplicate outcome’s transaction is the original posting — its id is the one to look up. If the id came from another economy instance (a fresh in-memory engine holds fresh history), it won’t resolve here. See outcomes & reason codes.

See also