> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kuru.io/llms.txt
> Use this file to discover all available pages before exploring further.

# OrderBook Events

> Packed records, operation outcomes, and canonical book reconstruction.

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](/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:

```text theme={null}
chainId, orderBook, tradeId
```

`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:

```text theme={null}
priceSizeUnits = floor(price * fillSize / sizePrecision)
quoteAmount = floor(priceSizeUnits * 10^quoteTokenDecimals / pricePrecision)
rawBaseAmount = fillSize * baseSizeMultiplier
```

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:

```text theme={null}
n = price * fillSize * 10^quoteTokenDecimals
D = sizePrecision * pricePrecision
quoteAmount = ceil((N + n) / D) - ceil(N / D)
N += n
```

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:

```text theme={null}
baseSizeMultiplier = 10^baseTokenDecimals / sizePrecision
```

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`:

```text theme={null}
takerFeeQuote = ceil(Q * effectiveTakerFeePps / PPS)
buyBuilderFeeBase = ceil(B * builderFeePps / PPS)
sellBuilderFeeQuote = ceil(Q * builderFeePps / PPS)
makerFeeQuote = sum(floor(recordQuote * makerFeePps / PPS))
```

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:

| Code | Outcome | Meaning |
| -: | - | - |
| 0 | `NONE` | Ordinary maker, hook, swap, or maintenance record; ignore operation metadata |
| 1 | `FILLED` | Requested quantity fully executed |
| 2 | `RESTED` | A new live order exists |
| 3 | `COMPLETED_NO_REST` | Fills are kept and no remainder is rested. With no fill, the book record is a zero-fill cross |
| 4 | `IOC_PARTIAL` | IOC executed partially and discarded its remainder |
| 5 | `IOC_UNFILLED` | IOC executed zero |
| 6 | `POST_ONLY_NOT_PLACED` | Native POST\_ONLY or packed ALO would cross |
| 7 | `CANCELLED` | User-requested live cancellation |
| 8 | `PROTOCOL_CANCELLED` | Governance/ops/emergency cancellation |
| 9 | `CANCEL_NOOP` | Packed cancel targeted an empty slot |
| 10 | `DUST_REMAINDER_DISCARDED` | A real fill left a sub-unit taker remainder |

`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](#outcomes-on-tradespacked) and
[Outcomes on BookUpdatesPacked](#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.

```mermaid theme={null}
flowchart TD
    row["TradesPacked record"] --> last{"MATCH_END of a batch order<br/>or packed replace?"}
    last -->|"no, or a swap record"| none["NONE"]
    last -->|yes| remaining{"Size left<br/>after matching?"}
    remaining -->|no| dust{"Sub-unit leftover<br/>dropped?"}
    dust -->|yes| dustOut["DUST_REMAINDER_DISCARDED"]
    dust -->|no| filled["FILLED"]
    remaining -->|yes| ioc{"Native IOC?"}
    ioc -->|yes| partial["IOC_PARTIAL"]
    ioc -->|"no: GTC or packed replace"| cross{"Leftover still crosses<br/>the opposite best price?"}
    cross -->|yes| noRest["COMPLETED_NO_REST"]
    cross -->|no| rested["RESTED<br/>the new live book record is also RESTED"]
```

| Outcome | Native `batch` order, `replacementSlot = 0xff` | Packed replace, `replacementSlot` = target slot |
| - | - | - |
| `NONE` | Every record of the match before `MATCH_END` | Same |
| `FILLED` | GTC, IOC, or FOK executed its full size | Executed its full size |
| `DUST_REMAINDER_DISCARDED` | GTC, IOC, or FOK had a real fill, then dropped a leftover below one executable unit | Same |
| `IOC_PARTIAL` | IOC filled some size and discarded the rest | Does not occur |
| `RESTED` | GTC filled some size, and the leftover no longer crosses, so it rests | Same |
| `COMPLETED_NO_REST` | GTC filled some size, but the leftover still crosses the opposite best price, so it is not placed | Same |

* 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.

```mermaid theme={null}
flowchart LR
    row["BookUpdatesPacked record"] --> kind{"Record kind"}
    kind -->|live| liveBy{"Placed by"}
    liveBy -->|"the operation"| rested["RESTED"]
    liveBy -->|"a post-fill hook"| liveNone["NONE"]
    kind -->|"delete, userId != 0"| deleteBy{"Removed by"}
    deleteBy -->|"batch cancel or packed cancel-only"| cancelled["CANCELLED"]
    deleteBy -->|protocolCancelBySlots| protocol["PROTOCOL_CANCELLED"]
    deleteBy -->|"packed ALO that still crosses"| aloDelete["POST_ONLY_NOT_PLACED"]
    deleteBy -->|"packed replace with no fill<br/>that still crosses"| zeroFillDelete["COMPLETED_NO_REST<br/>zero-fill cross"]
    deleteBy -->|"self-match cleanup, cancelAllOrders,<br/>hook cancel, or a replaced old order"| deleteNone["NONE"]
    kind -->|"sentinel, userId = 0"| sentinelBy{"Operation"}
    sentinelBy -->|"native POST_ONLY would cross"| s1["POST_ONLY_NOT_PLACED<br/>slot 0xff"]
    sentinelBy -->|"native IOC with no fill"| s2["IOC_UNFILLED<br/>slot 0xff"]
    sentinelBy -->|"native GTC with no fill<br/>that still crosses"| s3["COMPLETED_NO_REST<br/>slot 0xff"]
    sentinelBy -->|"packed ALO would cross,<br/>empty slot"| s4["POST_ONLY_NOT_PLACED<br/>target slot"]
    sentinelBy -->|"packed cancel-only,<br/>empty slot"| s5["CANCEL_NOOP<br/>target slot"]
    sentinelBy -->|"packed replace with no fill<br/>that still crosses, empty slot"| s6["COMPLETED_NO_REST<br/>target slot"]
```

Live records:

| Case | Outcome |
| - | - |
| Native `POST_ONLY` that does not cross | `RESTED` |
| Native GTC with no fill whose limit does not cross when it is placed | `RESTED` |
| Native GTC that filled some size, with a leftover that no longer crosses | `RESTED`. The `MATCH_END` trade record is also `RESTED` |
| Packed replace, including ALO, with no fill whose limit does not cross when it is placed | `RESTED`. A live old order is removed first by a `NONE` delete |
| Packed replace that filled some size, with a leftover that no longer crosses | `RESTED`. The `MATCH_END` trade record is also `RESTED`, and a live old order is removed by a `NONE` delete |
| Post-fill hook replenish or replacement order | `NONE` |

Delete records:

| Case | Outcome |
| - | - |
| `batch` cancel of a live slot | `CANCELLED` |
| Packed cancel-only of a live slot | `CANCELLED` |
| `protocolCancelBySlots` of a live slot | `PROTOCOL_CANCELLED`, followed by `ProtocolOrdersCancelled` after the packed logs |
| Packed ALO on a live slot that still crosses after its old order is removed | `POST_ONLY_NOT_PLACED` |
| Packed replace on a live slot with no fill that still crosses (zero-fill cross) | `COMPLETED_NO_REST` |
| Old order of a packed replace whose result is on another record: `FILLED`, `DUST_REMAINDER_DISCARDED`, `RESTED`, or trade-side `COMPLETED_NO_REST` | `NONE` |
| Self-match cleanup during a `batch` or packed-replace match | `NONE` |
| `cancelAllOrders` | `NONE` |
| Post-fill hook cancel of the order it replaces | `NONE` |

Sentinel records:

| Case | Outcome | `slotIdx` |
| - | - | - |
| Native `POST_ONLY` that would cross | `POST_ONLY_NOT_PLACED` | `0xff` |
| Native IOC that executed nothing: its limit did not reach the best price, the opposite side was empty, its size could not execute there, or it only removed the caller's own orders | `IOC_UNFILLED` | `0xff` |
| Native GTC that executed nothing and still crosses (zero-fill cross) | `COMPLETED_NO_REST` | `0xff` |
| Packed ALO that would cross, on an empty slot | `POST_ONLY_NOT_PLACED` | target |
| Packed cancel-only of an empty slot | `CANCEL_NOOP` | target |
| Packed replace on an empty slot that executed nothing and still crosses (zero-fill cross) | `COMPLETED_NO_REST` | target |

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:

```text theme={null}
live               = false
isBuy, bit 6       = 0
outcome            = COMPLETED_NO_REST, IOC_UNFILLED, POST_ONLY_NOT_PLACED, or CANCEL_NOOP
userId             = 0
price              = 0
size               = 0
orderId            = 0
minSizeAfterBlock  = 0
makerFeePps        = 0
slotIdx            = 0xff for a native order, otherwise the replace target (0-61)
operationIndex     = the operation's index in the call; 0 is valid
```

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.

```mermaid theme={null}
flowchart LR
    order["Native batch order"] --> postOnly{"POST_ONLY?"}
    postOnly -->|yes| poCross{"Would cross?"}
    poCross -->|yes| poSkip["POST_ONLY_NOT_PLACED<br/>TradesPacked: none<br/>BookUpdatesPacked: sentinel, userId 0,<br/>slot 0xff, outcome POST_ONLY_NOT_PLACED"]
    poCross -->|no| poRest["RESTED<br/>TradesPacked: none<br/>BookUpdatesPacked: live record, outcome RESTED,<br/>new orderId, size = order size"]
    postOnly -->|no| remaining{"Size left<br/>after matching?"}
    remaining -->|no| dust{"Sub-unit leftover<br/>dropped?"}
    dust -->|yes| dustOut["DUST_REMAINDER_DISCARDED<br/>TradesPacked: tagged MATCH_END, outcome<br/>DUST_REMAINDER_DISCARDED, replacementSlot 0xff<br/>BookUpdatesPacked: none"]
    dust -->|no| filled["FILLED<br/>TradesPacked: tagged MATCH_END, outcome FILLED,<br/>replacementSlot 0xff<br/>BookUpdatesPacked: none"]
    remaining -->|yes| tif{"Time in force"}
    tif -->|FOK| fok["Whole call reverts<br/>Nothing is emitted"]
    tif -->|IOC| iocFill{"Filled any?"}
    iocFill -->|yes| iocPartial["IOC_PARTIAL<br/>TradesPacked: tagged MATCH_END, outcome<br/>IOC_PARTIAL, replacementSlot 0xff<br/>BookUpdatesPacked: none"]
    iocFill -->|no| iocNone["IOC_UNFILLED<br/>TradesPacked: none<br/>BookUpdatesPacked: sentinel, userId 0,<br/>slot 0xff, outcome IOC_UNFILLED"]
    tif -->|GTC| gtcCross{"Leftover still crosses?"}
    gtcCross -->|no| gtcRest["RESTED<br/>TradesPacked: if it filled, tagged MATCH_END,<br/>outcome RESTED, replacementSlot 0xff<br/>BookUpdatesPacked: live record, outcome RESTED,<br/>new orderId, size = leftover"]
    gtcCross -->|yes| gtcFill{"Filled any?"}
    gtcFill -->|yes| noRest["COMPLETED_NO_REST<br/>TradesPacked: tagged MATCH_END, outcome<br/>COMPLETED_NO_REST, replacementSlot 0xff<br/>BookUpdatesPacked: none"]
    gtcFill -->|no| zeroFill["COMPLETED_NO_REST, zero-fill cross<br/>TradesPacked: none<br/>BookUpdatesPacked: sentinel, userId 0,<br/>slot 0xff, outcome COMPLETED_NO_REST"]
```

```mermaid theme={null}
flowchart LR
    op["Packed replace op"] --> cancelOnly{"Cancel-only?"}
    cancelOnly -->|"yes, slot live"| cancelled["CANCELLED<br/>TradesPacked: none<br/>BookUpdatesPacked: delete of the old order,<br/>outcome CANCELLED"]
    cancelOnly -->|"yes, slot empty"| noop["CANCEL_NOOP<br/>TradesPacked: none<br/>BookUpdatesPacked: sentinel, userId 0,<br/>slot = target, outcome CANCEL_NOOP"]
    cancelOnly -->|no| crosses{"Crosses once its<br/>old order is removed?"}
    crosses -->|yes| alo{"ALO?"}
    alo -->|"yes, slot live"| aloDelete["POST_ONLY_NOT_PLACED<br/>TradesPacked: none<br/>BookUpdatesPacked: delete of the old order,<br/>outcome POST_ONLY_NOT_PLACED"]
    alo -->|"yes, slot empty"| aloSentinel["POST_ONLY_NOT_PLACED<br/>TradesPacked: none<br/>BookUpdatesPacked: sentinel, userId 0,<br/>slot = target, outcome POST_ONLY_NOT_PLACED"]
    alo -->|no| remaining{"Size left<br/>after matching?"}
    remaining -->|no| dust{"Sub-unit leftover<br/>dropped?"}
    dust -->|yes| dustOut["DUST_REMAINDER_DISCARDED<br/>TradesPacked: tagged MATCH_END, outcome<br/>DUST_REMAINDER_DISCARDED, replacementSlot = target<br/>BookUpdatesPacked: NONE delete of the old order<br/>if the slot was live"]
    dust -->|no| filled["FILLED<br/>TradesPacked: tagged MATCH_END, outcome FILLED,<br/>replacementSlot = target<br/>BookUpdatesPacked: NONE delete of the old order<br/>if the slot was live"]
    remaining -->|yes| cross{"Leftover still crosses?"}
    cross -->|no| rested["RESTED<br/>TradesPacked: if it filled, tagged MATCH_END,<br/>outcome RESTED, replacementSlot = target<br/>BookUpdatesPacked: NONE delete of the old order<br/>if the slot was live, then live record,<br/>outcome RESTED, size = leftover"]
    cross -->|yes| fillAny{"Filled any?"}
    fillAny -->|yes| noRest["COMPLETED_NO_REST<br/>TradesPacked: tagged MATCH_END, outcome<br/>COMPLETED_NO_REST, replacementSlot = target<br/>BookUpdatesPacked: NONE delete of the old order<br/>if the slot was live"]
    fillAny -->|"no, slot live"| zeroFillDelete["COMPLETED_NO_REST, zero-fill cross<br/>TradesPacked: none<br/>BookUpdatesPacked: delete of the old order,<br/>outcome COMPLETED_NO_REST"]
    fillAny -->|"no, slot empty"| zeroFillSentinel["COMPLETED_NO_REST, zero-fill cross<br/>TradesPacked: none<br/>BookUpdatesPacked: sentinel, userId 0,<br/>slot = target, outcome COMPLETED_NO_REST"]
    crosses -->|no| rest["RESTED<br/>TradesPacked: none<br/>BookUpdatesPacked: NONE delete of the old order<br/>if the slot was live, then live record,<br/>outcome RESTED, new orderId"]
```

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`):

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Did not cross, rested | `RESTED` | None | Tagged live |
| Removed only its own orders, then rested | `RESTED` | None | `NONE` delete per own order, then tagged live |
| Filled in full | `FILLED` | Tagged `MATCH_END` | None |
| Filled, then dropped a leftover below one executable unit | `DUST_REMAINDER_DISCARDED` | Tagged `MATCH_END` | None |
| Filled some, and the leftover no longer crosses, so it rests | `RESTED` | Tagged `MATCH_END` | Tagged live |
| Filled some, but the leftover still crosses | `COMPLETED_NO_REST` | Tagged `MATCH_END` | None |
| Filled nothing, and the limit still crosses (zero-fill cross) | `COMPLETED_NO_REST` | None | Sentinel, slot `0xff` |

Native IOC order (`replacementSlot = 0xff`):

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Filled in full | `FILLED` | Tagged `MATCH_END` | None |
| Filled, then dropped a leftover below one executable unit | `DUST_REMAINDER_DISCARDED` | Tagged `MATCH_END` | None |
| Filled some and discarded the rest | `IOC_PARTIAL` | Tagged `MATCH_END` | None |
| Filled nothing: the limit did not reach the best price, the opposite side was empty, or its size could not execute there | `IOC_UNFILLED` | None | Sentinel, slot `0xff` |
| Removed only its own orders | `IOC_UNFILLED` | None | `NONE` delete per own order, then sentinel, slot `0xff` |

Native FOK order (`replacementSlot = 0xff`):

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Filled in full | `FILLED` | Tagged `MATCH_END` | None |
| Filled, then dropped a leftover below one executable unit | `DUST_REMAINDER_DISCARDED` | Tagged `MATCH_END` | None |
| Executable size left | Whole call reverts | None | None |

Native `POST_ONLY` order (GTC only):

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Did not cross, rested | `RESTED` | None | Tagged live |
| Would cross | `POST_ONLY_NOT_PLACED` | None | Sentinel, slot `0xff` |

Packed replace (`replacementSlot` and sentinel slot are the target slot):

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Did not cross, or stopped crossing once its old order was removed; rested | `RESTED` | None | `NONE` delete of a live old order, then tagged live |
| Filled in full | `FILLED` | Tagged `MATCH_END` | `NONE` delete of a live old order |
| Filled, then dropped a leftover below one executable unit | `DUST_REMAINDER_DISCARDED` | Tagged `MATCH_END` | `NONE` delete of a live old order |
| Filled some, and the leftover no longer crosses, so it rests | `RESTED` | Tagged `MATCH_END` | `NONE` delete of a live old order, then tagged live |
| Filled some, but the leftover still crosses | `COMPLETED_NO_REST` | Tagged `MATCH_END` | `NONE` delete of a live old order |
| Filled nothing, the limit still crosses, and the slot was live (zero-fill cross) | `COMPLETED_NO_REST` | None | Tagged delete of the old order |
| Filled nothing, the limit still crosses, and the slot was empty (zero-fill cross) | `COMPLETED_NO_REST` | None | Sentinel |

Packed ALO replace:

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Did not cross, or stopped crossing once its old order was removed; rested | `RESTED` | None | `NONE` delete of a live old order, then tagged live |
| Still crosses, and the slot was live | `POST_ONLY_NOT_PLACED` | None | Tagged delete of the old order |
| Still crosses, and the slot was empty | `POST_ONLY_NOT_PLACED` | None | Sentinel |

Packed cancel-only (size 0):

| Result | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| Slot was live | `CANCELLED` | None | Tagged delete |
| Slot was empty, including one cleared earlier in the same call | `CANCEL_NOOP` | None | Sentinel |

Cancels outside packed replace:

| Operation | Outcome | TradesPacked | BookUpdatesPacked |
| - | - | - | - |
| `batch` cancel of a live slot, indexed before the batch's orders | `CANCELLED` | None | Tagged delete |
| `protocolCancelBySlots` of a live slot | `PROTOCOL_CANCELLED` | None | Tagged delete, then `ProtocolOrdersCancelled` |
| `cancelAllOrders` | None | None | `NONE` delete per live order |
| `batch` or protocol cancel of an empty slot | Whole call reverts | None | None |

No operation outcome:

| Operation | TradesPacked | BookUpdatesPacked |
| - | - | - |
| `swap` | `NONE` on every record, including `MATCH_END` | None |
| Post-fill hook replenish or replacement | None | `NONE` live |
| Post-fill hook cancel of the order it replaces | None | `NONE` delete |

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:

```text theme={null}
activeBidDepth[price] = uint256
activeAskDepth[price] = uint256
```

For every non-passive `TradesPacked` record, apply one of these two rules:

```text theme={null}
updatedSize > 0:
    activeDepth[side][price] -= fillSize

updatedSize = 0:
    remove that order's remaining visible size at price
```

`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

```text theme={null}
visibleSize = min(orderSize, minimumExecutableSizeAtPrice(price, minQuoteNotional))
```

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:

```text theme={null}
if live:
    activeDepth[side][price] += size
else:
    activeDepth[side][price] -= size
```

`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:

```text theme={null}
New resting order:
  Book live ask 50_000 size 1
  L2: ask[50_000] += 1

User cancel:
  Book delete bid 49_000 size 2
  L2: bid[49_000] -= 2

Full fill:
  Trade ask 50_000 fill 1 updatedSize 0
  L2: ask[50_000] -= 1

Partial fill:
  Trade ask 50_000 fill 0.4 updatedSize 0.6
  L2: ask[50_000] -= 0.4

Dust remainder, fresh or stale:
  Trade fill 0.4 updatedSize 0
  L2 removes the order's remaining visible size, not only 0.4
  No BookUpdatesPacked for the dust

Replace old bid with new ask:
  Book delete bid 49_000 size 2 oldOrderId
  Book live ask 51_000 size 1 newOrderId
  L2: bid[49_000] -= 2
  L2: ask[51_000] += 1
```

`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:

```text theme={null}
orderBook, userId, slotIdx
```

Maintain historical order identity keyed by:

```text theme={null}
orderBook, userId, slotIdx, orderId
```

Slots are reused, so `orderId` is required for order history.

For `BookUpdatesPacked`:

```text theme={null}
if outcome != NONE and userId == 0 and price == 0 and size == 0:
    record operation result and skip L3 mutation

if live:
    upsert activeOrders[(userId, slotIdx)] =
        { orderId, side, price, size, minSizeAfterBlock, makerFeePps }
else:
    clear activeOrders[(userId, slotIdx)]
```

For `TradesPacked`:

```text theme={null}
orderHistory[(userId, slotIdx, orderId)].filled += fillSize
orderHistory[(userId, slotIdx, orderId)].lastTradePrice = price
orderHistory[(userId, slotIdx, orderId)].remainingAfterTrade = updatedSize

if updatedSize == 0:
    clear activeOrders[(userId, slotIdx)]
else:
    activeOrders[(userId, slotIdx)].size = updatedSize
```

Interpret common lifecycle patterns as:

```text theme={null}
Book live with new orderId:
  order opened, replaced, hook-created, or residual incoming order rested

Trade updatedSize > 0:
  partial fill

Trade updatedSize = 0:
  full fill or fill plus residual cancellation; clear the active slot/order,
  including leftover dust. There is no extra book delete for that dust.

Book delete without same-action trade:
  explicit cancel, replace removal, self-match cleanup, protocol cancel,
  cancelAllOrders, or ALO discard of an old live order

Book delete followed by Book live same userId/slotIdx with different orderId:
  replacement of that slot, by a packed replace or by a hook replacing the
  sibling slot. The filled slot of a hook has no delete

Trade updatedSize = 0 followed by Book live same userId/slotIdx with a new orderId:
  hook replenishment of the filled slot. The fill frees the slot; the live is the new order
```

## 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:

```text theme={null}
minSizeAfterBlock != 0 && block.number > minSizeAfterBlock
```

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:

```text theme={null}
visibleSize = min(orderSize, minimumExecutableSizeAtPrice(price, minQuoteNotional))
```

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:

```text theme={null}
orderBook, lowPrice
```

From `PassiveBandUpdated(lowPrice, baseAtHigh, quoteAtLow, totalShares)`:

```text theme={null}
passiveBands[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:

```text theme={null}
bid price = lowPrice
ask price = lowPrice + passiveSpreadTicks * tickSize
refPrice = floor((lowPrice + ask price) / 2)
```

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:

```text theme={null}
quoteAmount(price, size) =
    floor(floor(price * size / sizePrecision) * 10^quoteTokenDecimals / pricePrecision)

baseFromQuote(price, quoteAmount) =
    floor(floor(quoteAmount * pricePrecision / 10^quoteTokenDecimals) * sizePrecision / price)
```

Bid size:

```text theme={null}
if quoteAtLow == 0 or lowPrice == 0:
    bidSize = 0
else:
    raw = baseFromQuote(lowPrice, quoteAtLow)
    raw = min(raw, uint128.max / baseSizeMultiplier)
    bidSize = min(raw, uint96.max)
    edgeQuote = quoteAmount(lowPrice, bidSize)
    principalBase = baseFromQuote(refPrice, edgeQuote)
    if edgeQuote == 0 or principalBase == 0:
        bidSize = 0
```

Ask size:

```text theme={null}
if baseAtHigh == 0:
    askSize = 0
else:
    askSize = min(baseAtHigh, uint96.max)
    if quoteAmount(refPrice, askSize) == 0:
        askSize = 0
```

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:

```text theme={null}
bidDepth[price] = activeBidDepth[price] + passiveBidDepth[price]
askDepth[price] = activeAskDepth[price] + passiveAskDepth[price]
```

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:

```text theme={null}
passive bid:
  priceSizeUnits = floor(price * fillSize / sizePrecision)
  quoteAmount = floor(priceSizeUnits * 10^quoteTokenDecimals / pricePrecision)

passive ask:
  priceSizeUnits = ceil(price * fillSize / sizePrecision)
  quoteAmount = ceil(priceSizeUnits * 10^quoteTokenDecimals / pricePrecision)
```

For taker buys, gross quote debit is:

```text theme={null}
quoteAmount + takerFeeQuote
```

For taker sells, net quote credit is:

```text theme={null}
quoteAmount - takerFeeQuote - builderFeeQuote
```

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:

```text theme={null}
rootAccountIdOf[rootOwner] = rootId
accountsById[accountId] = { rootOwner, rootId, subaccountSeq }
subaccountsByRoot[rootId] = uint40[]
```

`AccountRegistered(accountId, rootAccountId, rootOwner, subaccountSeq)` is the canonical identity event.
All three fields before the sequence are indexed. Apply it as:

```text theme={null}
accountsById[accountId] = { rootOwner, rootId: rootAccountId, subaccountSeq }
if subaccountSeq == 0:
    rootAccountIdOf[rootOwner] = accountId
else:
    subaccountsByRoot[rootAccountId].push(accountId)
```

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:

```text theme={null}
signers[rootId][signer] = { permissions, expiry }
authorizationEpoch[rootId] = uint256
```

`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:

```text theme={null}
spotTokens[token] = { decimals, enabled }
spotRouter = address
spotMarkets[orderBook] = { baseToken, quoteToken, verified, sizePrecision, pricePrecision, minQuoteNotional, tickSize, passiveSpreadTicks, quoteTokenDecimals, takerFeePps, makerFeePps }
```

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:

```text theme={null}
spotMarkets[orderBook].verified = true
spotMarkets[orderBook].baseToken / quoteToken = pair
```

### Free And Reserved Balances

Maintain two balances per `(accountId, token)`:

```text theme={null}
freeBalance[accountId][token]
reservedBalance[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:

```text theme={null}
Deposit:
    history only; the preceding SpotReserveUpdated snapshot is the balance

Withdrawal:
    history only; the preceding SpotReserveUpdated snapshot is the balance

InternalAccountTransfer:
    history only; the two preceding SpotReserveUpdated snapshots are the balances,
    one for fromAccountId and one for toAccountId

SpotReserveUpdated:
    accountId = event.userId
    freeBalance[accountId][token] = freeBalance from event
    reservedBalance[accountId][token] = reservedBalance from event
```

`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:

```text theme={null}
if isBuy:
    freeBalance[account][quoteToken] -= amountInUsed
    freeBalance[account][baseToken] += amountOut
else:
    freeBalance[account][baseToken] -= amountInUsed
    freeBalance[account][quoteToken] += amountOut
```

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:

```text theme={null}
maker ask filled:
    base event:  reserved base decreases
    quote event: free quote increases by quoteAmount - makerFeeQuote

maker bid filled:
    quote event: reserved quote decreases and any reserve dust returns to free quote
    base event:  free base increases by baseAmount
```

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:

```text theme={null}
builderApprovals[rootId][builder] =
    { rootAccount, maxFeePps, expiry, active }

builderReferralTiers[builder] =
    { makerFeePps, takerFeePps, expiry, active }

builderReferrals[rootId] =
    { rootAccount, builder, makerFeePps, takerFeePps, expiry, active }
```

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:

```text theme={null}
builderApprovals[rootUserId][builder] =
    { rootAccount, maxFeePps, expiry, active }
```

On referral tier update:

```text theme={null}
builderReferralTiers[builder] =
    { makerFeePps, takerFeePps, expiry, active }
```

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`):

```text theme={null}
register builder root if needed
builderId = rootAccountIdOf(builder)
balances[builderId][asset] += amount
emit SpotReserveUpdated, BuilderFeeAccrued
```

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:

```text theme={null}
balances[builderId][asset] -= amount
emit SpotReserveUpdated, Withdrawal
```

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:

```text theme={null}
taker buy: builder fee is paid in base
taker sell: builder fee is paid in quote
```

For `amount == 0`, the same asset rule applies for attribution even though the builder's free balance does not change.

### Fee Tiers

Maintain:

```text theme={null}
defaultSpotFees = { makerFeePps, takerFeePps, active }
accountFeeTier[rootId] = { makerFeePps, takerFeePps, active }
builderReferralTier[builder] = { makerFeePps, takerFeePps, expiry, active }
builderReferral[rootId] = { builder, makerFeePps, takerFeePps, expiry, active }
```

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:

```text theme={null}
currentEffective = marketFee
if account root tier active:
    currentEffective = min(currentEffective, account root tier maker/taker fee)
else if default spot fees active:
    currentEffective = min(currentEffective, default maker/taker fee)

if root builder referral live:
    currentEffective = min(currentEffective, referral maker/taker fee)

effectiveFee = currentEffective
```

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

| End state | Test |
| - | - |
| Post-only would cross: no trade, sentinel on slot `0xff` | `test_PostOnly_WouldCross_EmitsPostOnlyNotPlacedSentinel` |
| Post-only does not cross: no trade, live `RESTED` | `test_PostOnly_NoCross_RestsTaggedLive` |
| `FILLED`: tagged `MATCH_END`, no book record | `test_Gtc_FullFill_TagsFilledOnMatchEnd`, `test_Ioc_FullFill_TagsFilled`, `test_Fok_FullFill_TagsFilled` |
| `DUST_REMAINDER_DISCARDED`: tagged `MATCH_END`, no book record | `test_Gtc_FillThenDust_TagsDustRemainderDiscarded`, `test_Ioc_FillThenDust_TagsDustRemainderDiscarded`, `test_Fok_FillThenDust_TagsDustRemainderDiscarded` |
| FOK with leftover: the call reverts | `test_Fok_ExecutableLeftover_RevertsWholeCall` |
| `IOC_PARTIAL`: tagged `MATCH_END`, no book record | `test_Ioc_PartialFill_TagsIocPartial` |
| `IOC_UNFILLED`: no trade, sentinel on slot `0xff` | `test_Ioc_LimitDoesNotReachBest_EmitsIocUnfilledSentinel`, `test_Ioc_EmptyOppositeSide_EmitsIocUnfilledSentinel`, `test_Ioc_SizeNotExecutableAtBest_EmitsIocUnfilledSentinel` |
| GTC rests, with a fill and without one | `test_Gtc_PartialFillThenRest_TagsTradeAndLiveRested`, `test_Gtc_NoCross_RestsTaggedLive` |
| GTC filled, leftover still crosses: tagged `MATCH_END`, no book record | `test_Gtc_PartialFillStillCrossing_TagsTradeCompletedNoRest` |
| GTC filled nothing and still crosses: no trade, sentinel on slot `0xff` | `test_Gtc_ZeroFillStillCrossing_EmitsZeroFillCrossSentinel` |

### Packed replace

| End state | Live slot | Empty slot |
| - | - | - |
| `CANCELLED` / `CANCEL_NOOP` | `test_Replace_CancelOnlyLiveSlot_TagsCancelledDelete` | `test_Replace_CancelOnlyEmptySlot_EmitsCancelNoopSentinel` |
| Does not cross, `RESTED` | `test_Replace_NonCrossingLiveSlot_UntaggedDeleteThenTaggedLive` | `test_Replace_NonCrossingEmptySlot_TaggedLiveOnly` |
| ALO still crosses | `test_Replace_AloStillCrossingOnLiveSlot_TagsOldDeletePostOnlyNotPlaced` | `test_Replace_AloCrossOnEmptySlot_EmitsPostOnlyNotPlacedSentinel` |
| `FILLED` | `test_Replace_FullFill_TagsFilledWithTargetSlot` | `test_Replace_FullFillOnEmptySlot_TagsFilledWithoutBookRecord` |
| `DUST_REMAINDER_DISCARDED` | `test_Replace_FillThenDustOnLiveSlot_TagsDustAndUntaggedOldDelete` | `test_Replace_FillThenDust_TagsDustRemainderDiscarded` |
| `RESTED` after a fill | `test_Replace_PartialFillThenRest_TagsTradeAndLiveRested` | `test_Replace_PartialFillThenRestOnEmptySlot_TagsTradeAndLiveRested` |
| `RESTED` after matching with no fill | `test_Replace_NoFillAfterMatchingThenRestOnLiveSlot_UntaggedDeletesThenTaggedLive` | `test_Replace_CrossingOnlyItsOwnOldOrder_UntaggedDeleteThenTaggedLive` |
| `COMPLETED_NO_REST` with fills | `test_Replace_PartialFillStillCrossing_TagsTradeCompletedNoRest` | `test_Replace_PartialFillStillCrossingOnEmptySlot_TagsTradeWithoutBookRecord` |
| Zero-fill cross | `test_Replace_ZeroFillCrossOnLiveSlot_TagsOldDeleteZeroFillCross` | `test_Replace_ZeroFillCrossOnEmptySlot_EmitsZeroFillCrossSentinel` |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.