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:
An explicit schema is a 32-byte identifier agreed by the emitter and subscriber. The host compares identifiers; it does not parse a schema or validate the payload bytes. An explicit emit declares the identifier for its (emitter, topic) on the event subscription index. An explicit subscription whose identifier differs from an existing declaration fails with HostError::EventSchemaMismatch (event_schema_mismatch, code 117) before registration fees or subscription writes. A subscription may precede the first declaration. When an explicit emit changes or first sets a declaration, the host checks every explicit subscriber identifier. If any differ, it fails with the same error before publishing the event or changing the declaration; otherwise it MUST write the new declaration. Emits and subscriptions without a schema retain opaque-payload behavior. The declaration survives removal of the last subscription; update_bid preserves each subscription’s identifier. Declaration and subscription changes follow transaction and nested-call rollback rules. Each new or changed declaration charges the emitter 32 storage cells for the identifier and uses the existing Access meter for index reads and writes. Schema identifiers must be exactly 32 bytes; malformed raw host arguments fail with InvalidInput (2), while the SDK rejects malformed values before the host call.
Implementation gap: 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 these refunds remains required work. 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 for subscription records or index entries and receives no cycles under any path; an explicit schema declaration separately charges the emitter 32 cells as stated above. 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). CIP-4 §4.2 is the authoritative prefix table; EventSub and EventSubIndex occupy 0x18 and 0x19. 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)

StatePrefix values

The registry uses two prefixes, following the Timer (0x05) and TimerIndex (0x06) pattern:
Subscription identity. SubscriptionId is 33 bytes:
The final byte is zero. Duplicate IDs are rejected at registration; the host does not increment that byte. storage::event_subs::make_sub_id is the implementation source. Subscription record (located directly by sub_id):
A subscription’s explicit schema identifier is not stored on this record; it lives on the subscription’s index entry, which is the only copy the emit path reads. 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: the index header holds the declared schema, and each index entry holds its subscription’s identifier; when an explicit emit changes the declaration, it checks the bounded index entries for conflicting identifiers before the usual 1 index lookup → take top K → N record lookups delivery path — no record is loaded for the schema check
  • 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):
The decorator and explicit SDK forms wrap the §2.1 host API. Actors can call rt.emit_event directly; decorator implementation does not change protocol semantics. The proposed decorator names remain an SDK decision (§6.2). Handlers have the signature handler(self, payload). The emitter is ctx.sender; the topic is determined by the subscription’s handler binding. A handler serving several subscriptions uses the emitter and its configured handler names to distinguish them.

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 evaluate higher caps against measured costs (full analysis and the validation criteria are in §6.4 and the unpublished ext_cip-29-sync-cap-analysis-en analysis note (not included in this repository)). 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. It is separate from Governance (0x09): bidding is frequent user activity, while governance owns SettlementConfig and other system parameters.
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.

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.

Subscription removal

Bid is burned at subscribe and update_bid; the stored bid is a ranking signal. Unsubscribe, automatic expiry, and emitter-forced removal never refund bid or pay it to the emitter. Removal deletes by sub_id regardless of index order or rank. Each update_bid call reorders immediately; calls are not batched. An asynchronous fire whose locked sub_id has been removed skips that slot without debiting it again. Refund obligations follow §2.1, including its outstanding on-chain crediting work.

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. Conformance and validation

Implementation must satisfy the host API, registry, execution, and accounting requirements together. Required validation includes:
  • Register and query merkleized subscriptions through contract code.
  • Exercise multiple synchronous and deferred subscribers, including a failing handler whose state and effects roll back without affecting the emitter or other subscribers.
  • Verify emit-time ordering, locked deferred order, receipt causality, payload/depth/fan-out limits, and deterministic execution across validators.
  • Verify on-chain residual-gas and cell refunds for unsubscribe and forced removal, and the subscriber’s cell refund on automatic expiry. Accounting events alone do not satisfy the refund requirement (§2.1).
  • Exercise the SDK host API and decorator forms in a complete liquidation example, with developer documentation that distinguishes synchronous reactions from deferred notifications.

6. Open Decisions

The protocol rules in §2 define ordering, receipt causality, deferred batching, payload charging, depth limits, and top-ups. The decisions and validation work below remain outstanding.

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
The SDK must select and document a consistent decorator surface before claiming this interface complete.

6.3 Remaining work

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 the unpublished ext_cip-29-sync-cap-analysis-en analysis note (not included in this repository); 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.

Cap validation

The specified synchronous cap is 64. Higher caps require measured handler costs, propose/verify latency, snapshot stress coverage, and CIP-3 fee-feedback analysis. Candidate evaluation criteria for 128 include average handler cost below 30K cycles and added propose/verify p99 latency below 50 ms. A candidate cap of 256 also requires sustained stress coverage without snapshot-subsystem failures. These are evaluation criteria, not a scheduled sequence of protocol changes. Under the cost model above, 256 consumes about 65% of the emitter’s lane budget; a higher cap leaves less room for the emitter’s own work and increases serial validation latency.

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

The alternatives below do not preserve the required semantics or resource bounds: