/** * @pwngh/economy-lab * * Copyright (c) Preston Neal * * This source code is licensed under the MIT license found in the * LICENSE.md file in the root directory of this source tree. * * @license MIT */ import type { Reservations } from './netting.js'; import type { SessionOptions, SettleReport } from './netting.js'; import type { ErrorCode, RejectionCode } from './errors.js'; import type { Amount } from './money.js'; import type { Config } from './config.js'; import type { FeePolicy, Recipient } from './contract.js'; import type { Clock, Digest, Ids, Logger, Meter, Scheduler, Store } from './ports.js'; /** The structural subset of Ports the instance economy needs; an openPorts host passes through. */ export type InstanceEconomyDeps = { store: Store; digest: Digest; clock: Clock; ids: Ids; pricing: FeePolicy; config: Config; logger?: Logger; meter?: Meter; /** Drives the manager's `start` loop; absent, a built-in interval timer stands in. */ scheduler?: Scheduler; }; /** * `permanent` and `temporary` grant durable ownership immediately; `instant` is consumable * and grants nothing. */ export type ProductKind = 'permanent' | 'temporary' | 'instant'; /** * One purchase offered to the lane. The economy service builds this from its own catalog and * routing, so a malformed shape (non-CREDIT price, unknown kind, shares off 10000 bps) throws a * fault rather than rejecting — a bad one is a wiring bug, not a buyer outcome. */ export type InstancePurchase = { buyerId: string; /** The full CREDIT price; drawn from spendable only (promo is main-lane). */ price: Amount; /** Sale split, spend's rule: shares of the post-fee net, summing to exactly 10000 bps. */ recipients: ReadonlyArray; product: { sku: string; kind: ProductKind; /** Epoch ms a `temporary` grant lapses; required for temporary, forbidden otherwise. */ expiresAt?: number; }; /** * Defaults to `sess::`. A caller-supplied id must carry the session prefix, so * session orders can never collide with main-lane orderIds by construction. */ orderId?: string; }; /** * What `purchase` resolves to. Accepted means granted and journaled with `seq` its session * sequence; the money turns ledger-final only at settle. A repeat of a seen orderId replays * this recorded outcome without re-running side effects. */ export type PurchaseOutcome = { status: 'accepted'; orderId: string; seq: number; } | { status: 'rejected'; orderId: string; reason: RejectionCode | ErrorCode; }; /** * The session's {@link SettleReport} plus the lane's cross-lane backstop, `revoked`: a movement * the settle replay refused moved no money, so the ownership its grant handed out is withdrawn. */ export type InstanceSettleReport = SettleReport & { /** Grants revoked because the settle replay refused their movement (the cross-lane backstop). */ revoked: ReadonlyArray<{ buyerId: string; sku: string; orderId: string; }>; }; /** * The lane's knobs: the session's batch bounds and shared registry, the per-buyer spend cap, * and the prefund escrow opt-in. */ export type InstanceEconomyOptions = Pick & { /** * Ceiling (CREDIT minor units) one buyer may spend in this session. Bounds the velocity/risk * exposure the fast lane forgoes and the cross-node residual race. Production configs set it. */ perUserCapMinor?: bigint; /** * Prefund mode (host opt-in): each buyer's first purchase moves `amountMinor` of matured * spendable credits into a per-(user, session) escrow account by a real ledger posting, and * every movement in this lane debits that escrow instead of `spendable`. The account key is * the crash-safe attribution — recovery derives the unspent remainder from durable postings * plus the journal — and the accept screen becomes session-local (the escrow belongs to this * session alone), which is what removes the cross-node contention on the buyer's spendable. * Settle refunds the remainder to `spendable`; the orphan sweep repairs a crashed refund. */ prefund?: { amountMinor: bigint; }; }; /** * Opens the fast lane over one netting session: a purchase grants ownership now, by a durable * entitlement write every reader sees, while its money rides the journal and nets to the * ledger at epoch-end settle. Same lifecycle rules as the session: settle once per session id, * rotate epochs (`sess::`) for cadence, share one reservations registry per process. * Most hosts open lanes through {@link openInstanceEconomies}, which owns those rules. */ export declare function openInstanceEconomy(deps: InstanceEconomyDeps, sessionId: string, options?: InstanceEconomyOptions): InstanceEconomy; /** * One epoch's lane over one netting session. `purchase` grants now and defers the money; * `settle` nets the epoch to the ledger and applies the backstops — grants whose movement the * settle replay refused are revoked (the report's `revoked` list), immature holds release, and * prefund escrows refund their remainder to spendable. Every entry point rides one internal * writer queue, so concurrent calls from an HTTP edge serialize instead of forking the session * chain. Construct through {@link openInstanceEconomy}, or {@link InstanceEconomies.laneFor}. */ export declare class InstanceEconomy { private readonly deps; private readonly sessionId; private readonly session; private readonly capMinor; private orderSeq; private accepted; private rejected; private tail; private settleReport; private settledEmitted; private readonly prefund; private readonly funded; private readonly outcomes; private readonly spent; private readonly granted; private readonly immatureHeld; private readonly reservations; constructor(deps: InstanceEconomyDeps, sessionId: string, options: InstanceEconomyOptions); /** * One in-world purchase, grant-now-settle-later: the entitlement grant is durable before the * movement is offered to the session, so ownership reads true everywhere immediately while * the money waits for epoch-end settle (the worst crash leaves one owned-but-unpaid item, * reconcilable by its `sale:` source — never lost money). Idempotent per orderId: a * repeat replays the recorded outcome. Malformed input throws; the expected refusals come * back rejected — INSUFFICIENT_FUNDS against matured balance minus everything pending (or a * failed prefund), RISK_DENIED over `perUserCapMinor` — and a rejection leaves no grant * behind. */ purchase(input: InstancePurchase): Promise; private runPurchase; /** This tier's pending-out total for the user, for display as `balance - pending`. */ pending(userId: string): Promise; /** Forces the journal batch out (a durability point between settles). */ flush(): Promise; /** Settles the session once (see the epoch-rotation rule) and applies the backstops. */ settle(): Promise; private runSettle; /** This epoch's accepted and rejected purchase counts; the manager's sweep reads `accepted` against its rotation bound. */ stats(): { accepted: number; rejected: number; }; /** The buyer's accepted spend this epoch — the total `perUserCapMinor` screens against. */ spentOf(userId: string): Amount; private serialize; private legsOf; private ensurePrefunded; private refundRemainders; private holdImmature; private releaseImmatureHolds; private overCap; private orderIdOf; private validate; private metered; private emitSettled; } /** The default epoch age bound (ms), shared by the lane manager's sweep and the cluster node's epoch-age law. */ export declare const DEFAULT_EPOCH_MAX_AGE_MS = 60000; /** * How the manager paces epochs; either bound rotates the scope's lane at the next `sweep`. * Movement-count bounds the settle's journal walk; age bounds how long money stays un-netted. */ export type InstanceEconomiesOptions = { lane?: Omit; /** Rotate a scope once its epoch accepted this many purchases. Default 512. */ epochMaxMovements?: number; /** Rotate a scope once its epoch is this old. Default 60_000 ms. */ epochMaxAgeMs?: number; /** * The registry every lane shares. Default: a private per-process registry (single-node). * A multi-node deployment passes `sharedReservations(store)` here so every node's accept * screen sees every other node's pending — the host opt-in for cross-node correctness. */ reservations?: Reservations; }; /** One manager pass over due scopes: what settled, and what failed and stays for retry. */ export type InstanceSweepReport = { settled: ReadonlyArray<{ scope: string; report: InstanceSettleReport; }>; /** These scopes' lanes are retained; the next sweep retries them. */ failed: ReadonlyArray<{ scope: string; code: string; }>; }; /** * The process-level front door to the fast lane: hands out the live lane for any scope key, * opening on demand, and owns the rules a host must never get wrong — every lane shares one * reservations registry, epochs rotate instead of ever re-settling a session id (ids come from * `epochMinter`, src/netting.ts), and a failed settle keeps its lane for retry instead of * stranding the epoch. * * Routing across processes is deliberately the host's; `scopeRouter` (src/router.ts) provides * the consistent-hash assignment, kept sticky for the scope's life. On failover, * `recoverSession` with the crashed epoch's session id finishes its settle, and the orphan * sweep (src/worker/orphans.ts) enumerates crashed epochs from the journal. * * The whole host program, over an openPorts composition: * * @example * const ports = await openPorts(process.env, init); * const lanes = openInstanceEconomies(ports, { * epochMaxAgeMs: 30_000, * lane: { perUserCapMinor: 500_000n }, * }); * const stopSettling = lanes.start(5_000); // rotate due epochs on cadence * * // Per request from a game server (an unprivileged caller): * await lanes.laneFor(worldInstanceId).purchase({ * buyerId, * price, * recipients: [{ sellerId: creatorId, shareBps: 10_000 }], * product: { sku, kind: 'permanent' }, * }); * * // Shutdown or drain: * stopSettling(); * await lanes.settleAll(); */ export declare function openInstanceEconomies(deps: InstanceEconomyDeps, options?: InstanceEconomiesOptions): InstanceEconomies; /** * The lane manager's handle. `laneFor` hands out the scope's current-epoch lane, opening on * demand; `rotate` settles one scope now; `sweep` rotates every scope past its movement or age * bound; `settleAll` drains everything; `start` runs the sweep on a timer; `pending` and * `stats` read across every lane. A settle that throws keeps its lane, so the next sweep * retries instead of stranding the epoch. Construct through {@link openInstanceEconomies}. */ export declare class InstanceEconomies { private readonly deps; private readonly options; private readonly registry; private readonly lanes; private readonly mint; constructor(deps: InstanceEconomyDeps, options: InstanceEconomiesOptions); /** The scope's current-epoch lane, opened on demand. */ laneFor(scope: string): InstanceEconomy; /** * Settles the scope's current epoch now; the next `laneFor` opens the next epoch. A settle * that throws keeps the lane (a settled session refuses new movements on its own, and the * journal is durable), so the next rotate or sweep retries instead of stranding the epoch. */ rotate(scope: string): Promise; /** Rotates every scope whose epoch is over its movement or age bound — the cadence hook. */ sweep(): Promise; /** Rotates everything — the shutdown or drain hook. */ settleAll(): Promise; /** * Runs `sweep` on a timer — the Scheduler port when the deps carry one, a built-in interval * otherwise — and returns the stop function. Stopping the timer settles nothing; call * `settleAll` to drain. */ start(everyMs: number): () => void; /** * The user's pending-out total across every lane this manager runs (they share one registry), * so a display layer shows `balance - pending` with one call, whatever scope the spend is in. */ pending(userId: string): Promise; /** The dashboard roll-up across every open lane. */ stats(): { scopes: number; accepted: number; rejected: number; }; private rotateAll; }