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
storedSizeby 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 tomin(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);
minSizeAfterBlockexecutable-size transition;- intent deadline and an ACTIVE trigger passing its expiry (the trigger becomes stored
EXPIREDonly whenexecuteTriggeris called and emitsTriggerExpired); - 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¶
SpotPeripheryhas no events. Its successful deposits are represented by AccountCoreAccountRegistered(if needed),SpotReserveUpdated, andDeposit.- 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.