Postgres tables that back the DEX.DO read-model and indexer. Source of truth is the migration set under /migrations; this document describes intent and field semantics. Schema changes ship as numbered migration files (NNNN_*.sql) and are applied by sqlx::migrate! at service startup (crates/infrastructure/src/database.rs).
Tables fall into five buckets:
| Bucket | Tables | Owner |
|---|---|---|
| Reference data | ref_tokens |
Seeded by migrations; read-only at runtime. |
| Indexer infrastructure | raw_events, indexer_cursors |
Indexer ingestion path. |
| Read-model — discovery | oracles, oracle_event_lists, oracle_events |
Indexer projectors + OracleEventList reconciler. |
| Read-model — markets | markets, market_outcomes, live_orders, order_book_snapshots |
Indexer projectors + market reconciler. |
| Read-model — inference markets | inference_markets, inference_orders, inference_trades |
Indexer projectors + inference reconciler. |
| Read-model — inference deals | inference_deals, inference_ticks |
Inference SETTLEMENT projector (writer). Intended to back the forthcoming rewards service (reader). |
| Authentication and credentials | accounts, api_keys |
Operator-provisioned; read on every signed request by the auth middleware. |
Read-model — Postgres tables prepared for API reads. They are derived from chain events and contract state so the API can answer requests without decoding the blockchain state on every call.
Projector — code that handles one decoded chain event and writes the corresponding read-model change. For example, the OrderBook.OrderPlaced event creates or refreshes a row in live_orders.
Reconciler — background indexer task that periodically reads contract state through getters and fills fields that events alone do not provide. The market reconciler reads PMP state (getDetails, getOrderBookAddress) and updates markets / market_outcomes. The OracleEventList reconciler reads _events from each EventList contract and fills missing event metadata in oracle_events, such as describe and trust_addr.
Static collateral-token catalogue. The indexer joins against it when a PMPDeployed event references a tokenType; the API surfaces precision and trading-rule constants per outcome through it.
| Column | Type | Notes |
|---|---|---|
token_type |
integer PK |
Numeric token type as the contract uses it (NACKL=1, SHELL=2, USDC=3). |
token_code |
text UNIQUE |
User-facing asset code (USDC, etc.). |
decimals |
integer |
On-chain decimal places. |
min_notional |
numeric(78,0) |
Minimum order notional, in raw uint256 units of the token. Scaled to a decimal at API render time. |
lot_size |
numeric(78,0) |
Minimum order quantity increment, raw units. |
tick_size_bps |
numeric(78,0) |
Price tick in basis points (contracts use TICK_SIZE = 10). |
price_precision |
integer |
Decimal places for the price field exposed to clients. |
quantity_precision |
integer |
Decimal places for the quantity field exposed to clients. |
enabled |
boolean |
Reserved — not read on the hot path today. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Seeded values: (1, NACKL, 9, ...), (2, SHELL, 9, ...), (3, USDC, 6, ...). Adding a new collateral token is a migration-time change.
The append-only event log. Every in-scope message edge from either filtered GraphQL stream lands here, decoded or not, before any projector runs. The gateway first selects the DEX src_dapp_id or legacy RootPN source; the indexer then keeps only the indexed dst allow-list, which explicitly excludes every TokenContract.* route. See Server-side capture scope and Ingest scope. It is the recovery boundary for the read-model: reprojection replays decoded but unprojected rows here, and downstream tables can always be rebuilt from this one plus a clean schema.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Insertion order. Not used for ordering — that's chain_order below. |
msg_id |
text UNIQUE |
Chain-side message id. Prevents duplicate ingestion across overlapping page fetches. |
chain_order |
text NOT NULL |
Global lex-sortable chain order from the GraphQL gateway's msg_chain_order. The strict-monotonic projection key — created_at_chain collides within one second and drifts across shards, so any reproject sweep that ordered on time could apply OrderFilled before its parent OrderPlaced. Required on every row; edges arriving without it are dropped at ingest. |
created_at_chain |
timestamptz |
Chain block timestamp from the GraphQL created_at field. Kept for diagnostics/analytics only — not load-bearing for ordering. Nullable, preserved as-is. |
src_address |
text |
Source contract address (the contract that emitted the event). |
dst_address |
text |
Destination address from the message header. |
event_type |
text |
"<ContractKind>.<EventName>", e.g. OrderBook.OrderPlaced. NULL when decoding failed or the body was not an event message. |
body_json |
jsonb |
Raw message body JSON as ingested. |
decoded |
jsonb |
ABI-decoded event payload. Filled at ingest time if decoding succeeds; reprojection reuses this — bodies are not re-decoded. |
processed_at |
timestamptz |
Stamped by the projector when the row is Applied or Unknown. NULL = pending; covered by the reprojection sweep. |
created_at |
timestamptz |
Indexer ingestion time (wall-clock). |
Indices:
| Index | Purpose |
|---|---|
raw_events_event_type_idx |
General event_type scans (debug, analytics). |
raw_events_event_type_decoded_idx (partial, event_type IS NOT NULL) |
Same scope but optimised for decoded rows. |
raw_events_created_at_chain_idx (desc) |
Time-window queries (analytics only). |
raw_events_chain_order_idx |
Backs the projection loop's ORDER BY chain_order ASC. |
raw_events_pending_chain_order_idx (partial: processed_at IS NULL AND event_type IS NOT NULL AND decoded IS NOT NULL) |
Backs the projection loop's keyset scan (crates/infrastructure/src/indexer_repo.rs::reproject_pending_from). Keyed on chain_order to match the loop's ORDER BY. |
raw_events_pending_src_idx (partial: processed_at IS NULL AND event_type IS NOT NULL AND decoded IS NOT NULL) |
Indexed on src_address. Allows the inference reconciler's sweep catch-up gate to probe "are there any pending events for this book?" as an index probe on src_address = orderbook_address, rather than a full-table scan. |
raw_events_unprocessed_src_idx (partial: processed_at IS NULL) |
Indexed on src_address. Backs the read gate's "we cannot see everything for this book" probe: every event an InferenceOrderBook emits is in the loaded ABI, so any unprojected row under that src_address — pending-decodable, undecoded, bodyless, or an unrecognized id — means the view of that book is incomplete. Broader than raw_events_pending_src_idx's decode-outcome-scoped predicate, so Postgres cannot reuse that index for this probe. |
Resume-points and the synchronized projection barrier. The indexer persists each source cursor after every page so a restart does not reprocess the full retained history. When no row exists for a source, capture bootstraps from the oldest matching event the gateway still retains rather than from a null cursor — see Cold start.
The production rows are:
stream_name |
Role |
|---|---|
blockchain_events_dex_dapp |
Resume cursor and at_head for blockchain.events(src_dapp_id = DEX_DAPP_ID). |
blockchain_events_root_pn |
Resume cursor and at_head for RootPN.account.events. |
blockchain_events |
Aggregate ordering/freshness row. Its cursor is the largest globally ordered prefix proved complete by both source streams; its at_head is true only when both streams reached head in one successful tick. The projector, metrics, inference orphan gate, and read API consume this row. |
| Column | Type | Notes |
|---|---|---|
stream_name |
text PK |
Logical stream identifier (e.g. one per filter-set the indexer subscribes to). |
cursor |
text |
Source rows: opaque msg_chain_order cursor returned by GraphQL. Aggregate row: synchronized projection watermark computed from the source cursors. |
updated_at |
timestamptz |
Source rows: last successful page commit. Aggregate row: last tick in which both source drains completed successfully. |
at_head |
boolean NOT NULL default false |
Source rows mirror that stream's has_next_page=false. Aggregate row is true only when both source streams are at head; the inference reconciler and read API use that aggregate state as their catch-up gate. |
The discovery side of the indexer tracks oracles, their event lists, and the events those lists carry. These tables feed the event.* block in /api/v1/prediction/markets responses.
One row per oracle service the system knows about. Populated by the RootOracle.OracleDeployed event and back-filled from EventList parent lookups.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal FK target. |
name |
text UNIQUE |
Oracle name as registered on chain (e.g. ElectionOracle). |
address |
text UNIQUE |
Oracle contract address. |
deploy_msg_id |
text UNIQUE (nullable) |
Message id of the deploy event. NULL if the oracle was discovered indirectly. |
pubkey |
text |
Oracle pubkey from the deploy event. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Each oracle owns a sequence of EventList contracts created by the Oracle.OracleEventListDeployed event. The indexer's OracleEventList reconciler processes one EventList at a time: it reads that contract's _events getter and updates the related oracle_events rows with metadata such as describe and trust_addr.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal FK target. |
msg_id |
text UNIQUE |
Deploy event message id. |
oracle_id |
bigint FK → oracles(id) ON DELETE CASCADE |
Parent oracle. |
address |
text UNIQUE |
EventList contract address. |
list_index |
bigint |
Oracle-local index of the event list. |
description |
text NOT NULL |
Human-readable list description from the OracleEventListDeployed event (always carried; projector reads it strictly). May be an empty string, never null. Surfaced as /api/v1/oracles eventLists[].description. |
created_at |
timestamptz |
Bookkeeping. |
last_reconcile_failed_at |
timestamptz |
Stamped when a reconcile attempt fails. Used for backoff and queue ordering. |
reconcile_attempts |
integer default 0 |
Diagnostic counter for permanently broken EventLists. |
Index: oracle_event_lists_oracle_id_idx speeds up loading all EventList rows for one oracle.
The actual events inside each EventList. Two writers:
- Projector writes
event_name,oracle_fee,deadline, and theconfirmed_*columns from theEventAddedandEventConfirmedevents, and — for numeric range events —range_ob_address+range_bounds_jsonbfrom theRangeEventAddedevent (which carries both). - OracleEventList reconciler fills the metadata that lives only in getter state:
describeandtrust_addrfrom_events.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal FK target. |
eventlist_id |
bigint FK → oracle_event_lists(id) ON DELETE CASCADE |
Parent EventList. |
internal_id_in_eventlist |
numeric(78,0) |
Event id within the EventList. The pair (eventlist_id, internal_id_in_eventlist) is UNIQUE. |
event_name |
text |
From the EventAdded event. Surfaces as event.eventName. |
oracle_fee |
numeric(78,0) |
From the EventAdded event. |
deadline |
bigint |
Event deadline (unix seconds). |
describe |
text |
Event description — reconciler-only field. NULL until OracleEventList reconciler runs. |
count |
numeric(78,0) |
Reserved metadata field from _events. |
trust_addr |
text |
Reconciler-only field. Optional on chain — may stay NULL even after reconciliation. |
outcome_names_jsonb |
jsonb default '{}'::jsonb |
Outcome label map (outcomeId → name). |
range_ob_address |
text (nullable) |
For a numeric range event: the InferenceOrderBook whose weekly-median price resolves the outcome (OracleEventList._rangeData[eventId].ob, spec §6.2). NULL for plain events. Set by the RangeEventAdded projector. Indexed by oracle_events_range_ob_idx; the reverse lookup (markets resolving from a given inference book) backs the resolvesFrom filter on /api/v1/prediction/markets. |
range_bounds_jsonb |
jsonb (nullable) |
For a range event: the strictly-increasing numeric upper bounds (n bounds → n+1 outcomes), as a JSON array of decimal strings. NULL for plain events. Set by the RangeEventAdded projector. The human labels for those ranges are already in outcome_names_jsonb, so the API does not re-expose the raw bounds. |
is_deleted |
boolean default false |
Soft-delete flag for events that disappear from the EventList. |
last_seen_at |
timestamptz |
Updated on every projector pass that touches the row. |
confirmed_pmp_address |
text |
Set by the EventConfirmed event. Links an event to the PMP that markets it. |
confirmed_at |
timestamptz |
Stamp time recorded when the projector observes the confirmation event. |
meta_reconciled_at |
timestamptz |
Per-row marker — set unconditionally by the OracleEventList reconciler after a successful getter pass, even when describe/trust_addr come back NULL on chain. Drives the pending-row predicate so legitimately-null fields don't cause infinite re-fetch. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Indices:
| Index | Purpose |
|---|---|
oracle_events_eventlist_id_idx |
Speeds up loading all event rows for one EventList. |
oracle_events_deadline_idx |
Time-window queries. |
oracle_events_confirmed_pmp_idx (partial: confirmed_pmp_address IS NOT NULL) |
Reverse-lookup from PMP back to event. |
oracle_events_pending_meta_idx (partial: meta_reconciled_at IS NULL) |
Drives the OracleEventList reconciler's pending-row SELECT. |
oracle_events_range_ob_idx (partial: range_ob_address IS NOT NULL) |
Reverse lookup from an inference order book to the range events (and thus prediction markets) that resolve from it. Backs the resolvesFrom filter on /api/v1/prediction/markets. |
One row per PMP (Prediction Market Pool) contract observed on chain. Discovered by the PMPDeployed event, completed by the market reconciler reading PMP.getDetails(), and transitioned by the TimingsSet event, the PoolsFrozen event, the Resolved event, the PMPRejected event, and the EventCancelled event. Hidden from the public API until last_reconciled_at is non-null.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal FK target. |
pmp_address |
text UNIQUE |
The PMP contract address. Exposed as predictionMarketAddress. |
market_id |
text |
Market identifier from getDetails(). NULL pre-reconcile. |
name |
text |
Market display name from getDetails(). Surfaces as marketName. |
token_type |
integer FK → ref_tokens(token_type) |
Quote-asset token type. |
token_code |
text |
Quote-asset code (denormalised from ref_tokens for read speed). |
event_id |
numeric(78,0) |
Oracle event id this market resolves against. |
oracle_list_hash |
numeric(78,0) |
EventList hash used in OrderBook derivation. NULL pre-reconcile. |
orderbook_address |
text |
The deterministic OrderBook address returned by PMP.getOrderBookAddress(). Written by the market reconciler on the first successful pass, including pre-PoolsFrozen rows. Nullable only during the pre-reconcile window; the CHECK predicate last_reconciled_at IS NULL OR orderbook_address IS NOT NULL enforces that every market visible to the API has a non-null predictionOrderBookAddress. A partial UNIQUE index on orderbook_address WHERE orderbook_address IS NOT NULL pins the contract-side per-market invariant — /api/v1/prediction/orders joins live_orders to markets on this column and relies on the at-most-one-row guarantee. |
approved |
boolean default false |
Approval flag from getDetails(); flipped to true by the TimingsSet event. |
is_cancelled |
boolean default false |
On-chain cancellation flag from getDetails(). Either this or cancelled_at being set is enough to flip the derived status to CANCELLED. |
stake_start / stake_end / result_start / result_end |
bigint (nullable) |
Lifecycle timings (unix seconds). Written only by the TimingsSet event; reconciler does not touch these (H2 fix). NULL on all four = PENDING. |
num_outcomes |
integer default 0 |
Outcome count from getDetails(). |
oracle_event_lists_json |
jsonb |
Auxiliary data from the PMPDeployed event for outcome-resolution. |
oracle_fee_json |
jsonb |
Same. |
last_reconciled_at |
timestamptz |
Stamped by the market reconciler after a successful pass. The public API filters on last_reconciled_at IS NOT NULL — markets without this are invisible to clients. |
frozen_at |
bigint |
Block timestamp of the PoolsFrozen event. Required for any post-freeze status (TRADING / RESOLVING / EXPIRED / RESOLVED). |
resolved_at |
bigint |
Block timestamp of the PMP.Resolved event. |
resolved_outcome_id |
integer |
Winning outcome id. |
cancelled_at |
bigint |
Block timestamp of the PMP.PMPRejected or PMP.EventCancelled event. May also be back-filled to now() by the reconciler if the chain flag flipped before the event was replayed. |
cancel_reason |
text |
'PMP_REJECTED_BY_ORACLE' or 'EVENT_CANCELLED'. Required when cancelled_at is set; the API fails closed (HTTP 503) when CANCELLED is derived without a valid reason. |
last_reconcile_failed_at |
timestamptz |
Backoff bookkeeping for the market reconciler. |
reconcile_attempts |
integer default 0 |
Diagnostic counter. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Indices:
| Index | Purpose |
|---|---|
markets_market_id_idx |
Lookup by market_id. |
markets_status_idx (approved, is_cancelled) |
Coarse status filters. |
markets_pending_reconcile_idx (partial: last_reconciled_at IS NULL) |
Drives the market reconciler's pending-row SELECT. |
markets_terminal_idx (partial: resolved_at IS NOT NULL OR cancelled_at IS NOT NULL) |
Terminal-status filters. |
markets_orderbook_address_unique (partial UNIQUE: orderbook_address IS NOT NULL) |
Pins the per-market invariant; relied on by /api/v1/prediction/orders's all-markets join. |
One row per outcome of each market. Source for outcome listings and the per-outcome trading-rule constants the API publishes. Populated by the reconciler after getDetails() resolves outcome names + per-outcome precision metadata.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal FK target. |
market_id_fk |
bigint FK → markets(id) ON DELETE CASCADE |
Parent market. |
pmp_address |
text |
Denormalised from markets.pmp_address for fast (pmp_address, outcome_id) joins. |
outcome_id |
integer |
Stable outcome id used in trading. The pair (pmp_address, outcome_id) is UNIQUE. |
outcome_name |
text |
Outcome display name. |
symbol |
text UNIQUE |
The outcome-token symbol (<marketName>-<OUTCOME_NAME>). |
price_precision |
integer |
Decimal places for prices. Used at API render time to scale raw uint256 prices. |
quantity_precision |
integer |
Decimal places for quantities. Same. |
tick_size |
text |
Minimum price increment as a decimal string. |
step_size |
text |
Minimum quantity increment as a decimal string. |
min_notional |
text |
Minimum order notional as a decimal string. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Index: market_outcomes_market_id_fk_idx speeds up loading all outcome rows for one market. Symbol is globally unique by construction.
Per-order read model backing /api/v1/prediction/depth and account-scoped
GET /api/v1/prediction/orders. One row per chain-side order, mutated in place as
OrderPlaced, OrderFilled, and OrderCancelled events arrive. Rows are never
deleted — FILLED / CANCELLED entries remain so that history queries and the depth
handler (max(last_chain_order) across all rows for the (orderbook, outcome) pair)
both see them.
| Column | Type | Notes |
|---|---|---|
orderbook_address |
text (PK part 1) |
OrderBook contract address. |
order_id |
numeric(78,0) (PK part 2) |
Chain-side order id. The pair (orderbook_address, order_id) is the primary key. |
outcome_id |
integer |
Which outcome this order is on. |
is_buy |
boolean |
Side. true = bid, false = ask. |
price |
numeric(78,0) |
Order price as the contract emitted it — raw uint256 in basis points (probability × FULL_PERCENT = 10 000). Decoded to a decimal at API render time (÷ FULL_PERCENT, formatted at price_precision). |
amount_initial |
numeric(78,0) |
Original order quantity from OrderBook.OrderPlaced, in raw token atoms (× 10^decimals). Used with amount_remaining to render origQty / executedQty (decoded ÷ 10^decimals, formatted at quantity_precision) in account order endpoints. |
amount_remaining |
numeric(78,0) |
Quantity not yet filled. Set by the OrderPlaced event and decremented by the OrderFilled event. OrderCancelled preserves the current value as the cancelled remainder so /api/v1/prediction/orders.executedQty can be derived as amount_initial - amount_remaining; depth ignores the row because status != 'OPEN'. See the orders cancel-remainder cutover note for data-bearing deployment guidance. |
client_order_id |
text |
Optional client-supplied id. |
owner_pn_address |
text |
Trading PrivateNote address that owns the order. Initially NULL from OrderBook.OrderPlaced; attached by PrivateNote.OrderPlacedConfirmed using the event source address. NULL rows can still contribute to public depth, but cannot appear in account-scoped order responses. |
status |
text CHECK IN ('OPEN', 'FILLED', 'CANCELLED') |
Order lifecycle. Depth aggregation filters on status = 'OPEN' AND amount_remaining > 0. The CHECK is extended to include 'REJECTED' by the contracts-side follow-up documented in read-api.md §REJECTED — future work; until then no row carries that value. |
last_chain_order |
text NOT NULL |
Chain-order key (msg_chain_order from the gateway) of the most recent OrderBook event that touched this order. Lex-monotonic via greatest(existing, new) on OrderBook writes. Feeds lastUpdateId in depth responses as a STRING. |
chain_created_at |
timestamptz |
On-chain block time of the originating OrderBook.OrderPlaced. Drives time in /api/v1/prediction/orders. Display-only. NULL for pre-migration rows. |
chain_updated_at |
timestamptz |
On-chain block time of the most recent order book event that affected the order — OrderPlaced, OrderFilled, OrderCancelled. Advanced via greatest(...). Drives updateTime in /api/v1/prediction/orders. Display-only. NULL for pre-migration rows. |
placed_chain_order |
text not null |
msg_chain_order of the OrderPlaced event that created the row. First-write-wins (coalesce on conflict). Sole sort key + cursor for /api/v1/prediction/orders (DESC). |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Index: live_orders_open_book_idx — partial index on (orderbook_address, outcome_id, is_buy, price DESC) with predicate status = 'OPEN'. Sized for the depth query: top-N price levels per side and outcome.
Index: live_orders_owner_idx — partial index on (owner_pn_address, placed_chain_order DESC)
with predicate owner_pn_address IS NOT NULL AND chain_created_at IS NOT NULL.
Serves as the seek path for the cursor-based /api/v1/prediction/orders query (DESC by chain-order): a single-
column lexicographic range scan over placed_chain_order. The partial predicate confines the index
to owner-attributed rows whose timestamps are renderable. Status filtering (OPEN vs FILLED vs
CANCELLED vs the future REJECTED) is intentionally a heap-side predicate so that one index
covers both the default "all statuses" query and any CSV-driven subset; per-owner row counts are
small enough that this is cheaper than maintaining a wider composite index.
The chain_updated_at IS NOT NULL condition stays in the SQL query as a heap filter, keeping the
index independent of a display-only timestamp column that advances on every OrderFilled event.
Cancel projection preserves amount_remaining, so
executedQty = amount_initial - amount_remaining holds across cancellation.
Data-bearing cutover guidance lives in
orders-cancel-remainder-cutover.md.
Append-only public trade tape backing GET /api/v1/prediction/trades. One row per maker↔taker
match, written by the OrderBook.OrderFilled projector on the taker-side event only
(isTaker = true) — the maker-side event mutates live_orders but writes no trades
row, so a match is recorded exactly once. Rows are immutable once written, except a
first-write-wins fill of a NULL chain_time on replay; never deleted. Write-side
derivation in indexer.md; read side in
read-api.md.
| Column | Type | Notes |
|---|---|---|
trade_id |
text PK |
The taker-side OrderFilled event's chain-order key (msg_chain_order from the gateway, copied from raw_events.chain_order). Globally unique per match and lex-sortable — the sole sort key and identity for /api/v1/prediction/trades (DESC). The identical value is specified to surface as orderUpdate's t field for the same fill (api-spec.md). |
orderbook_address |
text NOT NULL |
OrderBook contract address. With outcome_id, scopes the tape to one market outcome. |
outcome_id |
integer NOT NULL |
Which outcome the match is on. |
price |
numeric(78,0) NOT NULL |
Match (clearing) price from OrderFilled.clearingPrice — raw uint256 basis points (probability × FULL_PERCENT = 10 000). Decoded ÷ FULL_PERCENT, formatted at price_precision, at API render. |
qty |
numeric(78,0) NOT NULL |
Matched quantity from OrderFilled.filledAmount — raw token atoms (× 10^decimals). Decoded ÷ 10^decimals, formatted at quantity_precision. quoteQty is derived at render as price * qty / FULL_PERCENT (the contract's integer-division notional), not stored. |
is_buyer_maker |
boolean NOT NULL |
Trade direction, derived from the taker order's side: taker selling ⇒ the buyer is the maker ⇒ true; taker buying ⇒ false. Surfaces as isBuyerMaker. |
chain_time |
timestamptz |
On-chain block time of the taker OrderFilled event (raw_events.created_at_chain). Drives time (Unix ms) in trade responses. NULL when the gateway omitted created_at; such rows are filtered out of the read query, matching live_orders / /api/v1/prediction/orders. |
created_at |
timestamptz |
Bookkeeping (indexer ingestion wall-clock). |
Index: trades_tape_idx — (orderbook_address, outcome_id, trade_id DESC). Backs the newest-first per-outcome read (ORDER BY trade_id DESC LIMIT $limit) as an index range scan. trades is insert-only; a replayed insert conflicts on trade_id and only coalesces a NULL chain_time (first-write-wins), so reprojection from raw_events is idempotent.
Recovery notes for on-call:
- Row hidden by
chain_time IS NULL(gateway delivered the fill withoutcreated_at): fix thetradesrow directly —UPDATE trades SET chain_time = to_timestamp(...) WHERE trade_id = ..., sourcing the timestamp from the repairedraw_events.created_at_chain. Do not clear the event'sprocessed_atwhile its order is still live: replay re-runs the wholeOrderFilledprojection, and thelive_ordersfill arm is not replay-idempotent (filledAmountwould be subtracted again — seereproject_pending's doc). A replay-based heal via the conflict-arm coalesce is safe only when the order row is already terminal, or during a wholesale reprojection that rebuildslive_ordersfrom scratch. - Tape 503s for one outcome (
MarketInconsistentfrom an undecodableprice/qty): the table is append-only and the read is newest-first, so one corrupt row inside the newestlimitrows fails every request for that(orderbook_address, outcome_id)until it is fixed. The failingtrade_id/axis/raw value is logged by the read path; verify against the originatingraw_eventsrow, then correct or delete the corrupttradesrow manually.
Reserved table for cached depth snapshots. Not used by the current depth handler — /api/v1/prediction/depth aggregates live_orders on every request. Kept in the schema for a future cache-warming path; safe to ignore until then.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
|
symbol |
text UNIQUE |
Outcome symbol. |
orderbook_address |
text |
|
last_update_id |
bigint |
|
bids_jsonb / asks_jsonb |
jsonb default '[]'::jsonb |
|
updated_at |
timestamptz |
The inference side tracks the per-model order books of the private-inference market (contracts/airegistry/InferenceOrderBook.sol — one book per model) and the resting orders inside them. These tables back /api/v1/inference/markets (list, plus single-market via ?inferenceOrderBookAddress=) and /api/v1/inference/depth (order book). Inference-settled prediction markets add no table of their own — /api/v1/prediction/markets?resolvesFrom= reuses markets joined to the range-event columns on oracle_events (range_ob_address). As on the prediction-market side, a row is hidden from the public API until the inference reconciler stamps last_reconciled_at.
One row per InferenceOrderBook contract observed on chain — equivalently, one tradable model. The book's address is derived from the model identity alone (DexLib.computeInferenceOrderBookAddress(code, modelHash) — one book per _modelHash, the tick-size component was dropped), so the address and model_hash are 1:1. Discovered from the first order event on an unknown book address (see indexer.md), completed by the inference reconciler reading InferenceOrderBook.getParams() and getWeeklyMedianPrice().
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal FK target. |
orderbook_address |
text UNIQUE |
The InferenceOrderBook contract address. Exposed as inferenceOrderBookAddress — the public market id. |
model_hash |
numeric(78,0) UNIQUE |
On-chain model identity (_modelHash static), from getParams(). The only model identifier the order book itself carries. NULL only during the pre-reconcile window; the visibility gate guarantees it is set on every market the API returns. |
model_ref |
text (nullable) |
Human-readable model id producer--model--version. The order book carries only the hash; the name comes from the book's getModelName() getter (the ManifestMetadata contract that earlier held the manifest was removed upstream in v4.0.10). Filled by the inference reconciler on the discovery pass (fill_params); NULL when the getter returns an empty name, and the API then surfaces the model by hash alone. |
producer / model_name |
text (nullable) |
Parsed components of model_ref (the producer and model parts of producer--model--version), for the model.{producer,name} render. Filled together with model_ref from getModelName(); both NULL unless the name is a clean three-part producer--model--version. |
version |
text (nullable) |
Contract version reported by the book's getVersion() getter (e.g. 4.0.14), captured by the inference reconciler. Used by the model-slot supersede resolution (parsed as semver to pick the canonical book on a cross-version redeploy) — NOT the model version (that is model_version). Surfaced verbatim by the read API as contractVersion on /api/v1/inference/markets and /api/v1/inference/depth (see read-api.md). |
model_version |
text (nullable) |
Parsed model version — the version part of a producer--model--version model_name. Renders as the API's model.version. Filled with the other identity columns from getModelName(); NULL unless the name is a clean three-part value. |
manifest_address |
text (nullable) |
🚧 Vestigial. Was the model's ManifestMetadata contract address (the reconcile source for model_ref); that contract was removed upstream in v4.0.10, so the column stays NULL and the name is sourced from InferenceOrderBookDeployed / getModelName instead. |
root_model_address |
text (nullable) |
🚧 The model's RootModel address. Diagnostic / reconcile aid. NULL until linked. |
owner_pubkey |
numeric(78,0) (nullable) |
🚧 Model-owner pubkey (RootModel.getOwnerPubkey()). NULL until resolved. Note: buyerPubkey is present on-chain but is intentionally not stored — there is no per-order ownership column on inference_orders. |
platform_fee_bps |
integer |
Platform fee in basis points (getParams().platformFeeBps, e.g. 250). Filled by the inference reconciler on the first discovery pass from InferenceOrderBook.getParams(). Renders the buyer-side takerCommission (÷ 10 000 → "0.025"). The seller-side makerCommission is the rebate cap −REBATE_MAX_BPS (−0.02), a protocol constant; like /api/v1/prediction/markets, commissions are rendered (not stored per-row). |
quote_token_type |
integer FK → ref_tokens(token_type) |
Quote asset of the book. Reconciler sets this to SHELL (token_type = 2) as a constant on the discovery pass — it is not sourced from a getter field. |
price_precision |
integer |
Decimal places for price-per-tick at API render. Reconciler sets this to the constant 9 (SHELL decimals) on the discovery pass. |
quantity_precision |
integer |
Decimal places for tick quantity. Reconciler sets this to the constant 0 (ticks are integer units) on the discovery pass. |
tick_size |
text |
Minimum price-per-tick increment as a decimal string. Reconciler sets this to the constant "0.000000001" (1 SHELL atom) on the discovery pass. |
step_size |
text |
Minimum tick-quantity increment as a decimal string. Reconciler sets this to the constant "1" on the discovery pass. |
min_notional |
text |
Minimum order notional as a decimal string. Reconciler sets this to the constant "0.000000001" on the discovery pass. |
reference_price |
numeric(78,0) (nullable) |
Weekly-median reference price in SHELL atoms (getWeeklyMedianPrice()). Reconciler-filled on the discovery pass and re-fetched on the reference-price refresh cadence. NULL when the book is dry — the getter reverts ERR_NO_LIQUIDITY on insufficient volume, the reconciler records NULL, and the API surfaces referencePrice: null. |
reference_price_at |
timestamptz (nullable) |
When reference_price was last refreshed. |
created_at_chain |
timestamptz (nullable) |
On-chain block time the book was first observed (from the seed event's created_at_chain). Drives createdAt. |
last_reconciled_at |
timestamptz |
Stamped by the inference reconciler after a successful discovery pass. The public API filters on last_reconciled_at IS NOT NULL — books without this are invisible to clients (mirrors markets). |
last_reconcile_failed_at |
timestamptz |
Backoff bookkeeping for the inference reconciler. |
reconcile_attempts |
integer default 0 |
Diagnostic counter. |
superseded_at |
timestamptz (nullable) |
Set when this book is retired as a stale duplicate of a higher-version book for the same model (cross-version contract redeploy). NULL = active. Non-NULL = retired; excluded from discovery, the read API, and the discovering/visible/failing metric buckets. |
last_swept_at |
timestamptz (nullable) |
Stamped now() on every sweep batch tick that passes the catch-up gates — not only on cycle completion. It drives the Queue B sweep cadence (now() - last_swept_at >= inference_sweep_interval_ms), so it must advance each tick. NULL until the first sweep. Used with inference_markets_sweep_idx to drive Queue B sweep scheduling. |
sweep_cursor |
numeric(78,0) (nullable) |
The order_id cursor for the current bounded round-robin sweep of OPEN orders. NULL at cycle start (or after a reset). The sweep resumes from this cursor on the next tick; NULL means start from the lowest order_id. |
sweep_cycle_max |
numeric(78,0) (nullable) |
Snapshot of the highest order_id at sweep-cycle start. Newly-minted orders above this bound are deferred to the next cycle — they cannot be phantoms yet when the book just accepted them. NULL when no cycle is in progress. |
sweep_override_seq |
bigint NOT NULL default 0 |
Monotonic counter bumped whenever a InferenceFilled event overrides a provisionally sweep-cancelled order while the book is still in the discovery phase. The discovery visibility stamp (transition to last_reconciled_at IS NOT NULL) is CAS-guarded on this counter being unchanged across the completing sweep tick, preventing a premature stamp when an override reset sweep_cursor to NULL at the start of a cycle where a plain cursor-CAS cannot distinguish reset-from-NULL from a normal start-of-cycle-NULL. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Indices:
| Index | Purpose |
|---|---|
inference_markets_model_hash_idx (partial UNIQUE: model_hash IS NOT NULL) |
Lookup / dedup by on-chain model identity. Partial to tolerate NULL during the pre-reconcile window. |
inference_markets_pending_reconcile_idx (partial: last_reconciled_at IS NULL) |
Drives the inference reconciler Queue A (discovery) SELECT, ordered by last_reconcile_failed_at NULLS FIRST, id. |
inference_markets_refresh_idx (partial: last_reconciled_at IS NOT NULL) |
Drives the inference reconciler Queue B (reference-price refresh) SELECT, ordered by reference_price_at NULLS FIRST. |
inference_markets_sweep_idx (partial: last_reconciled_at IS NOT NULL) |
Drives the inference reconciler Queue B sweep scheduling, ordered by last_swept_at NULLS FIRST. |
reference_price re-queues independently of the discovery reconcile: it moves with trading, so the reconciler refreshes it on a cadence (it is not a write-once field like markets.orderbook_address). See indexer.md.
Per-order read model backing /api/v1/inference/depth (order-book depth) and /api/v1/inference/orders (per-order listing, public and unauthenticated). One row per chain-side order on an InferenceOrderBook, mutated in place as InferenceOrderPlaced, InferenceFilled, InferenceOrderCancelled, InferenceRefunded, and InferenceSubscriptionPlaced events arrive (the Inference prefix is part of the on-wire event name since v4.0.10). Mirrors live_orders: rows are never deleted, so FILLED / CANCELLED entries remain and the depth handler (max(last_chain_order) across all rows for the book) still sees them. This version exposes no account-scoped (owner-authenticated) inference order endpoint, so no ownership / private-read columns are required.
| Column | Type | Notes |
|---|---|---|
orderbook_address |
text (PK part 1) |
InferenceOrderBook contract address. |
order_id |
numeric(78,0) (PK part 2) |
Chain-side order id (InferenceOrderPlaced.orderId). The pair (orderbook_address, order_id) is the primary key. |
is_buy |
boolean |
Side. true = bid (buy order / subscription), false = ask (sell offer). |
price |
numeric(78,0) |
Price per tick P in SHELL atoms, as emitted (InferenceOrderPlaced.price). BUY = max price per tick; SELL = offer price. Pure taker orders (IOC / FOK / MARKET) never rest, so they produce no OPEN row. Decoded ÷ 10^9 at render, formatted at price_precision. |
amount_initial |
numeric(78,0) |
Original tick count (InferenceOrderPlaced.ticks). |
amount_remaining |
numeric(78,0) |
Ticks not yet filled. Set by InferenceOrderPlaced, decremented by InferenceFilled. InferenceOrderCancelled preserves the current value; depth ignores the row because status != 'OPEN'. |
is_subscription |
boolean default false |
true when the resting buy came from InferenceSubscriptionPlaced (spec §8 — a standing bid throttled by a weekly budget). Rests in the book like any other bid; flagged for diagnostics. |
status |
text CHECK IN ('OPEN','FILLED','CANCELLED','EXPIRED') |
Order lifecycle. Depth aggregation filters on status = 'OPEN' AND amount_remaining > 0. A SELL offer is a one-deal slot consumed on match; a BUY maker reduces across fills. EXPIRED is written only by InferenceOrderExpired, when the book drops an order whose deadline passed — a row whose deadline already lies in the past stays OPEN until that event lands, so nothing derives the status from wall-clock. It overrides a provisional sweep-cancel (swept_at NOT NULL, which the sweep set precisely because the order had vanished from the book) but never a FILLED row or a real event-cancel. |
swept_at |
timestamptz (nullable) |
Stamped by the reconciler sweep when getOrder() confirms the order is no longer in the book and the row is provisionally cancelled. NULL while the order has not yet been swept. |
note_address |
text (nullable) |
Owner note address (InferenceOrderPlaced.note). Not on the public hot path; kept for diagnostics and cancel attribution. |
token_contract |
text (nullable) |
Deal TokenContract. Two writers. (1) The InferenceOrderPlaced handler decodes the mandatory tokenContract field strictly (a missing field fails the projection rather than inserting a NULL) and normalizes a decoded zero address to NULL — on chain a BUY carries the zero address there, so token_contract is non-NULL only on SELL rows. A live SELL with a NULL value means the indexer does not know it yet, and the read API refuses TokenContract lookups until the reconciler fills it in. (2) The inference reconciler's phantom-cancel sweep (repair_order_fields) fills it from the tokenContract the getOrder probe already returns, but only while the column is still NULL and the getter supplies a non-zero address — repairing token_contract and deadline independently, so a BUY's zero address is left NULL. See Inference reconciler. The upsert's conflict arm is NULL-preserving (coalesce(excluded.token_contract, inference_orders.token_contract)), so a replay of InferenceOrderPlaced or of InferenceSubscriptionPlaced (which carries no tokenContract at all) can never erase a value the reconciler recovered from chain. |
deadline |
numeric(20,0) (nullable) |
Unix seconds. Two writers. (1) The InferenceOrderPlaced handler decodes the mandatory deadline field strictly and normalizes a decoded 0 to NULL — a resting SELL carries deadline 0 on chain. NULL also when not yet known: InferenceSubscriptionPlaced omits the deadline the chain stores (the handler passes NULL for it), so a subscription row starts with a NULL deadline. (2) The inference reconciler's phantom-cancel sweep (repair_order_fields) fills it from the deadline the getOrder probe already returns, but only while the column is still NULL and the getter supplies a non-zero value — independently of token_contract, so a resting SELL's zero deadline is left NULL. See Inference reconciler. Same NULL-preserving upsert as token_contract, so no replay can erase a reconciler-recovered value. |
last_chain_order |
text NOT NULL |
Chain-order key of the most recent book event that touched this order. Lex-monotonic via greatest(existing, new). Feeds lastUpdateId in depth responses as a STRING. |
chain_created_at / chain_updated_at |
timestamptz |
On-chain block times of the originating InferenceOrderPlaced and the most recent touch. Display / diagnostic only. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Indices:
| Index | Purpose |
|---|---|
inference_orders_open_book_idx (partial: status = 'OPEN') |
(orderbook_address, is_buy, price DESC). Sized for the depth query: top-N price levels per side for one book. |
inference_orders_sweep_idx (partial: status = 'OPEN') |
(orderbook_address, order_id). Backs the reconciler's bounded round-robin sweep SELECT over OPEN rows, keyed by book + cursor position. |
inference_orders_book_tc_idx (partial: token_contract IS NOT NULL) |
(orderbook_address, token_contract, status, order_id DESC). Backs the tokenContract lookup. status precedes the keyset because one TC's row count is unbounded — a seller may re-place the same TC after a cancel, and rows are never deleted, so a status residual would walk that TC's whole history. |
inference_orders_book_note_idx (partial: note_address IS NOT NULL) |
(orderbook_address, note_address, is_buy, status, order_id DESC). Backs the note filter alone or combined with side/status; every leading column is pinned per query branch, so the keyset order survives. |
inference_orders_live_sell_tc_null_idx (partial: status = 'OPEN' AND is_buy = false AND token_contract IS NULL) |
(orderbook_address). Backs the read path's fail-closed probe for a resting SELL whose TokenContract is unknown. Empty whenever every live SELL has one, so the EXISTS is O(1). |
inference_orders_book_side_status_idx |
(orderbook_address, is_buy, status, order_id DESC). Serves side-only, status-only, side+status and unfiltered listings: the query plan pins is_buy and status in every branch, so one composite covers all four shapes. |
inference_orders_book_chain_order_idx |
(orderbook_address, last_chain_order DESC). max(last_chain_order) per book is the lastUpdateId watermark for the depth and the orders endpoints; rows are never deleted, so without this index it scans a book's whole history on every request. |
Append-only public trade tape backing GET /api/v1/inference/trades (see read-api.md).
One row per maker↔taker match, written by the InferenceOrderBook.InferenceFilled projector.
Unlike trades on the prediction side there is no taker-side gate: the inference
book emits one InferenceFilled per match (carrying both makerId and takerId), so the
event itself is already one-per-match — every InferenceFilled that both legs resolve for
writes exactly one row. There is also no outcome dimension: an InferenceOrderBook is one book
per model, so the tape is keyed by orderbook_address alone. Write-side derivation in
indexer.md.
| Column | Type | Notes |
|---|---|---|
trade_id |
text PK |
The InferenceFilled event's chain-order key (msg_chain_order from the gateway, copied from raw_events.chain_order). Globally unique per match and lex-sortable — the sole sort key and identity for the tape (DESC). |
orderbook_address |
text NOT NULL |
InferenceOrderBook contract address. No outcome column — one book per model. |
price |
numeric(78,0) NOT NULL |
Match (clearing) price from InferenceFilled.clearingPrice — raw quote-asset (SHELL) base units per tick. Unlike the prediction tape's price this is not basis points. |
qty |
numeric(78,0) NOT NULL |
Matched tick count from InferenceFilled.ticks, raw. |
is_buyer_maker |
boolean NOT NULL |
Trade direction. Not carried by the event — InferenceFilled has no isBuyerMaker field. Resolved at projection time from the MAKER leg's is_buy in inference_orders (the resting side locked by makerId); when that leg is absent (orphan repair with a missing counterparty), the inverse of the taker leg's is_buy is used instead — a match has exactly one buyer and one seller. |
chain_time |
timestamptz |
On-chain block time of the InferenceFilled event (raw_events.created_at_chain). NULL when the gateway omitted created_at; such rows are filtered out of the read query, matching inference_orders / the prediction tape. |
created_at |
timestamptz |
Bookkeeping (indexer ingestion wall-clock). |
Index: inference_trades_tape_idx — (orderbook_address, trade_id DESC). Backs the newest-first
per-book read (ORDER BY trade_id DESC LIMIT $limit) as an index range scan. The table is
insert-only; a replayed insert conflicts on trade_id and only coalesces a NULL chain_time
(first-write-wins, guarded by every other column matching the recorded row — a divergent
conflict is skipped and error!-logged rather than silently overwritten), so reprojection from
raw_events is idempotent.
Recovery notes for on-call:
- Row hidden by
chain_time IS NULL(gateway delivered the fill withoutcreated_at): fix the row directly —UPDATE inference_trades SET chain_time = to_timestamp(...) WHERE trade_id = ..., sourcing the timestamp from the repairedraw_events.created_at_chain. As withtrades, do not clear the event'sprocessed_atto force a replay while the order rows are still live — the surroundingInferenceFilledprojection is not replay-idempotent oninference_orders.amount_remaining. - Missing tape row for a known match: check whether
resolve_is_buyer_makerhad a leg to read — a row is only omitted when neither the maker nor the taker leg exists ininference_ordersat projection time (bothInferenceOrderPlacedevents dropped at capture, the orphan-repair no-leg case logged atwarn!).
The inference settlement side can track the lifecycle of each deal escrow (TokenContract — a per-deal streaming-payment contract auto-deployed when a SELL offer is matched) and the individual finalized ticks within it. The SETTLEMENT projector still replays TokenContract.* rows already retained in raw_events, but the current two-stream live capture excludes every TokenContract dst before decode and therefore does not add new settlement-event rows. These tables are not used by the current public inference endpoints.
One row per TokenContract address. InferenceOrderBook.InferenceFilled from the live DEX dApp stream creates or enriches the deal cross-link. If retained TokenContract.* rows are replayed, the first one seeds a skeleton keyed by src_address, and the SETTLEMENT projector fills the corresponding settlement columns.
| Column | Type | Notes |
|---|---|---|
token_contract_address |
text PK |
Address of the TokenContract escrow deployed when a SELL order is matched. The per-deal identifier. |
orderbook_address |
text (nullable) |
The InferenceOrderBook address that matched this deal. Filled from InferenceOrderBook.InferenceFilled. |
seller_note |
text (nullable) |
PrivateNote address of the seller. Filled from InferenceOrderBook.InferenceFilled. |
buyer_note |
text (nullable) |
PrivateNote address of the buyer. Filled from InferenceOrderBook.InferenceFilled. |
deposit |
numeric(78,0) (nullable) |
Initial deposit amount (quote token units). |
price_per_tick |
numeric(78,0) (nullable) |
Agreed price per finalized tick (quote token units). |
finalized_ticks |
integer NOT NULL default 0 |
Count of TokenContract.TickFinalized events for this deal (= number of inference_ticks rows). NOT the contract's on-chain _ticksFinalized, which additionally counts the probe-accept tick (ProbeAccepted) and the closing tick (folded into StreamStopped/StreamReclaimed/DisputeResolved), neither emitted as TickFinalized. |
trusted_ticks |
numeric(78,0) (nullable) |
High-water mark of TokenContract.TicksClaimed.trusted — work the deal has actually credited. NULL until the seller first calls claimTokens. |
claimed_ticks |
numeric(78,0) (nullable) |
High-water mark of TicksClaimed.claimed — what the seller has claimed but that has not yet been trusted or contested, so it runs ahead of trusted_ticks while a claim is pending. Both columns track the position between weekly boundaries, where finalized_ticks and inference_ticks say nothing: a subscription week is 604800 s, so a deal would otherwise look motionless for days. Kept as high-water marks because the chain's own claimTokens is cumulative and non-decreasing (require(cumulativeTokens >= _tokensPend2)), so a replayed or out-of-order event cannot walk them backwards. |
funded_at_chain |
timestamptz (nullable) |
Chain timestamp of the funding event. |
opened_at_chain |
timestamptz (nullable) |
Chain timestamp when the deal was opened. |
settled_at_chain |
timestamptz (nullable) |
Chain timestamp when the deal closed cleanly or was resolved. |
close_kind |
text (nullable) |
Terminal close type: one of STOPPED, DISPUTE_RESOLVED, RECLAIMED, DESTROYED, PROBE_BURNED. Enforced by a CHECK constraint. |
clean_settlement |
boolean (nullable) |
true if the deal closed without a dispute; false or null otherwise. |
disputed_at_chain |
timestamptz (nullable) |
Chain timestamp of the dispute event, if any. |
last_chain_order |
text (nullable) |
The chain_order of the most recent InferenceOrderBook.InferenceFilled cross-link that wrote this row. Advanced via greatest(existing, new) by the InferenceFilled handler; not read or advanced by any TokenContract handler. |
created_at / updated_at |
timestamptz |
Bookkeeping. |
Indices:
| Index | Purpose |
|---|---|
inference_deals_orderbook_idx |
Lookup all deals for a given InferenceOrderBook. |
inference_deals_seller_idx |
Lookup all deals by seller PrivateNote — sized for per-seller aggregation queries (e.g. by the forthcoming rewards service). |
inference_deals_buyer_idx |
Lookup all deals by buyer PrivateNote. |
One row per finalized tick within a deal. Written by the SETTLEMENT projector when a retained TokenContract.TickFinalized row is replayed. The composite PK (token_contract_address, chain_order) is idempotent against redelivery. Current live capture does not add these rows.
| Column | Type | Notes |
|---|---|---|
token_contract_address |
text NOT NULL (part of PK) FK → inference_deals(token_contract_address) ON DELETE CASCADE |
Parent deal's TokenContract address. The projector always inserts the parent inference_deals row before any tick insert, so the FK is always satisfiable at runtime. ON DELETE CASCADE makes test cleanup order-independent. |
chain_order |
text NOT NULL (part of PK) |
The chain_order of the TickFinalized event. Uniquely identifies each tick within a deal. |
finalized_owed |
numeric(78,0) NOT NULL |
Cumulative SHELL-to-seller total (_finalizedOwed) as carried by the TickFinalized event — a running total at the moment of this tick, NOT a per-tick delta. Do not sum across rows. |
deposit |
numeric(78,0) NOT NULL |
Deposit snapshot at tick finalization. |
chain_at |
timestamptz (nullable) |
Chain timestamp of the finalization event. |
created_at |
timestamptz NOT NULL default now() |
Bookkeeping. |
Indices:
| Index | Purpose |
|---|---|
inference_ticks_tc_idx |
Fetch all ticks for a deal by token_contract_address. |
Identity and credential storage for the auth middleware. See auth.md for the user model, request-verification pipeline, and error mapping.
One row per logical user. Holds the custodied trading PrivateNote inline; multiple PNs per account are not supported in this version and replacing the PN is operator-only via direct UPDATE on this row.
| Column | Type | Notes |
|---|---|---|
id |
uuid PK default gen_random_uuid() |
Stable accountId surfaced to clients. The only identifier that crosses the API boundary. |
label |
text (nullable) |
Operator-facing label. Not exposed by the API. |
pn_address |
text UNIQUE |
Address of the trading PrivateNote bound to this account. Source of balances for GET /api/v1/account and GET /api/v1/account/balances. |
pn_pubkey |
numeric(78, 0) |
PN signing pubkey. |
pn_seckey_enc |
bytea |
PN signing seckey, encrypted at rest under the backend master key (crates/infrastructure/src/crypto.rs). Never read by the API; used by the trading path to submit transactions. |
pn_dih |
numeric(78, 0) UNIQUE |
Deploy-init hash of the PN. Disambiguates PNs that may share an address across redeploys. |
disabled_at |
timestamptz (nullable) |
Soft-disable marker. NULL = active. |
created_at |
timestamptz default now() |
Bookkeeping. |
API credential pairs. Multiple per account, each with its own permission set. The api_secret is generated at issuance and only the ciphertext is stored; the cleartext is shown to the operator once and cannot be recovered later.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
Internal identifier. Never surfaced. |
account_id |
uuid FK → accounts(id) ON DELETE CASCADE |
Owning account. |
api_key |
text |
Public half of the credential pair; sent by clients in the X-DODEX-APIKEY header. |
api_secret_enc |
bytea |
Encrypted api_secret. Decrypted in-process to recompute the request HMAC. |
permissions |
auth_permission[] default {USER_DATA} |
Subset of the auth_permission enum (USER_DATA, TRADE). Endpoints declare a required permission; auth rejects with -1002 if the key lacks it. |
disabled_at |
timestamptz (nullable) |
Soft-disable marker. Disabled keys are rejected with -1002. NULL = active. |
last_used_at |
timestamptz (nullable) |
Stamped by the auth middleware on successful verification. Used for operator audits and stale-key cleanup. |
created_at |
timestamptz default now() |
Bookkeeping. |
Indices:
api_keys_api_key_active_idx— UNIQUE partial index on(api_key) WHERE disabled_at IS NULL. Lets the auth middleware look up an active credential byapi_keyin O(1) without colliding with historical disabled rows that may have reused the same string (irrelevant in practice with 256-bit random keys, but the partial predicate captures the exact invariant).api_keys_account_id_idx— supports operator queries that list all keys under an account.
_sqlx_migrations is created and maintained by sqlx::migrate!. It records which migration files have been applied. Do not touch it in application code.
Every schema change ships as a new numbered migration file. Conventions:
- Use
if not exists/if existson DDL so re-runs are idempotent. - For new columns, prefer
add column if not existswith a sensible default — never break startup on an empty database. - Partial indices are preferred over full ones for "pending row" predicates; they shrink with reconciliation progress.
- Add a header comment on every migration explaining why the change is needed and which code path requires it. Migrations are read by reviewers and operators as much as the code is.
- Pre-deploy check: index builds inside sqlx transactions can block writers until the build finishes. For hot tables, estimate the lock window from production row counts before deploying or run the change through a non-transactional migration path.
The full migration set (migrations/*.sql) is the canonical reference; this document summarises intent but does not replace it.