Implementation-facing requirements for the HTTP layer that serves the market-data read-model. The public contract (URLs, field names, parameter rules, error shapes, response examples) lives in api-spec.md. Postgres tables referenced below are documented column-by-column in data-schema.md. The write side — how those tables get populated — is in indexer.md.
Read-model — Postgres tables prepared for API reads. The indexer builds these tables from chain events and contract state; the API reads them instead of querying contracts directly.
Owner attribution — the binding between a chain-side order (orderbook_address, order_id) and the trading PrivateNote address that placed it. OrderBook.OrderPlaced does not carry the owner; attribution arrives separately via PrivateNote.OrderPlacedConfirmed and is stored in live_orders.owner_pn_address.
Trading PN — the trading PrivateNote address of an authenticated account. Resolved from the API key by the existing auth hoop and exposed as ctx.trading_pn.pn_address.
Chain time — raw_events.created_at_chain of the event that produced a state transition. Used for response time and updateTime so they are stable under indexer backlog.
Market row — one row in the markets table. It represents one PMP contract and is the main source for /api/v1/prediction/markets.
Reconciled market — a market row with last_reconciled_at IS NOT NULL. This means the market reconciler has already read the PMP state and filled the fields required for public responses. /api/v1/prediction/markets hides markets until this is true.
Lifecycle status — the public market phase returned as status: PENDING, UPCOMING, STAKING, AWAITING_FREEZE, TRADING, RESOLVING, RESOLVED, CANCELLED, or EXPIRED. It is computed by the API from the market row and current request time; it is not stored as a separate database column.
serverTime — the unix-seconds timestamp captured once at the start of a /api/v1/prediction/markets request. The API returns it in the response and uses the same value to compute lifecycle status.
Depth — the /api/v1/prediction/depth response for one market outcome: sorted bid and ask price levels plus lastUpdateId. It is built from live_orders, not by querying the OrderBook contract during the HTTP request.
Trade tape — a bare, newest-first list of maker↔taker matches built from an append-only table, never by querying the chain contract during the HTTP request. Two instances share this contract: /api/v1/prediction/trades (per market outcome, from the trades table) and /api/v1/inference/trades (per model order book, from the inference_trades table).
DTO — Data Transfer Object. In this document it means the API response object after the backend has assembled it from database rows, but before it is serialized to JSON and sent to the client.
Available event — an oracle_events row eligible to surface in /api/v1/oracles: is_deleted = false, deadline strictly in the future relative to request now, and metadata-reconciled (meta_reconciled_at IS NOT NULL). Event lists and oracles with no available events are omitted from the response.
The backend treats predictionMarketAddress as the PMP address. predictionOrderBookAddress is the deterministic address returned by PMP.getOrderBookAddress() and is stamped on the first successful reconciler pass — pre-PoolsFrozen rows already carry it. The pre-reconcile window between PMPDeployed and the first reconciler pass is the only state where the column is legitimately null, and such rows are hidden from the API by the last_reconciled_at IS NOT NULL visibility filter. The write-side flow is described in indexer.md. Clients MUST use status to determine whether the order book is currently available for trading — a non-null predictionOrderBookAddress does not by itself imply the book is open.
Lifecycle status is not stored as a separate database column. The API computes it for each request from the indexed market row and a single unix-seconds now value. The same now is returned as serverTime and used for status calculation, so one response cannot mix timestamps from both sides of a lifecycle boundary.
The SQL query behind GET /api/v1/prediction/markets includes WHERE m.last_reconciled_at IS NOT NULL. Markets that the indexer has discovered (the PMPDeployed event arrived) but not yet reconciled are hidden — clients only see markets the backend can describe fully. See indexer.md for the symmetric write-side rule.
Source: a row in markets plus the request now. Order of checks (terminal events take precedence over time-derived phases):
cancelled_at IS NOT NULLORis_cancelled→CANCELLED.resolved_at IS NOT NULL→RESOLVED.stake_start IS NULL→PENDING.frozen_at IS NULL:now ≥ stake_end→AWAITING_FREEZE(indefinitely, no upper bound onnow).now ≥ stake_start→STAKING.- Otherwise →
UPCOMING.
frozen_at IS NOT NULL:now ≥ result_end→EXPIRED.now ≥ result_start→RESOLVING.- Otherwise →
TRADING.
The same logic is mirrored in the SQL STATUS_CASE used by the ?status= filter pushdown, so the SQL filter cannot drift from the Rust-side derivation.
For each row in the page, the API:
- Derives
statusas above. - Builds
timingsfrom the four timing columns — returnsnullonly when at least one is missing (PENDING). - Builds
terminal(withkind,at,resolvedOutcomeId,cancelReason) for terminal statuses,nullotherwise. - Joins
market_outcomesfor the outcomes array, including per-outcomepricePrecision,tickSize,stepSize,minNotional.maxBatchSizeis filled from api config (chain.max_batch_size) at render — backend policy mirroring the chain's compiled-in cap, not read-model data. - Fetches the
event.*block in a separate batch (fetch_oracle_events) joined acrossoracle_events⨝oracle_event_lists⨝oraclesfor everypmp_addresson the page. A PMP can be confirmed by multipleOracleEventListcontracts (PrivateNote.PMPDeployed.oracleEventLists: address[]), producing N rows here; the API collapses them into oneevent.oracles[]array. Joiningoracle_eventsdirectly into the main markets SELECT would have multiplied the market row by N, inflatinghas_more/cursor and emitting duplicate listings. - Stamps
makerCommissionandtakerCommissionfrom the global constantsMAKER_COMMISSION/TAKER_COMMISSIONincrates/domain/src/lib.rs. These mirrorTAKER_FEE_RATE/MAKER_REBATE_*/FEE_DENOMINATORincontracts/dex/modifiers/modifiers.soland are not stored in themarketstable — the contract defines fees globally today, so the read path does not look them up per market.
description and other reconciler-only fields rely on data filled by the OracleEventList reconciler — they may be null briefly after a market is discovered but before the reconciler-side metadata lands. eventName/description are derived from eventId = hash(eventName, description, deadline, outcomeNames), so every confirmation row for the same pmp_address must agree on those values; aggregate_oracle_events validates this cross-row equality and fails closed (MarketInconsistent) on mismatch.
A prediction market whose outcome is decided by a model's reference price (a numeric range event, spec §6.2) carries a resolvesFrom block; all other markets carry resolvesFrom: null. A market is inference-settled when its confirming event — joined markets.pmp_address = oracle_events.confirmed_pmp_address — has oracle_events.range_ob_address set (the bound InferenceOrderBook, filled by the RangeEventAdded projector, see indexer.md).
?resolvesFrom=<inferenceOrderBookAddress>filters the listing to markets settled from one inference book — backed byoracle_events_range_ob_idx. It composes with the other list filters (it is not a single-market selector, unlikepredictionMarketAddress).- The
resolvesFromblock is{inferenceOrderBookAddress (= range_ob_address), model, metric: "WEEKLY_MEDIAN_PRICE"}.modelis joined frominference_marketson that address and degrades tonull/hash-only if the inference book is not yet reconciled — the prediction market is not hidden on that account. - The numeric outcome ranges are the market's normal
outcomes; no separaterangesfield is emitted.
Two sort modes:
sort=resultStart(default, ascending) — sort key iscoalesce(result_start, +∞)so PENDING / UPCOMING rows without a resolvedresult_startsort to the end.sort=createdAt(descending) — sort key iscreated_at_micros(microsecond precision). Sub-second keying avoids the keyset bug where two markets created in the same second could be skipped or duplicated across page boundaries.
Cursor format: URL-safe base64 of "<sort_key>:<id>". The handler decodes and validates the cursor; a corrupted cursor surfaces as DomainError::InvalidParameter → HTTP 400, not as an internal error.
After building the DTO, the API checks the assembled shape against spec invariants. Any violation surfaces as DomainError::MarketInconsistent → HTTP 503. The 503 status is deliberate: the inconsistency is transient (the indexer is mid-replay), and the client should retry rather than treat the market as permanently broken. The checks live in postgres_repo.rs::validate_invariants:
| Rule | Source |
|---|---|
timings is null exactly when status is PENDING |
api-spec Timings: "timings itself is null only for PENDING." |
terminal is non-null exactly when status is RESOLVED, CANCELLED, or EXPIRED |
api-spec Terminal |
RESOLVED requires frozen_at, kind=RESOLVED, resolvedOutcomeId set |
api-spec Terminal ("without it the client cannot know which side won") |
CANCELLED requires kind=CANCELLED and a valid cancelReason (PMP_REJECTED_BY_ORACLE or EVENT_CANCELLED) |
api-spec Terminal: cancelReason must distinguish source |
| EXPIRED requires kind=EXPIRED | spec consistency |
TRADING / RESOLVING require frozen_at |
spec consistency with frozenAt != null for post-freeze statuses |
event.eventName / event.description agree across every confirming oracle for one market |
Hash invariant eventId = hash(eventName, description, deadline, outcomeNames) on chain. Enforced by aggregate_oracle_events in postgres_repo.rs. |
orderbook_address is non-blank on every reconciled market |
DB schema CHECK pins NOT NULL; assemble_market rejects whitespace-only strings that slip past the CHECK so listing/single-market match the depth contract. |
The validation works on the built DTO rather than the raw row so that downstream silent-elision bugs are caught — for example, an unknown cancel_reason string would be parsed to None and serialized as cancelReason: null; the validator rejects the assembled DTO instead of the raw column being non-null.
The matching write-side rules are in indexer.md.
| Condition | DomainError | HTTP |
|---|---|---|
| Market not found / not yet reconciled | InvalidMarketOrSymbol |
404 |
Invalid status / sort enum value |
InvalidParameter |
400 |
Mutually exclusive params (predictionMarketAddress together with list filters) |
MissingParameter |
400 |
| Corrupted cursor | InvalidParameter (from cursor decode) |
400 |
| Invariant violation on built DTO | MarketInconsistent |
503 |
Public discovery endpoint: lists oracles, their event lists, and the events those lists currently offer for market creation. Public contract: api-spec §Oracles. No authentication — the route is mounted alongside /api/v1/prediction/markets and /api/v1/prediction/depth, outside the auth subrouter. The Postgres source is oracles ⨝ oracle_event_lists ⨝ oracle_events; the write side (projectors plus the OracleEventList reconciler) is in indexer.md. The endpoint never queries the oracle contracts at request time — it reads the indexed discovery read-model.
The response is grouped oracle → event list → event. Pagination is by oracle (limit counts oracles); an oracle's full set of event lists and available events is always returned whole — there is no inner pagination in v1.
serverTime is the unix-seconds timestamp captured once at handler entry. The same value is the now used for the deadline-availability predicate, so one response cannot mix an availability boundary computed from a different clock reading than the one it reports — the same discipline /api/v1/prediction/markets applies to lifecycle status.
An oracle_events row is available (eligible to surface) when all hold:
is_deleted = false— not soft-deleted. No projector sets thistruetoday: the OracleEventList contract emits no delete/cancel event, so the conjunct is currently a no-op kept for forward-compatibility.deadline > now— the event can still be used for a new market. The boundary is strict: atnow == deadlinethe event is past and hidden, mirroring thenow >= result_end → EXPIREDboundary in/api/v1/prediction/markets.meta_reconciled_at IS NOT NULL— the OracleEventList reconciler has filled the metadata that lives only in the_eventsgetter (describe,trust_addr, andoutcome_names_jsonb). This is the symmetric analogue of thelast_reconciled_at IS NOT NULLvisibility gate on/api/v1/prediction/markets: an event is hidden until the backend can describe it fully, sooutcomesis never served empty merely because reconciliation has not run.event_name,oracle_fee, anddeadlinearrive earlier via theEventAddedprojector, but the reconciler-only fields gate visibility.
Event lists with no available events, and oracles with no non-empty event lists, are omitted (see api-spec §Oracles: "Event lists and oracles with no remaining events are omitted").
All four query filters combine freely — there is no mutually-exclusive mode like /api/v1/prediction/markets's predictionMarketAddress:
| Param | Predicate |
|---|---|
oracleAddress |
oracles.address = $addr, applied to oracle selection only. Blank / whitespace is treated as absent. |
eventId |
The client passes the hex form (as rendered in eventId responses); the read path converts it to decimal and matches oracle_events.internal_id_in_eventlist = $decimal::numeric. With this filter the events[] arrays contain only the matching event, and lists / oracles without it are omitted. Un-decodable hex → InvalidParameter / 400. |
deadlineBefore |
oracle_events.deadline < $deadlineBefore (unix seconds), combined with the availability deadline > now, i.e. now < deadline < deadlineBefore. Non-numeric → InvalidParameter / 400. |
limit |
Oracle page size. Default 50, clamped to [1, 200]; non-numeric → InvalidParameter / 400. Clamping (rather than rejecting) out-of-range values matches /api/v1/prediction/markets. |
The availability predicate plus the eventId / deadlineBefore event-level filters form a single shared SQL fragment, bound identically into the Phase-1 EXISTS sub-query and the Phase-2 fetch (see § Query). Sharing one fragment is load-bearing: were the two to diverge, Phase 1 could select an oracle whose events Phase 2 then filters away, emitting an empty oracle and inflating hasMore — the same class of bug the markets STATUS_CASE sharing prevents.
Two round-trips, mirroring /api/v1/prediction/markets's fetch_listing → fetch_outcomes shape:
-
Oracle page. Select the next page of oracle ids, keyset-ordered by
(name, id):select o.id, o.name, o.address from oracles o where ($oracle_address is null or o.address = $oracle_address) and ( $cursor_name is null or o.name > $cursor_name or (o.name = $cursor_name and o.id > $cursor_id) ) and exists ( select 1 from oracle_event_lists oel join oracle_events oe on oe.eventlist_id = oel.id where oel.oracle_id = o.id and <available-event predicate + event filters> ) order by o.name asc, o.id asc limit $limit + 1;
oracles.nameisUNIQUE, sonamealone is a total order andidis only a defensive tiebreaker. The+1lookahead is the sole signal distinguishing "exactly$limitoracles remain" from "more follow", identical to the markets listing.hasMoreis true iff$limit + 1rows return; the extra row is dropped andnextCursoris built from the last retained oracle. -
List + event fetch. For the retained oracle ids, one query returns every available list+event row:
select oel.oracle_id, oel.list_index, oel.address as eventlist_address, oel.description as eventlist_description, oe.internal_id_in_eventlist::text as event_id, oe.event_name, oe.describe as event_description, oe.oracle_fee::text as oracle_fee, oe.deadline, oe.trust_addr, oe.outcome_names_jsonb from oracle_event_lists oel join oracle_events oe on oe.eventlist_id = oel.id where oel.oracle_id = any($ids) and <available-event predicate + event filters> order by oel.oracle_id, oel.list_index asc, oe.deadline asc, oe.internal_id_in_eventlist asc;
The rows are grouped in Rust:
oracle_id→(list_index, address, description)→ events. SQL already emits them in the api-spec order (oracle name fixed by Phase 1, then list index, deadline, event id), so grouping preserves order without a re-sort.
Field mapping (see api-spec §Oracles for the public shapes):
| Response field | Source | Notes |
|---|---|---|
oracles[].name / .address |
oracles.name / .address |
|
eventLists[].index |
oracle_event_lists.list_index |
|
eventLists[].address |
oracle_event_lists.address |
|
eventLists[].description |
oracle_event_lists.description |
NOT NULL column written by the deploy projector from the OracleEventListDeployed payload (read strictly). The public field is therefore a plain STRING (possibly empty, never null). |
events[].eventId |
oracle_events.internal_id_in_eventlist → numeric_to_hex |
The same hex rendering /api/v1/prediction/markets uses for event.eventId, so the value round-trips back into the eventId filter. |
events[].eventName |
oracle_events.event_name |
EventAdded projector. |
events[].description |
oracle_events.describe |
Reconciler-only; null until reconciled to a non-null value. |
events[].oracleFee.asset |
literal "SHELL" |
The oracle contracts denominate fees in SHELL today; not stored per-event. A second fee asset would turn this into a ref_tokens lookup. |
events[].oracleFee.amount |
oracle_events.oracle_fee::text |
Raw chain integer as a decimal string — not scaled, matching the unscaled fee rendering in /api/v1/prediction/markets's event.oracles[]. |
events[].deadline |
oracle_events.deadline |
Unix seconds. |
events[].trustAddress |
oracle_events.trust_addr |
Reconciler-only; the raw 0x… form returned by the _events getter. null when absent on chain. |
events[].outcomes |
oracle_events.outcome_names_jsonb |
Decoded to [{outcomeId, outcomeName}] sorted by outcomeId ascending. |
outcome_names_jsonb holds the on-chain outcomeNames map as {"<outcomeId>": "<name>"}. The decoder parses each key as u32, sorts ascending, and rejects a malformed map (non-object, non-numeric or out-of-u32 key, non-string value) as MarketInconsistent — see fail-closed below. A legitimately empty {} (the chain published no labels) yields an empty outcomes array, not an error.
The top-level response is returned directly (no envelope), like /api/v1/prediction/markets: { serverTime, nextCursor, hasMore, oracles }.
nextCursor is URL_SAFE_NO_PAD(base64("<id>:<name>")) of the last retained oracle. The id is written first so the split stays unambiguous when an oracle name itself contains : — the decoder splits on the first :, parses the left side as i64, and takes the entire remainder as the name. A corrupted cursor surfaces as InvalidParameter → 400 (wrapped via the same typed-error pattern as the markets cursor), never as a 500.
The cursor is opaque to clients: pass it back verbatim, do not synthesize it. Standard keyset caveat: an oracle inserted (a fresh OracleDeployed) with a name sorting before the current position is missed until a fresh first-page read; one sorting after is picked up. No duplication or skipping of oracles already in range.
The read path depends on two write-side additions (write-side detail in indexer.md):
oracle_event_lists.description— a newtext NOT NULLcolumn (migration;data-schema.mdupdated synchronously). TheOracle.OracleEventListDeployedevent carriesdescriptionalongsideeventListAddressandindex; theapply_oracle_event_list_deployedprojector reads it strictly (a missing field is a decoder/ABI mismatch and fails the projection) and writes it viacoalesce(description, $new)so replays do not clobber it. Because every list is created from such an event, the column isNOT NULLand the public contract stays a plainSTRING.oracle_events.outcome_names_jsonbpopulation — the OracleEventList reconciler already fetches each list's_eventsgetter, whose per-event tuple includesoutcomeNames(map(uint32,string)), but today extracts onlydescribe/trustAddr. It is extended to also persistoutcomeNamesintooutcome_names_jsonb(coalesce, idempotent, stamped under the samemeta_reconciled_atpass). Until this ships everyoracle_eventsrow carries the default'{}', sooutcomeswould be empty for all events; the availability gate'smeta_reconciled_at IS NOT NULLconjunct keeps unreconciled events hidden in the meantime.
Decoder checkpoint: the contract change also added a new Oracle.EventPublished event, which changes Oracle's event count. The decoder's event-count assertion in crates/infrastructure/src/decoder.rs must be re-pinned to the new total when the ABI lands.
After building each oracle DTO the assembled shape is checked; any violation is MarketInconsistent → 503. The 503 is deliberate: the inconsistency is transient (the indexer is mid-replay) and the client should retry.
| Rule | Source |
|---|---|
outcome_names_jsonb is a JSON object; every key parses to u32; every value is a string |
The _events.outcomeNames map shape; a non-conforming blob is reconciler / ingestion corruption, not a renderable outcome set. |
eventId renders (numeric → hex conversion succeeds on internal_id_in_eventlist) |
The same numeric_to_hex invariant /api/v1/prediction/markets enforces on event.eventId. |
| Condition | DomainError | API code | HTTP |
|---|---|---|---|
limit present but non-numeric |
InvalidParameter |
-1130 |
400 |
deadlineBefore present but non-numeric |
InvalidParameter |
-1130 |
400 |
eventId present but not decodable as a uint256 hex |
InvalidParameter |
-1130 |
400 |
Corrupted cursor |
InvalidParameter |
-1130 |
400 |
| Invariant violation on built DTO | MarketInconsistent |
-1500 |
503 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
The endpoint is public, so there are no auth rows.
Three points decided here for the team to confirm:
- Confirmed events are not excluded. The default availability filter is exactly "not deleted, not past deadline" per api-spec §Oracles; an event with
confirmed_pmp_address IS NOT NULL(already backing a market) still lists. Rationale:OracleEventList.confirmEvent(eventId, oracleListHash, tokenType)is parameterized by(oracleListHash, tokenType), so one event can back more than one market, and the read-model's singleconfirmed_pmp_addresscolumn cannot express "fully consumed". If product intent is "hide once used", addconfirmed_pmp_address IS NULLto the availability predicate. eventLists[].descriptionis required (resolved). The api-spec keeps itSTRING; the fresh path is hardened to match —OracleEventListDeployedalways carriesdescription, the projector reads it strictly, and the column isNOT NULL. The value may be an empty string but is never null, so noSTRING | nullsoftening is needed.oracleFee.amountis unscaled. Rendered as the raw chain integer (decimal string), consistent with/api/v1/prediction/markets. If clients need a human-scaled amount, scale by SHELL'sref_tokens.decimals— deferred until a concrete need.
Three suites, the DB-backed ones gated on TEST_DATABASE_URL:
crates/infrastructure/tests/oracles.rs— grouping (oracle → list → event); api-spec ordering (name, list index, deadline, event id); the availability gate (deleted / past-deadline / unreconciled rows excluded); each filter (oracleAddress,eventIdnarrowingevents[]to one,deadlineBefore); empty-list and empty-oracle omission; cursor advance and stability across pages;limitdefault and clamp;outcomesdecoded fromoutcome_names_jsonbsorted byoutcomeId; fail-closed on a malformedoutcome_names_jsonb.- Projector / reconciler tests —
apply_oracle_event_list_deployedpersistsdescriptionfrom the deploy event (andcoalescekeeps it across replays); the reconciler persistsoutcomeNamesintooutcome_names_jsonbalongsidedescribe/trust_addr. services/api/tests/oracles_http.rs— happy path through the production router (top-levelserverTime/nextCursor/hasMore/oracles, plus the renderedeventIdhex and nestedoracleFee/outcomes); the single-pagenextCursor: nullshape; the four400shapes (limit,deadlineBefore,eventId,cursor); the route is reachable without an auth envelope (public). The multi-page cursor walk is exercised at the repo layer (oracles.rsabove), not re-driven through HTTP.
Domain note: the unused Oracle / OracleEventList / OracleEvent structs in crates/domain/src/lib.rs predate the api-spec shape and are replaced by the response types this endpoint introduces (OracleListing / OracleEventListEntry / OracleEventEntry / OracleOutcome / OracleFee).
Returns the top of the order book for one outcome of one market: a snapshot of resting bids and asks, the quantity available at each price level, and a sequence number the client uses to tell whether the snapshot has moved since the previous response. The endpoint never queries the contract at request time — every level shown is the projection of indexed OrderBook events into a per-order read-model (see indexer.md).
Resolve (predictionMarketAddress, symbol) to (orderbook_address, outcome_id, price_precision, quantity_precision) via markets joined with market_outcomes. The market must already be reconciled at least once (last_reconciled_at IS NOT NULL); otherwise the endpoint returns InvalidMarketOrSymbol → 404. The symbol identifies one of the market's outcomes — depth is per-outcome, not per-market.
A reconciled market always has an orderbook_address, so an empty book means exactly one thing: no OrderBook.OrderPlaced events have landed for (orderbook_address, outcome_id) yet. The response is structurally well-formed — empty bids, empty asks, lastUpdateId = "" — and is the steady-state shape for a market that has not yet started trading. Clients can poll cheaply while the market is warming up. A NULL or blank orderbook_address on a reconciled row is treated as MarketInconsistent (HTTP 503), not silently served as an empty book.
The API issues one SQL query that produces both sides of the book in a single round trip. Per side, the database:
- Filters
live_orderstostatus = 'OPEN' AND amount_remaining > 0scoped to this(orderbook_address, outcome_id). - Groups by
price, sumsamount_remaining— multiple resting orders at one price collapse into a single level. Clients see "quantity available at this price", not the underlying orders. - Orders by price (bids descending, asks ascending) and applies
LIMIT $limit.
Postgres applies the sort and LIMIT while reading, so the API receives only the top N price levels per side instead of loading the full open book into memory. The partial index live_orders_open_book_idx (WHERE status = 'OPEN') is designed for this depth query.
After the database returns, each side is re-sorted in Rust using exact-numeric BigUint comparison — lexicographic string comparison would silently misrank prices of different lengths ("100" < "99" lexicographically). Each [price, quantity] is then decoded from chain units: live_orders stores the raw integers the contract emitted (price in basis points, amount in token atoms), so price is divided by FULL_PERCENT (10 000) and amount by 10^decimals (decimals joined from ref_tokens), then formatted at the outcome's price_precision / quantity_precision. The result matches the [price, quantity] shape in api-spec.md.
max(live_orders.last_chain_order) over rows for this (orderbook_address, outcome_id) pair. last_chain_order is the lex-sortable chain-order string (msg_chain_order from the GraphQL gateway) of the most recent event that touched the row; the public lastUpdateId is therefore a STRING, not an integer (see api-spec.md §Order Book). The per-outcome scope is intentional: a single OrderBook serves multiple outcomes, and a per-orderbook cursor would let a quiet outcome inherit activity from sibling outcomes.
Empty string means no OrderBook event has touched this pair yet. The value never lex-decreases between successive snapshots — last_chain_order is updated via greatest(existing, new) on the write side, and the reproject loop applies events in chain_order so the natural arrival order is already monotonic (see indexer.md).
bidssorted by price descending;asksascending. Comparison is exact-numeric.- Each price level surfaces as one
[price, quantity]entry. Quantity is the sum across every resting order at that price. lastUpdateIdis scoped to(orderbook_address, outcome_id). It is an empty string when no OrderBook event has touched this pair yet, and never lex-decreases between successive snapshots.- A non-null
predictionOrderBookAddresson the underlying market is necessary for non-empty depth but not sufficient — orders only land after thePoolsFrozenevent is observed and clients start posting.
| Condition | DomainError | HTTP |
|---|---|---|
| Market unknown or pre-reconcile | InvalidMarketOrSymbol |
404 |
Reconciled market with NULL/blank orderbook_address |
MarketInconsistent |
503 |
Missing predictionMarketAddress or symbol |
MissingParameter |
400 |
Invalid limit (non-numeric) |
InvalidParameter |
400 |
Returns the most recent public trades for one outcome of one market: a newest-first tape of maker↔taker matches, each carrying price, size, quote notional, direction, and chain time. The endpoint never queries the contract at request time — every trade shown is the projection of an indexed OrderBook.OrderFilled event into the append-only trades read-model (write side in indexer.md). It is public (NONE) and unaffected by market lifecycle status: terminal markets (RESOLVED / CANCELLED / EXPIRED) still serve their tape so history stays readable after the book closes.
Resolve (predictionMarketAddress, symbol) to (orderbook_address, outcome_id, price_precision, quantity_precision, decimals) via markets ⨝ market_outcomes ⨝ ref_tokens — the quote-asset decimals feeds quoteQty scaling. The market must already be reconciled at least once (last_reconciled_at IS NOT NULL); otherwise the endpoint returns InvalidMarketOrSymbol → 404, the same way an unknown pair is reported. A pair that exists in markets but has never reconciled cannot be distinguished from one that does not exist, matching /api/v1/prediction/depth. The symbol identifies one of the market's outcomes — the tape is per-outcome, not per-market.
A reconciled market with no matched trades yet returns a bare empty array [], not an error — the steady-state shape for a market that has opened but not yet traded, and the trade-tape analogue of depth's empty-book contract. A NULL or blank orderbook_address on a reconciled row is MarketInconsistent (503), never silently served as an empty tape.
After resolution, one SQL produces the page in a single round trip:
SELECT trade_id,
price::text AS price,
qty::text AS qty,
is_buyer_maker,
(extract(epoch FROM chain_time) * 1000000)::bigint AS chain_time_us
FROM trades
WHERE orderbook_address = $1
AND outcome_id = $2
AND chain_time IS NOT NULL
ORDER BY trade_id DESC
LIMIT $limit;trades_tape_idx ((orderbook_address, outcome_id, trade_id DESC)) serves this as an index range scan — the top $limit rows per outcome, newest first, without loading the full tape. There is no pagination cursor in v1: the public contract exposes only limit (see api-spec §Recent Trades), so the endpoint is a bounded newest-first window, not a keyset walk.
trade_id is the taker-side chain_order (msg_chain_order from the gateway), which is globally unique and lexicographically monotonic by gateway design — the same total-order property /api/v1/prediction/orders relies on for placed_chain_order. The text ORDER BY trade_id DESC therefore already yields true chain order with no tie-breaker and no Rust-side numeric re-sort (unlike depth, where price levels of differing length must be re-ranked with exact-numeric comparison).
chain_time IS NOT NULL is a heap filter guarding the rare ingestion path where the gateway delivered the OrderFilled edge without a parseable created_at; such a row would otherwise crash the response decoder mapping NULL into the time i64. This mirrors the chain_created_at IS NOT NULL guard on /api/v1/prediction/orders.
trades holds the raw chain integers the contract emitted; the API decodes each field at render (see api-spec §Recent Trades for the public shapes):
| Response field | Source | Rendering |
|---|---|---|
tradeId |
trade_id |
Verbatim. Opaque lex-comparable token; the identical value is the t field on the orderUpdate fill frame for the same match. |
price |
price |
÷ FULL_PERCENT (10 000), formatted at price_precision — the same price decode as depth / orders. |
qty |
qty |
÷ 10^decimals, formatted at quantity_precision. |
quoteQty |
price, qty |
Quote-asset notional, computed as the contract computes it: notional_atoms = price * qty / FULL_PERCENT (integer division, BigUint), then ÷ 10^decimals formatted at the quote asset's decimals. Deriving from the same two raw integers — rather than storing a column — keeps the value reconciled with the on-chain notional that drove settlement; the chain emits no separate notional field. |
time |
chain_time |
Extracted to microseconds and truncated to Unix milliseconds — the same convention as time / updateTime on /api/v1/prediction/orders. |
isBuyerMaker |
is_buyer_maker |
Verbatim. true ⇒ the resting (maker) side was the buy order and the taker sold (downtick). |
The integer-division order matters: price * qty / FULL_PERCENT floors after multiplying, exactly as OrderBook derives the match notional, so quoteQty never drifts from chain by the rounding ulp a round(price_decimal × qty_decimal) could introduce.
limitdefaults to20when omitted.- Valid range is
[1, 1000]. Out-of-range →-1102/ 400; present but non-numeric →-1130/ 400. The split (range vs parse) matches/api/v1/prediction/orders'slimitsemantics rather than depth's silent clamp — the trade tape is aligned with the paginated-read family even though it carries no cursor.
- Rows are returned strictly newest-first by
trade_id(DESC), a total chain order; no duplication or skipping acrosslimitboundaries. - Each row is one taker-side fill = one match; the maker-side
OrderFilledwrites notradesrow, so a single match never double-counts (write-side rule in indexer.md). quoteQtyequals the on-chain match notional under the contract's integer-division rounding.
| Condition | DomainError |
API code | HTTP |
|---|---|---|---|
predictionMarketAddress or symbol missing or blank |
MissingParameter |
-1102 |
400 |
limit out of [1, 1000] |
MissingParameter |
-1102 |
400 |
limit present but non-numeric |
InvalidParameter |
-1130 |
400 |
| Pair not found, or its market is unreconciled | InvalidMarketOrSymbol |
-1121 |
404 |
Reconciled market with NULL/blank orderbook_address, or an undecodable raw price / qty |
MarketInconsistent |
-1500 |
503 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
The endpoint is public, so there are no auth rows. The 503 is deliberate but its two triggers differ in lifetime: a blank orderbook_address is transient (the reconciler is mid-replay; the client should retry), while an undecodable raw price/qty persists until an operator repairs the corrupt row — see the recovery notes in data-schema.md.
The read path depends on one write-side projection (detail in indexer.md §Projection — public trades):
tradestable +trades_tape_idx— an append-only table (data-schema.md). TheOrderBook.OrderFilledprojector that maintainslive_ordersalso inserts onetradesrow on the taker-side event (isTaker = true) and nothing on the maker side, so a match is recorded exactly once.trade_idis that taker event'schain_order; a replayed insert conflicts on it and only coalesces aNULLchain_time(first-write-wins), so reprojection fromraw_eventsis idempotent. AnOrderFilledobserved before its parentOrderPlacedisDeferredand replayed, the same deferral contract aslive_orders.
No read-side gate guards the projector: an empty trades table simply reads [], already the valid steady state for a market that has opened but not yet traded.
A just-matched trade briefly lags the fill that produced it: the row appears once the taker-side OrderFilled is projected (seconds, or after deferred-replay if the fill edge arrived before its parent OrderPlaced). This is the same indexer-backlog window /api/v1/prediction/orders exposes, surfaced to clients as the eventual-consistency note in api-spec §Recent Trades. The endpoint reads only the indexed trades table — it never reaches chain at request time.
Three suites, the DB-backed ones gated on TEST_DATABASE_URL:
crates/infrastructure/tests/trades.rs(repo) — resolution (unknown / unreconciled pair → theInvalidMarketOrSymbolmapping; blankorderbook_address→MarketInconsistent); per-outcome and per-orderbook scoping (neither a sibling outcome's trades nor another book's same-id outcome leaks); DESC-by-trade_idorder and theLIMITcut; empty tape →[];price/qty/quoteQtyscaling, including the integer-division notional matching the contract;isBuyerMakerpassthrough; achain_time IS NULLrow excluded beforeLIMIT. Thelimitdefault and[1, 1000]bounds live one layer up, in theGetTradesUseCase/TradesLimitunit tests incrates/application.- Projector test (alongside the
live_ordersprojector scenarios incrates/infrastructure/tests/reprojection.rs) — the taker-side event (isTaker = true) writes exactly onetradesrow and the maker-side writes none;trade_idequals the taker event'schain_order; replay is idempotent (ON CONFLICT); one taker crossing N makers yields N rows with N distincttrade_ids. services/api/tests/trades_http.rs— happy path returns a bare JSON array newest-first through the production router; the error shapes (-1102missing param,-1102limit out of range,-1130non-numeric limit,-1121unknown pair,-1500inconsistent); the route is reachable without an auth envelope (public); a terminal-status market still serves its tape.
Lists the tradable models — one entry per InferenceOrderBook. The public contract (fields, examples) is in api-spec.md. Structurally this mirrors /api/v1/prediction/markets: a serverTime + cursor + array envelope built from indexed read-model rows, never from a contract call at request time. Source is inference_markets.
WHERE last_reconciled_at IS NOT NULL. A book discovered by a first OrderPlaced but not yet reconciled (no model_hash / precision) is hidden — clients see only fully described markets. Symmetric write-side rule in indexer.md.
Superseded books are also hidden by this filter without any additional predicate. When the inference reconciler retires a book (lower-version duplicate superseded by a higher-version replacement, or the incoming book superseded by the existing incumbent), it clears last_reconciled_at as part of the retire write — so the retired row falls back to last_reconciled_at IS NULL and the existing visibility gate already excludes it. No separate superseded_at IS NULL predicate is needed on the read-API query.
Inference books have no multi-phase lifecycle. status is TRADING for every visible (reconciled) row; the enum is kept as a forward-compatible single value so a later INACTIVE / HALTED signal can be added without a shape change. It is not a stored column.
Per row: render model.{producer,name,version,ref} from model_ref and its parsed parts (NULL parts → model carries only ref/hash — see the model-id open question); takerCommission (buyer-side, charged) from platform_fee_bps ÷ 10 000 and makerCommission (seller-side rebate cap, credited → negative) as −REBATE_MAX_BPS ÷ 10 000 — mirroring how /api/v1/prediction/markets sources MAKER_COMMISSION / TAKER_COMMISSION from global constants rather than per-row columns. The displayed values are the buyer-side fee and the seller rebate cap; the per-deal split (ramped rebate vs burn, spec §5.3/§5.4) is settlement state, not a market property. Then the precision block (pricePrecision, quantityPrecision, tickSize, stepSize, minNotional) from the row; quoteAsset = "SHELL"; referencePrice from reference_price decoded ÷ 10^9, or null when the column is NULL (dry book — see indexer.md §Inference reconciler); createdAt from created_at_chain.
contractVersion is passed through verbatim from inference_markets.version — the contract version reported by the book's getVersion() getter (e.g. "4.0.30"), the same column the reconciler parses as semver for cross-version supersede resolution. It is not the model version: model.version renders from model_version (the --version component of the model name), and the two columns are kept distinct on purpose. null when the getter has not yet populated the column. No decode or validation — an unreconciled book is already hidden by the visibility gate, and whatever string the getter returned is served as-is.
Same cursor machinery as /api/v1/prediction/markets (URL-safe base64 of "<sort_key>:<id>"). One sort mode: sort=createdAt (default, DESC, key created_at_chain) — resultStart from the prediction side does not apply (inference markets have no result timing). A corrupted cursor → InvalidParameter → 400.
?inferenceOrderBookAddress= returns exactly one market and is mutually exclusive with the list filters (producer, status, sort, cursor) — passing both → MissingParameter → 400, mirroring /api/v1/prediction/markets's predictionMarketAddress single-market rule. An unknown or unreconciled address → InvalidMarketOrSymbol → 404. The response is the same market object built per Building the response, wrapped with serverTime.
| Condition | DomainError | HTTP |
|---|---|---|
inferenceOrderBookAddress unknown / not yet reconciled |
InvalidMarketOrSymbol |
404 |
Invalid status / sort enum value |
InvalidParameter |
400 |
inferenceOrderBookAddress together with list filters |
MissingParameter |
400 |
| Corrupted cursor | InvalidParameter |
400 |
Returns the order-book depth for one model — the inference analogue of /api/v1/prediction/depth, built from inference_orders, never from a contract call. Because an InferenceOrderBook is one book per model (no outcome dimension), it is keyed by inferenceOrderBookAddress alone — there is no symbol. Public contract in api-spec.md.
Resolve inferenceOrderBookAddress to (orderbook_address, price_precision, quantity_precision, version) via inference_markets. The book must be reconciled (last_reconciled_at IS NOT NULL); otherwise InvalidMarketOrSymbol → 404. The resolved version is passed through verbatim as the response contractVersion (the same inference_markets.version column and contract-vs-model distinction described under /api/v1/inference/markets § Building the response); null until the book's getVersion() getter has populated it.
A reconciled book with no OrderPlaced yet returns the well-formed empty shape — empty bids, empty asks, lastUpdateId = "" — the steady state before trading starts. Same contract as depth.
One SQL query produces both sides. Per side, the database:
- Filters
inference_orderstostatus = 'OPEN' AND amount_remaining > 0for thisorderbook_address(resting buy orders and subscriptions are bids, sell offers are asks). - Groups by
price, sumsamount_remaining— orders at one price collapse into one level ([pricePerTick, ticks]). - Orders by price (bids DESC, asks ASC),
LIMIT $limit. The partial indexinference_orders_open_book_idx(WHERE status = 'OPEN') backs this.
Each side is then re-sorted in Rust with exact-numeric BigUint comparison (lexicographic string order would misrank prices of differing length). Price is decoded ÷ 10^9 (SHELL atoms → SHELL) and formatted at price_precision; quantity (ticks) is integer, formatted at quantity_precision = 0.
max(inference_orders.last_chain_order) over rows for this orderbook_address — a lex-sortable STRING, empty when no book event has landed. Never lex-decreases (greatest(existing, new) on the write side; chain-order projection keeps arrival monotonic). Scope is per book (no outcome sub-scope, unlike depth).
bidsDESC,asksASC by price, exact-numeric.- One
[price, quantity]per price level; quantity is the summed resting ticks. lastUpdateIdscoped to the book; empty string before any event; never lex-decreases.
| Condition | DomainError | HTTP |
|---|---|---|
inferenceOrderBookAddress unknown or pre-reconcile |
InvalidMarketOrSymbol |
404 |
Reconciled book with NULL/blank orderbook_address |
MarketInconsistent |
503 |
Missing inferenceOrderBookAddress |
MissingParameter |
400 |
Invalid limit (non-numeric) |
InvalidParameter |
400 |
Lists orders on one InferenceOrderBook — the inference analogue of /api/v1/prediction/orders, but public and unauthenticated rather than owner-scoped: an InferenceOrderBook has no per-order ownership column (see inference_orders), so the endpoint filters by tokenContract or note instead of by caller identity. Source is inference_orders, never a contract call at request time.
This note covers the application-layer contract built in crates/application/src/lib.rs — the validated types (InferenceOrdersCursor, InferenceOrderStatus, InferenceSide, InferenceOrdersQuery, InferenceOrderRow, InferenceOrdersPage), the InferenceReadRepository::list_inference_orders trait method, and GetInferenceOrdersUseCase. The repository query and the HTTP handler consume this contract.
GetInferenceOrdersUseCase::execute validates the raw GetInferenceOrdersInput into an InferenceOrdersQuery before anything reaches the repository:
orderbookAddressblank / whitespace-only →MissingParameter→-1102/ 400.tokenContractandnoteare mutually exclusive — see § tokenContract / note exclusion.side, if present, must beBUYorSELL(case-sensitive); blank →MissingParameter→-1102/ 400; anything else →InvalidParameter→-1130/ 400.statusCSV — see § Status vocabulary.limit— see § Page-size protocol.cursor— see § Cursor format.
tokenContract and note are both optional narrowing filters over the same book, and passing both at once is refused: InvalidParameter → -1130 / 400, not MissingParameter — both values are present and well-formed, so nothing is missing; it is specifically the combination the endpoint cannot serve. Passing neither is fine (no token-contract / no note filter, side/status/cursor still apply).
The reason is structural, not a validation preference: inference_orders_book_tc_idx is keyed (orderbook_address, token_contract, status, order_id DESC) and inference_orders_book_note_idx is keyed (orderbook_address, note_address, is_buy, status, order_id DESC). Neither index carries the other's column, so a query naming both filters would have to pick one to seek on and apply the other as a heap residual — unbounded, because rows are never deleted and a TokenContract or note can accumulate history across many book cycles. Rather than serve a query whose cost is silently proportional to one filter's full history, the use case rejects the combination up front.
A resting SELL whose token_contract is still NULL (the indexer has not yet learned it — see inference_orders.token_contract) makes any TokenContract-filtered query over live SELLs suspect: the row might belong to the requested TokenContract and simply not say so yet. The repository probes inference_orders_live_sell_tc_null_idx for such rows and fails closed with MarketInconsistent → 503 rather than silently omitting a row that could match — one of three arms of the fail-closed gate; see § Fail-closed gate.
Three public values, exhaustive over every row (InferenceOrderStatus::ALL):
Public status |
inference_orders.status |
|---|---|
LIVE |
OPEN |
FILLED |
FILLED |
CANCELLED |
CANCELLED |
LIVE is exactly OPEN: every chain placement path on an InferenceOrderBook requires non-zero size, and the fill projector moves a row to FILLED as soon as its remainder reaches zero, so an OPEN row is always still resting. This three-way split is exhaustive — every row falls under exactly one value — which is what lets the default (no status filter) query claim to cover the whole book.
status is a CSV, parsed by InferenceOrderStatus::from_csv: blank / whitespace-only → MissingParameter → -1102 / 400 (a present-but-empty value is a client bug — an unbound template variable — not "no filter"); an unrecognized token → InvalidParameter → -1130 / 400. Tokens are de-duplicated on parse; omitting status entirely defaults to all three values.
Same contract as /api/v1/prediction/orders's page-size protocol: limit defaults to ORDERS_DEFAULT_LIMIT (100) when omitted; valid range is [1, ORDERS_MAX_LIMIT] ([1, 500]). Out of range (0, > 500, or a value that parses as a valid integer but does not fit u16 — negative or above u16::MAX) → MissingParameter → -1102 / 400, all folded into the same range check rather than split into a separate malformed-input case. A non-numeric limit is rejected earlier, at the HTTP boundary, as InvalidParameter.
The cursor is InferenceOrdersCursor, a validated u128 — the order_id of the last row on the previous page. This differs from /api/v1/prediction/orders's cursor, which is an opaque placed_chain_order string compared with SQL < on text: the inference cursor is fed into an order_id::numeric predicate against inference_orders_book_side_status_idx / inference_orders_book_tc_idx / inference_orders_book_note_idx (order_id is uint128 on chain, stored numeric(78,0)), so it is validated as an unsigned integer here rather than surfacing as a SQL cast error.
InferenceOrdersCursor::new validates, in order:
- Trim ASCII whitespace. Empty after trim →
DomainError::MissingParameter→-1102/ 400. - Length check against
MAX_CURSOR_LEN(128 chars) before any per-byte scan of the input — the endpoint is public and unauthenticated, so an arbitrarily long all-digits string must not buy an arbitrarily long scan. Oversized →DomainError::InvalidParameter→-1130/ 400. - Every remaining byte must be an ASCII digit — no leading
-, no interior whitespace, no+. Any non-digit byte →InvalidParameter→-1130/ 400. - Parse as
u128. An all-digit string with too many digits (39+) overflowsu128and is rejected the same way —InvalidParameter→-1130/ 400, not a panic or a silently truncated value.
| Condition | DomainError |
API code | HTTP |
|---|---|---|---|
cursor blank / whitespace-only |
MissingParameter |
-1102 |
400 |
cursor longer than MAX_CURSOR_LEN (128) |
InvalidParameter |
-1130 |
400 |
cursor contains a non-digit byte (including a leading -) |
InvalidParameter |
-1130 |
400 |
cursor is all digits but overflows u128 |
InvalidParameter |
-1130 |
400 |
PostgresReadModelRepository::list_inference_orders (crates/infrastructure/src/inference_read_repo.rs) reads the page, lastUpdateId, precision and the fail-closed gate in one SQL statement, so all four share a single MVCC snapshot. Splitting them into separate statements would let an order commit between them: the page would miss it while lastUpdateId already covered it, which the caller cannot detect.
The statement is with mkt as (…), gate as (…), wm as (…), page as (…) select … from mkt cross join gate cross join wm left join page p on true order by p.order_id desc nulls last limit $fetch, built with sqlx::QueryBuilder because the branch count in page varies with the filters and hand-numbered $n placeholders are how a query at this size acquires an off-by-one:
mktresolves visibility and precision: no row ⇒ the book is unknown or not yet reconciled ⇒InvalidMarketOrSymbol→-1121/ 404.gatecomputes the three fail-closed booleans — see § Fail-closed gate.wmcomputeslastUpdateId=max(last_chain_order)over the book.pageis aUNION ALLof one branch per(is_buy, status)pair the query admits. Each branch pinsorderbook_address,is_buyandstatusto one stored value (LIVEis exactlyOPEN, so no residual there either) and appliestoken_contract/note_address/ the cursor as index conditions, thenORDER BY order_id DESC LIMIT $fetch— so every branch already returns its own candidates in cursor order and the outer merge sorts at mostbranches × fetchrows, never the book's full history.
A token_contract filter admits SELL branches only — token_contract is non-null exclusively on SELL rows, so a tokenContract + side=BUY combination is provably empty:
tokenContract |
side |
Sides emitted |
|---|---|---|
| absent | absent | BUY + SELL |
| absent | BUY / SELL |
the requested side |
| present | BUY |
none — page becomes select … from inference_orders where false |
| present | absent / SELL |
SELL only |
The where false shape still resolves through mkt (an unknown book is still -1121) and still reports lastUpdateId, but page never scans inference_orders. This is not a cosmetic shortcut: inference_orders_book_tc_idx has no is_buy column, so without pinning SELL up front an impossible BUY+TC query would have to walk that TokenContract's entire status-scoped history on an unauthenticated endpoint before the residual discarded every row and the LIMIT never filled.
Each branch's index depends on which filter is present:
| Branch shape | Index ridden |
|---|---|
token_contract present |
inference_orders_book_tc_idx (orderbook_address, token_contract, status, order_id DESC) |
note present |
inference_orders_book_note_idx (orderbook_address, note_address, is_buy, status, order_id DESC) |
| neither present | inference_orders_book_side_status_idx (orderbook_address, is_buy, status, order_id DESC) |
A cursor, when present, adds order_id < $cursor::numeric to every branch as a range condition trailing that branch's equality prefix, so the same index serves it.
gate computes three independent booleans, each a different reason this book's read-model state cannot support a claim of absence:
tc_unknown— a resting SELL withtoken_contract is null:exists(select 1 from inference_orders where orderbook_address = $ob and is_buy = false and status = 'OPEN' and token_contract is null), backed byinference_orders_live_sell_tc_null_idx. A resting SELL always carries atoken_contractby the write-path invariant documented atdata-schema.md#inference_orders; a NULL here means either the indexer has not filled it in yet or that invariant has been violated.unprojected— captured-but-not-yet-projected events for this book:exists(select 1 from raw_events where src_address = $ob and processed_at is null), backed byraw_events_unprocessed_src_idx. Deliberately wider than the inference reconciler's ownpending_events_existgate (which additionally requiresevent_type is not null and decoded is not null, so a permanently undecodable row cannot wedge its sweep forever): a reader asking "is my view of this book complete?" must count every unprojected row for the book — pending, undecodable, bodyless, or an event id no loaded ABI recognizes — because every event anInferenceOrderBookemits is already in that ABI, so none of those shapes has a benign explanation for thissrc_address.capture_stale— the aggregate capture cursor is stale or either source stream is behind the chain head:not coalesce((select at_head and updated_at > now() - make_interval(secs => CAPTURE_FRESHNESS_SECS) from indexer_cursors where stream_name = CAPTURE_STREAM), false), so an absent cursor row is treated the same as a stale one.CAPTURE_STREAMis the synchronizedblockchain_eventsrow:at_headbecomes true only after both the DEX-dApp and legacy RootPN streams reach head in one successful tick, andupdated_atis refreshed only at that synchronization point.CAPTURE_FRESHNESS_SECS(30s,crates/infrastructure/src/config.rs) bounds how old that poll may be before the read API stops trusting it — see indexer.md § Capture-freshness / polling-interval coupling for the config-side half of this contract.
All three arms answer the wire the same way — MarketInconsistent, never InvalidMarketOrSymbol — because in every case the book exists and is reconciled; only the read model's coverage of it is incomplete. -1121 / 404 is cacheable and tells the client to stop asking; MarketInconsistent → 503 tells it to retry, which is the correct remedy for all three arms — including arm 1, where the underlying cause is an operator-visible defect but the caller's only available action is still to retry.
The gate refuses a request only when it names a tokenContract and admits at least one LIVE SELL (side absent or SELL, and the resolved status set includes LIVE) — the one shape an incomplete view could turn into a false "not in use". A query that names no tokenContract, or that filters side=BUY only, or that excludes LIVE from status, makes no claim the incomplete state could falsify, so it is served regardless of gate's booleans; an affected row's tokenContract may simply read null in the response.
Arm 1 (tc_unknown) logs at error! on every request that observes it, evaluated ahead of — and independent of — the refusal check: this alarm is not conditioned on the request happening to be one the gate refuses, because the condition itself (a live SELL the write path should never let go TokenContract-less) is a defect regardless of who asks or what they ask for. Arms 2 and 3 log at debug! / warn! respectively, and only when they actually cause a refusal, since their volume otherwise tracks ordinary indexer catch-up rather than anything worth an operator's attention.
| Condition | DomainError |
API code | HTTP |
|---|---|---|---|
orderbookAddress blank / missing |
MissingParameter |
-1102 |
400 |
tokenContract and note both present |
InvalidParameter |
-1130 |
400 |
side present but blank |
MissingParameter |
-1102 |
400 |
side present and not BUY / SELL |
InvalidParameter |
-1130 |
400 |
status CSV blank / whitespace-only |
MissingParameter |
-1102 |
400 |
status CSV contains an unknown token |
InvalidParameter |
-1130 |
400 |
limit out of [1, 500] (including values outside u16 range) |
MissingParameter |
-1102 |
400 |
limit present but non-numeric |
InvalidParameter |
-1130 |
400 |
cursor — see § Cursor format |
|||
orderbookAddress unknown / not yet reconciled |
InvalidMarketOrSymbol |
-1121 |
404 |
Fail-closed gate refusal — unresolved live-SELL tokenContract, unprojected events for the book, or a stale/behind-head capture cursor, under a query naming tokenContract and admitting LIVE SELLs; see § Fail-closed gate |
MarketInconsistent |
— | 503 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
Returns the most recent public trades on one model's order book: a newest-first tape of maker↔taker matches, each carrying price, size, quote notional, direction, and chain time — the inference analogue of /api/v1/prediction/trades. Because an InferenceOrderBook is one book per model (no outcome dimension), it is keyed by inferenceOrderBookAddress alone, matching /api/v1/inference/depth and /api/v1/inference/orders. The endpoint never queries the contract at request time — every trade shown is the projection of an indexed InferenceOrderBook.InferenceFilled event into the append-only inference_trades read-model (write side in indexer.md §Projection — inference order events). Public (NONE).
Resolve inferenceOrderBookAddress to (price_precision, quantity_precision, quote_decimals) via inference_markets ⨝ ref_tokens — the quote-asset decimals feeds quoteQty scaling. version is deliberately not selected here: the tape is a bare array with nowhere to carry the book's contract generation (see § Deliberate absences). The book must already be reconciled (last_reconciled_at IS NOT NULL); otherwise InvalidMarketOrSymbol → 404 — an unknown address and a not-yet-reconciled one collapse to the same client-visible miss, matching /api/v1/inference/depth.
A reconciled book with no matched trades yet returns a bare empty array [], not an error — the steady state for a book that has opened but not yet traded, and the inference analogue of /api/v1/prediction/trades's empty-tape contract. No read-side gate guards this: an empty inference_trades table for the book simply reads [].
After resolution, one SQL produces the page:
SELECT trade_id,
price::text AS price,
qty::text AS qty,
is_buyer_maker,
(extract(epoch FROM chain_time) * 1000000)::bigint AS chain_time_us
FROM inference_trades
WHERE orderbook_address = $1
AND chain_time IS NOT NULL
ORDER BY trade_id DESC
LIMIT $limit;inference_trades_tape_idx ((orderbook_address, trade_id DESC)) serves this as an index range scan. trade_id is the InferenceFilled event's chain order (globally unique, lexicographically monotonic), so ORDER BY trade_id DESC already yields true chain order with no Rust-side re-sort — the same property the prediction tape relies on. chain_time IS NOT NULL drops the rare row the gateway delivered without a parseable time, matching both the prediction tape and /api/v1/inference/orders.
There is no pagination cursor: the public contract exposes only limit (see api-spec §Inference Trades), so the endpoint is a bounded newest-first window over the most recent matches, not a keyset walk. There is no way to page past limit (max 1000) into older history.
inference_trades holds the raw chain integers the contract emitted; the API decodes each field at render (public shapes in api-spec §Inference Trades):
| Response field | Source | Rendering |
|---|---|---|
tradeId |
trade_id |
Verbatim. Opaque lex-comparable token — the InferenceFilled event's chain order. |
price |
price |
Rendered at price_precision via scale_uint_to_decimal — a pure decimal-point insertion, no division. |
qty |
qty |
Rendered at quantity_precision (0 — ticks are whole units) the same way. |
quoteQty |
price, qty |
Quote-asset notional, price × qty (plain BigUint multiplication, no division), then rendered at the quote asset's decimals. |
time |
chain_time |
Extracted to microseconds and truncated to Unix milliseconds — same convention as the prediction tape and /api/v1/inference/orders. |
isBuyerMaker |
is_buyer_maker |
Verbatim. true ⇒ the resting (maker) side was the buy order and the taker sold (downtick). |
Unlike the prediction tape, there is no FULL_PERCENT division and no descale_pow10 grid-consistency check: an inference price is already quoted in quote-asset base units per tick at exactly price_precision — there is no separate on-chain basis-point grid coarser than the display grid to descale from, so scale_uint_to_decimal (insert the decimal point, no rounding, no dropped-digit validation) is the whole transform. quoteQty is a plain price × qty product for the same reason: the contract does not divide by a percent-scale constant to derive the inference notional, so there is no integer-division floor to reproduce. This is the notional, not what the buyer paid — the book charges price + tickFee(price) per tick and reports that separately as InferenceExecuted.cost, which this endpoint does not surface.
limitdefaults to20when omitted or blank (optional_typed_querycollapses a present-but-blanklimit=to "absent", same as depth and orders).- Valid range is
[1, 1000], enforced by clamping, not rejecting:limit.clamp(1, 1000)at the HTTP boundary inservices/api/src/inference.rs. This follows this file's other inference handlers (/inference/markets,/inference/depth) rather than/api/v1/prediction/trades, whose contract rejects an out-of-rangelimitwith-1102. A present-but-non-numericlimit(e.g.limit=abc) is still-1130/ 400 — clamping only applies once the value has parsed.
- Rows are returned strictly newest-first by
trade_id(DESC), a total chain order; no duplication or skipping acrosslimitboundaries. - Each row is one
InferenceFilledevent = one match; unlike the prediction tape's taker-side gate, the inference book emits exactly oneInferenceFilledper match (carrying both leg ids), so there is no maker/taker double-counting risk to guard against on the write side. quoteQtyequalsprice × qtyexactly — no rounding, since the derivation is a plain product.
| Condition | DomainError |
API code | HTTP |
|---|---|---|---|
inferenceOrderBookAddress missing or blank |
MissingParameter |
-1102 |
400 |
limit present but non-numeric |
InvalidParameter |
-1130 |
400 |
inferenceOrderBookAddress unknown, or its book is unreconciled |
InvalidMarketOrSymbol |
-1121 |
404 |
Reconciled book with an undecodable raw price / qty, or a NULL/out-of-range precision or quote-decimals column |
MarketInconsistent |
-1500 |
503 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
An out-of-range limit is never an error here (see § Page-size protocol) — the row above that reads -1102 on the prediction tape has no inference counterpart.
The read path depends on one write-side projection (detail in indexer.md §Projection — inference order events):
inference_tradestable +inference_trades_tape_idx— an append-only table (data-schema.md). TheInferenceOrderBook.InferenceFilledprojector inserts exactly one row per event, keyed ontrade_id— the event's chain order; a replayed insert conflicts on it and only coalesces a NULLchain_time(first-write-wins), so reprojection fromraw_eventsis idempotent.
A just-matched trade briefly lags the fill that produced it: the row appears once InferenceFilled is projected (seconds, or after deferred-replay if the fill edge arrived before its parent order event). Same indexer-backlog window the other inference endpoints expose. The endpoint reads only the indexed inference_trades table — it never reaches chain at request time.
Two fields a client accustomed to the sibling inference endpoints might expect are intentionally missing, and both are documented in prose in api-spec §Inference Trades so their absence does not read as a bug:
contractVersion— present on/api/v1/inference/marketsand on/api/v1/inference/depth(which passesinference_markets.versionthrough verbatim, per depth's resolution note), absent here. The tape's response is a bare JSON array with no envelope to hold book metadata in, soversionis not even selected in this endpoint's own § Resolution query. A client that needs the book's contract generation reads it from/api/v1/inference/markets?inferenceOrderBookAddress=….- A pagination cursor — the endpoint serves at most
limit(ceiling1000) of the newest matches and nothing older; there is nocursor/hasMoreto walk further back, unlike the paginated inference-markets and inference-orders listings.
Three suites, the DB-backed ones gated on TEST_DATABASE_URL:
crates/infrastructure/tests/inference_trades_repo.rs(repo) — resolution (unknown / unreconciled book →InvalidMarketOrSymbol; corrupt precision / quote-decimals / rawprice/qty→MarketInconsistent); per-book scoping; DESC-by-trade_idorder and theLIMITcut; empty tape →[];price/qty/quoteQtyscaling (plain product, noFULL_PERCENT);isBuyerMakerpassthrough; achain_time IS NULLrow excluded beforeLIMIT.crates/infrastructure/tests/inference_projectors.rs— theInferenceFilledprojector writes exactly oneinference_tradesrow per event;trade_idequals the event's chain order; replay is idempotent (ON CONFLICT).services/api/tests/inference_trades_http.rs— happy path returns a bare JSON array newest-first through the production router; the error shapes (-1102missing/blank address,-1130non-numeric limit,-1121unknown book); out-of-rangelimitclamps rather than erroring (0clamps up to1, an oversized value clamps down to the max); a present-but-blanklimit=falls back to the default.
DELETE /api/v1/prediction/openOrders (cancel-all-open) is a separate TRADE operation and is out of scope here — its tech spec lives in write-api.md.
The endpoint reads exclusively from live_orders. A row contributes to the response iff all hold:
owner_pn_address = ctx.trading_pn.pn_address— caller is the owner.- The parent market in
marketshaslast_reconciled_at IS NOT NULL— pre-reconcile markets are hidden symmetrically with/api/v1/prediction/markets. chain_created_at IS NOT NULL AND chain_updated_at IS NOT NULL— rows that the gateway delivered without a parseable timestamp would otherwise crash the decoder when mappingNULLinto thetime/updateTimei64fields. See § SQL for how this is enforced and § Index reliance for why only thechain_created_atconjunct is part of the partial index.- The row's
status(combined withamount_remainingvsamount_initialfor OPEN rows) maps to at least one of the public statuses requested in thestatusfilter — or, ifstatusis omitted, all rows pass.
The query joins through markets and market_outcomes to recover the public identifiers pmp_address and symbol for each row, plus price_precision / quantity_precision for scaling. See § SQL for the two query variants.
Market filter (same three shapes as before):
| Inputs | Behaviour |
|---|---|
neither predictionMarketAddress nor symbol |
all-markets query, owner-scoped. |
| both present | resolve (predictionMarketAddress, symbol) to (orderbook_address, outcome_id) via markets ⨝ market_outcomes. If the pair is missing or its market is not reconciled → DomainError::InvalidMarketOrSymbol → -1121 / 404. |
| exactly one present | DomainError::MissingParameter → -1102 / 400. |
The pair-resolution lookup is a separate SQL round-trip that runs before the main query so the unknown-pair case can be distinguished cleanly from "owner has no orders here". Resolution is bound by last_reconciled_at IS NOT NULL so a pair that exists in markets but has never reconciled is reported the same way as a pair that does not exist.
Status filter (CSV). The handler parses status once at request entry into OrderStatusFilter, an All | Only(BTreeSet<QueryableOrderStatus>) enum:
- Split on
,, trim each token of ASCII whitespace, drop empty tokens, de-duplicate. - Each token must match exactly one of the five canonical strings
NEW,PARTIALLY_FILLED,FILLED,CANCELED,REJECTED. Anything else →DomainError::InvalidParameter→-1130/ 400. - Absent (or empty after trim)
statusparameter means "all five statuses".
The set is then translated into a SQL OR-disjunction (see § Status mapping). Allow-list matching guarantees the SQL fragment contains only safe literal status strings — no user input flows into the SQL string.
The public status enum is partly derived from row state (OPEN-side NEW vs PARTIALLY_FILLED), partly mirrored from the stored status column:
Requested public status |
live_orders predicate |
|---|---|
NEW |
status = 'OPEN' AND amount_remaining = amount_initial |
PARTIALLY_FILLED |
status = 'OPEN' AND amount_remaining < amount_initial AND amount_remaining > 0 |
FILLED |
status = 'FILLED' |
CANCELED |
status = 'CANCELLED' (the DB stores the British spelling; the public enum uses the American one — see api-spec §Order Status) |
REJECTED |
status = 'REJECTED' — projector unimplemented; no row currently matches and the filter returns empty. See § REJECTED status for the projector contract. |
For OPEN rows the projection layer derives the response-side public status with the same executed_qty == 0 ? 'NEW' : 'PARTIALLY_FILLED' split (see § Field projection). The OPEN-side amount_remaining > 0 guard is kept inside the PARTIALLY_FILLED predicate (rather than as a global filter) — a stale OPEN row with amount_remaining = 0 would be a projector bug and we don't want to silently surface it as NEW.
If status is absent, the SQL emits no status predicate at all and every owner row passes — defence-in-depth checks live in the projection layer instead.
origQty = decode(amount_initial), executedQty = decode(amount_initial - amount_remaining), price = decode(price). live_orders holds raw chain integers (price in basis points, amount in token atoms); decoding divides price by FULL_PERCENT (10 000) and amount by 10^decimals, then formats at market_outcomes.price_precision / quantity_precision (decimals joined from ref_tokens). timeInForce is always GTC, type is always LIMIT in v1 (no other combinations are produced by the order-placement path).
Public status per row:
Stored live_orders.status |
amount_remaining |
Public status |
|---|---|---|
OPEN |
= amount_initial |
NEW |
OPEN |
> 0 AND < amount_initial |
PARTIALLY_FILLED |
OPEN |
0 |
projector bug — log an error and skip the row |
FILLED |
(any) | FILLED |
CANCELLED |
(any) | CANCELED |
REJECTED |
(any) | REJECTED |
orderId rendering: the underlying column is numeric(78,0). The renderer emits the empty string for rows where the chain has not assigned an id — today that is exactly the status = 'REJECTED' lifecycle (the rejected placement never produced an OrderBook.OrderPlaced event). Otherwise it emits the decimal string form of order_id. The status-based predicate decouples this from whatever physical-storage choice the REJECTED follow-up adopts for order_id (see § REJECTED — future work). clientOrderId projects an empty string when the column is NULL.
time and updateTime come from live_orders.chain_created_at / chain_updated_at, not from DB bookkeeping columns. Rationale: DB created_at / updated_at drift from real chain time during indexer backlog, which is observable to clients and would make pagination cursors non-monotonic across replays.
Cursor-based on live_orders.placed_chain_order with a strict < comparison (DESC sort).
msg_chain_order is globally unique and lexicographically monotonic by GraphQL gateway
design, so no tie-breakers are needed. The column is set once by the
OrderPlaced projector (the REJECTED projector, when present, writes it identically — see below) via
coalesce (first-write-wins) and never changes on replay or subsequent
events, which preserves cursor stability across reprojects and fills.
Consequence: between two paginated reads, an order that transitions to FILLED or CANCELLED keeps its position in the result — closed rows do not drop out of /orders (they only drop out of a filter that excluded their new status). No duplication or skipping is possible. OrderFilled and OrderCancelled advance last_chain_order and chain_updated_at but do not modify placed_chain_order, so the row's position in the sort order remains fixed.
The cursor is the placed_chain_order value of the last retained row and is returned verbatim. The server validates that the value is a non-empty UTF-8 string after trimming whitespace AND no longer than MAX_CURSOR_LEN (128 chars — real msg_chain_order values are an order of magnitude shorter); an empty / blank cursor surfaces as DomainError::MissingParameter → -1102 / 400, an oversized cursor as DomainError::InvalidParameter → -1130 / 400. The length cap prevents an authenticated client from binding a multi-megabyte string into the SQL placed_chain_order < $cursor::text comparison. A well-formed cursor whose value lexicographically precedes every order in scope returns an empty page with nextCursor: null and is not treated as an error.
The format is not opaque: clients may read the cursor as a plain string, but they must not parse its internal structure or generate cursors of their own. It should be treated as a token to pass back verbatim.
limitdefaults to100when omitted.- Valid range is
[1, 500]. Out-of-range →-1102/ 400. - The SQL query fetches
LIMIT $limit + 1rows. If$limit + 1rows are returned, the last row is omitted from the response andnext_cursoris built from the row that remains at position$limit(the last retained row); otherwise,next_cursorisnull. The+1lookahead is the only mechanism by which the server distinguishes between "exactly$limitrows remaining" and "more rows available". Building the cursor from the last retained row ensures that the next page's strict<predicate advances past that boundary row, including any retained row the response mapper later drops as invalid, instead of re-reading it.
USER_DATA. Handled by the existing auth hoop:
-1003/ 401 for missing or unparseable envelope (X-DODEX-APIKEY,timestamp,signature,recvWindow).-1002/ 401 for unknown / disabled key.-1002/ 401 for a key without theUSER_DATApermission. Identical on the wire to a credential rejection — intentionalmsgopacity, see auth.md.
The handler reads ctx via require_auth(depot, Permission::UserData) and uses ctx.trading_pn.pn_address as the owner_pn_address filter. No additional permission logic.
| Condition | DomainError |
API code | HTTP |
|---|---|---|---|
predictionMarketAddress / symbol pair is incomplete (only one present, or either is present but blank/whitespace) |
MissingParameter |
-1102 |
400 |
limit out of [1, 500] |
MissingParameter |
-1102 |
400 |
limit present but non-numeric |
InvalidParameter |
-1130 |
400 |
cursor is empty or whitespace-only |
MissingParameter |
-1102 |
400 |
cursor length exceeds MAX_CURSOR_LEN (128) |
InvalidParameter |
-1130 |
400 |
Unknown token in status CSV |
InvalidParameter |
-1130 |
400 |
| Pair not found, or its market is unreconciled | InvalidMarketOrSymbol |
-1121 |
404 |
| Missing / invalid signature / API key / timestamp | upstream auth | -1003 |
401 |
Missing USER_DATA permission |
upstream auth | -1002 |
401 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
Both variants share the same projection list (pmp_address, symbol, order_id, client_order_id, price, orig_qty, executed_qty, fully_filled, corrupt_remainder, is_buy, chain_created_at_us, chain_updated_at_us, placed_chain_order, lo.status as raw_status, price_precision, quantity_precision). The base predicate is owner_pn_address = $1 AND m.last_reconciled_at IS NOT NULL AND chain_created_at IS NOT NULL AND chain_updated_at IS NOT NULL.
chain_created_at IS NOT NULL AND chain_updated_at IS NOT NULL are SQL-side heap filters. They guard against a rare ingestion path in which the GraphQL gateway omits created_at on an edge — such rows must not surface through the endpoint (otherwise the response decoder would fail when mapping NULL into i64) — while keeping the index independent of the display-only timestamp columns.
The status predicate is built dynamically from OrderStatusFilter. OrderStatusFilter::All emits no predicate; OrderStatusFilter::Only carries a non-empty BTreeSet<QueryableOrderStatus>:
- Empty set /
statusabsent → no status predicate (every row passes). - Otherwise →
AND (<per-status predicate> OR <per-status predicate> ...), one disjunct per public-status token, drawn from the § Status mapping table. The disjunct fragments are compile-time string constants; only the allow-listed set drives which fragments are joined.
The cursor predicate uses a single text comparison against placed_chain_order with strict <. No tie-breaker columns are required — msg_chain_order from the gateway is globally unique. Sort: ORDER BY lo.placed_chain_order DESC.
The filtered variant pre-resolves (orderbook_address, outcome_id) via a separate query against markets ⨝ market_outcomes. That query is likewise gated by last_reconciled_at IS NOT NULL. The pair predicate (lo.orderbook_address = $X AND lo.outcome_id = $Y) is appended to the base predicate; the all-markets variant omits it.
live_orders_owner_idx is a 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. It covers the default-status query (all five statuses) and any CSV-driven subset.
Status filters become heap predicates on top of the index range. Per-owner cardinalities are expected in the hundreds even on power-trader accounts; a heap filter over a single-owner range is cheap relative to maintaining a wider composite index that would also need to track the derived NEW/PARTIALLY_FILLED split.
The market-filter pair predicate (orderbook_address = $X AND outcome_id = $Y) is likewise a heap filter, matching the strategy already used for the OPEN-only variant.
live_orders_open_book_idx (used by /api/v1/prediction/depth) is unaffected.
The data-schema doc (live_orders) is updated synchronously with the migration.
Between OrderBook.OrderPlaced and PrivateNote.OrderPlacedConfirmed, the row exists in live_orders with owner_pn_address = NULL. The partial index excludes NULL owners, so the row contributes to public depth but cannot appear in /api/v1/prediction/orders.
The confirmation event projector attaches the owner; if the confirmation event arrives first, it is deferred and replayed once the OrderBook row exists (via the existing Deferred → Applied reprojection mechanism). This window is exposed to clients as an eventual-consistency note in api-spec.md; no additional mitigation is provided in v1.
REJECTED rows (when a projector for them is wired in) carry owner_pn_address from the start — the source event lives on the PN itself — so the lifecycle has no equivalent two-stage attribution window.
This endpoint is downstream of the indexer; it consumes only what the projectors write to live_orders. The chain-side surface consumed by /orders is:
| Event | Producer | Read-model effect |
|---|---|---|
OrderBook.OrderPlaced |
OrderBook | Creates live_orders row, status='OPEN'. |
OrderBook.OrderFilled |
OrderBook | Decrements amount_remaining; flips status to FILLED on full fill. |
OrderBook.OrderCancelled |
OrderBook | Preserves the current amount_remaining as the cancelled remainder; flips status to CANCELLED. |
PrivateNote.OrderPlacedConfirmed |
PrivateNote | Attaches owner_pn_address. |
PrivateNote may emit additional confirmation events for account accounting (for example fee or balance updates), but those are routed to the /api/v1/account code path, not to /orders. The outward shape of the three OrderBook events above and of OrderPlacedConfirmed is the only chain-side surface this endpoint depends on.
The REJECTED status surfaces orders that the OrderBook refused to place. The chain-side carrier is PrivateNote.OrderPlaceRejected (declared in contracts/dex/PrivateNote.sol, emitted from onOrderRejected, modifier id PRIVATENOTE_ORDER_REJECTED = 153); the decoder's event count test in crates/infrastructure/src/decoder.rs is pinned to the new total. The indexer projector that writes live_orders rows for these events is not yet shipped — status=REJECTED queries currently return empty.
event OrderPlaceRejected(address orderBook, uint256 eventId, uint128 clientOrderId, uint32 outcomeId, bool isBuy, uint8 flags, uint256 price, uint128 amount, uint64 opNonce);
OrderBook._notifyRejectedPlace calls PrivateNote.onOrderRejected(...) with the full original PlaceParams (outcomeId, isBuy, flags, price, amount, clientOrderId, opNonce). The external OrderBook.Rejected(entryType, depositHash) event has too little payload — no order parameters, no owner attribution — to reconstruct a live_orders row, so the projector reads OrderPlaceRejected directly.
Projector contract — OrderPlaceRejectedProjector in crates/infrastructure/src/projectors.rs is not implemented; the design below pins the contract any implementation must satisfy. It writes one row to live_orders per event:
orderbook_address = event.orderBook.order_id = 0(sentinel — no chain id is assigned; the API renders it as"").outcome_id,is_buy,price,client_order_id,amount_initial = amount,amount_remaining = 0from the event payload.owner_pn_address = event.source_address(the PN that emitted the event).status = 'REJECTED'.chain_created_at = chain_updated_at = event.created_at,placed_chain_order = last_chain_order = event.msg_chain_order.- Replays use
INSERT ... ON CONFLICT DO NOTHINGagainst the resulting PK to stay idempotent.
Primary-key collision — live_orders PK is (orderbook_address, order_id). Multiple rejected placements against the same OB would collide on order_id = 0. Two viable schema options:
- Add a
synthetic_id numeric(78,0) NOT NULL DEFAULT 0column and extend the PK to(orderbook_address, order_id, synthetic_id). REJECTED rows fillsynthetic_idfrom a deterministic hash ofmsg_chain_order; all other lifecycles keep the default0. - For REJECTED rows, store the hashed
msg_chain_orderdirectly inorder_id, partitioning the id space ("real" chain ids are bounded by uint128; we can carve the high half for synthetic ids). Cheaper schema-wise but couples the column's meaning to its high bit.
The choice should not perturb the /orders query plan (both options leave (owner_pn_address, placed_chain_order) as the seek key). The migration also extends the live_orders.status CHECK to IN ('OPEN', 'FILLED', 'CANCELLED', 'REJECTED') and updates data-schema.md.
Test coverage for the projector: scenarios in crates/infrastructure/tests/orders.rs exercise the projector against synthetic gateway fixtures, and services/api/tests/orders_http.rs pins the status=REJECTED query shape — empty when the live_orders.status CHECK does not yet admit 'REJECTED', populated once it does, against the same fixture row.
Three integration suites, all gated on TEST_DATABASE_URL:
crates/infrastructure/tests/orders.rs— owner scoping, DESC sort, scaling, the three market-filter shapes,statusCSV across all five tokens (REJECTED returns empty while thelive_orders.statusCHECK forbids'REJECTED'), cursor advance, cursor stability under concurrent fills and cancellations (closed rows retain their position),limitdefaults and bounds, invalidstatustokens, invalid cursor,executedQty > 0forCANCELEDpartial-then-cancel rows.crates/infrastructure/tests/reprojection.rs—OrderPlacedConfirmeddeferred-replay and idempotency-on-already-attributed paths pin owner attribution, and full place/fill/cancel pipeline scenarios pin terminal-state precedence: cancel-after-full-fill staysFILLED, cancel-before-fill staysCANCELEDwith the unfilled remainder, partial-fill-then-cancel reports a non-zeroexecutedQty.services/api/tests/orders_http.rs— happy path through the production router with the wrapped response, the four error codes (-1102,-1121,-1130, auth), and the pagination round-trip across mixed-status pages.
Public contract: api-spec §Account Balance. Balance sourcing rules: auth.md §Balance Source.
The endpoint reads collateral balances directly from chain state — every request runs one off-chain getter call against the caller's trading PrivateNote. Outcome-token holdings live behind /api/v1/account/balances instead, because outcome ownership is scoped per market and the chain-side accessor (PrivateNote._stakes) is a per-market mapping lookup.
Two inputs feed one response:
- Trading PN state. The auth context resolves the caller to a
pn_address(fromaccounts.pn_address). The handler fetches that PN's BOC through the GraphQL gateway (blockchain { account(address: $pn_address) { info { boc } } }) and executes thegetDetails()getter against it viatvm_runner::run_getter(the same off-chain TVM executor the market reconciler uses — see indexer.md §Reconciler). The getter returnsbalance: map[uint32 → uint128]andlockedInOrders: map[uint32 → uint128], both keyed bytokenType. (The underlying contract storage vars are_balance/_lockedInOrdersper Solidity convention; TVM's auto-generated getter strips the leading underscore in the ABI'soutputsdeclaration — seecontracts/dex/PrivateNote.abi.json.) - Token reference.
ref_tokensmaps eachtokenTypeto its publictoken_codeanddecimals. The lookup is a per-tokenTypeSELECT; cardinality is small (three tokens today) and the JOIN happens on the API side, not in SQL.
require_auth(Permission::UserData)resolves(account_id, pn_address).- Capture
now_msonce at handler entry — surfaces asupdateTime. - Fetch the PN BOC. A missing account (
Account::is_none) surfaces asDomainError::AccountNotDeployed→ 404 so clients can offer "deploy your account" rather than retry. HTTP / decode failures stay onMarketInconsistent→ 503 (transient: gateway hiccup or indexer lag clears on its own). Step 5 also surfaces 503, but for a different reason: unknowntokenTypeis read-model drift. - Run
getDetails()throughtvm_runner. ABI decode errors →MarketInconsistent. - For each key in the union of
balanceandlockedInOrders, look up the matchingref_tokensrow. A key absent fromref_tokens→MarketInconsistent(the indexer ships with the canonical set; an unknown token type means data drift the API cannot resolve safely). Iteratingbalancealone would skip a locked-only token and leak past the ref-token check; the union closes that gap. - Build
balances[]: one entry pertokenTypein that same union, withfreefrombalance[tokenType]andlockedfromlockedInOrders[tokenType](each side defaults to0when the key is missing in its own map). The textbook locked-only case is a LIMIT SELL that has consumed the caller's entire free balance —balance[X]is gone butlockedInOrders[X] > 0. Scale both withref_tokens.decimals. Sort byassetASC for deterministic output.
After assembly, the API checks:
| Rule | Source |
|---|---|
Every tokenType returned by getDetails() — in either balance or lockedInOrders — resolves to a ref_tokens row |
ref_tokens is authoritative for token codes/decimals; a locked-only tokenType cannot get a free pass since the API still needs decimals to render it |
accountId is non-nil (UUID) |
Auth context guarantee; sanity check before serializing |
Violations surface as MarketInconsistent → 503.
The endpoint never reads live_orders, so it does not inherit any indexer-backlog window. The single chain-side read is atomic with the PN state at the time the gateway captured the account snapshot.
| Condition | DomainError | API code | HTTP |
|---|---|---|---|
| Missing / invalid auth envelope | upstream | -1003 |
401 |
Unknown / disabled key, or key lacks USER_DATA |
upstream | -1002 |
401 |
| Authenticated PN address has no deployed contract on chain | AccountNotDeployed |
-2013 |
404 |
| Chain getter / BOC decode failure / unknown token type | MarketInconsistent |
-1500 |
503 |
| Request budget elapsed | RequestTimeout |
-1007 |
504 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
Public contract: api-spec §Market Outcome Balances. Balance sourcing rules: auth.md §Balance Source.
Returns the caller's outcome-token holdings for one market. free comes from a chain-side mapping lookup on the trading PrivateNote; lockedInOrders comes from the indexed live_orders read-model. The two sources differ on purpose — see Locked source split below.
Three inputs feed one response:
-
Market resolution. Two SELECTs (one on
marketsINNER-joined toref_tokensfor the quote-assetdecimals, one onmarket_outcomes) return(event_id, oracle_list_hash, token_type, orderbook_address, num_outcomes, decimals, [(outcome_id, symbol, quantity_precision) …]). The first is gated onlast_reconciled_at IS NOT NULL; pre-reconcile markets are hidden symmetrically with/api/v1/prediction/markets. Theref_tokensjoin cannot hide a market:markets.token_typeisNOT NULLand FK-references the statically seededref_tokensPK, so it is a strict 1:1. The market lifecycle status is NOT a gate — terminal markets still serve balances so holders can see what they own until they claim or settle. Keeping the per-outcome rows in a separate SELECT keeps the row types simple at the cost of one extra round trip. -
PN stake state. The chain-side accessor is the auto-generated getter for the public mapping
PrivateNote._stakes. TVM Solidity auto-getters for public mappings take no arguments and return the entiremap(uint256 → StakeInfo)— see the PN ABI undercontracts/dex/PrivateNote.abi.json. The API computes the per-market keystake_hash = tvm.hash(abi.encode(event_id, oracle_list_hash, token_type))— the same hash the PN itself uses internally — and looks it up on the returned map. The hash is built off-chain in Rust via a thin wrapper aroundtvm_types. EachStakeInfovalue carries three paralleluint128[]arrays (amount,debtAmount,couponsAmount) indexed byoutcome_id, plus housekeeping fields the API ignores. A missing key on the returned map (caller never staked on this market) is treated as "all outcomes at zero", not as an error.Returning the whole mapping in one call costs the same as one keyed lookup would on EVM (the ABI shape is fixed by TVM Solidity), so this is an opportunity, not a tax: a future "all my outcomes" view across markets needs no additional chain calls.
-
live_ordersaggregation. One SQL groups OPEN sell orders by outcome:SELECT outcome_id, SUM(amount_remaining) AS locked FROM live_orders WHERE orderbook_address = $1 AND owner_pn_address = $2 AND status = 'OPEN' AND is_buy = false GROUP BY outcome_id;
The partial index
live_orders_owner_idx(owner_pn_address IS NOT NULL) backs this scan; the(orderbook_address, status, is_buy)predicates fall on the heap, but per-owner cardinality is small enough that adding a wider composite index is not worth it.
require_auth(Permission::UserData)resolves(account_id, pn_address).- Parse
predictionMarketAddress— blank or missing →MissingParameter→ 400 /-1102. - Run the market-resolution SELECT. Unknown market or
last_reconciled_at IS NULL→InvalidMarketOrSymbol→ 404 /-1121. - Compute
stake_hash = tvm.hash(abi.encode(event_id, oracle_list_hash, token_type)). - In parallel (
tokio::try_join!— the first error short-circuits, but a typedDomainErrorfrom either branch is preserved so the handler still maps it correctly):- Fetch the PN BOC and run the
_stakesgetter throughtvm_runner(returns the fullmap(uint256 → StakeInfo)); the API then looks upmap[stake_hash]. - Run the
live_ordersaggregation SELECT.
- Fetch the PN BOC and run the
- Build
balances[]inoutcome_idASC order. For each outcome:free = scale(amount[outcome_id] + debtAmount[outcome_id] + couponsAmount[outcome_id], decimals). Scaled by the quote asset's on-chaindecimals(notquantity_precision) — the_stakesamounts are chain atoms, the same scale/api/v1/accountuses; scaling byquantity_precisionwould over-report by10^(decimals − quantity_precision). The three pools are summed because the public surface is "what the user owns" — clean, debt-bound, and coupon-bound stakes are all the user's tokens; the distinction is internal accounting that the UI does not need at this layer.lockedInOrders = scale(coalesce(SUM, 0), decimals)from the aggregation map (thelive_ordersamounts are chain atoms too); outcomes without a row default to 0.
- Capture
now_msonce in the handler before executing the use case — surfaces asupdateTime.
Why free reads chain and lockedInOrders reads live_orders:
freecomes fromPrivateNote._stakes(hash), which the contract mutates atomically with every stake / claim / split / merge / cancel-callback. There is no equivalent indexer projection today, and building one would require projectors for the five stake-mutation events listed in the stake-projection follow-up.lockedInOrdersis the sum of resting sell orders against this outcome. The indexer already tracks these inlive_orders, with a partial index already sized for per-owner queries. The chain-side analogue would require iterating the OrderBook's internal red-black tree of orders — there is no public per-outcome getter for it.
The split means the two numbers can drift while the indexer is replaying behind chain head: a sell that just landed on chain shows up in _stakes.amount (because the OB has not yet acknowledged the lock) AND in live_orders (because OrderPlaced was projected) — appearing as if both free and lockedInOrders count it. The window is small (seconds) and self-resolves once OrderPlacedConfirmed advances PN state; it surfaces to clients as the same eventual-consistency note that already applies to /api/v1/prediction/orders.
Three fail-closed checks guard the pipeline at different stages:
| Rule | Source |
|---|---|
Resolved market has a non-blank orderbook_address |
DB schema CHECK (last_reconciled_at IS NULL OR orderbook_address IS NOT NULL) plus a whitespace re-check, matching /api/v1/prediction/depth's contract |
_stakes.amount.len() == num_outcomes (and same for debtAmount, couponsAmount) when any array is non-empty |
The contract initializes all three arrays to num_outcomes length on first stake; a mismatch means the indexer's view of num_outcomes diverged from chain state |
Every live_orders.outcome_id returned by the aggregation is within [0, num_outcomes) |
Sanity: a row outside this range is indexer corruption (OrderBook.OrderPlaced projector wrote an unknown outcome_id) |
Violations surface as MarketInconsistent → 503.
lockedInOrders inherits the same indexer-backlog window as /api/v1/prediction/orders: a sell order whose OrderBook.OrderPlaced event has not been projected yet is invisible here. Once projected (typically seconds later), the next response shows it. free is read live from chain state and does not inherit this window.
| Condition | DomainError | API code | HTTP |
|---|---|---|---|
| Missing / invalid auth envelope | upstream | -1003 |
401 |
Unknown / disabled key, or key lacks USER_DATA |
upstream | -1002 |
401 |
predictionMarketAddress missing or blank |
MissingParameter |
-1102 |
400 |
predictionMarketAddress not found, or its market is unreconciled |
InvalidMarketOrSymbol |
-1121 |
404 |
| Authenticated PN address has no deployed contract on chain | AccountNotDeployed |
-2013 |
404 |
| Chain getter / BOC fetch / decode failure, or invariant violation on assembled DTO | MarketInconsistent |
-1500 |
503 |
| Request budget elapsed | RequestTimeout |
-1007 |
504 |
| Unexpected (DB / decode / etc.) | Unexpected |
-1000 |
500 |
The current design reads _stakes from chain on every request. Each request costs one GraphQL accounts(...) { boc } fetch plus one local TVM execution. For low call rates (one frontend session per user) this is acceptable; for power users polling rapidly or for shared dashboards this becomes the bottleneck.
A future projection table would mirror PN stake state in Postgres so the API can serve free from a DB read. The projection requires handling the full PN-side stake-mutation event surface — StakeConfirmed, StakeCancelled, FullSetStakeConfirmed, FullSetStakeCancelled, and ClaimAccepted (per contracts/dex/PrivateNote.abi.json) — and a reproject path that drains all of them in chain order before responses become trustworthy. The on-demand getter shipping in v1 lets the endpoint be useful immediately and gives us evidence about real-world call patterns before we commit to projector complexity.
PnStake carries three parallel Vec<String> arrays (amount, debt_amount, coupons_amount) indexed by outcome_id. The all-or-nothing length invariant ("every array is either empty OR exactly num_outcomes") is enforced at runtime by guards in GetMarketBalancesUseCase::execute — an illegal value such as PnStake { amount: vec!["1"], debt_amount: vec![], coupons_amount: vec![] } returns 503, not silently-wrong per-outcome balances. The mirror concern of a duplicate outcome_id in res.outcomes is ruled out by the schema UNIQUE (pmp_address, outcome_id) on market_outcomes (see data-schema.md), so the runtime length check is the only guarantee needed.
A future refactor could promote the invariant into the type system — for example, a single Vec<StakeRow { amount, debt_amount, coupons_amount }> shape that makes the parallel structure unrepresentable. That is purely a maintainability improvement: today the runtime guard already fails closed, so the change is not load-bearing.
Four test suites, all gated on TEST_DATABASE_URL:
- Use-case unit (
crates/application/src/lib.rs):get_account_use_case_tests: renders multiple assets sorted by asset code;lockeddefaults to zero when the_balancekey is absent on the locked side;freedefaults to zero when only_lockedInOrderscarries atokenType; unknown token type → 503; PN reader failure → 503;scale_decimalzero-padding.get_market_balances_use_case_tests: happy path sums the three stake pools per outcome; absent stake key yields zero free; stake arrays shorter / longer thannum_outcomes; mixed empty / populated stake arrays; unknown market; PN failure; hasher failure; out-of-rangeoutcome_id.
- Repo integration (
crates/infrastructure/tests/balances.rs):lookup_ref_tokenhappy path;resolve_market_for_balanceshappy path / unknown market / unreconciled market / num_outcomes mismatch; the three fail-closed guards (NULLoracle_list_hash, blankorderbook_address, negativetoken_type);sum_open_sell_remaininggroups by outcome and filters; empty when no rows match. - HTTP integration (
services/api/tests/account_http.rs): happy path; missing API key → 401 /-1003; chain-getter failure → 503 /-1500; unknown token type → 503 /-1500; two credentials produce distinctaccountId. - HTTP integration (
services/api/tests/account_balances_http.rs): happy path sorted byoutcomeId; absent stake key yields zero free with nonzero locked; missingpredictionMarketAddress→ 400 /-1102; unknown market → 404 /-1121; stake-array mismatch → 503 /-1500; terminal market still serves; stake gateway failure → 503 /-1500; missing API key → 401 /-1003; cross-tenant isolation; production hasher wiring; stake registered at wrong hash yields zero; trade-only key →-1002on the user-data route.