Docs/Use casesv0.0.1-preview

Rewards and closed-loop economies

Points, credits, and in-game currencies as first-class ledger money — earned, redeemed, conserved, and impossible to overspend.

On this page
  1. Points are a currency
  2. Earn, redeem, and the liability account
  3. Expiring points and promotions
  4. Gaming and usage economies
  5. Related

Loyalty points, store credits, and in-game currencies fail in the same way real money does — double-credits from retried webhooks, negative balances from race conditions, totals that drift from what was issued — but usually with none of real money’s protections, because “it’s just points” until the liability shows up on a balance sheet. Running them on the ledger gives them the same guarantees as dollars, on the same engine.

Points are a currency

Register the unit once; the engine then enforces that points never mix with money — a transfer whose currency doesn’t match its ledger is refused:

pts = c.create_currency(CurrencyInstance(numeric=0, alpha="PTS",
                                         scale=0, name="Loyalty points"))
P = c.create_ledger(LedgerInstance(ledger_id=0, currency=pts.numeric)).ledger_id

scale=0 makes points indivisible. A game with gems and gold registers two currencies and two ledgers; balances can never cross-contaminate.

Earn, redeem, and the liability account

The program pool is the issuing account in the points ledger P; every member account floors at zero:

POOL, MEMBER, APP = 40001, 42001, 1     # integer ids — unique across the engine in this release

c.create_account(AccountCreate(id=POOL, ledger_id=P, currency=pts.numeric,
                               limit_low=-10**15, limit_high=10**15))
c.create_account(AccountCreate(id=MEMBER, ledger_id=P, currency=pts.numeric,
                               limit_low=0, limit_high=10**9))

# Earn: pool → member (250 points for a purchase)
c.transfer(TransferCreate(source_id=APP, ledger_id=P, currency=pts.numeric,
        debit_account_id=POOL, credit_account_id=MEMBER,
        amount=250, txn_id="earn-order-7841"))

# Redeem: member → pool
c.transfer(TransferCreate(..., debit_account_id=MEMBER, credit_account_id=POOL,
        amount=100, txn_id="redeem-19"))

Engine behaviours that carry the whole use case:

  • Over-redemption is impossible: redeeming 500 against a 150-point balance is declined with ERR_LIMIT by the engine, not by a check you remembered to write.
  • The pool’s negative balance is your outstanding liability — total points in circulation, exact at any instant, because every earn and redeem is double-entry.
  • Retried earns credit once: txn_id="earn-order-7841" keyed to the triggering event makes webhook redelivery return OK_DUPLICATE instead of double-crediting.

Expiring points and promotions

Issue promotional points as a TTL hold instead of a posted transfer: commit when the member spends them, and let the engine expire the unspent remainder to its distinct EXPIRED state — breakage becomes a query, not a batch job.

Gaming and usage economies

The same shape runs in-game economies (mint into a sink account, floor player balances at zero, every drop and purchase idempotent by event id) and usage metering (each API call or kWh a transfer; the meter account’s balance is the invoice). The engine’s throughput characteristics are what make per-event granularity viable — batch frames carry up to 1,024 transfers.

Wallets · Accounts and limits · Idempotency