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)
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)— stateAccountCore.ProtocolStateUpdated(previousPaused, paused, operator)— stateAccountCore.PostFillHookAccessUpdated(accountId, allowed, operator)— stateSpotRouter.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:
baseTokenquoteTokenpricePrecisionsizePrecisionbaseSizeMultipliertickSizepassiveSpreadTicksquoteTokenDecimals- 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: bits255:216slotIdx: bits215:208makerFlags: bits207:200price: bits199:168fillSize: bits167:72orderId: bits71:8operationIndex: bits7:0(meaningful only when outcome is nonzero)
updatedSize: bits255:160makerFeePps: bits159:136(uint24)replacementSlot: bits135:128(meaningful only when outcome is nonzero)- reserved: bits
127:64 tradeId: bits63: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:
N = 0 after every MATCH_END; for each active-ask record in execution order use:
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:
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:
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
userId: bits255:216slotIdx: bits215:208bookFlags: bits207:200price: bits199:168size: bits167:72orderId: bits71:8operationIndex: bits7:0(meaningful only when outcome is nonzero)
minSizeAfterBlock: uint32, big-endianmakerFeePps: 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 inpackedOpsbatch: everycancelSlotIdxsentry first, followed by every native orderprotocolCancelBySlots: index inslotIdxs
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 theMATCH_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, andreplacementSlotstill apply;userId,slotIdx, andorderIduse the passive encoding. - In a packed replace, the old order’s delete is
NONEwhenever the result is on the trade record. IOC_UNFILLED,POST_ONLY_NOT_PLACED,CANCELLED,PROTOCOL_CANCELLED, andCANCEL_NOOPnever appear onTradesPacked. An operation with no fill has no trade record, so its outcome is onBookUpdatesPacked.
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
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:
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. “TaggedMATCH_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: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
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:orderId is required for order history.
For BookUpdatesPacked:
TradesPacked:
Soft skips
A successfulbatch 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
BookUpdatesPackedsentinel, outcomePOST_ONLY_NOT_PLACED, slot0xff clientOrderIdis on that log
- 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, plusSpotReserveUpdatedfor the released reserve - an empty slot emits one zero-delta sentinel, outcome
POST_ONLY_NOT_PLACED, with that slot index
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
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 callerauthorizer: principal whose direct authority or EIP-712 signature was validatedactor: economic or protocol principal responsible for a mixed-path updateoperator: caller that passed a governance, ops, or emergency role check
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:
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 extraBookUpdatesPackeddelete.
Passive Liquidity
Passive liquidity is not represented as maker slots. It does not emitBookUpdatesPacked. Passive fills are represented by passive-flagged
TradesPacked records.
Maintain passive band state keyed by:
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:
PassiveBandUpdated snapshot.
A nonzero quoteAtLow or baseAtHigh can still display as zero. Quote and
base conversions use the same floors as the contract:
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:
PassiveLiquidityMinted(userId, positionId, executor, lowPrice, baseAmount, quoteAmount, shares)PassiveLiquidityBurned(userId, positionId, executor, sharesBurned, basePrincipalOut, quotePrincipalOut, baseFeeOut, quoteFeeOut)PassiveFeesClaimed(userId, positionId, executor, baseFeeOut, quoteFeeOut)
PassiveBandUpdated for passive depth.
Passive Trade Records
A passive trade is aTradesPacked record with makerFlags & 2 != 0.
Fields:
userId = 0slotIdx = 0for passive bid,1for passive askorderId = lowPriceprice = execution pricefillSize = base filledupdatedSize = remaining visible size on the filled side after the fill
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 SpotSwapgives the final taker-level amount summary- persisted passive inventory comes only from
PassiveBandUpdated - the passive
TradesPackedrecord 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 byaccountId, 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:
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: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 implementationAccountCore.FeeCollectorUpdated(oldFeeCollector, newFeeCollector, operator)— state is the fee collectorAccountCore.ProtocolStateUpdated(previousPaused, paused, operator)— state is whether AccountCore is pausedAccountCore.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):
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.
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, useSpotReserveUpdated as the balance and use the orderbook events
as history.
SpotSwap reports the swap’s settlement contribution:
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:
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.
SpotReserveUpdated. Do not treat passive inventory as
reservedBalance; reservedBalance is only active maker-order escrow.
Builders
Maintain: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 accrualWithdrawal(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:
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):
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:
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:
amount == 0, the same asset rule applies for attribution even though the builder’s free balance does not change.
Fee Tiers
Maintain: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)
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.executoris history only.PostFillHookGasLimitUpdated(oldGasLimit, newGasLimit, operator)— state is the gas stipend.operatoris history only.PostFillHookMinQuoteNotionalUpdated(oldMinQuoteNotional, newMinQuoteNotional, operator)— state is the hook minimum notional.operatoris history only.PostFillHookAccessUpdated(accountId, allowed, operator)— state is the account’s hook permission.operatoris history only.FeeCollectorUpdated(oldFeeCollector, newFeeCollector, operator)— state is the fee collector.operatoris history only.ProtocolStateUpdated(previousPaused, paused, operator)— state is the protocol pause.operatoris history only.SpotMarketUpgraded(orderBook, newImplementation, operator)— state is the market implementation.operatoris history only.OwnershipTransferred(previousOwner, newOwner)AuthorityUpdated(oldAuthority, newAuthority)— emitted byAccountCoreandSpotRouterwhen their stored pointer moves. Live books do not emit this; they read the current owner’sauthority().GovernanceUpdated(oldGovernance, newGovernance)OpsUpdated(oldOps, newOps)EmergencyUpdated(oldEmergency, newEmergency)
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 correspondingPROTOCOL_CANCELLEDbook 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.
- Walk packed logs in receipt order. Apply each log before the next one.
- Decode the records in that log, in record order.
- Extract tagged operation outcomes and merge trade/live carriers by operation index.
- Record zero-delta sentinels, but do not apply them to L2 or L3.
- Transaction success,
IntentExecuted, andTriggerFireddo not imply a resting order. Apply an order only from a liveBookUpdatesPackedrecord. APOST_ONLY_NOT_PLACEDsentinel or delete means that order was not installed. - Apply active L2 additions/removals from non-sentinel
BookUpdatesPackedrecords. - Apply active L3/order-state upserts and deletes from non-sentinel book records.
- Apply active L2 trade deltas from non-passive
TradesPackedrecords. - Store active and passive trade tape records from
TradesPacked. - Replace passive band state from
PassiveBandUpdatedand recompute affected passive bid/ask depth from that snapshot. Do not also apply passiveTradesPackedrecords to inventory. - Store passive trade records from passive-flagged
TradesPackedrecords as tape only. - Store
SpotSwapsummaries. - Apply
SpotReserveUpdatedsnapshots for balances. KeepDeposit,Withdrawal,InternalAccountTransfer,BuilderFeeAccrued, andSpotSwapas history. - Periodically compare top levels with
getL2Book(levels).
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.