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: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: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 these refunds remains required work.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 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:
- 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)
StatePrefix values
The registry uses two prefixes, following theTimer (0x05) and TimerIndex (0x06) pattern:
SubscriptionId is 33 bytes:
storage::event_subs::make_sub_id is the implementation source.
Subscription record (located directly by 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: 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 lookupsdelivery path — no record is loaded for the schema check - 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 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.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.
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 atsubscribe 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:- 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. 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 = 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 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
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.

