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:
- Verify
parentHashagainst the last accepted block. - Decode transactions and logs, retaining raw input/data/topics.
- Apply logs in canonical order and append inverse mutations to a block-scoped undo journal.
- Commit the block hash and projection mutations atomically.
- On a parent mismatch, walk back to the common ancestor, apply undo journals in reverse, and replay the replacement branch.
- 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:
- Require the current slot identity to equal
makerOrderId, or raise a replay-gap alert. - Record the fill.
- Replace the slot's stored size with
updatedSize; subtract the old-to-new difference from L2. - Delete the slot when
updatedSize == 0. For Perp, decrement the Engine/account cross resting-order count exactly once when the deleted row's retainedplacementModeis 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 minSizeAfterBlockand big-endianuint32 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 == 0in 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: insertownerId, 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: subtractsharesBurned; delete at zero; record principal and realized fees. The precedingPassiveBandUpdatedis 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 noPassiveBandUpdated.
For exact fee-growth replay, process passive trade records in matching order:
- taker buys from passive ask: compute
askQuoteRawat execution price andprincipalQuoteat the band's midpoint reference price;feeQuote = askQuoteRaw - principalQuote; addfloor(feeQuote * 2^128 / totalShares)to quote fee growth; - taker sells to passive bid: compute execution
quoteRaw; computeprincipalBase = floor(floor(quoteRaw * pricePrecision / quoteScale) * sizePrecision / refPrice)at the midpoint reference price; letfeeBase = fillSize - principalBase; addfloor(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 crossslotIdxonPerpCrossPositionSlotUpdated(active=true), then letPerpIsolatedPositionCleareddelete only the source row.PerpPositionMigratedconfirms the completed move. - Cross to isolated: the leading
PerpIsolatedPositionUpdatedis the full destination snapshot. Install it, then delete the old cross slot onPerpCrossPositionSlotUpdated(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 destinationactive=trueuses the sameslotIdx; re-key the retained value to the destination after the paired mode events. Commit the group only when the finalPerpNamespaceTakenOver(...,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 schemaS1, setexpiresAt = block.timestamp + 172800, retain the timestamp used, and optionally confirm the omitted value withownershipHandoverExpiresAt(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 == 0meansrenounced). Do not clear candidates from the event. If calldata/trace provescompleteOwnershipHandover(p), delete onlyp; 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
unreconciledif transaction-level fallback is unavailable. - Treat AccountCore
PerpOrderBookUpdatedas the active settlement route. RouterPerpOrderBookRouteUpdatedconfirms discovery/history but must not authorize settlement by itself. IntentExecutedis an audit edge from signed intent to later orderbook action. The orderbook'sclientOrderIdis 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.TriggerFiredfollows successful orderbook execution. - Replace
PostFillHookUpdated,PostFillHookGasLimitUpdated,PostFillHookMinQuoteNotionalUpdated, and each Example hook setting from its exact catalog row. Use orderbookTradesPacked/BookUpdatesPacked—notSlotSync—as canonical order state.SlotSyncis 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.