ZAAR

Data model

Normative. Words like MUST follow RFC 2119. Changing anything here is a decision (decisions.md) and, if it touches the journal, a format version bump (journal.md).

Identifiers and time

Entities

Network (singleton, created by Genesis)
 ├─ Asset        id u32, code, class, decimals, name, status
 ├─ Market       id u32, symbol BASE-QUOTE, base, quote, tick, lot, filters, fees, status
 ├─ FeeSchedule  default maker/taker ppm; per-market override; per-org override (later)
 └─ Org          id, slug, name, status, fee_share_ppm, fee_account
     ├─ App      id, org, name, status            (attribution for orders; OAuth client later)
     ├─ User     id, org, external_ref, status    (external_ref = the org's own user id)
     │   └─ Account   id, owner, kind, label, status
     │        └─ Balance (account, asset)
     └─ ApiKey   id, public_key, principal, scopes, accounts?, ip_allow?, expires, status
Order, Trade, Transfer — facts produced by commands

Assets

code      1–12 chars [A-Z0-9.], unique, immutable     BTC, USD, EUR, XAU, AAPL, AAPL.L
class     Currency | Crypto | Commodity | Equity | Fund | Index | Bond | Other
decimals  0..=18, immutable: minimal units per whole unit = 10^decimals
status    Active | Disabled

Choose decimals with room for price precision (see markets). Recommended: fiat 8, crypto 8, commodities 8, equities 8 (fractional shares).

Balances (TigerBeetle model)

Per (account, asset):

credits_posted  u128    total ever credited
debits_posted   u128    total ever debited
debits_pending  u128    locked: reserved by open orders, not yet debited
balance   = credits_posted − debits_posted          (i128 view; only Issuance may be < 0)
free      = balance − debits_pending
locked    = debits_pending

Non-Issuance accounts MUST satisfy debits_posted + debits_pending ≤ credits_posted after every command. Balances exist implicitly at zero.

Transfers

Every change to *_posted is a transfer. Nothing else moves value.

Transfer { id u64, debit_account, credit_account, asset u32, amount u128 (> 0),
           kind, ref u64 (order/trade id or 0), app u64, ts }

kind: Mint (Issuance → acct)     Burn (acct → Issuance)
      Transfer (acct → acct, by a user or org)
      Trade (buyer ↔ seller leg)  Fee (payer → NetworkFees)  FeeShare (payer → OrgFees)
      Import (Issuance → acct, only during a migration genesis; see journal.md)

Markets

symbol   "BASE-QUOTE", e.g. BTC-USD, AAPL-USD, XAU-EUR (dash: unambiguous and URL-safe)
tick     quote minimal units per whole base unit; price MUST be a positive multiple of tick
lot      base minimal units; quantity MUST be a positive multiple of lot
filters  min_qty, max_qty, min_notional (quote minimal units), max_open_orders per account
status   Trading | CancelOnly | Halted

Orders

Order { id, account, market, side Buy|Sell, type, tif, price?, qty, quote_qty?,
        filled_qty, filled_quote, fee_paid, status, client_order_id, stp, app, created, updated }

type    Limit | Market | LimitMaker           (StopLoss*, TakeProfit*, Pegged: reserved)
tif     GTC | IOC | FOK                       (Limit only; Market is always IOC)
stp     None | ExpireTaker (default) | ExpireMaker | ExpireBoth   — self-trade = same account
status  New | PartiallyFilled | Filled | Canceled | Rejected | Expired

Entry validation (rejections)

An order is rejected only when it is invalid at entry. A rejected order gets no id: it changes nothing, and if the key was authorized for the account it emits one Order event with exec = Rejected and id = 0 so the user stream sees it. Checks, in order:

  1. Key, scope TRADE, account reachable (auth.md); the account is Trading or OrgTreasury, Active, and its owner is Active.
  2. Market known and Trading; both assets Active.
  3. Shape. Limit: price and qty, any tif. LimitMaker: price and qty, tif GTC. Market: no price, tif IOC, exactly one of qty / quote_qty; quote_qty only on a buy.
  4. Price > 0 on the tick; qty > 0 on the lot and within [min_qty, max_qty]; for limit types notional(price, qty) ≥ min_notional; for a quote-qty market buy quote_qty ≥ min_notional (a qty market order has no notional check).
  5. client_order_id not used by an open order of the account; for an order that may rest (GTC limit, LimitMaker) the account has fewer than max_open_orders open orders on the market; a LimitMaker must not cross the book.
  6. Funds: every Limit and LimitMaker (whatever its tif) locks its full notional (buy) or quantity (sell) at entry; not enough free balance → rejected.

Outcomes of accepted orders

Accepted orders get the next order id and emit Order { exec: New }; then they match, then:

Events

Each command emits its events in causal order (per fill: Trade, its transfers, the maker's Order, the taker's Order), then one Balance per touched (account, asset) sorted by (account, asset), then one Level per touched (market, side, price), sorted, with qty = 0 for a removed level.

Fees

FeeSchedule { maker_ppm u32, taker_ppm u32 }    network default: 1000 / 1000  (0.1% = 1000 ppm)
Org.fee_share_ppm u32                            default: 500_000  (50% of fees its users pay)

Per fill, each side pays a fee in the asset it receives (buyer pays base, seller pays quote), so no extra funds are ever locked for fees:

fee       = ceil(received × rate_ppm / 1_000_000)        rounds towards the network
org_part  = floor(fee × org.fee_share_ppm / 1_000_000)    → payer's org OrgFees     (FeeShare)
net_part  = fee − org_part                                 → NetworkFees             (Fee)

The org that earns the share is the org of the user who owns the paying account. Fee resolution order: per-org override (reserved) → per-market override → network default. Rates and shares are changed by admin commands and take effect for the next fill.

A fill therefore settles as up to six transfers in one atomic step, in this order: base seller→buyer, quote buyer→seller, the maker's Fee and FeeShare, then the taker's Fee and FeeShare. Zero-amount parts are skipped (e.g. a 1-unit fee goes entirely to the network). Fee rates and shares are at most 1 000 000 ppm. For an org-owned account the earning org is the owner.

Invariants (asserted after every command in debug, every step in the simulator)

  1. Per asset: Σ balance over all accounts = 0.
  2. Every non-Issuance balance: debits_posted + debits_pending ≤ credits_posted.
  3. Per (account, asset): debits_pending = Σ locks of that account's open orders in that asset.
  4. Every book is uncrossed: best bid < best ask.
  5. Every resting order has 0 < remaining, a price on the tick, and status New or PartiallyFilled.
  6. Replay: applying the same journal twice yields the same state hash.