Skip to content

Replay recipes

These reducers assume the emitter has already been classified and assigned the correct ABI version. They are deterministic from canonical logs plus the fallbacks explicitly named below.

1. Canonical ingestion and rollback

For each block:

  1. Verify parentHash against the last accepted block.
  2. Decode transactions and logs, retaining raw input/data/topics.
  3. Apply logs in canonical order and append inverse mutations to a block-scoped undo journal.
  4. Commit the block hash and projection mutations atomically.
  5. On a parent mismatch, walk back to the common ancestor, apply undo journals in reverse, and replay the replacement branch.
  6. Expose “latest” and “finalized” cursors separately. Finality depth is a deployment policy, not a contract constant.

The idempotency key for an ordinary log is (chainId, blockHash, transactionHash, logIndex). A packed record adds (recordType, recordIndex).

2. Decode packed trades

TradesPacked(uint40,bytes32,bytes) payload length must be nonzero and divisible by 64. For each record, read two big-endian 32-byte words:

word0:
  [255:216] makerAccountId uint40
  [215:208] makerSlot      uint8
  [207:200] flags          uint8
  [199:168] price          uint32
  [167:72]  fillSize       uint96
  [71:8]    makerOrderId   uint64
  [7:0]     reserved       uint8 (must be zero for this schema)

word1:
  [255:160] updatedSize    uint96
  [159:64]  reserved       uint96 (must be zero for this schema)
  [63:0]    tradeId        uint64

Common flag bit 0x01 means the maker is buying. Spot adds 0x02 for a passive-band fill. Perp adds 0x04 maker reduce-only and 0x08 taker reduce-only. Reject unknown nonzero reserved/flag bits for schema S1; preserve the raw record for future decoding.

The event's indexed accountId is the action/taker account, including the liquidation target for forced Perp execution. It is not the maker. The canonical trade key is (orderBook, tradeId).

After decoding all records in one payload, replace the orderbook's last-trade observation with:

priceX8  = floor(lastRecord.price * 100_000_000 / pricePrecision)
timestamp = uint32(containing block.timestamp)

This applies to active and Spot passive records. A transaction can contain multiple action payloads, so log order decides the transaction-final observation. A missing TradesPacked leaves the prior value unchanged.

For an active maker record:

  1. Require the current slot identity to equal makerOrderId, or raise a replay-gap alert.
  2. Record the fill.
  3. Replace the slot's stored size with updatedSize; subtract the old-to-new difference from L2.
  4. Delete the slot when updatedSize == 0. For Perp, decrement the Engine/account cross resting-order count exactly once when the deleted row's retained placementMode is CROSS.

Do not infer the liquidity delta from fillSize: the fill can also clear an unfilled dust residual.

For a Spot passive record, makerAccountId == 0, slot 0 means passive bid and slot 1 passive ask, and makerOrderId encodes the band's lowPrice. Update trade history but take aggregate band state from PassiveBandUpdated; never create an AccountCore account 0 or an active slot.

3. Decode packed book updates

BookUpdatesPacked has the same topic on Spot and Perp. Select the product and schema from the registered emitter, not from the payload length alone. For schema S1, reject an empty payload; the byte length must be divisible by 36 for Spot or 40 for Perp. A length valid for the other product is still invalid for the selected emitter.

The shared 32-byte big-endian header is:

[255:216] makerAccountId uint40
[215:208] makerSlot      uint8
[207:200] flags          uint8
[199:168] price          uint32
[167:72]  size           uint96
[71:8]    orderId        uint64
[7:0]     reserved       uint8 (must be zero)

Flag 0x01 is buy and 0x80 is live after the action. Perp also uses 0x04 reduce-only.

  • Spot records are 36 bytes: header followed by big-endian uint32 minSizeAfterBlock.
  • Perp records are 40 bytes: header followed by big-endian uint32 minSizeAfterBlock and big-endian uint32 makerFeePps.

For a live record, replace (book,maker,slot) with the full record. Require slot < 62; order ID is the generation identity and a replacement always loses prior FIFO priority. For a non-live record, delete only if the current order ID matches. Delete records set minSizeAfterBlock to zero; their price/size/side/order ID describe the removed order.

For a newly live Perp order ID, also persist placementMode. Resolve it from the order-action calldata/config and the preceding PerpOrderExposureUpdated(...,mode,...) records; a transaction- boundary getPositionMode(accountId,marketId) is only a fallback. Preserve the stored mode when a fill updates the same order ID. On any identity-matched delete, decrement cross resting-order count only when that pre-delete row says CROSS. A final cancellation/fill can clear Engine position mode before BookUpdatesPacked, so consulting current mode at this point is incorrect. If a prior TradesPacked.updatedSize == 0 already removed the identity, the later non-live record is a no-op and must not decrement again.

For Spot, also maintain per-book reserve totals from active slots: an ask reserves its stored base size. A bid reserves quote + floor(quote * makerFeePps / 10_000_000), where quote = floor(floor(price * size / sizePrecision) * quoteScale / pricePrecision). AccountCore's SpotReserveUpdated remains authoritative for the aggregate per-token reserve. At checkpoints, compare the derived per-book pair with makerLockedReserves(accountId); never split an aggregate AccountCore reserve across books heuristically.

Perp's 40-byte record carries the maker-fee PPS snapshot. Spot's 36-byte record does not. For each new Spot live order, derive and store its placement-time maker PPS exactly as AccountCore does: choose the active root account tier, else the active default tier, else the market maker PPS; cap that result by the market maker PPS; then cap it by a live root builder referral if one exists. Evaluate expiries at that log's block timestamp. A mid-history or policy-ambiguous placement requires effectiveSpotMakerFeePps(accountId,marketMakerFeePps) at the exact transaction boundary; a block-end call is insufficient if policy and placement changed in separate transactions within that block. Balance replay remains exact from SpotReserveUpdated even when historical fee attribution is unavailable.

Full fills normally delete through TradesPacked.updatedSize == 0 without an additional non-live record. The two dust paths differ:

  • Spot stale-visible dust frees the slot during matching and writes updatedSize == 0 in the trade record. When the discarded visible residual is nonzero, a later non-live 36-byte update describes that residual but is an identity-checked no-op because the trade already removed the slot.
  • Perp can write the nonzero visible residual to TradesPacked.updatedSize, then emit a non-live 40-byte update that deletes that still-projected residual. Apply both records in order.

Hidden stale excess discarded by matching has no separate record. Identity checks prevent either path from subtracting L2 or cross-order count twice.

4. Build stored and executable L2

Maintain L2 incrementally from L3 slot replacement/deletion. For Spot, add passive band depth:

passive ask price = lowPrice + passiveSpreadTicks * tickSize
passive ask size  = conservativeAsk(baseAtHigh, lowPrice, highPrice)
passive bid price = lowPrice
passive bid size  = conservativeBase(quoteAtLow, lowPrice)

For ABI schema S1, reproduce the contract's two separately rounded divisions exactly:

priceSizeUnits = floor(quoteAtLow * pricePrecision / quoteScale)
rawBidSize     = floor(priceSizeUnits * sizePrecision / lowPrice)
passiveBidSize = min(rawBidSize, 2^96 - 1)
visibleBidSize = passiveBidSize when
                 floor(floor(lowPrice * passiveBidSize / sizePrecision)
                       * quoteScale / pricePrecision) != 0;
                 otherwise 0

If quoteAtLow == 0 or lowPrice == 0, the visible bid size is zero. Do not combine the two divisions algebraically: the intermediate floor is observable. Passive queue markers are at the head of their price FIFO, so passive liquidity executes before active FIFO at the same price.

The ask leg is also clipped and dust-filtered. With refPrice = floor((lowPrice + highPrice) / 2):

rawAskSize     = min(baseAtHigh, 2^96 - 1)
visibleAskSize = rawAskSize when
                 floor(floor(refPrice * rawAskSize / sizePrecision)
                       * quoteScale / pricePrecision) != 0;
                 otherwise 0

If baseAtHigh == 0, the visible ask size is zero. At each price and side, report min(activeFifoVisibleSize + matchingPassiveSize, 2^96 - 1). Do not add the opposite passive leg that shares the same queue marker.

At query block B, an order is stale when:

minSizeAfterBlock != 0 && B > minSizeAfterBlock

Its executable size is then:

min(storedSize,
    ceil(ceil(minQuoteNotional * pricePrecision / quoteScale)
         * sizePrecision / price))

Otherwise executable size equals stored size. The threshold changes with block number and emits no log. Recompute affected levels at minSizeAfterBlock + 1.

Normalize empty BBO sentinels: bid type(uint32).max and ask 0 both mean absent. Fixed-length getL2Book(levels) arrays are zero-padded after returned levels.

5. Replay AccountCore identity and free/reserved balances

Apply identity events as follows:

AccountRegistered        INSERT account/address/root/owner/sequence
SubaccountCreated        INSERT root-child edge; increment subaccount address auth epoch
AccountSignerAuthorized  REPLACE signer permissions/expiry; increment account auth epoch
AccountSignerRevoked     DELETE signer; increment account auth epoch

AccountRegistered can precede SubaccountCreated in the same transaction. It can also occur implicitly on first deposit, builder approval, referral enrollment, fee-collector initialization, or fee-collector change.

For SpotReserveUpdated(accountId,token,free,reserved), set both fields exactly. Keep Deposit/Withdrawal only as activity; their paired snapshot is earlier in the same call.

InternalAccountTransfer(from,to,token,amount) has no snapshots:

free[from,token] -= amount
free[to,token]   += amount

AccountCore functions that reserve/release/settle Spot orders can emit several snapshots for one account/token. Apply all in order. Builder fee accrual is separate from free balance until claimed.

For Spot protocol fees, identify the fee collector from the manifest/current reconciled control state. Its SpotReserveUpdated snapshot is authoritative. There is no dedicated Spot protocol-fee event; if fee attribution is required, calculate maker/taker fees from each settlement using the snapshotted fee configuration and retain it as derived data.

Resolve Spot maker/taker PPS separately. For each side, select an active root account tier, else an active default tier, else the market PPS; cap by the market PPS; then cap by a live Spot builder referral. Preserve AccountCore settlement-call groups; a TradesPacked record is not necessarily a fee-rounding boundary:

active maker fee_i = floor(fillQuoteRaw_i * snapshottedMakerPps_i / 10_000_000)

active taker fee   = floor(sum(active fillQuoteRaw in this settle call)
                           * effectiveTakerPps / 10_000_000)

active builder fee = taker buys
                   ? floor(sum(active fillBaseRaw in this settle call) * builderPps / 10_000_000)
                   : floor(sum(active fillQuoteRaw in this settle call) * builderPps / 10_000_000)

Each passive fill invokes a separate settlement call, so its taker fee and base-or-quote builder fee are each floored independently from that one passive fill. Active fills may occur on both sides of a passive fill while remaining transiently aggregated until _settleMatch; conversely, one packed replacement/batch action may invoke _settleMatch more than once. Do not round each packed trade or the entire final packed payload.

Exact group boundaries require call traces or unambiguous AccountCore snapshot sequences. When they are unavailable, the fee collector's SpotReserveUpdated snapshots and each BuilderFeeAccrued event are authoritative; derived per-fill taker/builder attribution must be marked unavailable. The Spot fee grouping tests demonstrate the non-equivalent rounding boundaries.

For Perp, begin with the configured base maker/taker PPS, cap by an active root Perp account tier, then cap by a live Perp builder referral. The Perp book update stores each resting order's maker PPS; its order-level reserve is ceil(quoteRaw(price,remainingBaseLots) * makerPps / 10_000_000). A fill consumes reserve(before) - reserve(before - fillBase) and a dust/order removal additionally releases reserve(before - fillBase) - reserve(finalStoredBase). This difference-of-ceilings rule is not equivalent to independently rounding the fill notional. Realized maker fee attribution is authoritative from PerpFeeCharged(maker=true) and aggregate reserve snapshots. Perp taker fee is ceil(totalFillQuoteRaw * effectiveTakerPps / 10_000_000) per action and is reported by PerpFeeCharged(maker=false). A configured Perp builder fee independently uses ceil(totalFillQuoteRaw * builderFeePps / 10_000_000) and is authoritative in BuilderFeeAccrued; taker PPS plus builder PPS cannot exceed the protocol cap. PerpProtocolFeesAccrued aggregates maker/taker protocol fees and the following SpotReserveUpdated fixes the collector balance.

6. Replay passive liquidity

Maintain the band snapshot from every PassiveBandUpdated(lowPrice,baseAtHigh,quoteAtLow,totalShares). The band is empty/deleted when all three values are zero.

For positions:

  • PassiveLiquidityMinted: insert ownerId, lowPrice, shares; initialize fee-growth checkpoints to the band's values at this log position. Increase position shares only if a future schema permits adding to an existing ID; current IDs are newly allocated.
  • PassiveLiquidityBurned: subtract sharesBurned; delete at zero; record principal and realized fees. The preceding PassiveBandUpdated is emitted inside the burn before this burn summary.
  • PassiveFeesClaimed: record realized fees and advance the position checkpoints to current band fee growth. It intentionally has no PassiveBandUpdated.

For exact fee-growth replay, process passive trade records in matching order:

  • taker buys from passive ask: compute askQuoteRaw at execution price and principalQuote at the band's midpoint reference price; feeQuote = askQuoteRaw - principalQuote; add floor(feeQuote * 2^128 / totalShares) to quote fee growth;
  • taker sells to passive bid: compute execution quoteRaw; compute principalBase = floor(floor(quoteRaw * pricePrecision / quoteScale) * sizePrecision / refPrice) at the midpoint reference price; let feeBase = fillSize - principalBase; add floor(feeBase * 2^128 / totalShares) to base fee growth.

The midpoint reference price is floor((lowPrice + highPrice) / 2). Validate replay against getPassiveBand(lowPrice) and getPassivePosition(positionId) at checkpoints. Without genesis replay or those getters, exact claimable fees are not recoverable from PassiveBandUpdated alone.

7. Replay Perp margin, exposure, positions, and OI

Margin and maker-fee reserve

PerpMarginUpdated(accountId,marginMarketId,balance) and PerpMakerFeeReserveUpdated(accountId,marginMarketId,reserveAfter) are replacement snapshots. marginMarketId == 0 is cross margin for that emitter Engine. Treat PerpMarginTransferred as audit-only because its following snapshot already includes the delta.

Mode, slots, and exposure

PerpPositionModeUpdated replaces the per-market mode. PerpCrossPositionSlotUpdated(active=true) maps the market to a cross slot; active=false releases it. PerpOrderExposureUpdated replaces openBidLots, openAskLots, and reduceOnlyLots for the named position.

Maintain crossRestingOrderCount(accountId) from Perp packed slot liveness, counting a live order iff that order generation's retained placementMode is CROSS. A live-to-live replacement and a partial fill do not change the count; insertion increments and identity-matched deletion/full fill decrements. Forced cancellations use the same rule. Reconcile at checkpoints with getCrossRestingOrderCount(accountId) and read the initializer/getter-only maxCrossRestingOrders cap from the deployment manifest or Engine getter.

PerpIsolatedPositionUpdated is a full isolated snapshot. Decode position flags as documented in the overview. PerpIsolatedPositionCleared deletes it.

There is no cross snapshot. A genesis replay maintains cross position contents with the fill reducer below. For a mid-history start, enumerate active slots from the Engine's position count/slot mapping and call getCrossPosition(accountId,slotIdx); record the checkpoint block.

Exact threshold, health, and transfer projections

For ABI schema S1, use arbitrary-precision integers and preserve every intermediate round. Let PPS = 10_000_000, LEV = 1_000_000, and ceil(a/b) = floor((a+b-1)/b) for positive values:

Qdown(lots, price) = floor(floor(price * lots / sizePrecision)
                           * quoteScale / pricePrecision)

Qup(lots, price)   = ceil(ceil(price * lots / sizePrecision)
                          * quoteScale / pricePrecision)

positionMargin(lots) = 0, when lots == 0
                     = ceil(Qup(lots, markPrice) * LEV / maxInitialLeverageE6), otherwise

Do not replace either nested quote formula with one rational multiply/divide. For each active position, project both opening sides from its signed base:

signedBase = LONG ? +baseLots : -baseLots
bidAbs     = abs(signedBase + openBidLots)
askAbs     = abs(signedBase - openAskLots)

positionIM = positionMargin(baseLots)
bidIM      = positionMargin(bidAbs)
askIM      = positionMargin(askAbs)

bidIncrease = bidIM > positionIM
            ? ceil((bidIM - positionIM) * limitOrderRiskFactorPps / PPS) : 0
askIncrease = askIM > positionIM
            ? ceil((askIM - positionIM) * limitOrderRiskFactorPps / PPS) : 0

limitOrderMargin = max(bidIncrease, askIncrease)
initialMargin     = positionIM + limitOrderMargin

reduceOnlyLots is not added to either opening projection. For unrealized PnL:

markNotional = Qdown(baseLots, markPrice)
unrealized   = LONG ? markNotional - entryQuoteLots
                    : entryQuoteLots - markNotional

collateralPnl = unrealized > 0
              ? floor(unrealized * positivePnlRiskFactorPps / PPS)
              : unrealized

Recover signed funding values from their absolute field and negative flag:

delta = signedCumulativeFunding - signedPositionCheckpoint
receives = delta != 0 && ((delta > 0) == LONG)
fundingAbs = receives
           ? floor(abs(delta) * baseLots / sizePrecision)
           : ceil(abs(delta) * baseLots / sizePrecision)
unsettledFunding = delta == 0 ? 0 : (receives ? +fundingAbs : -fundingAbs)

For isolated state, apply these values once. For cross state, compute every market independently, then sum the already-rounded values:

thresholds.positionMargin  += positionIM
thresholds.limitOrderMargin += limitOrderMargin
thresholds.initialMargin   += initialMargin
thresholds.cancelMargin    += ceil(initialMargin * cancelOrderRiskFactorPps / PPS)
thresholds.maintenanceMargin += ceil(initialMargin * maintenanceRiskFactorPps / PPS)
thresholds.takeoverMargin  += ceil(initialMargin * takeoverRiskFactorPps / PPS)
thresholds.unrealizedPnl   += unrealized
thresholds.discountedPositiveUnrealizedPnl += max(collateralPnl, 0)
thresholds.unsettledFunding += unsettledFunding

effectiveCollateral = depositedCollateral + sum(collateralPnl) + sum(unsettledFunding)
health(requirement)  = effectiveCollateral - requirement

All collateral and requirement fields are raw quote-token units; effectiveCollateral, PnL, funding, and health are signed. Maker-fee reserve is reported separately because reservation has already removed it from deposited/free margin. The projected initial-health check applies to a risk-increasing order that may rest; a risk-increasing taker path returns before that precheck. After a non-reduce-only fill, or the final non-reduce-only order action when risk remains, the Engine instead requires transfer health: nonnegative effective collateral at least equal to the larger of initial margin and the 10% notional floor below. Liquidation start compares cancel health < 0; recovery/clear uses maintenance health >= 0; and takeover requires takeover health < 0.

Transfer/withdrawal checks reuse effective collateral but compute an additional floor:

initialMarginRequired = sum(per-market initialMargin)
totalPositionNotional = sum(per-market Qup(baseLots, markPrice))
notionalFloor         = ceil(totalPositionNotional * 1_000_000 / PPS)
transferRequired      = max(initialMarginRequired, notionalFloor)
excessEquity          = max(effectiveCollateral - transferRequired, 0)
withdrawableMargin    = min(depositedCollateral, excessEquity)

The Qup notionals are rounded per market before summation; only the 10% floor multiplication is performed once on the total. Transfer getters require for every active position a nonzero mark, nonzero staleness config/timestamp, and block.timestamp <= lastPriceUpdateTime + maxPriceStaleness. They do not enforce index/oracle divergence. Trading admission and liquidation additionally require nonzero index/oracle prices and, when maxOracleDivergencePps != 0:

ceil(abs(oraclePrice - indexPrice) * PPS / indexPrice) <= maxOracleDivergencePps

Raw cross/isolated threshold getters perform neither freshness nor divergence validation; attach the source block timestamp and independently label stale results. Risk-increasing resting admission and risk-increasing taker/relevant fill paths require sane prices. Exposure release and a reduce-only order that only reserves closing capacity skip that price gate. Liquidation start, partial execution, and takeover require sane prices; permissionless liquidation clear uses raw maintenance health without it.

Exact fill reducer

Maintain a replay position independent of snapshot presentation so OI is updated exactly once. For each PerpFillSettled, compute fillQuote using the market formula in the overview and apply:

oldBase = baseLotsBefore
oldEntry = entryQuoteLotsBefore
oldLong = (oldBase != 0 && position LONG flag is set)
oldSigned = oldBase == 0 ? 0 : (oldLong ? +oldBase : -oldBase)

if oldBase == 0:
    sideIsLong = isBuy
    baseLots = fillBase
    entryQuoteLots = fillQuote
else if isBuy == oldLong:
    baseLots += fillBase
    entryQuoteLots += fillQuote
else:
    closing = min(fillBase, oldBase)
    entryClosed = floor(oldEntry * closing / oldBase)
    quoteClosed = floor(fillQuote * closing / fillBase)
    if fillBase < oldBase:
        baseLots = oldBase - fillBase
        entryQuoteLots = oldEntry - entryClosed
        sideIsLong = oldLong
    else if fillBase == oldBase:
        baseLots = 0; entryQuoteLots = 0
        sideIsLong = false
    else:
        sideIsLong = isBuy
        baseLots = fillBase - closing
        entryQuoteLots = fillQuote - quoteClosed

newSigned = baseLots == 0 ? 0 : (sideIsLong ? +baseLots : -baseLots)

Reject an impossible trace if a reduce-only fill opens an empty position, increases the current side, or crosses through flat. A non-reduce-only side flip is valid only when the old position has no surviving reduce-only exposure.

Validate computed realized PnL against the event:

oldLong:   realized = quoteClosed - entryClosed
oldShort:  realized = entryClosed - quoteClosed

Before the position mutation, funding is settled implicitly. Apply any emitted margin snapshot and set the replay position's signed funding checkpoint to the market cumulative accumulator current at that log position. For isolated mode, the preceding PerpIsolatedPositionUpdated is the authoritative full snapshot; do not apply the fill a second time to the published position. Still run the independent reducer for validation and OI.

Update OI from signed-position magnitude change for each maker and taker PerpFillSettled:

longOI  += max(newSigned,0)  - max(oldSigned,0)
shortOI += max(-newSigned,0) - max(-oldSigned,0)

Migrations and takeovers transfer position ownership/mode but do not change aggregate OI. There is no OI event. Validate the derived totals against getMarket(marketId) at checkpoints.

Preserve full replay position contents across those lifecycle events; the cross-slot event carries only identity, not base, entry, flags, exposure, or funding checkpoint:

  • Isolated to cross: cache the current full isolated position before processing PerpPositionModeUpdated(ISOLATED,CROSS). Copy it into the newly active cross slotIdx on PerpCrossPositionSlotUpdated(active=true), then let PerpIsolatedPositionCleared delete only the source row. PerpPositionMigrated confirms the completed move.
  • Cross to isolated: the leading PerpIsolatedPositionUpdated is the full destination snapshot. Install it, then delete the old cross slot on PerpCrossPositionSlotUpdated(active=false); do not derive a second position mutation from the final migration summary.
  • Cross takeover: before each target active=false, retain that slot's complete replay position. The paired destination active=true uses the same slotIdx; re-key the retained value to the destination after the paired mode events. Commit the group only when the final PerpNamespaceTakenOver(...,mode=CROSS) is present. Never create an empty destination position.
  • Isolated takeover: delete the target only after installing the emitted full destination PerpIsolatedPositionUpdated; the namespace summary is confirmation, not another mutation.

If genesis replay or the pre-transaction checkpoint did not supply a source position, the omitted cross contents cannot be recovered from these logs after the move. Use a transaction pre-state trace or mark the position incomplete; a block-end getter sees only the destination state.

For every action containing fills, replace the market's derived lastTradePrice with the final fill price in matching order. There is no dedicated event for this field. If a consumer derives it from TradesPacked, use the last packed trade record from the Perp orderbook action; if it derives it from PerpFillSettled, remember that maker and taker settlement rows repeat one economic fill price. Validate through getMarket(marketId) and seed a mid-history start from that getter.

Funding

PerpFundingUpdated replaces the signed market accumulator and timestamp. A positive accumulator delta pays longs and charges shorts. For a position:

delta = currentAccumulator - positionCheckpoint
amountDown = floor(abs(delta) * baseLots / sizePrecision)  // receiving
amountUp   = ceil (abs(delta) * baseLots / sizePrecision)  // paying

PerpFundingSettled supplies the realized signed amount, post-state margin, and signed checkpoint; use them as authoritative. Fills can settle funding without emitting PerpFundingSettled, but the margin snapshot and current accumulator allow exact replay.

8. Replay liquidation

Key a lock by (engine, accountId, marginMarketId), where cross uses 0.

  • PerpLiquidationStarted(...,recovered=false): set locked.
  • PerpLiquidationStarted(...,recovered=true): record the attempt but leave unlocked; cancellation restored maintenance health before the event.
  • PerpLiquidationCleared: clear locked.
  • PerpNamespaceTakenOver: clear the target lock after applying the preceding mode/slot/position/ margin transfer events.
  • PerpPositionLiquidated: audit summary only. Position, margin, OI, fee, and lock projections were already mutated by preceding orderbook, Engine, and AccountCore events.

Forced cancellation packed deletes precede Engine exposure snapshots. Forced execution packed trades precede position settlement. Follow ordering.md, not the normal-action order.

9. Replay controls, routes, intents, and hooks

  • Ownership uses two independent projections:
  • OwnershipHandoverRequested(p) upserts only (chainId,emitter,p). In schema S1, set expiresAt = block.timestamp + 172800, retain the timestamp used, and optionally confirm the omitted value with ownershipHandoverExpiresAt(p).
  • OwnershipHandoverCanceled(p) deletes only that candidate. The function emits on a no-op cancellation, so deleting a missing row succeeds idempotently.
  • OwnershipTransferred(old,new) replaces only the owner (new == 0 means renounced). Do not clear candidates from the event. If calldata/trace proves completeOwnershipHandover(p), delete only p; direct transfer, initialization, and renounce preserve every request. If the call path is unavailable, retain candidates until candidate-scoped getters reconcile them, and mark the exact intra-block transition uncertain when only a block-end read exists.
  • A candidate is live exactly when expiresAt != 0 && currentTimestamp <= expiresAt. Expiry emits no event and does not zero the slot; keep the row and derive its active/expired status by time.
  • Replace authority pointers, authority roles, Router/Engine relations, market enablement, updater addresses, keeper memberships, token enablement, fee policies, and orderbook market state from their cataloged snapshot/lifecycle events.
  • For protocol pause and fee collector, decode successful AccountCore calldata and verify the getter at the transaction boundary. Mark the state unreconciled if transaction-level fallback is unavailable.
  • Treat AccountCore PerpOrderBookUpdated as the active settlement route. Router PerpOrderBookRouteUpdated confirms discovery/history but must not authorize settlement by itself.
  • IntentExecuted is an audit edge from signed intent to later orderbook action. The orderbook's clientOrderId is caller supplied and not globally unique; join by transaction plus fields.
  • Trigger lifecycle is NONE -> ACTIVE -> CANCELED|FIRED|EXPIRED. Expiry by wall clock alone does not mutate stored status. TriggerFired follows successful orderbook execution.
  • Replace PostFillHookUpdated, PostFillHookGasLimitUpdated, PostFillHookMinQuoteNotionalUpdated, and each Example hook setting from its exact catalog row. Use orderbook TradesPacked/BookUpdatesPacked—not SlotSync—as canonical order state. SlotSync is the example hook's own expected-ID cache.

10. Checkpoint reconciliation

At an explicit finalized block, compare:

  • AccountCore account/address/root maps and all hot balances touched since the prior checkpoint;
  • Router/Engine/orderbook relationships and ERC-1967 implementation slots;
  • each market's orderbook parameters, BBO, sampled L2, and live slot IDs;
  • passive bands/positions with nonzero shares;
  • Perp market OI/prices/funding/risk state, margin namespaces, modes, slots, positions, exposures, reserves, cross resting-order counts, derived health/threshold structures, and liquidation flags;
  • Spot aggregate reserves and each touched book/account pair through makerLockedReserves;
  • owners, every known ownership-handover candidate, authority pointers/roles, updater/keeper/router addresses, protocol pause, and fee collector. Re-read each candidate with ownershipHandoverExpiresAt; the getter is keyed but non-enumerable, so the candidate universe must come from the full log scan or the trusted checkpoint.

A mismatch is evidence of a replay gap, decoder-version error, or an eventless mutation. Do not silently overwrite history. Record the mismatch, pin a getter snapshot, repair from that checkpoint, and retain the provenance change.