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:Implementation status (Phase 1, devnet):Subscribers prepay a gas budget at registration time, stored in the subscription record (mirroring the timer pre-fund pattern), and may optionally attach aunsubscribe_eventandforce_unsubscribe_eventdelete the record and index entry and compute the residualgas_remaining, but the residual-gas and cell refunds are currently accounting-only — they are recorded ascip29.unsubscribe/cip29.force_unsubscribeevents for off-chain trackers rather than credited on-chain. On-chain crediting of the refund lands in a later phase (Phase 1 plan T10).bidandSUBSCRIPTION_REGISTRATION_FEEare burned at subscribe time and are never refunded, matching the tables below. The auto-expiry (zombie-reap) path differs: onemit, a sub whosegas_remaining < MIN_FIRE_COSTis silently reaped (record + index entry deleted) with no cell refund and nocip29.*accounting event — the “freed cells credited to the subscriber” behaviour of §2.3 is not yet implemented for reap (onlyunsubscribe/force_unsubscribeemit the refund-obligation event).
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:
Core asymmetric design: the cycles-class costs (
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). Prefixes are allocated up to 0x1D (CIP-4 §4.2); EventSub / EventSubIndex occupy 0x18 / 0x19 — the intervening slots 0x14–0x17 were already taken (CIP-26 Library 0x14 / ActorLibPin 0x15, TxReturnData 0x16, CIP-25 PublishRootDedup 0x17).
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
0x18*/0x19*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:
These mirror the existing
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
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:
Potential conflict points flagged during review (confirmed to be non-conflicts):
- 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.
The only protocol-level additions are:
- §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
No subsystem is built from scratch.
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:
Impact on the existing
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
5. Rollout Plan
5.1 Phased Delivery
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)
P2 (economic model / DX)
P3 (implementation / testing / cross-subsystem)
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:
Lane occupancy at different caps:
500 directly exceeds the User lane’s 22M cap under typical handler costs — this is the protocol-level hard cap and cannot be crossed. 256 is the practical edge where “the emitter tx can still do other things”; beyond that, the primitive degenerates into “exists only to emit.”
Sync segment vs defer segment: asymmetric marginal cost
This is why “overflow → defer” works but “unbounded sync segment” does not — the two classes of subs are not equivalent.
Upgrade path (governance decision)
Once the conservative 64 launch is in, the cap can be raised in stages based on testnet data:
Why 256 is the stopping point: any further raise pushes a single emit’s lane share past 65%, the emitter loses meaningful capacity for other work in the same tx, propose/verify serial latency amplifies more than 8× relative to 64, and consensus latency becomes sensitive.
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-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.

