On-chain Event Hooks Proposal
1. Motivation
1.1 The Gap
In Ethereum, “events” are semantically just log writes — every actual subscribe-and-react flow lives off-chain (indexers, relayers, bots). This means any “on-chain reaction triggered by an on-chain event” requires at least one off-chain intermediary, which adds trust assumptions and latency. The primitives we already have:call()— synchronous, atomic, directed; the caller fully decides the calleesend()— asynchronous, directed, cross-block delivery; point-to-pointdefer transaction— future block or explicit dispatch; delayed executiontimer— system-scheduled time-based firing
try { external.call(...) } catch { ... } pattern.
1.2 Flagship Use Case: Pre-liquidation
A typical cascade in a DeFi lending protocol:- User A locks ETH as collateral, borrows stablecoin
- An on-chain swap pushes ETH below A’s liquidation threshold
- Liquidation logic fires, A’s collateral enters the liquidation flow
- Liquidity providers B/C/D had previously subscribed to “A enters liquidation”
- B/C/D must get a reaction window inside the same swap tx, before liquidation executes — otherwise a third-party liquidator front-runs them on the spread
- Oracle price update → multiple dependent contracts re-evaluate state in the same tx
- DAO vote passes → multiple execution modules apply the result synchronously
- NFT mint → multiple marketplace/index contracts reflect inventory in the same tx
1.3 Design Principles
- Same-tx, synchronous: hooks run to completion inside the emit call stack; control returns to the emitter only after every subscriber has run
- Failure isolation: a single subscriber’s failure (panic / OOG / revert) affects neither the emitter nor any other subscriber
- Reuse, don’t rebuild: ride on top of existing cross-actor
call(), PVM snapshot, the unified QMDB storage layer, and cycle/cell accounting - Don’t disturb cross-block invariants: propose/verify shared path,
tx_root↔ receipt 1:1 mapping, basefee feedback, admission gate, and lane isolation all remain untouched
2. Proposal
2.1 Core Abstraction
The protocol-level primitives:bid as a priority offer. When emit_event runs, the PVM host reads the subscription index in descending bid order:
- Top
MAX_SYNC_FIRES_PER_TOPIC(default 64): fire synchronously inside the emitter’s call stack, each subscriber wrapped in a snapshot — failure rolls back the subscriber’s state, preserves the emitter’s, and proceeds to the next subscriber. - Rank K+1 and beyond: automatically forked into a
defer transaction, fired in the same bid order at blockH+1(riding on the existing defer subsystem; no new execution channel).
MAX_SUBSCRIBERS_PER_TOPIC = 512; bidders willing to pay win the synchronous reaction window (e.g. liquidation front-run), while everyone else still receives the notification via H+1 async fire.
There are three exit paths, treated differently:
| Path | Triggered by | cycles (gas_remaining) | bid | cells |
|---|---|---|---|---|
Subscriber-initiated unsubscribe | The subscriber | Fully refunded; REGISTRATION_FEE not refunded | Not refunded (sunk) | Fully refunded to subscriber |
gas_remaining depleted (auto-expiry) | Protocol (lazy GC, see §2.3) | Nothing left to refund | Not refunded (already sunk) | Fully refunded to subscriber (not the reaper) |
| Emitter forced removal | The emitter contract | Fully refunded to subscriber; emitter receives no share | Not refunded; emitter receives no share | Fully refunded to subscriber; emitter receives no share |
REGISTRATION_FEE and bid) are sunk at the moment they are paid — the former suppresses register/unregister churn, the latter blocks the “buy a slot → immediately unsubscribe → free squat” attack; gas_remaining (the actual fire budget) is consumed as it is used and the residual is refundable; cells (storage) is recoverable occupation and is fully refunded to the subscriber on cleanup. The emitter pays no cells and receives no cycles under any path — EventSub / EventSubIndex live under their own prefix, fully decoupling subscription cost from the emitter, and bid flows into the burn pool rather than to the emitter (eliminating any way for the emitter to extract rent through the bidding market).
2.2 Data Model
Storage backbone
All chain state lives in a single QMDB instance under a unified 54-byte fixed-length key:[1B prefix][20B address][33B slot] (see storage/src/state_key.rs). Existing prefixes are allocated up to 0x13; the next available is 0x14.
The subscription registry must support two access paths:
- At emit time:
(emitter, topic) → ordered list of subscribers(drives fire order) - At subscribe / unsubscribe / gas debit time:
sub_id → single subscription record(direct lookup, in-place updates togas_remaining)
New StatePrefix values
Mirroring the existingTimer(0x05) + TimerIndex(0x06) two-prefix pattern, we add:
sub_id):
(emitter, topic), ordered for fire):
Design notes
- Deterministic order: the index value is sorted lexicographically by
(bid_inv, sub_height, sub_id)— drives fire order, identical across nodes, no consensus divergence; the bidding market gets price-based rank while determinism is preserved - Bids take effect on write:
update_bidreinserts into the index immediately; subscribers can bump bid at any time to claim a higher rank, and all nodes observe the change synchronously - Bounded emit-path cost: split into two layers (index + records) so a single index lookup doesn’t drag along every full record; emit performs
1 index lookup → take top K → N record lookups - Merkleization is free: QMDB merkleizes everything under
0x14*/0x15*automatically — the subscription state lands instate_rootalongsideCode/Actor - No emitter actor-storage quota consumption:
ActorKvCount/ActorKvBytes(0x12/0x13) only track KVs the emitter writes itself. Subscription records are protocol-level data and don’t pollute user-contract quotas
2.3 Execution Model
Pseudo-code foremit_event at the PVM host layer:
call_actor_with_isolated_gas + snapshot path as the sync segment; the only difference is that the entry point is a system defer tx, and each such tx produces an independent receipt carrying triggered_by_emit = EmitOrigin {..} — external light clients / indexers use this field to correlate H+1 async receipts back to the original H-block emit (see §3.2 for the receipt schema extension).
unsubscribe_event / force_unsubscribe_event share the following internal path:
subscribe_event and update_bid:
-
Snapshot/rollback reuses the existing PVM mechanism (
pvm/crates/vm/src/vm/snapshot.rs) — not built from scratchTwo distinct mechanisms — do not conflate (COW-1251): event-hook failure isolation is the snapshot/rollback mechanism described here — a per-subscriber state snapshot taken before the sub-call, rolled back if that subscriber panics / OOGs / reverts, so its writes are discarded while the emitter’s state is preserved. This is not the PVM continuation checkpoint (
__continuation:<cid>state, serialized to resume a handler across anawait/async boundary). Checkpoint = “save VM state so a suspended handler can continue later”; snapshot/rollback = “discard a failed sub-call’s effects.” A handler may use both, but they serve different purposes and have different lifetimes (a checkpoint persists across blocks; a snapshot lives only for the duration of one synchronous sub-call). -
Gas isolation: each subscriber executes within its own prepaid
gas_remainingand never touches the emitter’s cycles/cells. This is the precondition for failure isolation — otherwise a malicious subscriber could OOG the emitter’s tx by burning emitter gas -
call()reuse: the underlying call path is the existing cross-actor call; no new execution subsystem -
deferreuse: the overflow segment rides directly on the existingdefer transactionchannel (see §3.2) — no “event async lane” or other new execution channel introduced -
Deterministic order: the subscription index is lexicographically ordered by
(bid_inv, sub_height, sub_id)— every validator walks the sync segment in the same order, and the async segment is enqueued into the defer queue in the same order, ruling out consensus divergence -
Bid never flows to emitter: bid is burned immediately at subscribe / update_bid time; the
bidfield on the record is purely a sort signal — the emitter cannot collect any auction revenue, eliminating the attack surface where the emitter manipulates the subscription market for rent extraction - Lazy cleanup: zombie subscriptions are auto-reaped when the next emit’s sync segment encounters them, with no separate GC subsystem; async-segment zombies are likewise cleaned when the H+1 defer fires
-
Refund never flows to emitter: whether
unsubscribeorforce_unsubscribe, the residual gas / cells always go back to the subscriber’s account — preventing emitters from gaming “lure subscriber → force-remove → harvest”
2.4 Decorators and Explicit API (SDK)
The SDK offers two emit styles, both compiling down to the §2.1rt.emit_event host API:
Form A: @emit decorator (return-only, simple version)
Fits “notify on function return” semantics — bound to return, at most one event per function call:
ctx.emit explicit API (anywhere, any number of times, any branch)
Fits “procedural events” in complex business flows — fire at any point in the function body, inside branches, or repeatedly inside a loop:
ctx.emit calls rt.emit_event directly, so a single function can emit an arbitrary number of events (subject to the §2.5 MAX_EMITS_PER_TX total) and supports arbitrary control flow.
Subscriber-side decorator (including the bid parameter):
rt.emit_event / ctx.emit, and decorators ship later as a Phase 3 DX iteration.
2.5 Protocol Constants
To prevent fan-out attacks and consensus divergence, the following must be defined as protocol constants and applied identically across validators:| Constant | Suggested initial | Purpose |
|---|---|---|
MAX_SUBSCRIBERS_PER_TOPIC | 512 | Max subscribers per (emitter, topic), counting sync + async segments together |
MAX_SYNC_FIRES_PER_TOPIC | 64 (conservative launch value, governance-tunable; suggested ceiling 256) | Cap on subscribers fired synchronously inside the emitter call stack per emit (top K by bid). Constrained by lane cycle budget, validator latency, and snapshot/rollback attack surface; see §6.4 and ext_cip-29-sync-cap-analysis-en |
MAX_EMITS_PER_TX | 16 | Max emit_event calls inside a single tx |
MAX_SYNC_FIRE_PER_TX | 256 | Cap on total synchronous fires per tx (emits × sync_subs); async segment does not count |
MAX_EVENT_PAYLOAD_BYTES | 4,096 | Per-emit payload byte cap; exceeding fails the emit; payload is billed 1 cell/byte against the emitter |
ASYNC_FIRES_PER_DEFER_TX | 64 | Cap on async subs per system defer tx; overflow is split into multiple defer txs at this granularity; aligned with MAX_SYNC_FIRES_PER_TOPIC |
MAX_EVENT_DEPTH | 4 | Emit nesting depth (a handler invoked by an emit emits another topic) — independent from PVM max_call_depth = 32: K sibling handlers serialize their snapshot/rollback rather than nesting, so they do not consume call-depth slots |
EMIT_SAME_TOPIC_REENTRY | false | Same topic cannot be re-emitted inside its own emit call stack |
MIN_SUBSCRIPTION_GAS_PREPAID | 50,000 cycles | Floor on prepaid budget (blocks dust subscriptions spamming the registry) |
SUBSCRIPTION_REGISTRATION_FEE | 10,000 cycles | One-time write-compute charge at subscribe, not refunded; suppresses register/unregister churn |
SUBSCRIPTION_CELL_COST | Per actual storage delta (typical ≈ 9 cells) | Charged at subscribe based on the actual EventSub record + index-entry growth; fully refunded to the subscriber on unsubscribe / reap |
MIN_BID | 0 | Floor on bid — zero bids are allowed; zero-bid subs sort by sub_height time order, placed after every positive bid |
MIN_FIRE_COST | 5,000 cycles | At emit time, subscriptions whose gas_remaining falls below this are skipped and marked for zombie cleanup |
ASYNC_FIRE_DEFERRAL_BLOCKS | 1 | Block delay applied when forking overflow subscribers into a defer transaction (H+1) |
TimerConfig pattern and are governance-tunable. MAX_SYNC_FIRES_PER_TOPIC = 64 is a deliberately conservative launch value: under typical handler costs, it leaves the emitter ≥80% of its lane budget for its own logic; once testnet data on real handler-cost distributions is in, governance can raise the cap to 128 / 256 in steps (full analysis and the upgrade criteria are in §6.4 and ext_cip-29-sync-cap-analysis-en). 256 is a hard practical ceiling — beyond that the emitter tx loses the ability to do anything else.
2.6 Bidding System Actor
A dedicated system actor handles the subscription-bidding market’s query and write surface. A separate address was chosen rather than co-locating under0x09 (Governance): 0x09 already owns SettlementConfig and other governance duties, and bolting on the bidding market would bloat its responsibilities and conflict with the “system-level stable parameters” semantics of settlement — bidding is high-frequency user behavior and must stand alone.
Address rationale.0x1Dis outside the protocol-reserved system-actor band0x01..=0x0F(enforced inpvm_host.rsagainst actor deploy andfee_payer_override). Calls to0x1Dare intercepted inpvm_host::call_actorand routed toexecution::event_sub_system_actor::dispatch_rpc— no code-bearing actor exists at this address; the slot is a “virtual” system actor managed by the host. An earlier draft of this CIP claimed0x0A, which collides withSTORAGE_MANAGER(CIP-9) in code;0x1Dis the activated value.
Endpoints
| Method | Type | Semantics |
|---|---|---|
get_rank(sub_id) -> u32 | read | Returns this subscription’s current rank (0-indexed) for future emits; rank < MAX_SYNC_FIRES_PER_TOPIC means the next emit will fire it synchronously. Note: does not reflect async-fire positions already locked by an in-flight emit and queued to H+1 (see §2.3 “async segment ordering is locked”) |
get_topic_orderbook(emitter, topic, limit) -> Vec<OrderbookEntry> | read | Returns the top-limit (sub_id, subscriber, bid) tuples — the visible orderbook of the bidding market for the next emit |
get_min_bid_for_rank(emitter, topic, target_rank) -> u64 | read | Returns the minimum bid required to claim target_rank (= the current holder’s bid + 1); subscribers price their bumps against this |
update_bid(sub_id, additional_bid) -> u64 | write | Callable only by the subscriber; adds to bid (cannot decrease), burns immediately and reinserts into the index; returns the new cumulative bid. No effect on already-locked emits — the new bid only affects subsequent emits |
topup_subscription(sub_id, additional_gas) -> u64 | write | Any account may top up a subscription’s gas_remaining; returns the new balance |
update_bid is also exposed directly as a host API (see §2.1); calling it via the system actor is the SDK-friendly wrapper — the @on_event decorator’s runtime-upgrade API also routes through this path.
Compatibility check with existing unsubscribe / force_unsubscribe
With bidding introduced, the three exit paths from §2.1 remain fully compatible — bid is burned immediately at subscribe / update_bid, thebid field on the record is purely a sort signal, and the exit paths require no special handling for bid:
| Path | Interaction with bid |
|---|---|
Subscriber-initiated unsubscribe | bid already burned — not refunded; the subscriber’s financial commitment is identical to the “pre-bidding” version |
gas_remaining depleted (auto-expiry) | bid already burned — no action required; the reaper just cleans the index and the record |
| Emitter forced removal | bid already burned — the emitter gets no portion either; rules out “lure with high bid → forced eviction” arbitrage |
- Index sort-key change (
(sub_height, sub_id)→(bid_inv, sub_height, sub_id)): unsubscribe is still a single-point delete bysub_id, decoupled from how the index is ordered update_bidintroduces “mid-flight rank changes”: every validator reorders synchronously, determinism is preserved; subscribers can observe whether their rank has been pushed out of the sync window- Unsubscribe timing:
unsubscribeis callable at any rank, with no “must exit the bidding first” requirement — simplifies the user surface - Already-locked async emits:
unsubscribeis still a single-point delete bysub_id; an in-flight system defer tx hitting H+1 will see thesub_idis gone and skip that slot (same path as zombie cleanup) —gas_remainingand cells have already been refunded atunsubscribeand are not double-debited
User perception / participation
Subscribers have full observability into the bidding market:- Call
get_topic_orderbookto see competitors’ bids and ranks - Call
get_min_bid_for_rank(target_rank=63)to see the threshold for entering the sync window - Call
update_bidto outbid into the sync segment, or stay with a low bid and accept the async segment’s H+1 timing - Call
get_rankto monitor rank movement
3. Why This Design Is Safe
Event hooks are a derivative ofcall() (same tx, synchronous, state-isolatable) — they are not a derivative of send() or defer transaction. The protocol risk surfaces are categorically different.
| Subsystem | Same-tx hook (sync segment) | Cross-block hook (async segment) |
|---|---|---|
| propose / verify shared path | Unchanged — hooks run inside the user-tx execution stack; propose/verify still complete in a single pass | Unchanged — H+1 system defer txs travel the same path as any other defer tx |
tx_root ↔ receipt 1:1 mapping | Unchanged — sync fires are intra-tx cross-actor calls, they don’t produce independent receipts | Unchanged — each system defer tx produces its own receipt, belonging to the H+1 block’s tx_root |
| bridge / IBC inclusion proofs | Unchanged | Lightly extended — receipts gain a triggered_by_emit field marking cross-block causality (§3.2); the inclusion-proof structure is not affected |
| admission gate | Unchanged — every sync trigger lives inside a user tx that has already passed admission | Unchanged — system defer txs travel the same admission as any other defer tx; H+1 overflow naturally rolls forward |
| basefee feedback loop | Unchanged — hook gas comes from the subscriber prepaid pool, but does count toward the current block’s block-level cycle consumption (CIP-3 §2.4), feeding the basefee loop | Unchanged — async-segment cycles count toward H+1 block consumption |
| Lane budgets (User / Runner / Timer / System) | Hook gas comes from the subscription prepay pool but consumes User-lane budget | System defer txs travel the System lane (same column as any defer tx) |
- §2.2 — the subscription registry (a new merkleized table)
- §2.3 — the emit / snapshot / rollback call semantics
- §2.5 — the protocol constants
3.1 Reuse Map
| Existing mechanism | Role in this proposal |
|---|---|
PVM snapshot.rs | Subscriber failure rollback (direct reuse) |
Cross-actor call() | Underlying call path for hook fires (shared between sync and async segments) |
| Single-QMDB + StateKey encoding | The new EventSub / EventSubIndex prefixes plug straight into the existing QMDB (see §2.2) |
| Timer pre-fund pattern | Reference implementation for subscription gas prepay |
| Cycle / cell accounting | Hook execution accounting (drawn from the subscriber prepaid pool) |
defer transaction subsystem | Execution channel for async-segment (rank ≥ K) overflow subscribers — no “event async lane” added |
| EIP-1559 basefee burn channel | Sink for both bid and REGISTRATION_FEE; shares the basefee burn path so no new burn subsystem is needed |
3.2 Relationship to defer transaction
Event hooks (sync segment) and defer transaction remain parallel primitives, but the async segment (rank ≥ MAX_SYNC_FIRES_PER_TOPIC) reuses defer transaction as its execution channel — yielding a coordinated sync + async structure:
| Dimension | defer transaction (standalone use case) | event hook sync segment (rank < K) | event hook async segment (rank ≥ K) |
|---|---|---|---|
| Execution block | future block | current block, current tx | H+1 block (driven by ASYNC_FIRE_DEFERRAL_BLOCKS) |
| Trigger mechanism | height-triggered or explicit dispatch | inside the emit call stack | host auto-enqueues a system defer tx at emit time |
| Failure semantics | independent receipt, independent commit | snapshot rollback, no receipt | independent receipt (same as any defer tx) |
| Fit | user-scheduled async tasks, scheduled work | synchronous event subscription (e.g. liquidation front-run requires same tx) | notifications, analytics, slow-path alerts that tolerate 1-block latency |
| Billing source | scheduler | subscriber’s gas_remaining | subscriber’s gas_remaining |
defer transaction implementation: a single new trigger source (the system defer tx enqueued from inside emit_event) is added; all other scheduling / admission / execution paths are unchanged. The system defer tx carries an EmitOrigin tuple:
sub_id travels the same call_actor_with_isolated_gas + snapshot path as the sync segment.
Receipt schema extension: every receipt belonging to a system defer tx born from an emit’s async segment must carry:
triggered_by_emit to correlate the H+1 async receipt back to the original H-block emit. Key invariants:
triggered_by_emitis just a receipt field; it does not break thetx_root↔ receipt 1:1 mapping- The inclusion-proof structure is unchanged (an H+1 receipt still belongs to the H+1 block’s
tx_root) - The correlation is one-way and observable (receipt → original emit); no back-pointer needs to live in the H-block
tx_root
4. Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Breadth fan-out attack: a malicious emitter accumulates many subscribers → a single emit slows the tx dramatically | §2.5’s MAX_SYNC_FIRES_PER_TOPIC + MAX_SYNC_FIRE_PER_TX double cap pins the sync-segment cost; the overflow segment rides defer and is throttled a second time by the admission gate |
| Reentry loop: a handler re-emits forming an A→B→A cycle | MAX_EVENT_DEPTH = 4 + EMIT_SAME_TOPIC_REENTRY = false |
| Subscription spam: an attacker registers vast numbers of dust subscriptions to bloat an emitter’s index list | MIN_SUBSCRIPTION_GAS_PREPAID floor (every subscription must carry a minimum prepay) + MAX_SUBSCRIBERS_PER_TOPIC = 512 index-list cap; subscription records live under their own prefix and don’t consume the emitter’s actor-storage quota; bidding naturally suppresses spam — sync-segment slots are protected by bid ranking, and spam subscriptions can only land in the tail of the async segment with no impact on the head |
Unclear gas responsibility: who pays for the registry traversal and snapshot overhead inside emit_event? | The emit call itself is metered as “registry read + N × snapshot” and charged to the emitter; per-subscriber fire cost is drawn from the subscriber’s prepaid pool — the boundary is explicit |
| Subscription order manipulation: gaining fire priority through non-market means | Order is pinned to lexicographic (bid_inv, sub_height, sub_id), publicly observable; subscribers gain sync-segment rank by bidding (same logic as an auction), and ties fall back to time order — a market-driven primary axis with a deterministic tiebreak |
| Silent failure semantics | EmitResult exposes per-subscriber outcomes for explicit emitter-side checks; the receipt’s events field records every fire result (success / failure + gas) — observable and auditable |
| Invisible subscription expiry | The registry exposes a query API; subscribers can read their own gas_remaining and top up proactively |
register/unregister churn: rapid subscribe + immediate unsubscribe to thrash storage | SUBSCRIPTION_REGISTRATION_FEE is charged at subscribe and not refunded on unsubscribe — the storage-write cost is collected up-front, so the more the attacker churns, the more they lose |
| Emitter harvesting gas via forced removal | force_unsubscribe_event hard-codes that both gas_remaining and freed cells go back to the subscriber; the emitter receives no portion of either |
| Emitter forced to absorb subscriber cells → economic spam | EventSub / EventSubIndex live under their own prefix and never count against the emitter’s ActorKvCount / ActorKvBytes quota; cells are fully borne by the subscriber — the core advantage of a protocol-level primitive over an “emitter-built subscription table” approach |
| Snap-buy rank → unsubscribe arbitrage: an attacker bids high to claim a sync-window slot, then unsubscribes moments before the emit to claw back the money | bid is burned immediately at subscribe / update_bid (same channel as EIP-1559 basefee); the bid field on the record is purely a sort signal, and unsubscribe / force_unsubscribe always treat bid as non-refundable → snap-buy is a sunk cost, the arbitrage doesn’t work |
| Overflow subscribers silently delayed: rank ≥ K subscribers fire one block later and don’t know they fell into the async segment | (1) EmitResult.deferred_subs is observable to the emitter / caller during the sync segment; (2) EVENT_SUBSCRIPTION_SYSTEM_ACTOR.get_rank(sub_id) can be queried at any time; (3) subscribers can update_bid to climb back into the sync segment → falling behind is an observable, intervenable state, not an implicit failure |
| Emitter manipulating the bidding market for rent: emitter controls the subscription market to extract auction revenue | Bid flows directly into the burn pool — the emitter gets no share; the emitter has no update_bid privilege over subscribers → no auction revenue leaks out, no counterparty for the emitter to manipulate |
| Bid inflation / cycle deflation imbalance: heavy bidding burns excessive cycles and warps token economics | Bid uses the same burn channel as the EIP-1559 basefee and is captured by existing burn dashboards; governance can adjust MIN_BID and update_bid’s minimum step to dampen if needed |
| Oversized payload attack: emitter sends a 1MB payload so every sync sub receives a data copy, blowing the lane | MAX_EVENT_PAYLOAD_BYTES = 4096 hard cap; oversize emits fail; payload is billed 1 cell/byte against the emitter, forcing the emitter to bear the propagation cost |
Post-emit bid bump to cut in line on the current async segment: subscriber calls update_bid between emit and H+1 fire, hoping to leapfrog within the in-flight async segment | The async-segment ordering is locked at emit time (§2.3) — the new bid only affects subsequent emits; the current async segment’s fire order is fixed and every validator executes in that locked order |
| Async-segment OOM accumulation: a single emit produces 448 overflow subs × N emits in the same block → an H+1 fire flood | The async segment is split into multiple defer txs at ASYNC_FIRES_PER_DEFER_TX = 64; whatever overflows the H+1 block budget naturally rolls forward to H+2 / H+3 via the existing defer admission gate — no new “event queue explosion” path is introduced |
| Emit-call nesting depletes the stack: handlers invoked inside an emit perform cross-actor calls, repeated nesting depletes the PVM call stack | MAX_EVENT_DEPTH = 4 and PVM max_call_depth = 32 are independent counters; K sibling handlers execute serially without nesting; worst case 4 × 8 = 32 still sits within the PVM bound |
5. Rollout Plan
5.1 Phased Delivery
| Phase | Deliverable | Effort | Validation |
|---|---|---|---|
| Phase 0 — Pure SDK prototype | Actor library ships an @hookable decorator backed by existing call() + actor-side try/except, with a subscription table stored in the emitter’s own actor storage | 1–2 weeks | Run a real liquidation flow against it; if business-side requirements are satisfied, Phase 1+ can be deferred or scoped down |
Phase 1 — Protocol-level subscription registry + subscribe_event host API | Global registry storage model, register/unregister host APIs, merkleized subscription records | 4–6 weeks | Testnet actors can register subscriptions; the registry is queryable from contract code |
Phase 2 — emit_event host API + snapshot/rollback integration | emit_event implementation, PVM snapshot + traversal, failure-isolation tests | 4–6 weeks | End-to-end: emitter → fire multiple subscribers → one subscriber fails without affecting the rest |
| Phase 3 — SDK decorators + docs + sample app | Python @emit / @on_event decorators, liquidation demo, developer docs | 2–3 weeks | Decorator-based liquidation scenario runs end-to-end |
5.2 Why This Order
- Phase 0 is the cheap probe. Many event-subscription scenarios may already be expressible as “subscriber registers with emitter + emitter explicitly calls subscribers” entirely in SDK. Phase 0 establishes whether that’s true at minimum cost — saving us from finishing Phase 1+ only to discover the protocol-level support wasn’t load-bearing.
- Phase 1 must precede Phase 2. Without a registry,
emithas nothing to read. - Phase 3 is DX sugar. The decorator layer is purely SDK-level; the protocol works without it. Developers can use the host API directly while the decorator API iterates.
6. Open Decisions
The P0 items (must be settled before implementation starts) are already addressed in §2.1 / §2.3 / §2.5 / §2.6 / §3.2:- Async-segment ordering locked at emit time (§2.3)
- Receipt causality via the
triggered_by_emitfield (§3.2) - Defer-tx split via
ASYNC_FIRES_PER_DEFER_TX = 64(§2.5) - Payload cap
MAX_EVENT_PAYLOAD_BYTES = 4096, billed 1 cell/byte to the emitter (§2.5) - Independent depth counter:
MAX_EVENT_DEPTHdecoupled from PVMmax_call_depth(§2.5) - Gas top-up via
rt.topup_subscription(§2.1)
6.1 Initial Values for Protocol Constants
§2.5’s caps need business validation:- Does
MAX_SUBSCRIBERS_PER_TOPIC = 512cover the expected “long-tail notifications + top-tier reaction-window” split? - Does
MAX_SYNC_FIRE_PER_TX = 256cover typical multi-event cascades? - Is
MIN_SUBSCRIPTION_GAS_PREPAID = 50,000too high or too low? - Is
ASYNC_FIRE_DEFERRAL_BLOCKS = 1reasonable (does the business side accept a 1-block async delay, or would they prefer a configurable longer delay to amortize H+1 pressure)?
6.2 Decorator Naming
@emit / @on_event are direct. Alternatives:
@event/@subscribe@publishes/@listens- Or a different shape that fits existing SDK conventions better
6.3 P1 / P2 / P3 Backlog
Each of the following must be decided before its corresponding Phase’s spec is frozen. Short summaries below; detailed analysis lives in follow-up extension documents.P1 (implementation-complexity impact)
| # | Topic | Lean |
|---|---|---|
| P1-A | SubscriptionId generation rule (counter / hash / random?) | Lean toward keccak256(emitter ‖ subscriber ‖ topic ‖ sub_height)[..33] — deterministic, collision-resistant, easy to cross-check between nodes |
| P1-B | MIN_BID_STEP (prevents 1-cycle micro-bump spam) + MAX_CUMULATIVE_BID (prevents u64 overflow) | Lean toward MIN_BID_STEP = 1000, MAX_CUMULATIVE_BID = u64::MAX / 2 as a soft ceiling |
| P1-C | force_unsubscribe_event trigger constraints (may the emitter call it arbitrarily?) | Lean toward “arbitrary calls are legal but emitters can make on-chain commitments to restrain themselves” — via an optional manifest-level “promises not to force-evict” flag |
| P1-D | Handler function signature standard (handler(payload) vs handler(emitter, topic, payload)) | Resolved (COW-1155): handler(self, payload) — matches the implementation (event_fire.rs invokes the handler with payload only). The emitter is delivered as ctx.sender (the subscriber sees sender = origin.emitter), and the topic is implicit in the subscription that bound the handler. A handler serving multiple topics distinguishes them via ctx.sender + the per-subscription handler_name, not via positional args. The subscriber-side example above (def on_liquidation(self, position_id)) already follows this form. |
| P1-E | Emitter actor upgrade / redeployment semantics for existing subscriptions | Co-defined with CIP-1; initial lean is “upgrade preserves subscriptions and the new code takes effect; deleting the actor counts as force_unsubscribe against every subscription” |
P2 (economic model / DX)
| # | Topic | Lean |
|---|---|---|
| P2-A | Bid price-discovery continuity (preventing bidding-race spikes) | Defer to testnet observation in Phase 0 / Phase 1; may introduce a second-price auction or smooth via MIN_BID_STEP |
| P2-B | Economic calibration of REGISTRATION_FEE = 10,000 cycles | Data-driven adjustment from testnet |
| P2-C | Phase 0 → Phase 1 data migration | Lean toward “no migration” — Phase 0 is the probe; when Phase 1 ships, every subscription re-registers |
| P2-D | Topic character rules (MAX_TOPIC_BYTES, UTF-8 requirement?) | Lean toward MAX_TOPIC_BYTES = 64, no UTF-8 requirement (binary topics allowed) |
| P2-E | update_bid batching across the same block (merge multiple bumps) | Lean toward “no batching, every call reorders immediately” — simple, observable; high-frequency reordering is naturally throttled by bid burn |
P3 (implementation / testing / cross-subsystem)
| # | Topic | Trigger window |
|---|---|---|
| P3-A | Snapshot subsystem “wide sibling” stress test | At Phase 2 kickoff |
| P3-B | CIP-3 basefee feedback coefficients under cap=128/256 | Before each Phase B/C upgrade |
| P3-C | Cross-topic MEV arbitrage modeling | After Phase 3 |
6.4 Fan-out Cap as a Design Boundary
MAX_SYNC_FIRES_PER_TOPIC = 64 is not a hardcoded physical ceiling; it is a conservative launch choice. The full argument lives in ext_cip-29-sync-cap-analysis-en; the executive summary is here.
Estimating the cost of a single fully-loaded synchronous emit against the 22M-cycle User-lane budget:
| Item | Per call | × 64 subs |
|---|---|---|
| Index read | ~1K | 1K |
EventSub record read | ~500 | 32K |
| snapshot creation | ~1K | 64K |
| Handler invocation overhead | ~5K | 320K |
| Handler business logic (typical) | ~50K | 3.2M |
| Gas-deduction write | ~500 | 32K |
| Total | ~3.6M cycles (≈ 16% of the lane) |
| Sync cap | Loaded cycles | Lane share | Budget left for emitter | Verdict |
|---|---|---|---|---|
| 64 (launch) | 3.6M | 16% | 18.4M | Ample, conservative |
| 128 | 7.2M | 33% | 14.8M | Still leaves the emitter substantial room |
| 256 (suggested practical ceiling) | 14.3M | 65% | 7.7M | Tight but workable |
| 500 | 28M | 127% | −6M, the tx is guaranteed OOG | Blows the protocol-level hard cap |
Sync segment vs defer segment: asymmetric marginal cost
| +1 sub in sync segment | +1 sub in async segment (defer) | |
|---|---|---|
| Whose budget | The emitter tx’s own User lane (22M) | H+1’s overall block budget |
| Validator latency | Serial execution on the propose/verify critical path | Independent tx, parallel with other user txs |
| Failure-blast surface | The emitter tx’s snapshot/rollback chain | Independent receipt, no cross-impact |
| Basefee feedback | Pushes the current block’s cycle feedback | Pushes H+1’s feedback, distributed |
Upgrade path (governance decision)
Once the conservative 64 launch is in, the cap can be raised in stages based on testnet data:| Stage | Trigger | Target cap |
|---|---|---|
| Phase A (launch) | — | 64 |
| Phase B | Testnet average handler cycles < 30K, and propose/verify p99 latency overhead < 50ms | 128 |
| Phase C | Phase B stable for 1 month with no snapshot-subsystem edge cases | 256 (practical ceiling) |
How the tiered model addresses “500+ subscribers”
Raising the sync cap directly is not viable, but §2.3’s tiered execution model still serves the theoretical “500+ subscribers” scenarios:MAX_SUBSCRIBERS_PER_TOPIC = 512(total registration cap)MAX_SYNC_FIRES_PER_TOPIC = 64(arithmetic hard cap on the sync segment)- Ranks 65–512 are auto-forked into a
defer transaction, which fires through the same path at H+1 - Ordering is by descending bid — those who can pay get the sync window (liquidation front-run), those who can’t or don’t care about timing accept a 1-block delay (notification), market-driven tiering
| Business class | rank | Latency | Typical use case |
|---|---|---|---|
| Liquidation front-run / price-update racing | 0 – 63 | Same-tx synchronous | Must be same-tx; high bid required to enter |
| Notification / analytics / slow path | 64 – 511 | H+1 async | Tolerates 1-block latency; zero-bid acceptable |
Business-side escape valves still useful
Even with tiering, three business-side splitting strategies remain valuable: 1. Topic bucketing Split one over-broad topic into multiple finer-grained topics; subscribers attach to the ones they care about:- Don’t:
emit("liquidation")← 8000 subscribers crammed into one topic will still pile up in the async segment even with tiering - Do:
emit(f"liquidation:tier_{tier}")by risk band,emit(f"liquidation:asset_{asset}")by collateral asset
- Tier 1 (emitter → relays) is a synchronous emit, preserving same-tx semantics
- Tier 2 (relay → end subscribers) is also a synchronous emit
- Capacity: 64 × 64 = 4096 end subscribers, all reachable same-tx synchronously (still bounded by the lane cycle budget; pair with topic bucketing to keep handler-logic cost low)
bid=0 deliberately — they fall into the async segment naturally and don’t contend with reaction-window racers.
Why protocol-level “expand the sync segment” paths are rejected
Several protocol-level alternatives were considered and all rejected:| Approach | Reason for rejection |
|---|---|
| Auto-split a single emit across multiple blocks (including the sync segment) | Breaks “same-tx synchronous” semantics — the very reason this primitive exists. The tiered model only async-izes the overflow; the sync segment keeps same-tx semantics |
Raise MAX_SYNC_FIRES_PER_TOPIC to 500+ | Under typical handler costs this blows past the User lane’s 22M cap (500 × 56K = 28M); even with ultra-cheap handlers, a single emit consumes a quarter of the block’s cycle target and basefee swings hard; validator serial-fire latency physically amplifies 8× |
Single-emit adaptive cap (dynamically split sync/async by total gas_remaining) | Subscribers can’t predict whether they’ll fire synchronously; consensus complexity grows; malicious subscribers can register dust gas_remaining to crowd into the sync segment |
| Index sharding (shard by hash) | 64 subs × 49 bytes ≈ 3.1 KB single blob — QMDB handles this trivially; sharding adds complexity to a non-problem |
| Round-robin sync fire (different batch of 64 per block) | Breaks the “high bid guarantees sync” contract — subscribers cannot rely on this primitive to lock the liquidation reaction window |

