Skip to main content

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 callee
  • send() — asynchronous, directed, cross-block delivery; point-to-point
  • defer transaction — future block or explicit dispatch; delayed execution
  • timer — system-scheduled time-based firing
What’s missing is a primitive that is multi-subscriber, fired synchronously inside the same tx, with subscriber failure isolated from the emitter — the equivalent of EVM’s try { external.call(...) } catch { ... } pattern.

1.2 Flagship Use Case: Pre-liquidation

A typical cascade in a DeFi lending protocol:
  1. User A locks ETH as collateral, borrows stablecoin
  2. An on-chain swap pushes ETH below A’s liquidation threshold
  3. Liquidation logic fires, A’s collateral enters the liquidation flow
  4. Liquidity providers B/C/D had previously subscribed to “A enters liquidation”
  5. 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
This cannot be done natively on Ethereum: B/C/D have to run off-chain bots watching mempool / pending logs and compete via MEV. This is a clean differentiation we can claim: subscription and reaction as a same-tx synchronous on-chain primitive. Other use cases in the same shape:
  • 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): unsubscribe_event and force_unsubscribe_event delete the record and index entry and compute the residual gas_remaining, but the residual-gas and cell refunds are currently accounting-only — they are recorded as cip29.unsubscribe / cip29.force_unsubscribe events for off-chain trackers rather than credited on-chain. On-chain crediting of the refund lands in a later phase (Phase 1 plan T10). bid and SUBSCRIPTION_REGISTRATION_FEE are burned at subscribe time and are never refunded, matching the tables below. The auto-expiry (zombie-reap) path differs: on emit, a sub whose gas_remaining < MIN_FIRE_COST is silently reaped (record + index entry deleted) with no cell refund and no cip29.* accounting event — the “freed cells credited to the subscriber” behaviour of §2.3 is not yet implemented for reap (only unsubscribe/force_unsubscribe emit the refund-obligation event).
Subscribers prepay a gas budget at registration time, stored in the subscription record (mirroring the timer pre-fund pattern), and may optionally attach a 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 block H+1 (riding on the existing defer subsystem; no new execution channel).
This forms a tiered same-tx synchronous + cross-block async execution model — the total subscriber cap is raised to 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 0x140x17 were already taken (CIP-26 Library 0x14 / ActorLibPin 0x15, TxReturnData 0x16, CIP-25 PublishRootDedup 0x17). The subscription registry must support two access paths:
  1. At emit time: (emitter, topic) → ordered list of subscribers (drives fire order)
  2. At subscribe / unsubscribe / gas debit time: sub_id → single subscription record (direct lookup, in-place updates to gas_remaining)

New StatePrefix values

Mirroring the existing Timer(0x05) + TimerIndex(0x06) two-prefix pattern, we add:
Subscription record (located directly by sub_id):
Subscription index (one lookup yields all subscribers for (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_bid reinserts 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 in state_root alongside Code / 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 for emit_event at the PVM host layer:
At block H+1 the async segment shares the same 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:
Bid handling inside subscribe_event and update_bid:
Key points:
  • Snapshot/rollback reuses the existing PVM mechanism (pvm/crates/vm/src/vm/snapshot.rs) — not built from scratch
    Two 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 an await/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_remaining and 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
  • defer reuse: the overflow segment rides directly on the existing defer transaction channel (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 bid field 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 unsubscribe or force_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.1 rt.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:
Form B: 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):
Decorators are SDK sugar — Form A, Form B, and the subscriber decorator all sit on top of the §2.1 host API. This means the host API and the decorator layer can be released independently: once the Phase 1/2 host API is in, business actors can write directly against 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 under 0x09 (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. 0x1D is outside the protocol-reserved system-actor band 0x01..=0x0F (enforced in pvm_host.rs against actor deploy and fee_payer_override). Calls to 0x1D are intercepted in pvm_host::call_actor and routed to execution::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 claimed 0x0A, which collides with STORAGE_MANAGER (CIP-9) in code; 0x1D is 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, the bid 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 by sub_id, decoupled from how the index is ordered
  • update_bid introduces “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: unsubscribe is callable at any rank, with no “must exit the bidding first” requirement — simplifies the user surface
  • Already-locked async emits: unsubscribe is still a single-point delete by sub_id; an in-flight system defer tx hitting H+1 will see the sub_id is gone and skip that slot (same path as zombie cleanup) — gas_remaining and cells have already been refunded at unsubscribe and are not double-debited

User perception / participation

Subscribers have full observability into the bidding market:
  1. Call get_topic_orderbook to see competitors’ bids and ranks
  2. Call get_min_bid_for_rank(target_rank=63) to see the threshold for entering the sync window
  3. Call update_bid to outbid into the sync segment, or stay with a low bid and accept the async segment’s H+1 timing
  4. Call get_rank to monitor rank movement
This forms a complete secondary market for event subscriptions — bid, query, raise, observe are all programmable primitives composable inside actor code; business actors can wrap automated bidding strategies at the contract layer.

3. Why This Design Is Safe

Event hooks are a derivative of call() (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
All three are storage + host API layer changes. None of them touches cross-block invariants.

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:
At block H+1 the protocol unpacks this, and each 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:
External light clients / indexers use triggered_by_emit to correlate the H+1 async receipt back to the original H-block emit. Key invariants:
  • triggered_by_emit is just a receipt field; it does not break the tx_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, emit has 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_emit field (§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_DEPTH decoupled from PVM max_call_depth (§2.5)
  • Gas top-up via rt.topup_subscription (§2.1)
The following still need to be aligned with stakeholders before implementation kicks off.

6.1 Initial Values for Protocol Constants

§2.5’s caps need business validation:
  • Does MAX_SUBSCRIBERS_PER_TOPIC = 512 cover the expected “long-tail notifications + top-tier reaction-window” split?
  • Does MAX_SYNC_FIRE_PER_TX = 256 cover typical multi-event cascades?
  • Is MIN_SUBSCRIPTION_GAS_PREPAID = 50,000 too high or too low?
  • Is ASYNC_FIRE_DEFERRAL_BLOCKS = 1 reasonable (does the business side accept a 1-block async delay, or would they prefer a configurable longer delay to amortize H+1 pressure)?
Governance can retune these, but the launch values shape early developer experience.

6.2 Decorator Naming

@emit / @on_event are direct. Alternatives:
  • @event / @subscribe
  • @publishes / @listens
  • Or a different shape that fits existing SDK conventions better
If you have a preference for the decorator surface, raise it before Phase 3 starts.

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
This lets the same event serve two business classes simultaneously:

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
Subscribers self-segment by business relevance; in most cases a single bucket has fewer than 64 subscribers and fires entirely in the sync segment. 2. Relay pattern (multi-tier fan-out) The emitter registers 64 “relay actors” as synchronous subscribers; each relay then maintains its own 64 synchronous subscribers:
  • 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)
3. Opt-in async (zero or low bid) Subscribers that don’t need same-tx reaction (notifications, analytics, slow-path alerts) can register with 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: