Skip to content

Indexed state model

The safest projection mirrors protocol ownership: AccountCore owns identity and custody, routers/Engines own discovery and risk configuration, and each orderbook owns market-local order state. Do not collapse these into one mutable “market” row without preserving provenance.

Canonical keys

Every key in this table is canonically prefixed by chainId, including rows where the shorter suffix is shown for readability. Never merge otherwise identical contract addresses, account IDs, market IDs, or orderbook-local IDs across chains.

Projection Primary key Authority Derivation
Chain block (chainId, blockNumber, blockHash) canonical chain RPC (M)
Contract/version (chainId, proxy, activationLogPosition) manifest + Upgraded M+E
Owner (chainId, contract) ownable contract E; getter checkpoint G
Pending ownership handover (chainId, contract, pendingOwner) ownable contract request/cancel E; expiry/getter G
Authority pointer (chainId, contract) each ProtocolAccess child E; getter checkpoint G
Authority roles (chainId, authority) ProtocolAuthority constructor C/M, then E
Account (accountCore, accountId) AccountCore E
Account signer (accountCore, accountId, signer) AccountCore E, expiry evaluated by time
Spot token (accountCore, token) AccountCore E
Free/reserved balance (accountCore, accountId, token) AccountCore snapshot E
Spot per-book reserve (orderBook, accountId, token) Spot orderbook active-slot replay; checkpoint G
Builder approval (accountCore, rootId, builder) AccountCore E, expiry evaluated by time
Builder/referral fee policy (accountCore, scope, rootId-or-builder) AccountCore E
Builder claimable balance (accountCore, builder, asset) AccountCore E deltas
Spot market registry (spotRouter, orderBook) Router + Engine + AccountCore E+C/G+M
Perp Engine registry (perpRouter, engine) Router + AccountCore E+M
Perp market route (accountCore, marketId) AccountCore E; Router is discovery mirror
Market implementation history (proxy, activationLogPosition) ERC-1967 proxy E+M
Active order (orderBook, accountId, slot) orderbook BookUpdatesPacked + TradesPacked (E)
Order history (orderBook, accountId, slot, orderId) orderbook BookUpdatesPacked + TradesPacked (E)
Trade (orderBook, tradeId) orderbook TradesPacked (E)
Last trade observation (orderBook) orderbook final packed trade + block timestamp (E); checkpoint G
Stored L2 level (orderBook, side, price) orderbook sum active slots + passive depth (E)
Passive band (orderBook, lowPrice) Spot orderbook snapshots E, fee growth replay/getter
Passive position (orderBook, positionId) Spot orderbook E, checkpoints need replay/getter
Perp market state (engine, marketId) PerpEngine mixed E+C/G
Perp margin namespace (engine, accountId, marginMarketId) PerpEngine snapshot E; 0 means cross
Perp position mode (engine, accountId, marketId) PerpEngine E
Perp cross slot (engine, accountId, slotIdx) PerpEngine lifecycle E; contents replay/getter
Perp isolated position (engine, accountId, marketId) PerpEngine snapshot/delete E
Perp order exposure (engine, accountId, marketId) PerpEngine snapshot E
Perp cross resting-order count (engine, accountId) PerpEngine active cross-order replay; checkpoint G
Perp maker-fee reserve (engine, accountId, marginMarketId) PerpEngine snapshot E
Perp liquidation lock (engine, accountId, marginMarketId) PerpEngine lifecycle E
EIP-7702 wallet intent (wallet, intentHash) delegated EOA audit E; calldata for full payload
EIP-7702 wallet trigger (wallet, triggerId) delegated EOA lifecycle E; wallet getter/calldata for payload
Trigger keeper membership (keeperAuthority, keeper) standalone keeper registry replacement E; getter checkpoint G
Maker hook (orderBook, accountId) Spot orderbook E
Example hook configuration (hook, field-or-slot) hook E; constructor immutables M/G

orderBook and engine must always be part of keys. Account IDs, market IDs, slots, order IDs, position IDs, and trade IDs are not globally unique.

For EIP-7702 wallet logs and getters, use the delegated EOA as the emitter and state address. The shared implementation owns neither the wallet's nonce nor its trigger records. Resolve the EOA's active delegation implementation at the log position and keep that implementation/code hash with the decoder provenance.

Ownership and concurrent handovers

Store current ownership separately from handover candidates. An owner row records owner plus the structured state owned, renounced, unknown, or not-applicable; zero from a successful owner() read means renounced, not unknown.

Solady stores one request slot per candidate, so any number of addresses can concurrently request the same contract. A candidate row contains at least contract, pendingOwner, and expiresAt. For the pinned implementation, a request replaces only that candidate's expiry with block.timestamp + 172800. It is executable when expiresAt != 0 && currentTimestamp <= expiresAt; equality at the expiry is valid, and the first invalid timestamp is expiresAt + 1. Passing time does not clear storage or emit a log, so expose active/expired as a time-derived view rather than deleting the historical row.

OwnershipHandoverCanceled(pendingOwner) deletes only (chainId,contract,pendingOwner). OwnershipTransferred always replaces the owner, but cannot distinguish direct transfer, renunciation, initialization, or completeOwnershipHandover. Delete a candidate only when decoded calldata or a trace proves completion with that address, or when a candidate-specific ownershipHandoverExpiresAt read returns zero at the required state boundary. Never clear the other candidates.

Account and custody model

An account row contains address, accountId, rootAccountId, owner, and subaccountSeq from AccountRegistered. SubaccountCreated is a lifecycle/audit record and confirms the root-child edge. AccountSignerAuthorized replaces (permissions, expiry); AccountSignerRevoked deletes it. Authorization is live only if the relevant bit is set and expiry == 0 || block.timestamp <= expiry.

Maintain these balances separately:

free(accountId, token)
spotReserved(accountId, token)
perpMargin(engine, accountId, 0 or isolatedMarketId)
perpMakerFeeReserve(engine, accountId, 0 or isolatedMarketId)
passivePrincipal(orderBook, positionId / band)
builderClaimable(builder, asset)

SpotReserveUpdated is the authoritative post-state pair (free, spotReserved). Passive principal is not part of spotReserved. Perp margin is physically custodied by AccountCore but logically reclassified to the Engine ledger; use both the AccountCore free-balance snapshot and Engine margin snapshot when margin moves.

AccountCore's Spot reserved value is aggregate across all books using that token; there is no per-book reserve event. For a particular Spot orderbook, derive asks as the sum of active stored base sizes and bids as the sum of each active order's placement-time quote reserve, including its maker-fee reserve. This requires retaining the Spot maker-fee PPS derived at placement. Reconcile the result with makerLockedReserves(accountId) at a pinned block. Without genesis L3 replay (or a trusted active-slot checkpoint), per-book historical reserves are not recoverable from the aggregate AccountCore snapshots.

Market discovery records

A Spot market record needs:

router, engine, accountCore, orderBook
baseToken, quoteToken, token decimals
sizePrecision, pricePrecision, tickSize, passiveSpreadTicks
minQuoteNotional, maxQuoteNotional, makerFeePps, takerFeePps
deployment block/log, implementation history, owner, authority, marketState

The three SpotMarketRegistered/Added events carry only addresses. Obtain numeric configuration from Router deployment calldata or the Router/Engine/orderbook getters at that transaction block.

A Perp market record needs:

router, accountCore, engine, quoteAsset, quoteScale, marketId, orderBook
sizePrecision, pricePrecision, tickSize, min/maxQuoteNotional
status, flags, all risk factors/caps, funding interval
route history, enabled state, implementation history, marketState

PerpMarketDeployed is the user-facing discovery event, while AccountCore's PerpOrderBookUpdated is the settlement-authority route. Do not infer authorization solely from the Router mapping.

Active order and depth model

An active slot stores:

accountId, slot, orderId, side, price, storedSize
minSizeAfterBlock
makerFeePps (emitted in Perp; derived at placement for Spot because the record omits it)
reduceOnly (Perp only)
placementMode (Perp only; CROSS or ISOLATED, retained for this order-ID generation)
fifoRevision/log position

Slots 0..61 are user order slots. Slot 62 is reserved for the synthetic passive queue marker and slot 63 is the free-list tombstone; reject either as an active maker slot. Every live replacement, including a same-price replacement, has a new order ID and moves to the FIFO tail.

Maintain two depth views:

  • stored L2: sum storedSize by side/price (plus passive executable depth for Spot);
  • executable L2 at block B: for each active order, if minSizeAfterBlock != 0 && B > minSizeAfterBlock, cap visible size to min(storedSize, minimumExecutableSizeAtPrice).

Perp getL2Book and matching apply that cap. Spot matching and estimates apply it, but the current Spot getL2Book can report full stored size until the order is touched. An executable index must apply the cap itself.

For either product, a successful action with one or more active or passive fills replaces the orderbook's last-trade observation with (floor(finalPrice * 1e8 / pricePrecision), uint32(block.timestamp)). The final price is the last record in that action's TradesPacked payload. Actions without a trade record leave the observation unchanged. Reconcile it through lastTradeObservation(); this orderbook observation is distinct from PerpEngine's raw lastTradePrice field.

Perp position model

Cross and isolated modes are mutually exclusive per (engine, accountId, marketId).

position = {
  mode, side, baseLots, entryQuoteLots,
  openBidLots, openAskLots, reduceOnlyLots,
  signedFundingCheckpoint, liquidating
}

Isolated positions have full snapshot events. Cross positions do not. For cross, replay PerpPositionModeUpdated, PerpCrossPositionSlotUpdated, PerpOrderExposureUpdated, PerpFillSettled, PerpFundingSettled, funding accumulator changes, and liquidation lifecycle. Use getCrossPosition(accountId, slotIdx) at a checkpoint when replay does not begin at Engine deployment.

Open interest is not emitted. Maintain signed base per position, and update market long/short OI by the change in positive/negative magnitude after each PerpFillSettled. A mid-history baseline must read getMarket(marketId).longOpenInterestLots and .shortOpenInterestLots at a pinned block.

lastTradePrice has no dedicated event. When a Perp action has fills, replace it with the price of the last PerpFillSettled pair in matching order (equivalently, the final Perp packed trade record for that action), then validate it through getMarket(marketId). A mid-history baseline must read the getter; PerpPricesUpdated does not carry lastTradePrice.

Cross resting-order count also has no event. Maintain it as the number of live CROSS-mode Perp orders owned by the account across all markets on that Engine. Attach placementMode when a new order ID becomes live, using the action's requested/resolved mode and preceding PerpOrderExposureUpdated; preserve it through partial fills. Increment on a new CROSS identity and decrement when that stored identity is deleted. Do not inspect the current per-market mode at delete time: Engine settlement can clear the final flat mode before the orderbook emits its final packed delete. Fills whose updatedSize stays nonzero do not change the count. Reconcile with getCrossRestingOrderCount(accountId). The admission cap maxCrossRestingOrders is initializer/getter state and therefore belongs in the deployment manifest/checkpoint, not in an inferred event.

Health and margin thresholds are derived, not event state. Recompute them only from a coherent same-block snapshot of margin, positions, open exposure, fee reserve, market prices/funding, and risk parameters. At checkpoints compare the full structures returned by getCrossMarginThresholds(accountId) and getIsolatedMarginThresholds(accountId,marketId); use the transfer-requirement getters for withdrawable-margin projections. There is no independent health-update event. PerpPositionLiquidated carries before/after maintenance health for that one liquidation action only and must not be treated as the current account-health snapshot.

State that expires without logs

The following transitions are time- or block-derived and emit no event at expiry:

  • signer authorization expiry;
  • builder approval/referral tier/referral expiry;
  • ownership-handover candidate expiry (the nonzero stored expiry remains until cancel/complete);
  • minSizeAfterBlock executable-size transition;
  • intent deadline and an ACTIVE trigger passing its expiry (the trigger becomes stored EXPIRED only when executeTrigger is called and emits TriggerExpired);
  • price staleness and funding-interval eligibility.

Store the configured boundary and evaluate liveness against the query block timestamp/number. Never synthesize a protocol event for an expiry that has not been written on-chain.

Deliberately eventless surfaces

  • SpotPeriphery has no events. Its successful deposits are represented by AccountCore AccountRegistered (if needed), SpotReserveUpdated, and Deposit.
  • AccountCore protocol pause and fee collector changes need calldata/getter reconciliation.
  • Constructor/initializer dependency addresses and immutables may be absent from semantic events.
  • Query-only estimates and validations create no indexed state.