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
- Every entity id is a
u64allocated by the core from a per-kind counter, starting at 1.0means "none". Ids are never reused. JSON carries them as numbers (they stay far below 2^53). - Assets and markets also have ids (
u32) and immutable codes (BTC,BTC-USD). - Client-chosen ids (
clientOrderId,clientTransferId) are strings of 1–36 chars[A-Za-z0-9_.:-], unique per account among that account's open orders (orders) or forever (transfers). They make retries idempotent. - Time is
u64nanoseconds since the Unix epoch, assigned by the sequencer:ts = max(wall_clock_ns, prev_ts + 1). It is journaled, so every timestamp is unique and monotonic. The API speaks milliseconds by default.
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
Org is a tenant: an exchange, broker or anyone hosting an app on ZAAR. Network admins create orgs. The network itself trades through an org of its own (
zaar).App belongs to an org. Every order and transfer records the app it came through, so fees and volume can be attributed per app later without migration.
User belongs to exactly one org and is identified there by
external_ref, so an org can map its own users without sharing their identity with ZAAR. A person who uses two orgs is two users. (A cross-org Identity that links users is reserved for later; see auth.md.)Account holds balances. Kinds:
kind owner may go negative purpose Tradinguser no a user's account (a user may have many, like sub-accounts) OrgTreasuryorg no an org's own funds OrgFeesorg no the org's share of fees (one per org, created with it) NetworkFeesnetwork no the network's share of fees (one, created at genesis) Issuancenetwork yes source of minted and sink of burned assets (one, at genesis) Accounts, users, orgs and keys are never deleted, only set to
DisabledorClosed. History must always resolve.Genesis creates, with fixed ids: account 1
Issuanceand account 2NetworkFees(ownerNetwork), org 1zaar(nameZAAR, fee share = genesisorg_fee_share_ppm) with account 3 as itsOrgFees, and key 1 (admin_public_key, principalNetwork, all scopes). EachCreateOrgallocates the org id, then itsOrgFeesaccount id, then its admin key id (if given).Account status.
Frozenrefuses debits by its owner (transfers out, new orders) but accepts credits; freezing cancels the account's open orders.Closedrequires all balances zero and no open orders, is final, and refuses credits too. AdminBurnworks on a frozen account. System accounts (Issuance,NetworkFees,OrgFees) cannot change status. A disabled user (or org) cannot debit its accounts either; its resting orders stay on the book.
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)
- One command's transfers apply atomically (TigerBeetle "linked"): all or none. A rejected command changes nothing (besides the journal sequence).
- A
Transferdebits a reachableTrading,OrgTreasuryorOrgFeesaccount and credits any other account that is notIssuanceand notClosed;amount > 0. Mint and Burn never targetIssuanceitself. - A transfer's
appis the app of the command's key (Appprincipal) or 0. For a fill, the trade legs and the taker's fees carry the taker's app; the maker's fees carry the maker order's app. - Debit and credit are different accounts, except the two
Tradelegs of a self-trade (allowed only withstp = None), which net to zero. - Invariant: for each asset, Σ over all accounts of
balance= 0. - Transfers between accounts of different orgs are allowed by default.
An org-level policy to restrict them is reserved
(
Org.transfer_policy).
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
priceis in quote minimal units per whole base unit (what users see: "65000.00 USD").notional = price × qty / 10^base.decimals(quote minimal units).- Market creation MUST reject params unless
(tick × lot) mod 10^base.decimals = 0. Then every notional is an exact integer: nothing in matching ever rounds. Example: BTC(8)/USD(8), tick $0.01 = 10^6, lot 0.00001 BTC = 10^3 → 10^9 mod 10^8 = 0 ✓. - Creation also requires: base ≠ quote, both assets known and
Active,tick > 0,lot > 0,lot ≤ min_qty ≤ max_qtywith both multiples oflot,max_open_orders > 0, fee rates ≤ 100%, and no existing market for the same (base, quote). - Status:
Tradingaccepts orders and cancels;CancelOnlyaccepts only cancels;Haltedaccepts neither. Resting orders stay on the book in every status. - Markets of every asset class work the same way. Trading hours, corporate actions and expiries are later market fields; v1 markets trade 24/7.
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
- Locks. A resting Buy locks
notional(price, remaining)quote; a Sell locksremainingbase. Fills consume the lock; cancel/expire release the rest. A buy filled at a better price than its limit releases the difference. - Market orders walk the book at once (they are never
resting), so they lock nothing: they fill while funds last and expire
the rest. A Market Buy may give
quote_qtyinstead ofqty. - Matching is price-time priority per market. A
LimitMakerthat would cross is rejected.FOKfills completely or not at all (checked before any fill). - Amend (reduce qty, keep priority) and order lists (OCO etc.) are reserved for v1.1.
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:
- Key, scope
TRADE, account reachable (auth.md); the account isTradingorOrgTreasury,Active, and its owner isActive. - Market known and
Trading; both assetsActive. - Shape.
Limit: price and qty, any tif.LimitMaker: price and qty, tifGTC.Market: no price, tifIOC, exactly one ofqty/quote_qty;quote_qtyonly on a buy. - Price
> 0on the tick; qty> 0on the lot and within[min_qty, max_qty]; for limit typesnotional(price, qty) ≥ min_notional; for a quote-qty market buyquote_qty ≥ min_notional(a qty market order has no notional check). client_order_idnot used by an open order of the account; for an order that may rest (GTClimit,LimitMaker) the account has fewer thanmax_open_ordersopen orders on the market; aLimitMakermust not cross the book.- Funds: every
LimitandLimitMaker(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:
- Filled: nothing remains.
- Rests (
NeworPartiallyFilled): aGTClimit whose remainder no longer crosses. - Expired, never rejected: an
IOCremainder; aFOKthat cannot fill completely; a market order that runs out of liquidity or funds; a remainder stopped by self-trade prevention. The remainder's lock is released. - FOK is checked before any fill, with the same
matching rules (STP and funds included). If it cannot fill completely it
is accepted and expires with no fills, no transfers and no effect on
resting orders: events
Order { New },Order { Expired }; reply with empty fills. - Market orders lock nothing and spend free balance
at each maker's price: a buy fills while free quote lasts, a sell while
free base lasts (both rounded down to the lot). A market buy by
quote_qtystops when the remaining budget cannot buy one lot at the current price: it isFilledif it filled anything, elseExpired. - Self-trade prevention follows the taker's
stpwhen the next maker belongs to the same account:ExpireTakerstops the taker (its remainder expires, evenGTC);ExpireMakerexpires that maker (releasing its lock) and keeps matching;ExpireBothdoes both.Nonelets the account trade with itself (fees are still paid). - Closed orders (filled, canceled, expired) leave live state after their final event; history serves them.
- Cancel and cancel-all release the remaining lock; they are accepted
while the market is
TradingorCancelOnly.
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)
- Per asset: Σ balance over all accounts = 0.
- Every non-Issuance balance:
debits_posted + debits_pending ≤ credits_posted. - Per (account, asset):
debits_pending= Σ locks of that account's open orders in that asset. - Every book is uncrossed: best bid < best ask.
- Every resting order has
0 < remaining, a price on the tick, and status New or PartiallyFilled. - Replay: applying the same journal twice yields the same state hash.