Skip to main content
This reference follows the merged OrderBook implementation at cdac5ae1. Packed record widths remain unchanged: trade records are 64 bytes and book records are 39 bytes. Their outcome bits now explain operations that may not create a resting order. Start with SDK Events if you only need to decode receipts. Read the reducers below when maintaining your own book or fill history. This guide describes how to index Spot orderbooks from events. The orderbook emits separate streams for active FIFO orders, passive bands, swaps, account accounting, and market configuration. A production indexer should process logs in blockNumber, transactionIndex, logIndex order and should handle chain reorgs by rolling back all derived state for orphaned blocks. The core active-order events are:
  • TradesPacked(accountId, executor, clientOrderId, effectiveTakerFeePps, builder, builderFeePps, packedTrades)
  • BookUpdatesPacked(accountId, executor, clientOrderId, packedUpdates)
The core passive-liquidity events are:
  • PassiveBandUpdated(...)
  • PassiveLiquidityMinted(...)
  • PassiveLiquidityBurned(...)
  • PassiveFeesClaimed(...)
SpotSwap is a taker-level execution summary. It is useful for user history and analytics, but it is not sufficient for orderbook mirroring.

Market Discovery

Index these events to discover markets and maintain configuration state:
  • SpotRouter.SpotMarketRegistered(orderBook, baseToken, quoteToken, operator, sizePrecision, pricePrecision, minQuoteNotional, tickSize, passiveSpreadTicks, quoteTokenDecimals, takerFeePps, makerFeePps)
  • AccountCore.SpotMarketRegistered(orderBook, baseToken, quoteToken, registrar)
  • SpotRouter.SpotMarketStateUpdated(orderBook, previousState, newState, operator)
  • OrderBook.MarketStateUpdated(previousState, newState)
  • AccountCore.SpotTokenConfigured(token, decimals, enabled, operator)
  • SpotRouter.SpotTokenWhitelisted(token, status, operator)
  • AccountCore.DefaultSpotFeesUpdated(makerFeePps, takerFeePps, active, operator)
  • AccountCore.AccountFeeTierUpdated(rootAccountId, makerFeePps, takerFeePps, active, operator)
  • AccountCore.BuilderReferralTierUpdated(builder, makerFeePps, takerFeePps, expiry, active, operator)
  • AccountCore.BuilderReferralUpdated(rootAccountId, builder, actor, makerFeePps, takerFeePps, expiry, active)
  • AccountCore.FeeCollectorUpdated(oldFeeCollector, newFeeCollector, operator) — state
  • AccountCore.ProtocolStateUpdated(previousPaused, paused, operator) — state
  • AccountCore.PostFillHookAccessUpdated(accountId, allowed, operator) — state
  • SpotRouter.SpotMarketUpgraded(orderBook, newImplementation, operator) — state
operator on these config events is provenance. It does not replace the new value as the stored state. For each orderBook, cache:
  • baseToken
  • quoteToken
  • pricePrecision
  • sizePrecision
  • baseSizeMultiplier
  • tickSize
  • passiveSpreadTicks
  • quoteTokenDecimals
  • current market state
getMarketParams() returns common price/size/notional/fee fields. Spot-specific fields such as baseToken, quoteToken, quoteTokenDecimals, baseSizeMultiplier, and passiveSpreadTicks are public getters on OrderBook. The router event is the canonical deployment signal and includes sizePrecision, pricePrecision, minQuoteNotional, tickSize, passiveSpreadTicks, quoteTokenDecimals, and market-level takerFeePps / makerFeePps. minQuoteNotional is raw quote atoms. The AccountCore event confirms settlement authorization. For liveness changes, the nested orderbook event remains the market-state mutation while the outer router event identifies the real operator. Forwarded ownership proposes emit the book’s OwnershipTransferStarted log and SpotRouter.ManagedContractOwnershipTransferStarted with the router caller. Accept emits OwnershipTransferred and, when the router accepts a reclaim, ManagedContractOwnershipTransferred.

Packed Trade Records

TradesPacked contains one or more 64-byte records. Each record is two 256-bit words. Word 0:
  • userId: bits 255:216
  • slotIdx: bits 215:208
  • makerFlags: bits 207:200
  • price: bits 199:168
  • fillSize: bits 167:72
  • orderId: bits 71:8
  • operationIndex: bits 7:0 (meaningful only when outcome is nonzero)
Word 1:
  • updatedSize: bits 255:160
  • makerFeePps: bits 159:136 (uint24)
  • replacementSlot: bits 135:128 (meaningful only when outcome is nonzero)
  • reserved: bits 127:64
  • tradeId: bits 63:0
makerFlags & 1 == 1 means the maker side was buying, so the trade hit a bid and the taker sold base. makerFlags & 1 == 0 means the maker side was selling, so the trade hit an ask and the taker bought base. makerFlags & 2 != 0 marks a passive-band fill. Passive records use userId = 0, slotIdx = 0 for passive bid fills, slotIdx = 1 for passive ask fills, and orderId = lowPrice. makerFlags & 4 != 0 marks the final record of one successfully finalized match. A swap has one match. Each native order that matches, and each packed replacement that matches, has its own match. A crossing POST_ONLY never opens a match. A crossing ALO does not match. Empty matches add no marker. Every nonempty match ends at exactly one marked record, including passive-only and hook-replenished matches. (makerFlags >> 3) & 0x1f is the terminal OperationOutcome. A nonzero outcome appears only on a MATCH_END record. It binds that match to operationIndex; replacementSlot is the caller-selected packed-replace slot or 0xff for a native batch order. For fill-plus-rest, both this trade record and the resulting live book record carry RESTED and the same operation index. Swap matches keep outcome zero. Active records carry the resting order’s snapshotted makerFeePps. Passive records carry zero because passive LP spread accounting is not an active maker protocol fee. For active FIFO records, updatedSize is the maker order’s remaining size after the fill. It is zero when the slot was removed by the fill or by a residual cancellation. For passive records, updatedSize is the remaining visible size on the filled side after the fill. It is tape context. Band inventory and displayed passive depth come from PassiveBandUpdated, not from this field. Use TradesPacked for the complete active plus passive trade tape. Each record’s tradeId is a monotonic market-local fill id. A stable individual fill key is:
recordIndex remains useful for preserving execution order inside one packed event, but it is not part of the canonical fill identity. To compute raw quote notional offchain, first classify the record by side and passive flag. Active bids and passive bids use the market’s two-stage floor:
Passive asks replace both floors with ceilings for each record. Active asks share one exact accumulator within a match. Reset N = 0 after every MATCH_END; for each active-ask record in execution order use:
Passive records do not contribute to N. This reconstructs the exact quote credited to each active ask maker and the quote charged to the taker. FIFO order determines which active maker receives a rounding atom. fillSize and updatedSize remain normalized orderbook quantities. AccountCore settlement, swap results, passive-liquidity amounts, and token custody use raw token atoms. The multiplier is exact and immutable for a fresh market:
Split records at MATCH_END. For each match, sum raw quote Q, convert and sum raw base B, and the per-active-record maker fee floors. With PPS = 10_000_000:
The action-level fee context applies to every delimited match in that packed event. The match markers are necessary because ceiling once per match differs from ceiling once across the whole batch.

Packed Active Book Updates

BookUpdatesPacked contains one or more 39-byte records. Each record is:
  • a 32-byte header word
  • a trailing 4-byte minSizeAfterBlock
  • a trailing 3-byte makerFeePps
Header word:
  • userId: bits 255:216
  • slotIdx: bits 215:208
  • bookFlags: bits 207:200
  • price: bits 199:168
  • size: bits 167:72
  • orderId: bits 71:8
  • operationIndex: bits 7:0 (meaningful only when outcome is nonzero)
Trailing bytes:
  • minSizeAfterBlock: uint32, big-endian
  • makerFeePps: uint24, big-endian
bookFlags & 1 == 1 means maker buy/bid. bookFlags & 1 == 0 means maker sell/ask. bookFlags & 0x80 != 0 means the slot is live after this update. (bookFlags >> 1) & 0x1f is the terminal OperationOutcome; bit 6 remains reserved. Always mask these fields rather than comparing the complete flags byte. Live records carry the live order’s price, size, orderId, side, minSizeAfterBlock, and snapshotted maker fee. The rate remains per record because hook-generated updates in one action can belong to different makers. Non-live records carry the removed contribution’s price, size, side, and orderId; minSizeAfterBlock and makerFeePps are zero. The size in a non-live record is the non-trade amount removed from visible active depth. This is usually the full cancelled/replaced order size. Pure full fills do not emit a matching BookUpdatesPacked delete because the executed size is already represented by TradesPacked. An outcome-tagged non-live record with userId = 0, price = 0, size = 0, orderId = 0, and zero trailers is an operation sentinel, not a book delta. Its slotIdx is the packed-replace target or 0xff for a native order. Record the outcome and skip the record entirely for L2 and L3.

Per-Operation Outcomes

Numeric outcome codes are stable protocol schema: COMPLETED_NO_REST does not mean the submitted order was fully filled. It means matching is complete for this operation, any successful fills remain committed, and the residual is deliberately not placed because the limit still crosses a best price at which matching could not make progress. Its receipt therefore has the actual summed executed quantity, restingQuantity = 0, and no new order id. When at least one fill exists, the outcome is on the TradesPacked MATCH_END record. When nothing filled, the same code is on BookUpdatesPacked and is read as a zero-fill cross: nothing executed, the limit still crosses, and nothing is rested. Outcomes on TradesPacked and Outcomes on BookUpdatesPacked list every case for each event. Operation order is deterministic:
  • replaceBySlotPacked: index in packedOps
  • batch: every cancelSlotIdxs entry first, followed by every native order
  • protocolCancelBySlots: index in slotIdxs
Self-match cleanup during a native or packed-replace match is not an additional input operation. Its book DELETE carries outcome NONE and must be applied to L2/L3 normally. The incoming order keeps its original operation index and later receives its own terminal result. For example, a self-only IOC produces an untagged self-order DELETE followed by an IOC_UNFILLED sentinel for the IOC. Exact-input swap instead enables self-trade reversion, so its transaction reverts rather than performing this cleanup. Post-fill hook cancels, replacements, and replenishments are also maker-side effects rather than separate submitted operations. Their DELETE and LIVE book records carry outcome NONE, have no meaningful operation index, and remain canonical L2/L3 changes. If a hook-created order is consumed synchronously, its fill remains inside the parent taker match. Intermediate fill records remain untagged; only the final MATCH_END carries the parent operation’s outcome and index. Do not create separate operation receipts for hook actions. Packed logs are one log per contiguous run. A cycle holds at most one book run and one trade run. The run appended first is emitted first. A fill followed by a hook emits TradesPacked and then BookUpdatesPacked. A cancel or rest followed by a fill emits BookUpdatesPacked and then TradesPacked. A second run of either kind starts the next cycle. Apply the logs in that receipt order. A TradesPacked does not have to end with MATCH_END; the flag is on the last fill of a match and can be in a later trade log. A hook replenish is appended after the fill that freed its slot, so that fill is emitted before the live. updatedSize = 0 clears the slot while that order id is still the occupant. The following live is then the only order on the slot. batch permits at most 256 total cancellations plus orders. Packed replacements already permit at most 62 unique slots. For log-only reconstruction, callers should provide a nonzero clientOrderId that is unique per orderbook call within one transaction. A transaction can call the same book more than once, so transactionHash plus a zero or reused client id is not a unique action key. In that case an indexer must use the transaction call trace/call ordinal rather than merging records by client id. Build a receipt keyed by (chainId, orderBook, actionKey, operationIndex), where actionKey is the unique client id or traced call ordinal. Sum fillSize from the prior MATCH_END through a tagged MATCH_END for executedQuantity. A tagged live book record supplies restingQuantity = size and newOrderId = orderId. All other outcomes imply zero resting quantity and no new order id. IOC unfilled quantity remains submittedQuantity - executedQuantity, using the submitted calldata or signed intent. Hard validation failures and a FOK with nonzero executable remaining quantity revert the whole transaction, so they have no surviving operation record. A post-fill remainder below one executable unit is instead discarded as dust; the FOK succeeds and emits DUST_REMAINDER_DISCARDED. Use the transaction receipt for reverted failures. Each successful operation ends in exactly one tagged record. The exception is RESTED after a fill, which tags both the MATCH_END trade record and the new live book record with the same operationIndex. The sections below list every case twice: first per event, then per operation. test/spot/SpotOutcomeScenarioTest.t.sol has a test for each case.

Outcomes on TradesPacked

Only the MATCH_END record of a batch order or a replaceBySlotPacked operation carries a nonzero outcome. Every other trade record has outcome NONE, including every swap record.
  • The tagged record can be a passive record (makerFlags & 2 != 0) when the match ends on a passive band. Its outcome, operationIndex, and replacementSlot still apply; userId, slotIdx, and orderId use the passive encoding.
  • In a packed replace, the old order’s delete is NONE whenever the result is on the trade record.
  • IOC_UNFILLED, POST_ONLY_NOT_PLACED, CANCELLED, PROTOCOL_CANCELLED, and CANCEL_NOOP never appear on TradesPacked. An operation with no fill has no trade record, so its outcome is on BookUpdatesPacked.

Outcomes on BookUpdatesPacked

Every book record is one of three kinds:
  • live: bookFlags & 0x80 != 0
  • delete: not live, and userId != 0
  • sentinel: not live, and userId = 0
Apply live and delete records to L2 and L3 whatever their outcome. Never apply a sentinel. Live records: Delete records: Sentinel records: For packed replace, empty means empty when that operation runs. A slot that was live at submission but was removed earlier in the same call by self-match cleanup counts as empty, and that removal is its own NONE delete. Every sentinel has this shape:
Identify a sentinel by live = false and userId = 0. Account ids start at 1, so a real record never has userId = 0. The outcome alone is not enough, because POST_ONLY_NOT_PLACED and COMPLETED_NO_REST also tag real deletes. The log’s accountId, executor, and clientOrderId are still set. FILLED, IOC_PARTIAL, and DUST_REMAINDER_DISCARDED never appear on BookUpdatesPacked. Dust is cleared by updatedSize = 0 on the trade record. Some calls emit no book record at all. A batch or protocol cancel of an empty slot reverts the whole call. An empty protocol slot list, cancelAllOrders with nothing live, and an empty packed replace succeed without records.

Outcomes by operation

The same cases, grouped by what was submitted. Each row names the outcome and the records that carry it. “Tagged MATCH_END” is the last trade record of the operation’s match. “Sentinel” is the zero-delta book record described above. Every tagged record carries the operation’s operationIndex. Self-match cleanup can add NONE deletes of the caller’s own orders before the result; apply them to L2 and L3 normally. Native GTC order (replacementSlot = 0xff): Native IOC order (replacementSlot = 0xff): Native FOK order (replacementSlot = 0xff): Native POST_ONLY order (GTC only): Packed replace (replacementSlot and sentinel slot are the target slot): Packed ALO replace: Packed cancel-only (size 0): Cancels outside packed replace: No operation outcome: When a match has several fills, only its last record is tagged. When that last fill is against a passive band, the tagged record is the passive record.

Active L2 Book

Maintain active FIFO depth as:
For every non-passive TradesPacked record, apply one of these two rules:
updatedSize > 0 is a partial fill. The remainder stays, so only fillSize leaves the level. updatedSize = 0 removes the order. fillSize can be smaller than the size still on the level. The difference is dust, below one quote atom, and it is not a separate BookUpdatesPacked delete. Drop the order’s whole remaining visible size, not only fillSize. A fresh order and a stale order use this same dust rule. A stale order is already capped before the fill. At the first block where block.number > minSizeAfterBlock, set
The size above that cap is not a trade and not a book delete. The later fill’s fillSize and updatedSize are relative to visibleSize. updatedSize = 0 removes what is left of that cap, including dust inside the cap. Do not subtract the original order size again. For every passive TradesPacked record, do not touch the active FIFO maps and do not change passive band inventory or passive L2 depth. The record is the tape: price, side, and fillSize. PassiveBandUpdated is emitted during the match, as soon as the band changes, and is already the inventory after the fill. The trade log is buffered and emitted later. Persisted band inventory comes only from that snapshot. Applying the snapshot and then subtracting fillSize, or writing updatedSize on top of it, removes the fill a second time. For every non-sentinel BookUpdatesPacked record:
BookUpdatesPacked does not mirror trade-induced partial or full fills. It describes newly resting liquidity, non-trade removals, and tagged zero-delta operation sentinels. Do not treat TradesPacked as optional for active L2. Trade records are the only source of execution-induced active depth reduction. Examples:
getL2Book(levels) returns combined active plus passive visible depth. Use it as a periodic checksum for top-of-book state, not as the primary event stream.

Active L3 Book And Orders

Maintain active order state keyed by:
Maintain historical order identity keyed by:
Slots are reused, so orderId is required for order history. For BookUpdatesPacked:
For TradesPacked:
Interpret common lifecycle patterns as:

Soft skips

A successful batch or replaceBySlotPacked can return without installing the requested order. The skip is a book record, not a missing log. Crossing POST_ONLY:
  • no trade and no order id
  • one zero-delta BookUpdatesPacked sentinel, outcome POST_ONLY_NOT_PLACED, slot 0xff
  • clientOrderId is on that log
Crossing ALO replace:
  • the new order is not inserted and does not match
  • a live slot emits one non-live delete of the old order, outcome POST_ONLY_NOT_PLACED, plus SpotReserveUpdated for the released reserve
  • an empty slot emits one zero-delta sentinel, outcome POST_ONLY_NOT_PLACED, with that slot index
A real replace on that slot is a delete followed by a live record with a new orderId. A lone POST_ONLY_NOT_PLACED delete is the ALO discard. A lone zero-fill cross delete is a GTC replace that filled nothing and still crosses. A lone CANCELLED delete is a user cancel. A lone PROTOCOL_CANCELLED delete is a protocol cancel. cancelAllOrders deletes are untagged. Zero-fill cross (COMPLETED_NO_REST with no trade):
  • the order is GTC, matching leaves size unfilled, and the limit still crosses the opposite best price
  • nothing is rested
  • a native order emits one sentinel, slot 0xff
  • a packed replace of a live slot emits one tagged delete of the old order
  • a packed replace of an empty slot emits one sentinel on that target slot
  • this is not CANCEL_NOOP, which is a packed cancel of an already empty slot
Batch cancels run before the new orders. A cancel followed by a crossing POST_ONLY emits the cancel delete and then the POST_ONLY_NOT_PLACED sentinel. The new order is absent. Other orders in the same action still emit their own trades and live records. A missing live record is per order, not per transaction. Install an order only from a live BookUpdatesPacked record. BookUpdatesPacked.accountId is the action account, not necessarily the maker whose slot changed. Use userId in each record for maker identity. Its indexed executor is the immediate authenticated caller. Provenance fields use these meanings consistently:
  • executor: immediate authenticated action caller
  • authorizer: principal whose direct authority or EIP-712 signature was validated
  • actor: economic or protocol principal responsible for a mixed-path update
  • operator: caller that passed a governance, ops, or emergency role check
These additions do not reorder existing logs. Nested orderbook/ownership events remain where they were; router provenance events follow the successful forwarded call. Passive band updates still occur during matching, while action-level packed events flush at the end. Process transaction logs strictly by logIndex. clientOrderId is a user-supplied batch-level correlation id. It is emitted with packed event batches but is not stored on-chain in the orderbook. Each TradesPacked record’s tradeId is protocol-generated and canonical for trade lookup.

minSizeAfterBlock

minSizeAfterBlock is present on live book updates. It is zero when inactive. When nonzero, a fresh order must have minSizeAfterBlock >= block.number at placement time. A stale order is one where:
The order is not removed automatically at the beginning of the block. It changes only when touched by matching or explicit cancellation. Indexers that maintain executable L2 should apply the stale visibility cap at block start:
The on-chain getL2Book view applies this cap to each stale FIFO order before aggregating depth at the price level. The stored order and its full reserve remain unchanged until matching or cancellation touches it. When a stale order is later touched by matching, events are emitted relative to that visible size:
  • the executed portion is in TradesPacked.fillSize
  • a remaining live visible residual is in TradesPacked.updatedSize
  • a cancelled visible dust residual is cleared by updatedSize = 0, the same as a non-stale dust remainder. There is no extra BookUpdatesPacked delete.
Hidden stale excess above the visible size is released internally but is not emitted as an active L2 delete, because it was already removed from executable L2 at block start.

Passive Liquidity

Passive liquidity is not represented as maker slots. It does not emit BookUpdatesPacked. Passive fills are represented by passive-flagged TradesPacked records. Maintain passive band state keyed by:
From PassiveBandUpdated(lowPrice, baseAtHigh, quoteAtLow, totalShares):
baseAtHigh is the stored normalized size, the same unit as an active order’s fillSize. Raw base atoms are baseAtHigh * baseSizeMultiplier. quoteAtLow and totalShares are unchanged: quote inventory is raw atoms, and shares are the band’s share supply. getPassiveBand().baseAtHigh is still raw atoms, so do not treat the view and the event as the same unit. A zero baseAtHigh, zero quoteAtLow, and zero totalShares update means the band is empty. Replace the stored band from this event. Do not also apply the later passive TradesPacked record as an inventory delta. Passive depth contributes to L2 at two prices:
Recompute both displayed sizes from the latest PassiveBandUpdated snapshot. A nonzero quoteAtLow or baseAtHigh can still display as zero. Quote and base conversions use the same floors as the contract:
Bid size:
Ask size:
The ask gate is the midpoint, refPrice, not the ask price. A size that converts to a nonzero quote at the ask and to zero at the midpoint is hidden. After the low-price conversion, bid size is capped so raw base cannot exceed uint128.max / baseSizeMultiplier, then capped at uint96.max, and is shown only when settlement can pay both the edge quote at lowPrice and the principal base at refPrice. Combined L2 is:
Passive LP position/accounting events:
  • PassiveLiquidityMinted(userId, positionId, executor, lowPrice, baseAmount, quoteAmount, shares)
  • PassiveLiquidityBurned(userId, positionId, executor, sharesBurned, basePrincipalOut, quotePrincipalOut, baseFeeOut, quoteFeeOut)
  • PassiveFeesClaimed(userId, positionId, executor, baseFeeOut, quoteFeeOut)
Use those for LP position history. Use PassiveBandUpdated for passive depth.

Passive Trade Records

A passive trade is a TradesPacked record with makerFlags & 2 != 0. Fields:
  • userId = 0
  • slotIdx = 0 for passive bid, 1 for passive ask
  • orderId = lowPrice
  • price = execution price
  • fillSize = base filled
  • updatedSize = remaining visible size on the filled side after the fill
Passive execution quote is side-dependent. Passive bids floor both stages; passive asks ceil both stages:
For taker buys, gross quote debit is:
For taker sells, net quote credit is:
Do not write updatedSize or fillSize into band inventory or L2 depth. Those come from PassiveBandUpdated. updatedSize matches the filled side’s post-fill displayed size and is tape context only. A passive fill also changes the other leg, which this record does not carry. Passive LP fee growth tracks only passive spread economics. Protocol taker fees and builder fees are visible through AccountCore settlement/builder fee events, not through passive band fee growth.

Swaps And Mixed Liquidity

SpotSwap(userId, executor, isBuy, amountInUsed, amountOut, minAmountOut) is a summary of the taker’s exact-input swap settlement. Its amounts are attributable to that swap’s fills, rather than the account’s whole balance change during the call. For a swap that touches active FIFO liquidity, active fills are emitted later in the same transaction through TradesPacked and BookUpdatesPacked. For a swap that touches passive liquidity, passive fills are emitted later in the same transaction as passive-flagged TradesPacked records, while passive inventory snapshots still emit PassiveBandUpdated. Mixed active/passive swaps can produce all of these in one transaction. Active and passive trade records share one packed trade stream, so their relative execution order is the record order inside TradesPacked. PassiveBandUpdated logs are emitted immediately when passive inventory changes. Therefore:
  • active and passive trade records are ordered relative to each other within TradesPacked
  • SpotSwap gives the final taker-level amount summary
  • persisted passive inventory comes only from PassiveBandUpdated
  • the passive TradesPacked record is the tape for that fill: price, size, and side. It does not change inventory again
SpotSwap deliberately does not repeat fee rates. Its corresponding TradesPacked event is the canonical action fee context.

AccountCore Indexing

AccountCore is the custody, account, permission, builder, and fee-tier ledger behind the Spot orderbooks. Index it as a separate subsystem and join it to orderbook events by accountId, userId, rootId, token, and orderBook.

Account Identity

Maintain:
AccountRegistered(accountId, rootAccountId, rootOwner, subaccountSeq) is the canonical identity event. All three fields before the sequence are indexed. Apply it as:
Roots have rootAccountId == accountId and sequence zero. Children have positive sequences, the same owner as their root, and no separate address. SubaccountCreated(rootAccountId, subaccountId, authorizer) records who authorized creation (the verified signer for relayed creation). All three fields are indexed. Use it for provenance, without appending the child twice. Creation does not change the root signer epoch. Orderbook userId and AccountCore accountId share the same global ID space.

Signers And Permissions

Maintain one registry and epoch per root ID:
AccountSignerAuthorized(accountId, signer, authorizer, permissions, expiry) replaces the root grant. AccountSignerRevoked(accountId, signer, authorizer) removes it. Both advance that root’s epoch by one. The account ID in either event is always the root ID. authorizer records who granted or revoked the signer; it is provenance, not part of the registry key. Permission bits are 1 << 0 TRADE, 1 << 2 INTERNAL_TRANSFER, 1 << 3 WITHDRAW and 1 << 4 ADMIN. Root owners control the family. ADMIN includes TRADE, WITHDRAW and INTERNAL_TRANSFER; WITHDRAW includes INTERNAL_TRANSFER. TRADE authority (whether granted directly or inherited from ADMIN) applies to every existing and future child. ADMIN also manages root grants and permits child creation. Root withdrawals and family transfers check authority against the root. No grant can enable child external withdrawals or deposits. Authorization events contain the exact granted mask, not its expanded permissions. Preserve those bits in the indexed registry and apply the hierarchy above when computing effective authority. Expiry/revocation applies to the whole grant, including every inherited permission. Expiry is evaluated at use time: zero means no expiry; otherwise the grant is live through its expiry timestamp. Do not delete expired grants from indexed history. accountAuthorizationEpoch(accountId) resolves the root epoch for both roots and children; use it with the target ID when building signed trading intents.

Spot Token And Market Config

Maintain:
Events. These update config state. operator is history only.
  • SpotTokenConfigured(token, decimals, enabled, operator)
  • SpotRouterUpdated(oldRouter, newRouter, operator)
  • SpotRouter.SpotMarketRegistered(orderBook, baseToken, quoteToken, operator, sizePrecision, pricePrecision, minQuoteNotional, tickSize, passiveSpreadTicks, quoteTokenDecimals, takerFeePps, makerFeePps)
  • AccountCore.SpotMarketRegistered(orderBook, baseToken, quoteToken, registrar)
  • SpotRouter.SpotMarketUpgraded(orderBook, newImplementation, operator) — state is the market implementation
  • AccountCore.FeeCollectorUpdated(oldFeeCollector, newFeeCollector, operator) — state is the fee collector
  • AccountCore.ProtocolStateUpdated(previousPaused, paused, operator) — state is whether AccountCore is paused
  • AccountCore.PostFillHookAccessUpdated(accountId, allowed, operator) — state is whether that account may set a post-fill hook
SpotTokenConfigured is a snapshot of cached token metadata. Decimals are read by governance configuration and are immutable for already-seen tokens. Native asset is address(0) and uses 18 decimals. The two SpotMarketRegistered events are different ABIs. The router event is the create-market config source of truth (minQuoteNotional is raw quote atoms; takerFeePps / makerFeePps are market defaults and may be overridden by account or builder tiers). The AccountCore event only confirms settlement authority:

Free And Reserved Balances

Maintain two balances per (accountId, token):
Free balance is withdrawable/spendable balance. Reserved balance is active maker-order escrow. Passive LP inventory is neither free nor active-order reserved; track it from passive position/band events. AccountCore emits a mix of activity events and snapshots:
  • Deposit(accountId, token, payer, amount) records who funded the account.
  • Withdrawal(accountId, token, recipient, amount) records who received the asset.
  • InternalAccountTransfer(fromAccountId, toAccountId, token, executor, amount) records who moved free balance; use the accompanying snapshots for balances.
  • SpotReserveUpdated(userId, token, freeBalance, reservedBalance) is an authoritative snapshot for that AccountCore id/token pair.
Use SpotReserveUpdated to maintain balances:
Deposit, Withdrawal, and InternalAccountTransfer are activity/provenance events. The same operation emits SpotReserveUpdated first; an internal transfer emits one snapshot per distinct account before InternalAccountTransfer. Self-transfers revert. Do not apply the activity amount on top of that snapshot. SpotReserveUpdated is emitted for reserve, release, batched reserve deltas, active maker settlement, active/passive taker settlement, passive lock/release, deposits, withdrawals, and internal account transfers. Since it is a snapshot, it replaces the stored free and reserved balances for that account and token.

Trade Settlement Balance Derivation

Not every settlement leg emits an AccountCore balance event. For exact portfolio indexing, use SpotReserveUpdated as the balance and use the orderbook events as history. SpotSwap reports the swap’s settlement contribution:
These are attribution equations, not additional balance updates. finalizeSpotMatch emits the taker’s authoritative SpotReserveUpdated snapshots before SpotSwap. These equations describe the swap leg, not necessarily the account’s final delta for the transaction. Authorized post-fill hooks can transfer funds or trade on another market before the outer swap completes. Apply those independent actions separately, and prefer the authoritative SpotReserveUpdated snapshots for final free and reserved balances. For active maker fills, AccountCore emits SpotReserveUpdated snapshots for both the token whose reserve was consumed and the token the maker received. Apply those snapshots. The shape of each snapshot is:
Do not also apply those amounts as a second balance delta. To audit the numbers, use the makerFeePps stored in the active trade record. It is the effective maker fee snapshotted when the resting order was created. Passive LP inventory changes are represented by PassiveBandUpdated, not by passive-flagged TradesPacked records and not by per-LP AccountCore balance events. The trade record is the fill tape. The taker’s free-balance change is the SpotReserveUpdated pair from finalizeSpotMatch, not a second application of SpotSwap. For passive fills, the taker’s passive input and output are included in SpotSwap exactly once. Protocol fees credited to feeCollector do not have a dedicated fee-accrual event. The fee collector’s SpotReserveUpdated snapshot is the balance. A MATCH_END sum can audit that snapshot; it must not be added on top of it.

Passive Custody And LP Positions

Passive liquidity locks free balances into band inventory:
  • PassiveLiquidityMinted(...) describes the provider id, position id, low price, deposited base/quote, and shares.
  • PassiveLiquidityBurned(...) describes principal and fee amounts released.
  • PassiveFeesClaimed(...) describes fee-only releases.
  • SpotReserveUpdated(...) gives the provider’s free-balance snapshot for each base/quote token whose free balance changed during AccountCore lock or release.
Maintain LP position state from passive orderbook events, and maintain free balances from SpotReserveUpdated. Do not treat passive inventory as reservedBalance; reservedBalance is only active maker-order escrow.

Builders

Maintain:
Builder free balance comes from SpotReserveUpdated, not from adding BuilderFeeAccrued.amount. Events:
  • BuilderApprovalUpdated(rootAccountId, builder, maxFeePps, expiry, active)
  • BuilderReferralTierUpdated(builder, makerFeePps, takerFeePps, expiry, active, operator)
  • BuilderReferralUpdated(rootAccountId, builder, actor, makerFeePps, takerFeePps, expiry, active)
  • BuilderFeeAccrued(builder, asset, takerAccountId, orderBook, builderFeePps, amount)
  • SpotReserveUpdated(builderAccountId, asset, freeBalance, reservedBalance) on positive accrual
  • Withdrawal(builderAccountId, asset, recipient, amount) when the builder withdraws
BuilderFeeAccrued is also the builder-attribution event. One event is emitted per finalized match when a builder context is present. amount == 0 remains valid for an approved zero-fee builder flow and does not credit the builder’s free balance. orderBook identifies the settlement source and builderFeePps is the exact match rate represented by the amount. On approval:
On referral tier update:
Builder referral tiers are enrollable only while active and unexpired. A finite user referral must expire at or before the tier expiry, and referralExpiry == 0 is only valid when the tier expiry is also 0. On fee accrual (amount > 0):
The snapshot is the canonical balance; BuilderFeeAccrued is history only and must not be applied a second time. amount == 0 emits BuilderFeeAccrued and no SpotReserveUpdated, because the builder’s free balance does not change. On withdraw:
The snapshot is the canonical balance; Withdrawal is history only and must not be applied a second time. Builder approvals are root-level. Subaccounts inherit the root approval through their rootId. Expiry is checked at execution time, so retain expired approvals and mark them unusable in views when block.timestamp > expiry. Builder referral tiers and enrollments are a separate fee-discount system, not the builder-fee approval system. A tier says what protocol maker/taker fee (in pps) a builder is allowed to sponsor. A referral enrollment snapshots that tier onto a root account after a builder-signed EIP-712 authorization. Subaccounts inherit the root referral through rootId. Treat a referral as live only when active == true, builder != address(0), and expiry == 0 || block.timestamp <= expiry. Revocation and governance clears emit BuilderReferralUpdated(..., active=false) with the stored builder/fee/expiry values that were cleared. Builder fee assets depend on trade direction:
For amount == 0, the same asset rule applies for attribution even though the builder’s free balance does not change.

Fee Tiers

Maintain:
Events:
  • DefaultSpotFeesUpdated(makerFeePps, takerFeePps, active, operator)
  • AccountFeeTierUpdated(rootAccountId, makerFeePps, takerFeePps, active, operator)
  • BuilderReferralTierUpdated(builder, makerFeePps, takerFeePps, expiry, active, operator)
  • BuilderReferralUpdated(rootAccountId, builder, actor, makerFeePps, takerFeePps, expiry, active)
Effective spot fee for an account is:
For makers, snapshot the effective maker fee when an order is placed. For takers, use the effective taker fee at execution time. Referral expiry and revocation therefore affect new taker actions immediately, while already resting maker orders keep the maker fee snapshot stored in their order flags.

Maker And Operational Metadata

Index these for maker management and operational dashboards. State events change stored configuration. History-only events explain who did it, or repeat a change that another event already applies. State:
  • MakerRegistered(userId)
  • PostFillHookUpdated(userId, hook, executor) — state is the hook address. executor is history only.
  • PostFillHookGasLimitUpdated(oldGasLimit, newGasLimit, operator) — state is the gas stipend. operator is history only.
  • PostFillHookMinQuoteNotionalUpdated(oldMinQuoteNotional, newMinQuoteNotional, operator) — state is the hook minimum notional. operator is history only.
  • PostFillHookAccessUpdated(accountId, allowed, operator) — state is the account’s hook permission. operator is history only.
  • FeeCollectorUpdated(oldFeeCollector, newFeeCollector, operator) — state is the fee collector. operator is history only.
  • ProtocolStateUpdated(previousPaused, paused, operator) — state is the protocol pause. operator is history only.
  • SpotMarketUpgraded(orderBook, newImplementation, operator) — state is the market implementation. operator is history only.
  • OwnershipTransferred(previousOwner, newOwner)
  • AuthorityUpdated(oldAuthority, newAuthority) — emitted by AccountCore and SpotRouter when their stored pointer moves. Live books do not emit this; they read the current owner’s authority().
  • GovernanceUpdated(oldGovernance, newGovernance)
  • OpsUpdated(oldOps, newOps)
  • EmergencyUpdated(oldEmergency, newEmergency)
History only:
  • MakerPagePrimed(userId, primedCount)
  • QueuePagesPrimed(caller, centerPrice, groupsAround, wordsPerGroup, pagesPrimed, wordsPrimed)
  • ProtocolOrdersCancelled(userId, operator, clientOrderId, slotIdxs) is the privileged-action audit summary. It is emitted after that call’s packed logs. Removed order ids and sizes remain canonical in the corresponding PROTOCOL_CANCELLED book records. Do not apply this event as a second cancel.

Processing Checklist

For each market, _emitPackedEvents logs one packed event per contiguous run. Each cycle emits the run that was appended first, then the other run. A nonempty protocolCancelBySlots emits ProtocolOrdersCancelled after that call’s packed logs. Forward each packed log as it appears.
  1. Walk packed logs in receipt order. Apply each log before the next one.
  2. Decode the records in that log, in record order.
  3. Extract tagged operation outcomes and merge trade/live carriers by operation index.
  4. Record zero-delta sentinels, but do not apply them to L2 or L3.
  5. Transaction success, IntentExecuted, and TriggerFired do not imply a resting order. Apply an order only from a live BookUpdatesPacked record. A POST_ONLY_NOT_PLACED sentinel or delete means that order was not installed.
  6. Apply active L2 additions/removals from non-sentinel BookUpdatesPacked records.
  7. Apply active L3/order-state upserts and deletes from non-sentinel book records.
  8. Apply active L2 trade deltas from non-passive TradesPacked records.
  9. Store active and passive trade tape records from TradesPacked.
  10. Replace passive band state from PassiveBandUpdated and recompute affected passive bid/ask depth from that snapshot. Do not also apply passive TradesPacked records to inventory.
  11. Store passive trade records from passive-flagged TradesPacked records as tape only.
  12. Store SpotSwap summaries.
  13. Apply SpotReserveUpdated snapshots for balances. Keep Deposit, Withdrawal, InternalAccountTransfer, BuilderFeeAccrued, and SpotSwap as history.
  14. Periodically compare top levels with getL2Book(levels).
Full active L3 reconstruction requires replaying from market deployment because there is no complete on-chain iterator for all maker slots. If an indexer starts midstream, it can use getL2Book() to seed top-of-book aggregate L2, but it cannot reconstruct historical maker-slot state without older logs.

Scenario coverage

test/spot/SpotOutcomeScenarioTest.t.sol has one test for every end state in the native-batch and packed-replace graphs above. The live-slot tests check the NONE delete of the old order. The empty-slot tests check that no book record is emitted, except RESTED, which checks the live record. Each test compares the book records byte for byte and checks the trade outcome, MATCH_END, and replacementSlot.

Native batch order

Packed replace