From df6fa37f7332ed26a40977cbd63fbf864b68b9b4 Mon Sep 17 00:00:00 2001 From: Lucas Cullen Date: Mon, 17 Nov 2025 08:24:42 +1000 Subject: [PATCH 1/6] add eip --- EIPS/eip-8078.md | 818 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 EIPS/eip-8078.md diff --git a/EIPS/eip-8078.md b/EIPS/eip-8078.md new file mode 100644 index 00000000000000..eda8307583b7fe --- /dev/null +++ b/EIPS/eip-8078.md @@ -0,0 +1,818 @@ +--- +eip: 8078 +title: Contract Event Subscription +description: Allows contracts to subscribe to and react to events emitted by other contracts with gas-bounded execution +author: Lucas Cullen (@bitcoinbrisbane) +discussions-to: https://ethereum-magicians.org/t/xxxxx +status: Draft +type: Standards Track +category: Core +created: 2025-11-15 +requires: +--- + +## Abstract + +This EIP introduces a mechanism for smart contracts to subscribe to events emitted by other contracts and automatically execute callback functions when those events occur. Subscriptions are paid for by the subscribing contract, execute with bounded gas, and fail gracefully without blocking the original transaction if gas runs out or execution fails. + +## Motivation + +Currently, smart contracts cannot natively react to events emitted by other contracts. Developers must rely on off-chain infrastructure (indexers, bots, relayers) to listen for events and trigger subsequent transactions. This creates several problems: + +1. **Centralization**: Requires trusted off-chain infrastructure +2. **Latency**: Introduces delays between event emission and reaction +3. **Complexity**: Requires maintaining off-chain services and private keys +4. **Cost**: Users must pay for multiple transactions +5. **Atomicity**: Cannot guarantee atomic execution with the original transaction + +On-chain event subscriptions would enable: + +- **Reactive DeFi protocols** (automatic liquidations, rebalancing) +- **Cross-contract coordination** (DAO proposals triggering dependent actions) +- **Decentralized automation** (eliminating relayer centralization) +- **Atomic multi-step protocols** (oracle updates triggering derivative settlements) + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. + +### Overview + +1. Contracts declare subscribable events using enhanced event syntax +2. Contracts subscribe to events using a new `subscribe` keyword +3. When an event is emitted, subscribed callbacks are executed in isolated contexts +4. Each subscription executes with caller-provided gas limits +5. Subscription failures are caught and logged but do not revert the parent transaction + +### Solidity Language Changes + +#### 1. Subscribable Event Declaration + +Events can be marked as `subscribable` to indicate they support on-chain subscriptions: + +```solidity +// Basic subscribable event +event subscribable Transfer(address indexed from, address indexed to, uint256 value); + +// Event with subscription gas hint +event subscribable PriceUpdated(uint256 price) gasHint(100000); +``` + +The `gasHint` annotation suggests minimum gas needed for reasonable subscription handling. + +#### 2. Subscription Syntax + +Contracts subscribe to events using the `subscribe` statement in their constructor or a dedicated subscription management function: + +```solidity +contract Subscriber { + // Subscribe in constructor + constructor(address targetContract) { + subscribe targetContract.Transfer(from, to, value) + with onTransfer(from, to, value) + gasLimit 150000 + gasPrice 20 gwei; + } + + // Callback function - MUST be payable to receive gas payment refunds + function onTransfer(address from, address to, uint256 value) + external + payable + onlyEventCallback + { + // Handle the event + // If this runs out of gas or reverts, the original Transfer event still succeeds + } + + // Unsubscribe + function cleanup(address targetContract) external { + unsubscribe targetContract.Transfer; + } +} +``` + +#### 3. Event Callback Modifier + +A new modifier `onlyEventCallback` ensures functions can only be called by the EVM's subscription dispatcher: + +```solidity +modifier onlyEventCallback { + require(msg.sender == address(0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF), "Only event callbacks"); + _; +} +``` + +The special address `0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF` is reserved for the subscription dispatcher. + +#### 4. Subscription Management + +```solidity +// Check if subscribed +bool isSubscribed = this.isSubscribedTo(targetContract, "Transfer"); + +// Get subscription details +(uint256 gasLimit, uint256 gasPrice, address callback) = + this.getSubscription(targetContract, "Transfer"); + +// Update subscription gas parameters +updateSubscription(targetContract, "Transfer", newGasLimit, newGasPrice); +``` + +### EVM Changes + +#### 1. New Opcodes + +**`SUBSCRIBE` (0x5c)** + +- Stack input: `[target_address, event_signature, callback_address, callback_selector, gas_limit, gas_price]` +- Stack output: `[subscription_id]` +- Gas cost: 20,000 + storage costs +- Creates a subscription record in global subscription storage + +**`UNSUBSCRIBE` (0x5d)** + +- Stack input: `[subscription_id]` +- Stack output: `[success]` +- Gas cost: 5,000 + storage refund +- Removes subscription and refunds storage + +**`NOTIFYSUBSCRIBERS` (0x5e)** + +- Stack input: `[event_signature, data_offset, data_size]` +- Stack output: `[num_notified]` +- Gas cost: 2,000 + (500 \* num_subscribers) +- Called automatically during LOG operations for subscribable events +- Schedules callback executions + +#### 2. Subscription Storage Model + +Subscriptions are stored in a new EVM state trie separate from contract storage: + +``` +SubscriptionKey = keccak256(target_address, event_signature, subscriber_address) +SubscriptionValue = RLP([callback_address, callback_selector, gas_limit, gas_price, deposit]) +``` + +#### 3. Event Emission Flow + +When a subscribable event is emitted: + +``` +1. Event is logged normally (LOG0-LOG4 opcodes) +2. If event is marked subscribable, NOTIFYSUBSCRIBERS is called +3. For each subscription: + a. Check subscriber has sufficient deposited gas payment + b. Deduct gas payment (gas_limit * gas_price) from deposit + c. Schedule callback execution in isolated context + d. Execute callback with try-catch semantics + e. Refund unused gas to subscriber + f. Log callback success/failure +4. Original transaction continues regardless of callback outcomes +``` + +#### 4. Callback Execution Context + +Callbacks execute in an isolated context: + +``` +- msg.sender = 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF (subscription dispatcher) +- tx.origin = original tx.origin (preserved from parent) +- block.* = same as parent transaction +- Gas limit = subscription gas_limit +- Reverts/failures are caught and logged but don't propagate +- State changes are included if callback succeeds +- DELEGATECALL and CALLCODE are disabled in callbacks +``` + +#### 5. Gas Accounting + +Subscription gas costs are separate from the transaction that emits the event: + +1. **Subscription Deposit**: Subscribers must deposit ETH to cover future callback executions +2. **Per-Callback Deduction**: When event is emitted, `gas_limit * gas_price` is deducted from deposit +3. **Refunds**: Unused gas is refunded to subscriber's deposit balance +4. **Insufficient Balance**: If deposit insufficient, callback is skipped and event logged +5. **Withdrawal**: Subscribers can withdraw unused deposits + +#### 6. New Precompile: Subscription Manager (0x0a) + +Address: `0x000000000000000000000000000000000000000a` + +Functions: + +- `deposit(subscription_id)` - Add ETH to subscription deposit +- `withdraw(subscription_id, amount)` - Withdraw from deposit +- `getBalance(subscription_id)` - Query deposit balance +- `getSubscriptionInfo(subscription_id)` - Get subscription details + +### Compiler Changes (Solidity) + +#### 1. Event Declaration Parsing + +The Solidity compiler must: + +- Parse `subscribable` keyword on event declarations +- Parse optional `gasHint(uint256)` annotation +- Emit metadata indicating event is subscribable +- Include subscription hints in contract ABI + +```json +{ + "type": "event", + "name": "Transfer", + "inputs": [...], + "subscribable": true, + "gasHint": 100000 +} +``` + +#### 2. Subscribe Statement Compilation + +The `subscribe` statement compiles to: + +``` +1. Load subscription parameters onto stack +2. Call SUBSCRIBE opcode +3. Store returned subscription_id +4. Emit SubscriptionCreated event for off-chain indexing +``` + +#### 3. Built-in Subscription Functions + +The compiler provides built-in functions: + +```solidity +// Automatically available in all contracts +function isSubscribedTo(address target, string memory eventSig) internal view returns (bool); +function getSubscription(address target, string memory eventSig) internal view returns (...); +function updateSubscription(address target, string memory eventSig, uint256 gasLimit, uint256 gasPrice) internal; +``` + +#### 4. Callback Function Validation + +The compiler enforces: + +- Callback functions MUST be `external` +- Callback functions SHOULD be `payable` to receive gas refunds +- Callback functions MUST use `onlyEventCallback` modifier or equivalent check +- Parameter types MUST match subscribed event types + +### Client Implementation (Geth) + +#### 1. Subscription State Management + +New database schema: + +```go +type Subscription struct { + ID common.Hash + TargetContract common.Address + EventSignature common.Hash + SubscriberContract common.Address + CallbackAddress common.Address + CallbackSelector [4]byte + GasLimit uint64 + GasPrice *big.Int + DepositBalance *big.Int + Active bool +} +``` + +#### 2. EVM Modification + +In `core/vm/evm.go`: + +```go +// New field in EVM struct +type EVM struct { + // ... existing fields + SubscriptionManager *SubscriptionManager + PendingCallbacks []*CallbackExecution +} + +// Execute callbacks after main execution +func (evm *EVM) ProcessCallbacks() error { + for _, cb := range evm.PendingCallbacks { + evm.executeCallback(cb) + } + return nil +} + +func (evm *EVM) executeCallback(cb *CallbackExecution) { + // Create isolated context + snapshot := evm.StateDB.Snapshot() + + // Set special msg.sender + evm.Context.Origin = cb.OriginalOrigin + + // Execute with try-catch semantics + ret, gasUsed, err := evm.Call( + AccountRef(SUBSCRIPTION_DISPATCHER_ADDRESS), + cb.CallbackAddress, + cb.CallbackData, + cb.GasLimit, + big.NewInt(0), + ) + + if err != nil { + // Revert callback state changes but continue + evm.StateDB.RevertToSnapshot(snapshot) + // Log callback failure + evm.StateDB.AddLog(&types.Log{ + Address: cb.SubscriberAddress, + Topics: []common.Hash{CallbackFailedEvent, cb.SubscriptionID}, + Data: []byte(err.Error()), + }) + } else { + // Refund unused gas + refund := (cb.GasLimit - gasUsed) * cb.GasPrice + evm.SubscriptionManager.RefundGas(cb.SubscriptionID, refund) + } +} +``` + +#### 3. LOG Opcode Modification + +In `core/vm/instructions.go`: + +```go +func opLogN(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { + // ... existing LOG implementation + + // Check if event is subscribable + eventSig := scope.Stack.peek().Bytes32() + if interpreter.evm.SubscriptionManager.IsSubscribableEvent(scope.Contract.Address(), eventSig) { + // Notify subscribers + subscribers := interpreter.evm.SubscriptionManager.GetSubscribers( + scope.Contract.Address(), + eventSig, + ) + + for _, sub := range subscribers { + // Deduct gas from deposit + if !sub.DeductGas() { + // Insufficient deposit, skip and log + interpreter.evm.StateDB.AddLog(insufficientGasLog(sub)) + continue + } + + // Schedule callback + callback := &CallbackExecution{ + SubscriptionID: sub.ID, + SubscriberAddress: sub.SubscriberContract, + CallbackAddress: sub.CallbackAddress, + CallbackData: buildCallbackData(sub, logData), + GasLimit: sub.GasLimit, + GasPrice: sub.GasPrice, + OriginalOrigin: interpreter.evm.Context.Origin, + } + interpreter.evm.PendingCallbacks = append( + interpreter.evm.PendingCallbacks, + callback, + ) + } + } + + return nil, nil +} +``` + +#### 4. State Trie Extension + +Add new subscription trie alongside existing state tries: + +```go +type StateDB struct { + // ... existing fields + subscriptionTrie Trie + subscriptionCache *lru.Cache +} +``` + +#### 5. RPC Extensions + +New RPC methods: + +```go +// Get all subscriptions for an address +eth_getSubscriptions(address) -> []Subscription + +// Get subscription details +eth_getSubscription(subscriptionId) -> Subscription + +// Get callback execution history +eth_getCallbackHistory(subscriptionId, fromBlock, toBlock) -> []CallbackLog +``` + +## Rationale + +### Design Decisions + +**Why isolated execution context?** +Prevents subscription callbacks from blocking or reverting the original transaction. The emitting contract should not care about subscriber behavior. + +**Why require payable callbacks?** +Enables gas refunds to be returned to the subscribing contract, improving efficiency. + +**Why separate deposit model?** +Prevents DoS attacks where subscriptions drain the emitting contract's gas. Subscribers pay for their own execution. + +**Why special dispatcher address?** +Provides a secure, verifiable way for callbacks to know they're being called by the subscription system rather than an attacker. + +**Why bounded gas?** +Prevents infinite loops or excessive gas consumption from blocking event emission or consuming unreasonable resources. + +**Why not use CREATE2 deterministic callbacks?** +CREATE2 would require deploying a new contract for each subscription, wasting storage and gas. The proposed system is more efficient. + +### Alternative Approaches Considered + +1. **Event Relayer Precompile**: A precompile that stores events and allows polling. Rejected because it still requires off-chain infrastructure. + +2. **Callback in Same Transaction**: Execute callbacks synchronously in the same call frame. Rejected because callback failures would revert the emitting transaction. + +3. **Deferred Transaction Queue**: Store callbacks as pending transactions for future blocks. Rejected due to complexity and unpredictable execution timing. + +## Backwards Compatibility + +This EIP introduces new opcodes and language features but maintains full backwards compatibility: + +1. **Existing Contracts**: Continue to work without modification +2. **Existing Events**: Can be emitted normally; `subscribable` is opt-in +3. **Non-upgraded Clients**: Can process blocks but will skip subscription execution (fork required) +4. **ABI Compatibility**: New ABI fields are additive only + +### Hard Fork Required + +This EIP requires a coordinated hard fork to activate: + +- All clients must implement new opcodes +- Subscription state trie must be initialized +- Subscription dispatcher precompile must be activated + +## Security Considerations + +### 1. Reentrancy Protection + +Callbacks execute after the main transaction completes, preventing reentrancy attacks on the emitting contract. The isolated context ensures callbacks cannot call back into the emitter within the same transaction. + +### 2. Gas Griefing + +**Attack**: Subscribing to popular events with insufficient deposits to waste emitter gas. + +**Mitigation**: + +- Subscription notification cost (500 gas per subscriber) is low +- Insufficient deposits skip execution rather than failing +- Emitters can limit subscribable events + +### 3. DoS via Excessive Subscriptions + +**Attack**: Creating millions of subscriptions to slow down event emission. + +**Mitigation**: + +- SUBSCRIBE opcode has high base cost (20,000 gas) +- NOTIFYSUBSCRIBERS charges per subscriber (500 gas each) +- Practical limit: ~60,000 gas / 500 = ~120 subscribers per event emission +- Emitters can choose not to mark events as subscribable + +### 4. Front-Running Subscriptions + +**Attack**: Front-running subscription creation to intercept events meant for others. + +**Mitigation**: Subscriptions are public state; this is expected behavior. Sensitive events should not be subscribable. + +### 5. Callback Impersonation + +**Attack**: Calling a callback function directly, bypassing event emission. + +**Mitigation**: The `onlyEventCallback` modifier checks for the special dispatcher address, which cannot be impersonated by user transactions. + +### 6. Deposit Draining + +**Attack**: Emitting events rapidly to drain subscriber deposits. + +**Mitigation**: Subscribers control their gas limits and can withdraw deposits. This is similar to users controlling their own transaction gas. + +### 7. State Inconsistency + +**Attack**: Callback executes based on stale state if emitter's state changes before callback runs. + +**Mitigation**: Callbacks execute immediately after the emitting transaction in the same block. State is consistent within the transaction context. + +### 8. Cross-Contract Reentrancy + +**Attack**: Callback modifies state that affects other pending callbacks. + +**Mitigation**: Callbacks are executed sequentially in the order they were subscribed. Each callback sees the cumulative state changes from previous callbacks (similar to transaction ordering). + +## Reference Implementation + +### Solidity Example: Price Oracle with Subscribers + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract PriceOracle { + uint256 public price; + + event subscribable PriceUpdated(uint256 newPrice) gasHint(50000); + + function updatePrice(uint256 _price) external { + price = _price; + emit PriceUpdated(_price); + // Subscribers are automatically notified + } +} + +contract DerivedProtocol { + PriceOracle public oracle; + uint256 public lastSyncedPrice; + uint256 public depositBalance; + + event PriceSynced(uint256 price); + event SubscriptionGasRefund(uint256 amount); + + constructor(address _oracle) payable { + oracle = PriceOracle(_oracle); + + // Subscribe to price updates + subscribe oracle.PriceUpdated(newPrice) + with onPriceUpdate(newPrice) + gasLimit 100000 + gasPrice 20 gwei; + + // Deposit gas payment + depositBalance = msg.value; + } + + // Callback function - automatically called when PriceUpdated is emitted + function onPriceUpdate(uint256 newPrice) + external + payable + onlyEventCallback + { + lastSyncedPrice = newPrice; + emit PriceSynced(newPrice); + + // Process gas refund if any + if (msg.value > 0) { + depositBalance += msg.value; + emit SubscriptionGasRefund(msg.value); + } + + // Perform derivative calculations + // If this reverts, the oracle's updatePrice() still succeeds + rebalancePositions(newPrice); + } + + function rebalancePositions(uint256 newPrice) internal { + // Complex logic that might fail + // Failures are graceful and logged + } + + // Withdraw unused deposit + function withdrawDeposit(uint256 amount) external { + require(depositBalance >= amount, "Insufficient balance"); + depositBalance -= amount; + payable(msg.sender).transfer(amount); + } +} +``` + +### Geth Implementation Sketch + +```go +// core/vm/subscription_manager.go +package vm + +type SubscriptionManager struct { + stateDB StateDB + subscriptions map[common.Hash]*Subscription + subscriptionsByEvent map[common.Hash][]*Subscription +} + +func (sm *SubscriptionManager) Subscribe( + target common.Address, + eventSig common.Hash, + subscriber common.Address, + callback common.Address, + selector [4]byte, + gasLimit uint64, + gasPrice *big.Int, +) (common.Hash, error) { + // Create subscription ID + subID := crypto.Keccak256Hash( + target.Bytes(), + eventSig.Bytes(), + subscriber.Bytes(), + ) + + // Create subscription record + sub := &Subscription{ + ID: subID, + TargetContract: target, + EventSignature: eventSig, + SubscriberContract: subscriber, + CallbackAddress: callback, + CallbackSelector: selector, + GasLimit: gasLimit, + GasPrice: gasPrice, + DepositBalance: big.NewInt(0), + Active: true, + } + + // Store in state + sm.subscriptions[subID] = sub + + // Index by event + eventKey := crypto.Keccak256Hash(target.Bytes(), eventSig.Bytes()) + sm.subscriptionsByEvent[eventKey] = append( + sm.subscriptionsByEvent[eventKey], + sub, + ) + + // Persist to trie + sm.stateDB.SetSubscription(subID, sub) + + return subID, nil +} + +func (sm *SubscriptionManager) NotifySubscribers( + target common.Address, + eventSig common.Hash, + eventData []byte, +) []*CallbackExecution { + eventKey := crypto.Keccak256Hash(target.Bytes(), eventSig.Bytes()) + subscribers := sm.subscriptionsByEvent[eventKey] + + callbacks := make([]*CallbackExecution, 0, len(subscribers)) + + for _, sub := range subscribers { + if !sub.Active { + continue + } + + // Calculate gas cost + gasCost := new(big.Int).Mul( + new(big.Int).SetUint64(sub.GasLimit), + sub.GasPrice, + ) + + // Check deposit balance + if sub.DepositBalance.Cmp(gasCost) < 0 { + // Insufficient balance, skip + sm.stateDB.AddLog(&types.Log{ + Address: sub.SubscriberContract, + Topics: []common.Hash{ + InsufficientDepositEvent, + sub.ID, + }, + }) + continue + } + + // Deduct gas + sub.DepositBalance.Sub(sub.DepositBalance, gasCost) + sm.stateDB.SetSubscription(sub.ID, sub) + + // Build callback data + callbackData := append(sub.CallbackSelector[:], eventData...) + + // Create callback execution + callbacks = append(callbacks, &CallbackExecution{ + SubscriptionID: sub.ID, + SubscriberAddress: sub.SubscriberContract, + CallbackAddress: sub.CallbackAddress, + CallbackData: callbackData, + GasLimit: sub.GasLimit, + GasPrice: sub.GasPrice, + OriginalOrigin: common.Address{}, // Set by caller + }) + } + + return callbacks +} +``` + +## Test Cases + +### Test Case 1: Basic Subscription and Callback + +```solidity +function testBasicSubscription() public { + // Deploy oracle + PriceOracle oracle = new PriceOracle(); + + // Deploy subscriber with gas deposit + DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( + address(oracle) + ); + + // Verify subscription created + assertTrue(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); + + // Emit event + oracle.updatePrice(1000); + + // Verify callback executed + assertEq(subscriber.lastSyncedPrice(), 1000); +} +``` + +### Test Case 2: Callback Out of Gas + +```solidity +function testCallbackOutOfGas() public { + // Create subscription with insufficient gas + DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( + address(oracle) + ); + subscriber.updateSubscription(address(oracle), "PriceUpdated", 10000, 20 gwei); // Too low + + // Emit event + oracle.updatePrice(1000); + + // Verify original transaction succeeded + assertEq(oracle.price(), 1000); + + // Verify callback failed gracefully + assertEq(subscriber.lastSyncedPrice(), 0); // Not updated + + // Verify failure was logged + // (check logs for CallbackFailed event) +} +``` + +### Test Case 3: Insufficient Deposit + +```solidity +function testInsufficientDeposit() public { + DerivedProtocol subscriber = new DerivedProtocol{value: 0.001 ether}( + address(oracle) + ); + + // Emit events until deposit exhausted + for (uint i = 0; i < 100; i++) { + oracle.updatePrice(i); + } + + // Verify early events succeeded + assertTrue(subscriber.lastSyncedPrice() > 0); + + // Verify later events skipped due to insufficient deposit + assertLt(subscriber.lastSyncedPrice(), 99); +} +``` + +### Test Case 4: Multiple Subscribers + +```solidity +function testMultipleSubscribers() public { + PriceOracle oracle = new PriceOracle(); + + DerivedProtocol sub1 = new DerivedProtocol{value: 1 ether}(address(oracle)); + DerivedProtocol sub2 = new DerivedProtocol{value: 1 ether}(address(oracle)); + DerivedProtocol sub3 = new DerivedProtocol{value: 1 ether}(address(oracle)); + + // Emit event + oracle.updatePrice(500); + + // Verify all callbacks executed + assertEq(sub1.lastSyncedPrice(), 500); + assertEq(sub2.lastSyncedPrice(), 500); + assertEq(sub3.lastSyncedPrice(), 500); +} +``` + +### Test Case 5: Unsubscribe + +```solidity +function testUnsubscribe() public { + DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( + address(oracle) + ); + + // Verify subscribed + assertTrue(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); + + // Unsubscribe + subscriber.cleanup(address(oracle)); + + // Verify unsubscribed + assertFalse(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); + + // Emit event + oracle.updatePrice(1000); + + // Verify callback not executed + assertEq(subscriber.lastSyncedPrice(), 0); +} +``` + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md). From 00991e18f932510408228f065633ab02ce3cb798 Mon Sep 17 00:00:00 2001 From: Lucas Cullen Date: Tue, 18 Nov 2025 07:52:03 +1000 Subject: [PATCH 2/6] chore: change to draft --- EIPS/{eip-8078.md => contract_event_subscription.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename EIPS/{eip-8078.md => contract_event_subscription.md} (100%) diff --git a/EIPS/eip-8078.md b/EIPS/contract_event_subscription.md similarity index 100% rename from EIPS/eip-8078.md rename to EIPS/contract_event_subscription.md From ee66506a9dd43edd8cb0a679f93044f70a527865 Mon Sep 17 00:00:00 2001 From: Lucas Cullen Date: Tue, 18 Nov 2025 08:01:43 +1000 Subject: [PATCH 3/6] chore: roll back title / eip --- EIPS/contract_event_subscription.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/EIPS/contract_event_subscription.md b/EIPS/contract_event_subscription.md index eda8307583b7fe..c14acf0c3b7178 100644 --- a/EIPS/contract_event_subscription.md +++ b/EIPS/contract_event_subscription.md @@ -1,5 +1,5 @@ --- -eip: 8078 +eip: draft title: Contract Event Subscription description: Allows contracts to subscribe to and react to events emitted by other contracts with gas-bounded execution author: Lucas Cullen (@bitcoinbrisbane) From 771d8994db9939df2f0ee2d46f96542d1aef197e Mon Sep 17 00:00:00 2001 From: Lucas Cullen Date: Fri, 21 Nov 2025 07:07:21 +1000 Subject: [PATCH 4/6] chore: update eip --- EIPS/contract_event_subscription.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/EIPS/contract_event_subscription.md b/EIPS/contract_event_subscription.md index c14acf0c3b7178..16fbc841f7fac6 100644 --- a/EIPS/contract_event_subscription.md +++ b/EIPS/contract_event_subscription.md @@ -1,9 +1,9 @@ --- -eip: draft +eip: 8082 title: Contract Event Subscription description: Allows contracts to subscribe to and react to events emitted by other contracts with gas-bounded execution author: Lucas Cullen (@bitcoinbrisbane) -discussions-to: https://ethereum-magicians.org/t/xxxxx +discussions-to: https://ethereum-magicians.org/t/eip-to-be-assigned-contract-event-subscription/26575 status: Draft type: Standards Track category: Core From 57f3377c5efe46b5c3f7737d498f9a04b78a9b21 Mon Sep 17 00:00:00 2001 From: Lucas Cullen Date: Fri, 21 Nov 2025 07:07:37 +1000 Subject: [PATCH 5/6] chore: update eip --- EIPS/contract_event_subscription.md | 818 ---------------------------- 1 file changed, 818 deletions(-) delete mode 100644 EIPS/contract_event_subscription.md diff --git a/EIPS/contract_event_subscription.md b/EIPS/contract_event_subscription.md deleted file mode 100644 index 16fbc841f7fac6..00000000000000 --- a/EIPS/contract_event_subscription.md +++ /dev/null @@ -1,818 +0,0 @@ ---- -eip: 8082 -title: Contract Event Subscription -description: Allows contracts to subscribe to and react to events emitted by other contracts with gas-bounded execution -author: Lucas Cullen (@bitcoinbrisbane) -discussions-to: https://ethereum-magicians.org/t/eip-to-be-assigned-contract-event-subscription/26575 -status: Draft -type: Standards Track -category: Core -created: 2025-11-15 -requires: ---- - -## Abstract - -This EIP introduces a mechanism for smart contracts to subscribe to events emitted by other contracts and automatically execute callback functions when those events occur. Subscriptions are paid for by the subscribing contract, execute with bounded gas, and fail gracefully without blocking the original transaction if gas runs out or execution fails. - -## Motivation - -Currently, smart contracts cannot natively react to events emitted by other contracts. Developers must rely on off-chain infrastructure (indexers, bots, relayers) to listen for events and trigger subsequent transactions. This creates several problems: - -1. **Centralization**: Requires trusted off-chain infrastructure -2. **Latency**: Introduces delays between event emission and reaction -3. **Complexity**: Requires maintaining off-chain services and private keys -4. **Cost**: Users must pay for multiple transactions -5. **Atomicity**: Cannot guarantee atomic execution with the original transaction - -On-chain event subscriptions would enable: - -- **Reactive DeFi protocols** (automatic liquidations, rebalancing) -- **Cross-contract coordination** (DAO proposals triggering dependent actions) -- **Decentralized automation** (eliminating relayer centralization) -- **Atomic multi-step protocols** (oracle updates triggering derivative settlements) - -## Specification - -The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. - -### Overview - -1. Contracts declare subscribable events using enhanced event syntax -2. Contracts subscribe to events using a new `subscribe` keyword -3. When an event is emitted, subscribed callbacks are executed in isolated contexts -4. Each subscription executes with caller-provided gas limits -5. Subscription failures are caught and logged but do not revert the parent transaction - -### Solidity Language Changes - -#### 1. Subscribable Event Declaration - -Events can be marked as `subscribable` to indicate they support on-chain subscriptions: - -```solidity -// Basic subscribable event -event subscribable Transfer(address indexed from, address indexed to, uint256 value); - -// Event with subscription gas hint -event subscribable PriceUpdated(uint256 price) gasHint(100000); -``` - -The `gasHint` annotation suggests minimum gas needed for reasonable subscription handling. - -#### 2. Subscription Syntax - -Contracts subscribe to events using the `subscribe` statement in their constructor or a dedicated subscription management function: - -```solidity -contract Subscriber { - // Subscribe in constructor - constructor(address targetContract) { - subscribe targetContract.Transfer(from, to, value) - with onTransfer(from, to, value) - gasLimit 150000 - gasPrice 20 gwei; - } - - // Callback function - MUST be payable to receive gas payment refunds - function onTransfer(address from, address to, uint256 value) - external - payable - onlyEventCallback - { - // Handle the event - // If this runs out of gas or reverts, the original Transfer event still succeeds - } - - // Unsubscribe - function cleanup(address targetContract) external { - unsubscribe targetContract.Transfer; - } -} -``` - -#### 3. Event Callback Modifier - -A new modifier `onlyEventCallback` ensures functions can only be called by the EVM's subscription dispatcher: - -```solidity -modifier onlyEventCallback { - require(msg.sender == address(0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF), "Only event callbacks"); - _; -} -``` - -The special address `0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF` is reserved for the subscription dispatcher. - -#### 4. Subscription Management - -```solidity -// Check if subscribed -bool isSubscribed = this.isSubscribedTo(targetContract, "Transfer"); - -// Get subscription details -(uint256 gasLimit, uint256 gasPrice, address callback) = - this.getSubscription(targetContract, "Transfer"); - -// Update subscription gas parameters -updateSubscription(targetContract, "Transfer", newGasLimit, newGasPrice); -``` - -### EVM Changes - -#### 1. New Opcodes - -**`SUBSCRIBE` (0x5c)** - -- Stack input: `[target_address, event_signature, callback_address, callback_selector, gas_limit, gas_price]` -- Stack output: `[subscription_id]` -- Gas cost: 20,000 + storage costs -- Creates a subscription record in global subscription storage - -**`UNSUBSCRIBE` (0x5d)** - -- Stack input: `[subscription_id]` -- Stack output: `[success]` -- Gas cost: 5,000 + storage refund -- Removes subscription and refunds storage - -**`NOTIFYSUBSCRIBERS` (0x5e)** - -- Stack input: `[event_signature, data_offset, data_size]` -- Stack output: `[num_notified]` -- Gas cost: 2,000 + (500 \* num_subscribers) -- Called automatically during LOG operations for subscribable events -- Schedules callback executions - -#### 2. Subscription Storage Model - -Subscriptions are stored in a new EVM state trie separate from contract storage: - -``` -SubscriptionKey = keccak256(target_address, event_signature, subscriber_address) -SubscriptionValue = RLP([callback_address, callback_selector, gas_limit, gas_price, deposit]) -``` - -#### 3. Event Emission Flow - -When a subscribable event is emitted: - -``` -1. Event is logged normally (LOG0-LOG4 opcodes) -2. If event is marked subscribable, NOTIFYSUBSCRIBERS is called -3. For each subscription: - a. Check subscriber has sufficient deposited gas payment - b. Deduct gas payment (gas_limit * gas_price) from deposit - c. Schedule callback execution in isolated context - d. Execute callback with try-catch semantics - e. Refund unused gas to subscriber - f. Log callback success/failure -4. Original transaction continues regardless of callback outcomes -``` - -#### 4. Callback Execution Context - -Callbacks execute in an isolated context: - -``` -- msg.sender = 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF (subscription dispatcher) -- tx.origin = original tx.origin (preserved from parent) -- block.* = same as parent transaction -- Gas limit = subscription gas_limit -- Reverts/failures are caught and logged but don't propagate -- State changes are included if callback succeeds -- DELEGATECALL and CALLCODE are disabled in callbacks -``` - -#### 5. Gas Accounting - -Subscription gas costs are separate from the transaction that emits the event: - -1. **Subscription Deposit**: Subscribers must deposit ETH to cover future callback executions -2. **Per-Callback Deduction**: When event is emitted, `gas_limit * gas_price` is deducted from deposit -3. **Refunds**: Unused gas is refunded to subscriber's deposit balance -4. **Insufficient Balance**: If deposit insufficient, callback is skipped and event logged -5. **Withdrawal**: Subscribers can withdraw unused deposits - -#### 6. New Precompile: Subscription Manager (0x0a) - -Address: `0x000000000000000000000000000000000000000a` - -Functions: - -- `deposit(subscription_id)` - Add ETH to subscription deposit -- `withdraw(subscription_id, amount)` - Withdraw from deposit -- `getBalance(subscription_id)` - Query deposit balance -- `getSubscriptionInfo(subscription_id)` - Get subscription details - -### Compiler Changes (Solidity) - -#### 1. Event Declaration Parsing - -The Solidity compiler must: - -- Parse `subscribable` keyword on event declarations -- Parse optional `gasHint(uint256)` annotation -- Emit metadata indicating event is subscribable -- Include subscription hints in contract ABI - -```json -{ - "type": "event", - "name": "Transfer", - "inputs": [...], - "subscribable": true, - "gasHint": 100000 -} -``` - -#### 2. Subscribe Statement Compilation - -The `subscribe` statement compiles to: - -``` -1. Load subscription parameters onto stack -2. Call SUBSCRIBE opcode -3. Store returned subscription_id -4. Emit SubscriptionCreated event for off-chain indexing -``` - -#### 3. Built-in Subscription Functions - -The compiler provides built-in functions: - -```solidity -// Automatically available in all contracts -function isSubscribedTo(address target, string memory eventSig) internal view returns (bool); -function getSubscription(address target, string memory eventSig) internal view returns (...); -function updateSubscription(address target, string memory eventSig, uint256 gasLimit, uint256 gasPrice) internal; -``` - -#### 4. Callback Function Validation - -The compiler enforces: - -- Callback functions MUST be `external` -- Callback functions SHOULD be `payable` to receive gas refunds -- Callback functions MUST use `onlyEventCallback` modifier or equivalent check -- Parameter types MUST match subscribed event types - -### Client Implementation (Geth) - -#### 1. Subscription State Management - -New database schema: - -```go -type Subscription struct { - ID common.Hash - TargetContract common.Address - EventSignature common.Hash - SubscriberContract common.Address - CallbackAddress common.Address - CallbackSelector [4]byte - GasLimit uint64 - GasPrice *big.Int - DepositBalance *big.Int - Active bool -} -``` - -#### 2. EVM Modification - -In `core/vm/evm.go`: - -```go -// New field in EVM struct -type EVM struct { - // ... existing fields - SubscriptionManager *SubscriptionManager - PendingCallbacks []*CallbackExecution -} - -// Execute callbacks after main execution -func (evm *EVM) ProcessCallbacks() error { - for _, cb := range evm.PendingCallbacks { - evm.executeCallback(cb) - } - return nil -} - -func (evm *EVM) executeCallback(cb *CallbackExecution) { - // Create isolated context - snapshot := evm.StateDB.Snapshot() - - // Set special msg.sender - evm.Context.Origin = cb.OriginalOrigin - - // Execute with try-catch semantics - ret, gasUsed, err := evm.Call( - AccountRef(SUBSCRIPTION_DISPATCHER_ADDRESS), - cb.CallbackAddress, - cb.CallbackData, - cb.GasLimit, - big.NewInt(0), - ) - - if err != nil { - // Revert callback state changes but continue - evm.StateDB.RevertToSnapshot(snapshot) - // Log callback failure - evm.StateDB.AddLog(&types.Log{ - Address: cb.SubscriberAddress, - Topics: []common.Hash{CallbackFailedEvent, cb.SubscriptionID}, - Data: []byte(err.Error()), - }) - } else { - // Refund unused gas - refund := (cb.GasLimit - gasUsed) * cb.GasPrice - evm.SubscriptionManager.RefundGas(cb.SubscriptionID, refund) - } -} -``` - -#### 3. LOG Opcode Modification - -In `core/vm/instructions.go`: - -```go -func opLogN(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { - // ... existing LOG implementation - - // Check if event is subscribable - eventSig := scope.Stack.peek().Bytes32() - if interpreter.evm.SubscriptionManager.IsSubscribableEvent(scope.Contract.Address(), eventSig) { - // Notify subscribers - subscribers := interpreter.evm.SubscriptionManager.GetSubscribers( - scope.Contract.Address(), - eventSig, - ) - - for _, sub := range subscribers { - // Deduct gas from deposit - if !sub.DeductGas() { - // Insufficient deposit, skip and log - interpreter.evm.StateDB.AddLog(insufficientGasLog(sub)) - continue - } - - // Schedule callback - callback := &CallbackExecution{ - SubscriptionID: sub.ID, - SubscriberAddress: sub.SubscriberContract, - CallbackAddress: sub.CallbackAddress, - CallbackData: buildCallbackData(sub, logData), - GasLimit: sub.GasLimit, - GasPrice: sub.GasPrice, - OriginalOrigin: interpreter.evm.Context.Origin, - } - interpreter.evm.PendingCallbacks = append( - interpreter.evm.PendingCallbacks, - callback, - ) - } - } - - return nil, nil -} -``` - -#### 4. State Trie Extension - -Add new subscription trie alongside existing state tries: - -```go -type StateDB struct { - // ... existing fields - subscriptionTrie Trie - subscriptionCache *lru.Cache -} -``` - -#### 5. RPC Extensions - -New RPC methods: - -```go -// Get all subscriptions for an address -eth_getSubscriptions(address) -> []Subscription - -// Get subscription details -eth_getSubscription(subscriptionId) -> Subscription - -// Get callback execution history -eth_getCallbackHistory(subscriptionId, fromBlock, toBlock) -> []CallbackLog -``` - -## Rationale - -### Design Decisions - -**Why isolated execution context?** -Prevents subscription callbacks from blocking or reverting the original transaction. The emitting contract should not care about subscriber behavior. - -**Why require payable callbacks?** -Enables gas refunds to be returned to the subscribing contract, improving efficiency. - -**Why separate deposit model?** -Prevents DoS attacks where subscriptions drain the emitting contract's gas. Subscribers pay for their own execution. - -**Why special dispatcher address?** -Provides a secure, verifiable way for callbacks to know they're being called by the subscription system rather than an attacker. - -**Why bounded gas?** -Prevents infinite loops or excessive gas consumption from blocking event emission or consuming unreasonable resources. - -**Why not use CREATE2 deterministic callbacks?** -CREATE2 would require deploying a new contract for each subscription, wasting storage and gas. The proposed system is more efficient. - -### Alternative Approaches Considered - -1. **Event Relayer Precompile**: A precompile that stores events and allows polling. Rejected because it still requires off-chain infrastructure. - -2. **Callback in Same Transaction**: Execute callbacks synchronously in the same call frame. Rejected because callback failures would revert the emitting transaction. - -3. **Deferred Transaction Queue**: Store callbacks as pending transactions for future blocks. Rejected due to complexity and unpredictable execution timing. - -## Backwards Compatibility - -This EIP introduces new opcodes and language features but maintains full backwards compatibility: - -1. **Existing Contracts**: Continue to work without modification -2. **Existing Events**: Can be emitted normally; `subscribable` is opt-in -3. **Non-upgraded Clients**: Can process blocks but will skip subscription execution (fork required) -4. **ABI Compatibility**: New ABI fields are additive only - -### Hard Fork Required - -This EIP requires a coordinated hard fork to activate: - -- All clients must implement new opcodes -- Subscription state trie must be initialized -- Subscription dispatcher precompile must be activated - -## Security Considerations - -### 1. Reentrancy Protection - -Callbacks execute after the main transaction completes, preventing reentrancy attacks on the emitting contract. The isolated context ensures callbacks cannot call back into the emitter within the same transaction. - -### 2. Gas Griefing - -**Attack**: Subscribing to popular events with insufficient deposits to waste emitter gas. - -**Mitigation**: - -- Subscription notification cost (500 gas per subscriber) is low -- Insufficient deposits skip execution rather than failing -- Emitters can limit subscribable events - -### 3. DoS via Excessive Subscriptions - -**Attack**: Creating millions of subscriptions to slow down event emission. - -**Mitigation**: - -- SUBSCRIBE opcode has high base cost (20,000 gas) -- NOTIFYSUBSCRIBERS charges per subscriber (500 gas each) -- Practical limit: ~60,000 gas / 500 = ~120 subscribers per event emission -- Emitters can choose not to mark events as subscribable - -### 4. Front-Running Subscriptions - -**Attack**: Front-running subscription creation to intercept events meant for others. - -**Mitigation**: Subscriptions are public state; this is expected behavior. Sensitive events should not be subscribable. - -### 5. Callback Impersonation - -**Attack**: Calling a callback function directly, bypassing event emission. - -**Mitigation**: The `onlyEventCallback` modifier checks for the special dispatcher address, which cannot be impersonated by user transactions. - -### 6. Deposit Draining - -**Attack**: Emitting events rapidly to drain subscriber deposits. - -**Mitigation**: Subscribers control their gas limits and can withdraw deposits. This is similar to users controlling their own transaction gas. - -### 7. State Inconsistency - -**Attack**: Callback executes based on stale state if emitter's state changes before callback runs. - -**Mitigation**: Callbacks execute immediately after the emitting transaction in the same block. State is consistent within the transaction context. - -### 8. Cross-Contract Reentrancy - -**Attack**: Callback modifies state that affects other pending callbacks. - -**Mitigation**: Callbacks are executed sequentially in the order they were subscribed. Each callback sees the cumulative state changes from previous callbacks (similar to transaction ordering). - -## Reference Implementation - -### Solidity Example: Price Oracle with Subscribers - -```solidity -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.20; - -contract PriceOracle { - uint256 public price; - - event subscribable PriceUpdated(uint256 newPrice) gasHint(50000); - - function updatePrice(uint256 _price) external { - price = _price; - emit PriceUpdated(_price); - // Subscribers are automatically notified - } -} - -contract DerivedProtocol { - PriceOracle public oracle; - uint256 public lastSyncedPrice; - uint256 public depositBalance; - - event PriceSynced(uint256 price); - event SubscriptionGasRefund(uint256 amount); - - constructor(address _oracle) payable { - oracle = PriceOracle(_oracle); - - // Subscribe to price updates - subscribe oracle.PriceUpdated(newPrice) - with onPriceUpdate(newPrice) - gasLimit 100000 - gasPrice 20 gwei; - - // Deposit gas payment - depositBalance = msg.value; - } - - // Callback function - automatically called when PriceUpdated is emitted - function onPriceUpdate(uint256 newPrice) - external - payable - onlyEventCallback - { - lastSyncedPrice = newPrice; - emit PriceSynced(newPrice); - - // Process gas refund if any - if (msg.value > 0) { - depositBalance += msg.value; - emit SubscriptionGasRefund(msg.value); - } - - // Perform derivative calculations - // If this reverts, the oracle's updatePrice() still succeeds - rebalancePositions(newPrice); - } - - function rebalancePositions(uint256 newPrice) internal { - // Complex logic that might fail - // Failures are graceful and logged - } - - // Withdraw unused deposit - function withdrawDeposit(uint256 amount) external { - require(depositBalance >= amount, "Insufficient balance"); - depositBalance -= amount; - payable(msg.sender).transfer(amount); - } -} -``` - -### Geth Implementation Sketch - -```go -// core/vm/subscription_manager.go -package vm - -type SubscriptionManager struct { - stateDB StateDB - subscriptions map[common.Hash]*Subscription - subscriptionsByEvent map[common.Hash][]*Subscription -} - -func (sm *SubscriptionManager) Subscribe( - target common.Address, - eventSig common.Hash, - subscriber common.Address, - callback common.Address, - selector [4]byte, - gasLimit uint64, - gasPrice *big.Int, -) (common.Hash, error) { - // Create subscription ID - subID := crypto.Keccak256Hash( - target.Bytes(), - eventSig.Bytes(), - subscriber.Bytes(), - ) - - // Create subscription record - sub := &Subscription{ - ID: subID, - TargetContract: target, - EventSignature: eventSig, - SubscriberContract: subscriber, - CallbackAddress: callback, - CallbackSelector: selector, - GasLimit: gasLimit, - GasPrice: gasPrice, - DepositBalance: big.NewInt(0), - Active: true, - } - - // Store in state - sm.subscriptions[subID] = sub - - // Index by event - eventKey := crypto.Keccak256Hash(target.Bytes(), eventSig.Bytes()) - sm.subscriptionsByEvent[eventKey] = append( - sm.subscriptionsByEvent[eventKey], - sub, - ) - - // Persist to trie - sm.stateDB.SetSubscription(subID, sub) - - return subID, nil -} - -func (sm *SubscriptionManager) NotifySubscribers( - target common.Address, - eventSig common.Hash, - eventData []byte, -) []*CallbackExecution { - eventKey := crypto.Keccak256Hash(target.Bytes(), eventSig.Bytes()) - subscribers := sm.subscriptionsByEvent[eventKey] - - callbacks := make([]*CallbackExecution, 0, len(subscribers)) - - for _, sub := range subscribers { - if !sub.Active { - continue - } - - // Calculate gas cost - gasCost := new(big.Int).Mul( - new(big.Int).SetUint64(sub.GasLimit), - sub.GasPrice, - ) - - // Check deposit balance - if sub.DepositBalance.Cmp(gasCost) < 0 { - // Insufficient balance, skip - sm.stateDB.AddLog(&types.Log{ - Address: sub.SubscriberContract, - Topics: []common.Hash{ - InsufficientDepositEvent, - sub.ID, - }, - }) - continue - } - - // Deduct gas - sub.DepositBalance.Sub(sub.DepositBalance, gasCost) - sm.stateDB.SetSubscription(sub.ID, sub) - - // Build callback data - callbackData := append(sub.CallbackSelector[:], eventData...) - - // Create callback execution - callbacks = append(callbacks, &CallbackExecution{ - SubscriptionID: sub.ID, - SubscriberAddress: sub.SubscriberContract, - CallbackAddress: sub.CallbackAddress, - CallbackData: callbackData, - GasLimit: sub.GasLimit, - GasPrice: sub.GasPrice, - OriginalOrigin: common.Address{}, // Set by caller - }) - } - - return callbacks -} -``` - -## Test Cases - -### Test Case 1: Basic Subscription and Callback - -```solidity -function testBasicSubscription() public { - // Deploy oracle - PriceOracle oracle = new PriceOracle(); - - // Deploy subscriber with gas deposit - DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( - address(oracle) - ); - - // Verify subscription created - assertTrue(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); - - // Emit event - oracle.updatePrice(1000); - - // Verify callback executed - assertEq(subscriber.lastSyncedPrice(), 1000); -} -``` - -### Test Case 2: Callback Out of Gas - -```solidity -function testCallbackOutOfGas() public { - // Create subscription with insufficient gas - DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( - address(oracle) - ); - subscriber.updateSubscription(address(oracle), "PriceUpdated", 10000, 20 gwei); // Too low - - // Emit event - oracle.updatePrice(1000); - - // Verify original transaction succeeded - assertEq(oracle.price(), 1000); - - // Verify callback failed gracefully - assertEq(subscriber.lastSyncedPrice(), 0); // Not updated - - // Verify failure was logged - // (check logs for CallbackFailed event) -} -``` - -### Test Case 3: Insufficient Deposit - -```solidity -function testInsufficientDeposit() public { - DerivedProtocol subscriber = new DerivedProtocol{value: 0.001 ether}( - address(oracle) - ); - - // Emit events until deposit exhausted - for (uint i = 0; i < 100; i++) { - oracle.updatePrice(i); - } - - // Verify early events succeeded - assertTrue(subscriber.lastSyncedPrice() > 0); - - // Verify later events skipped due to insufficient deposit - assertLt(subscriber.lastSyncedPrice(), 99); -} -``` - -### Test Case 4: Multiple Subscribers - -```solidity -function testMultipleSubscribers() public { - PriceOracle oracle = new PriceOracle(); - - DerivedProtocol sub1 = new DerivedProtocol{value: 1 ether}(address(oracle)); - DerivedProtocol sub2 = new DerivedProtocol{value: 1 ether}(address(oracle)); - DerivedProtocol sub3 = new DerivedProtocol{value: 1 ether}(address(oracle)); - - // Emit event - oracle.updatePrice(500); - - // Verify all callbacks executed - assertEq(sub1.lastSyncedPrice(), 500); - assertEq(sub2.lastSyncedPrice(), 500); - assertEq(sub3.lastSyncedPrice(), 500); -} -``` - -### Test Case 5: Unsubscribe - -```solidity -function testUnsubscribe() public { - DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( - address(oracle) - ); - - // Verify subscribed - assertTrue(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); - - // Unsubscribe - subscriber.cleanup(address(oracle)); - - // Verify unsubscribed - assertFalse(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); - - // Emit event - oracle.updatePrice(1000); - - // Verify callback not executed - assertEq(subscriber.lastSyncedPrice(), 0); -} -``` - -## Copyright - -Copyright and related rights waived via [CC0](../LICENSE.md). From 2ce869b665f8926b02a7627df25324cecf6f3e01 Mon Sep 17 00:00:00 2001 From: Lucas Cullen Date: Fri, 21 Nov 2025 07:07:45 +1000 Subject: [PATCH 6/6] chore: update eip --- EIPS/eip-8082.md | 818 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 818 insertions(+) create mode 100644 EIPS/eip-8082.md diff --git a/EIPS/eip-8082.md b/EIPS/eip-8082.md new file mode 100644 index 00000000000000..16fbc841f7fac6 --- /dev/null +++ b/EIPS/eip-8082.md @@ -0,0 +1,818 @@ +--- +eip: 8082 +title: Contract Event Subscription +description: Allows contracts to subscribe to and react to events emitted by other contracts with gas-bounded execution +author: Lucas Cullen (@bitcoinbrisbane) +discussions-to: https://ethereum-magicians.org/t/eip-to-be-assigned-contract-event-subscription/26575 +status: Draft +type: Standards Track +category: Core +created: 2025-11-15 +requires: +--- + +## Abstract + +This EIP introduces a mechanism for smart contracts to subscribe to events emitted by other contracts and automatically execute callback functions when those events occur. Subscriptions are paid for by the subscribing contract, execute with bounded gas, and fail gracefully without blocking the original transaction if gas runs out or execution fails. + +## Motivation + +Currently, smart contracts cannot natively react to events emitted by other contracts. Developers must rely on off-chain infrastructure (indexers, bots, relayers) to listen for events and trigger subsequent transactions. This creates several problems: + +1. **Centralization**: Requires trusted off-chain infrastructure +2. **Latency**: Introduces delays between event emission and reaction +3. **Complexity**: Requires maintaining off-chain services and private keys +4. **Cost**: Users must pay for multiple transactions +5. **Atomicity**: Cannot guarantee atomic execution with the original transaction + +On-chain event subscriptions would enable: + +- **Reactive DeFi protocols** (automatic liquidations, rebalancing) +- **Cross-contract coordination** (DAO proposals triggering dependent actions) +- **Decentralized automation** (eliminating relayer centralization) +- **Atomic multi-step protocols** (oracle updates triggering derivative settlements) + +## Specification + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119 and RFC 8174. + +### Overview + +1. Contracts declare subscribable events using enhanced event syntax +2. Contracts subscribe to events using a new `subscribe` keyword +3. When an event is emitted, subscribed callbacks are executed in isolated contexts +4. Each subscription executes with caller-provided gas limits +5. Subscription failures are caught and logged but do not revert the parent transaction + +### Solidity Language Changes + +#### 1. Subscribable Event Declaration + +Events can be marked as `subscribable` to indicate they support on-chain subscriptions: + +```solidity +// Basic subscribable event +event subscribable Transfer(address indexed from, address indexed to, uint256 value); + +// Event with subscription gas hint +event subscribable PriceUpdated(uint256 price) gasHint(100000); +``` + +The `gasHint` annotation suggests minimum gas needed for reasonable subscription handling. + +#### 2. Subscription Syntax + +Contracts subscribe to events using the `subscribe` statement in their constructor or a dedicated subscription management function: + +```solidity +contract Subscriber { + // Subscribe in constructor + constructor(address targetContract) { + subscribe targetContract.Transfer(from, to, value) + with onTransfer(from, to, value) + gasLimit 150000 + gasPrice 20 gwei; + } + + // Callback function - MUST be payable to receive gas payment refunds + function onTransfer(address from, address to, uint256 value) + external + payable + onlyEventCallback + { + // Handle the event + // If this runs out of gas or reverts, the original Transfer event still succeeds + } + + // Unsubscribe + function cleanup(address targetContract) external { + unsubscribe targetContract.Transfer; + } +} +``` + +#### 3. Event Callback Modifier + +A new modifier `onlyEventCallback` ensures functions can only be called by the EVM's subscription dispatcher: + +```solidity +modifier onlyEventCallback { + require(msg.sender == address(0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF), "Only event callbacks"); + _; +} +``` + +The special address `0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF` is reserved for the subscription dispatcher. + +#### 4. Subscription Management + +```solidity +// Check if subscribed +bool isSubscribed = this.isSubscribedTo(targetContract, "Transfer"); + +// Get subscription details +(uint256 gasLimit, uint256 gasPrice, address callback) = + this.getSubscription(targetContract, "Transfer"); + +// Update subscription gas parameters +updateSubscription(targetContract, "Transfer", newGasLimit, newGasPrice); +``` + +### EVM Changes + +#### 1. New Opcodes + +**`SUBSCRIBE` (0x5c)** + +- Stack input: `[target_address, event_signature, callback_address, callback_selector, gas_limit, gas_price]` +- Stack output: `[subscription_id]` +- Gas cost: 20,000 + storage costs +- Creates a subscription record in global subscription storage + +**`UNSUBSCRIBE` (0x5d)** + +- Stack input: `[subscription_id]` +- Stack output: `[success]` +- Gas cost: 5,000 + storage refund +- Removes subscription and refunds storage + +**`NOTIFYSUBSCRIBERS` (0x5e)** + +- Stack input: `[event_signature, data_offset, data_size]` +- Stack output: `[num_notified]` +- Gas cost: 2,000 + (500 \* num_subscribers) +- Called automatically during LOG operations for subscribable events +- Schedules callback executions + +#### 2. Subscription Storage Model + +Subscriptions are stored in a new EVM state trie separate from contract storage: + +``` +SubscriptionKey = keccak256(target_address, event_signature, subscriber_address) +SubscriptionValue = RLP([callback_address, callback_selector, gas_limit, gas_price, deposit]) +``` + +#### 3. Event Emission Flow + +When a subscribable event is emitted: + +``` +1. Event is logged normally (LOG0-LOG4 opcodes) +2. If event is marked subscribable, NOTIFYSUBSCRIBERS is called +3. For each subscription: + a. Check subscriber has sufficient deposited gas payment + b. Deduct gas payment (gas_limit * gas_price) from deposit + c. Schedule callback execution in isolated context + d. Execute callback with try-catch semantics + e. Refund unused gas to subscriber + f. Log callback success/failure +4. Original transaction continues regardless of callback outcomes +``` + +#### 4. Callback Execution Context + +Callbacks execute in an isolated context: + +``` +- msg.sender = 0xFFfFfFffFFfffFFfFFfFFFFFffFFFffffFfFFFfF (subscription dispatcher) +- tx.origin = original tx.origin (preserved from parent) +- block.* = same as parent transaction +- Gas limit = subscription gas_limit +- Reverts/failures are caught and logged but don't propagate +- State changes are included if callback succeeds +- DELEGATECALL and CALLCODE are disabled in callbacks +``` + +#### 5. Gas Accounting + +Subscription gas costs are separate from the transaction that emits the event: + +1. **Subscription Deposit**: Subscribers must deposit ETH to cover future callback executions +2. **Per-Callback Deduction**: When event is emitted, `gas_limit * gas_price` is deducted from deposit +3. **Refunds**: Unused gas is refunded to subscriber's deposit balance +4. **Insufficient Balance**: If deposit insufficient, callback is skipped and event logged +5. **Withdrawal**: Subscribers can withdraw unused deposits + +#### 6. New Precompile: Subscription Manager (0x0a) + +Address: `0x000000000000000000000000000000000000000a` + +Functions: + +- `deposit(subscription_id)` - Add ETH to subscription deposit +- `withdraw(subscription_id, amount)` - Withdraw from deposit +- `getBalance(subscription_id)` - Query deposit balance +- `getSubscriptionInfo(subscription_id)` - Get subscription details + +### Compiler Changes (Solidity) + +#### 1. Event Declaration Parsing + +The Solidity compiler must: + +- Parse `subscribable` keyword on event declarations +- Parse optional `gasHint(uint256)` annotation +- Emit metadata indicating event is subscribable +- Include subscription hints in contract ABI + +```json +{ + "type": "event", + "name": "Transfer", + "inputs": [...], + "subscribable": true, + "gasHint": 100000 +} +``` + +#### 2. Subscribe Statement Compilation + +The `subscribe` statement compiles to: + +``` +1. Load subscription parameters onto stack +2. Call SUBSCRIBE opcode +3. Store returned subscription_id +4. Emit SubscriptionCreated event for off-chain indexing +``` + +#### 3. Built-in Subscription Functions + +The compiler provides built-in functions: + +```solidity +// Automatically available in all contracts +function isSubscribedTo(address target, string memory eventSig) internal view returns (bool); +function getSubscription(address target, string memory eventSig) internal view returns (...); +function updateSubscription(address target, string memory eventSig, uint256 gasLimit, uint256 gasPrice) internal; +``` + +#### 4. Callback Function Validation + +The compiler enforces: + +- Callback functions MUST be `external` +- Callback functions SHOULD be `payable` to receive gas refunds +- Callback functions MUST use `onlyEventCallback` modifier or equivalent check +- Parameter types MUST match subscribed event types + +### Client Implementation (Geth) + +#### 1. Subscription State Management + +New database schema: + +```go +type Subscription struct { + ID common.Hash + TargetContract common.Address + EventSignature common.Hash + SubscriberContract common.Address + CallbackAddress common.Address + CallbackSelector [4]byte + GasLimit uint64 + GasPrice *big.Int + DepositBalance *big.Int + Active bool +} +``` + +#### 2. EVM Modification + +In `core/vm/evm.go`: + +```go +// New field in EVM struct +type EVM struct { + // ... existing fields + SubscriptionManager *SubscriptionManager + PendingCallbacks []*CallbackExecution +} + +// Execute callbacks after main execution +func (evm *EVM) ProcessCallbacks() error { + for _, cb := range evm.PendingCallbacks { + evm.executeCallback(cb) + } + return nil +} + +func (evm *EVM) executeCallback(cb *CallbackExecution) { + // Create isolated context + snapshot := evm.StateDB.Snapshot() + + // Set special msg.sender + evm.Context.Origin = cb.OriginalOrigin + + // Execute with try-catch semantics + ret, gasUsed, err := evm.Call( + AccountRef(SUBSCRIPTION_DISPATCHER_ADDRESS), + cb.CallbackAddress, + cb.CallbackData, + cb.GasLimit, + big.NewInt(0), + ) + + if err != nil { + // Revert callback state changes but continue + evm.StateDB.RevertToSnapshot(snapshot) + // Log callback failure + evm.StateDB.AddLog(&types.Log{ + Address: cb.SubscriberAddress, + Topics: []common.Hash{CallbackFailedEvent, cb.SubscriptionID}, + Data: []byte(err.Error()), + }) + } else { + // Refund unused gas + refund := (cb.GasLimit - gasUsed) * cb.GasPrice + evm.SubscriptionManager.RefundGas(cb.SubscriptionID, refund) + } +} +``` + +#### 3. LOG Opcode Modification + +In `core/vm/instructions.go`: + +```go +func opLogN(pc *uint64, interpreter *EVMInterpreter, scope *ScopeContext) ([]byte, error) { + // ... existing LOG implementation + + // Check if event is subscribable + eventSig := scope.Stack.peek().Bytes32() + if interpreter.evm.SubscriptionManager.IsSubscribableEvent(scope.Contract.Address(), eventSig) { + // Notify subscribers + subscribers := interpreter.evm.SubscriptionManager.GetSubscribers( + scope.Contract.Address(), + eventSig, + ) + + for _, sub := range subscribers { + // Deduct gas from deposit + if !sub.DeductGas() { + // Insufficient deposit, skip and log + interpreter.evm.StateDB.AddLog(insufficientGasLog(sub)) + continue + } + + // Schedule callback + callback := &CallbackExecution{ + SubscriptionID: sub.ID, + SubscriberAddress: sub.SubscriberContract, + CallbackAddress: sub.CallbackAddress, + CallbackData: buildCallbackData(sub, logData), + GasLimit: sub.GasLimit, + GasPrice: sub.GasPrice, + OriginalOrigin: interpreter.evm.Context.Origin, + } + interpreter.evm.PendingCallbacks = append( + interpreter.evm.PendingCallbacks, + callback, + ) + } + } + + return nil, nil +} +``` + +#### 4. State Trie Extension + +Add new subscription trie alongside existing state tries: + +```go +type StateDB struct { + // ... existing fields + subscriptionTrie Trie + subscriptionCache *lru.Cache +} +``` + +#### 5. RPC Extensions + +New RPC methods: + +```go +// Get all subscriptions for an address +eth_getSubscriptions(address) -> []Subscription + +// Get subscription details +eth_getSubscription(subscriptionId) -> Subscription + +// Get callback execution history +eth_getCallbackHistory(subscriptionId, fromBlock, toBlock) -> []CallbackLog +``` + +## Rationale + +### Design Decisions + +**Why isolated execution context?** +Prevents subscription callbacks from blocking or reverting the original transaction. The emitting contract should not care about subscriber behavior. + +**Why require payable callbacks?** +Enables gas refunds to be returned to the subscribing contract, improving efficiency. + +**Why separate deposit model?** +Prevents DoS attacks where subscriptions drain the emitting contract's gas. Subscribers pay for their own execution. + +**Why special dispatcher address?** +Provides a secure, verifiable way for callbacks to know they're being called by the subscription system rather than an attacker. + +**Why bounded gas?** +Prevents infinite loops or excessive gas consumption from blocking event emission or consuming unreasonable resources. + +**Why not use CREATE2 deterministic callbacks?** +CREATE2 would require deploying a new contract for each subscription, wasting storage and gas. The proposed system is more efficient. + +### Alternative Approaches Considered + +1. **Event Relayer Precompile**: A precompile that stores events and allows polling. Rejected because it still requires off-chain infrastructure. + +2. **Callback in Same Transaction**: Execute callbacks synchronously in the same call frame. Rejected because callback failures would revert the emitting transaction. + +3. **Deferred Transaction Queue**: Store callbacks as pending transactions for future blocks. Rejected due to complexity and unpredictable execution timing. + +## Backwards Compatibility + +This EIP introduces new opcodes and language features but maintains full backwards compatibility: + +1. **Existing Contracts**: Continue to work without modification +2. **Existing Events**: Can be emitted normally; `subscribable` is opt-in +3. **Non-upgraded Clients**: Can process blocks but will skip subscription execution (fork required) +4. **ABI Compatibility**: New ABI fields are additive only + +### Hard Fork Required + +This EIP requires a coordinated hard fork to activate: + +- All clients must implement new opcodes +- Subscription state trie must be initialized +- Subscription dispatcher precompile must be activated + +## Security Considerations + +### 1. Reentrancy Protection + +Callbacks execute after the main transaction completes, preventing reentrancy attacks on the emitting contract. The isolated context ensures callbacks cannot call back into the emitter within the same transaction. + +### 2. Gas Griefing + +**Attack**: Subscribing to popular events with insufficient deposits to waste emitter gas. + +**Mitigation**: + +- Subscription notification cost (500 gas per subscriber) is low +- Insufficient deposits skip execution rather than failing +- Emitters can limit subscribable events + +### 3. DoS via Excessive Subscriptions + +**Attack**: Creating millions of subscriptions to slow down event emission. + +**Mitigation**: + +- SUBSCRIBE opcode has high base cost (20,000 gas) +- NOTIFYSUBSCRIBERS charges per subscriber (500 gas each) +- Practical limit: ~60,000 gas / 500 = ~120 subscribers per event emission +- Emitters can choose not to mark events as subscribable + +### 4. Front-Running Subscriptions + +**Attack**: Front-running subscription creation to intercept events meant for others. + +**Mitigation**: Subscriptions are public state; this is expected behavior. Sensitive events should not be subscribable. + +### 5. Callback Impersonation + +**Attack**: Calling a callback function directly, bypassing event emission. + +**Mitigation**: The `onlyEventCallback` modifier checks for the special dispatcher address, which cannot be impersonated by user transactions. + +### 6. Deposit Draining + +**Attack**: Emitting events rapidly to drain subscriber deposits. + +**Mitigation**: Subscribers control their gas limits and can withdraw deposits. This is similar to users controlling their own transaction gas. + +### 7. State Inconsistency + +**Attack**: Callback executes based on stale state if emitter's state changes before callback runs. + +**Mitigation**: Callbacks execute immediately after the emitting transaction in the same block. State is consistent within the transaction context. + +### 8. Cross-Contract Reentrancy + +**Attack**: Callback modifies state that affects other pending callbacks. + +**Mitigation**: Callbacks are executed sequentially in the order they were subscribed. Each callback sees the cumulative state changes from previous callbacks (similar to transaction ordering). + +## Reference Implementation + +### Solidity Example: Price Oracle with Subscribers + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract PriceOracle { + uint256 public price; + + event subscribable PriceUpdated(uint256 newPrice) gasHint(50000); + + function updatePrice(uint256 _price) external { + price = _price; + emit PriceUpdated(_price); + // Subscribers are automatically notified + } +} + +contract DerivedProtocol { + PriceOracle public oracle; + uint256 public lastSyncedPrice; + uint256 public depositBalance; + + event PriceSynced(uint256 price); + event SubscriptionGasRefund(uint256 amount); + + constructor(address _oracle) payable { + oracle = PriceOracle(_oracle); + + // Subscribe to price updates + subscribe oracle.PriceUpdated(newPrice) + with onPriceUpdate(newPrice) + gasLimit 100000 + gasPrice 20 gwei; + + // Deposit gas payment + depositBalance = msg.value; + } + + // Callback function - automatically called when PriceUpdated is emitted + function onPriceUpdate(uint256 newPrice) + external + payable + onlyEventCallback + { + lastSyncedPrice = newPrice; + emit PriceSynced(newPrice); + + // Process gas refund if any + if (msg.value > 0) { + depositBalance += msg.value; + emit SubscriptionGasRefund(msg.value); + } + + // Perform derivative calculations + // If this reverts, the oracle's updatePrice() still succeeds + rebalancePositions(newPrice); + } + + function rebalancePositions(uint256 newPrice) internal { + // Complex logic that might fail + // Failures are graceful and logged + } + + // Withdraw unused deposit + function withdrawDeposit(uint256 amount) external { + require(depositBalance >= amount, "Insufficient balance"); + depositBalance -= amount; + payable(msg.sender).transfer(amount); + } +} +``` + +### Geth Implementation Sketch + +```go +// core/vm/subscription_manager.go +package vm + +type SubscriptionManager struct { + stateDB StateDB + subscriptions map[common.Hash]*Subscription + subscriptionsByEvent map[common.Hash][]*Subscription +} + +func (sm *SubscriptionManager) Subscribe( + target common.Address, + eventSig common.Hash, + subscriber common.Address, + callback common.Address, + selector [4]byte, + gasLimit uint64, + gasPrice *big.Int, +) (common.Hash, error) { + // Create subscription ID + subID := crypto.Keccak256Hash( + target.Bytes(), + eventSig.Bytes(), + subscriber.Bytes(), + ) + + // Create subscription record + sub := &Subscription{ + ID: subID, + TargetContract: target, + EventSignature: eventSig, + SubscriberContract: subscriber, + CallbackAddress: callback, + CallbackSelector: selector, + GasLimit: gasLimit, + GasPrice: gasPrice, + DepositBalance: big.NewInt(0), + Active: true, + } + + // Store in state + sm.subscriptions[subID] = sub + + // Index by event + eventKey := crypto.Keccak256Hash(target.Bytes(), eventSig.Bytes()) + sm.subscriptionsByEvent[eventKey] = append( + sm.subscriptionsByEvent[eventKey], + sub, + ) + + // Persist to trie + sm.stateDB.SetSubscription(subID, sub) + + return subID, nil +} + +func (sm *SubscriptionManager) NotifySubscribers( + target common.Address, + eventSig common.Hash, + eventData []byte, +) []*CallbackExecution { + eventKey := crypto.Keccak256Hash(target.Bytes(), eventSig.Bytes()) + subscribers := sm.subscriptionsByEvent[eventKey] + + callbacks := make([]*CallbackExecution, 0, len(subscribers)) + + for _, sub := range subscribers { + if !sub.Active { + continue + } + + // Calculate gas cost + gasCost := new(big.Int).Mul( + new(big.Int).SetUint64(sub.GasLimit), + sub.GasPrice, + ) + + // Check deposit balance + if sub.DepositBalance.Cmp(gasCost) < 0 { + // Insufficient balance, skip + sm.stateDB.AddLog(&types.Log{ + Address: sub.SubscriberContract, + Topics: []common.Hash{ + InsufficientDepositEvent, + sub.ID, + }, + }) + continue + } + + // Deduct gas + sub.DepositBalance.Sub(sub.DepositBalance, gasCost) + sm.stateDB.SetSubscription(sub.ID, sub) + + // Build callback data + callbackData := append(sub.CallbackSelector[:], eventData...) + + // Create callback execution + callbacks = append(callbacks, &CallbackExecution{ + SubscriptionID: sub.ID, + SubscriberAddress: sub.SubscriberContract, + CallbackAddress: sub.CallbackAddress, + CallbackData: callbackData, + GasLimit: sub.GasLimit, + GasPrice: sub.GasPrice, + OriginalOrigin: common.Address{}, // Set by caller + }) + } + + return callbacks +} +``` + +## Test Cases + +### Test Case 1: Basic Subscription and Callback + +```solidity +function testBasicSubscription() public { + // Deploy oracle + PriceOracle oracle = new PriceOracle(); + + // Deploy subscriber with gas deposit + DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( + address(oracle) + ); + + // Verify subscription created + assertTrue(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); + + // Emit event + oracle.updatePrice(1000); + + // Verify callback executed + assertEq(subscriber.lastSyncedPrice(), 1000); +} +``` + +### Test Case 2: Callback Out of Gas + +```solidity +function testCallbackOutOfGas() public { + // Create subscription with insufficient gas + DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( + address(oracle) + ); + subscriber.updateSubscription(address(oracle), "PriceUpdated", 10000, 20 gwei); // Too low + + // Emit event + oracle.updatePrice(1000); + + // Verify original transaction succeeded + assertEq(oracle.price(), 1000); + + // Verify callback failed gracefully + assertEq(subscriber.lastSyncedPrice(), 0); // Not updated + + // Verify failure was logged + // (check logs for CallbackFailed event) +} +``` + +### Test Case 3: Insufficient Deposit + +```solidity +function testInsufficientDeposit() public { + DerivedProtocol subscriber = new DerivedProtocol{value: 0.001 ether}( + address(oracle) + ); + + // Emit events until deposit exhausted + for (uint i = 0; i < 100; i++) { + oracle.updatePrice(i); + } + + // Verify early events succeeded + assertTrue(subscriber.lastSyncedPrice() > 0); + + // Verify later events skipped due to insufficient deposit + assertLt(subscriber.lastSyncedPrice(), 99); +} +``` + +### Test Case 4: Multiple Subscribers + +```solidity +function testMultipleSubscribers() public { + PriceOracle oracle = new PriceOracle(); + + DerivedProtocol sub1 = new DerivedProtocol{value: 1 ether}(address(oracle)); + DerivedProtocol sub2 = new DerivedProtocol{value: 1 ether}(address(oracle)); + DerivedProtocol sub3 = new DerivedProtocol{value: 1 ether}(address(oracle)); + + // Emit event + oracle.updatePrice(500); + + // Verify all callbacks executed + assertEq(sub1.lastSyncedPrice(), 500); + assertEq(sub2.lastSyncedPrice(), 500); + assertEq(sub3.lastSyncedPrice(), 500); +} +``` + +### Test Case 5: Unsubscribe + +```solidity +function testUnsubscribe() public { + DerivedProtocol subscriber = new DerivedProtocol{value: 1 ether}( + address(oracle) + ); + + // Verify subscribed + assertTrue(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); + + // Unsubscribe + subscriber.cleanup(address(oracle)); + + // Verify unsubscribed + assertFalse(subscriber.isSubscribedTo(address(oracle), "PriceUpdated")); + + // Emit event + oracle.updatePrice(1000); + + // Verify callback not executed + assertEq(subscriber.lastSyncedPrice(), 0); +} +``` + +## Copyright + +Copyright and related rights waived via [CC0](../LICENSE.md).