Smart Contract Event Hooks
相关视频
正文
Abstract
This EIP proposes a standard for creating "hooks" that allow a smart contract function to be called automatically in response to a trigger fired by another contract, by using a public relayer network as a messaging bus.
While there are many similar solutions in existence already, this proposal describes a simple yet powerful primitive that can be employed by many applications in an open, permissionless and decentralized manner.
It relies on two interfaces, one for a publisher contract and one for a subscriber contract. The publisher contract emits events that are picked up by "relayers", who are independent entities that subscribe to "hook" events on publisher contracts, and call a function on the respective subscriber contracts, whenever a hook event is fired by the publisher contracts. Whenever a relayer calls the respective subscriber's contract with the details of the hook event emitted by the publisher contract, they are paid a fee by the subscriber. Both the publisher and subscriber contracts are registered in a central registry smart contract that relayers can use to discover hooks.
Motivation
There exists a number of use cases that require some off-chain party to monitor the chain and respond to on-chain events by broadcasting a transaction. Such cases usually require some off-chain process to run alongside an Ethereum node in order to subscribe to events emitted by smart contract, and then execute some logic in response and subsequently broadcast a transaction to the network. This requires an Ethereum node and an open websocket connection to some long-running process that may only be used infrequently, resulting in a sub-optimal use of resources.
This proposal would allow for a smart contract to contain the logic it needs to respond to events without having to store that logic in some off-chain process. The smart contract can subscribe to events fired by other smart contracts and would only execute the required logic when it is needed. This method would suit any contract logic that does not require off-chain computation, but usually requires an off-chain process to monitor the chain state. With this approach, subscribers do not need their own dedicated off-chain processes for monitoring and responding to contract events. Instead, a single incentivized relayer can subscribe to many different events on behalf of multiple different subscriber contracts.
Examples of use cases that would benefit from this scheme include:
Collateralised Lending Protocols
Collateralised lending protocols or stablecoins can emit events whenever they receive price oracle updates, which would allow borrowers to automatically "top-up" their open positions to avoid liquidation.
For example, Maker uses the "medianizer" smart contract which maintains a whitelist of price feed contracts which are allowed to post price updates. Every time a new price update is received, the median of all feed prices is re-computed and the medianized value is updated. In this case, the medianizer smart contract could fire a hook event that would allow subscriber contracts to decide to re-collateralize their CDPs.
Automated Market Makers
AMM liquidity pools could fire a hook event whenever liquidity is added or removed. This could allow a subscriber smart contracts to add or remove liquidity once the total pool liquidity reaches a certain point.
AMMs can fire a hook whenever there is a trade within a trading pair, emitting the time-weighted-price-oracle update via an hook event. Subscribers can use this to create an automated Limit-Order-Book type contract to buy/sell tokens once an asset's spot price breaches a pre-specified threshold.
DAO Voting
Hook events can be emitted by a DAO governance contract to signal that a proposal has been published, voted on, carried or vetoed, and would allow any subscriber contract to automatically respond accordingly. For example, to execute some smart contract function whenever a specific proposal has passed, such as an approval for payment of funds.
Scheduled Function Calls
A scheduler service can be created whereby a subscriber can register for a scheduled funtion call, this could be done using unix cron format and the service can fire events from a smart contract on separate threads. Subscriber contracts can subscriber to the respective threads in order to subscribe to certain schedules (e.g. daily, weekly, hourly etc.), and could even register customer cron schedules.
Recurring Payments
A service provider can fire Hook events that will allow subscriber contracts to automatically pay their service fees on a regular schedule. Once the subscriber contracts receive a hook event, they can call a function on the service provider's contract to transfer funds due.
Coordination via Delegation
Hook event payloads can contain any arbitrary data, this means you can use things like the Delegatable framework to sign off-chain delegations which can faciliate a chain of authorized entities to publish valid Hook events. You can also use things like BLS threshold signatures, to facilitate multiple off-chain publishers to authorize the firing of a Hook.
Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Registering a Publisher
Both the publisher and subscriber contracts MUST register in a specific register contract, similarly to how smart contracts register an interface in the ERC-1820 contract. The registry contract MUST must use a deterministic deployment mechanism, i.e. using a factory contract and a specific salt.
To register a publisher contract's hook, the registerHook
function MUST be called on the registry contract. The parameters that need to be supplied are:
- (address) The publisher contract address
- (uint256) The thread id that the hooks events will reference (a single contract can fire hook events with any number of threads, subscribers can choose which threads to subscribe to)
- (bytes) The public key associated with the hook events (optional)
When the registerHook
function is called on the registry contract, the registry contract MUST make a downstream call to the publisher contract address, by calling the publisher contract's verifyEventHookRegistration
function, with the same arguments as passed to the registerHook
function on the registry contract. The verifyEventHookRegistration
function in the publisher contract MUST return true in order to indicate that the contract will allow itself to be added to the registry as a publisher. The registry contract MUST emit a HookRegistered
event to indicate that a new publisher contract has been added.
Updating a Hook
Publishers may want to update the details associated with a Hook event, or indeed remove support for a Hook event completely. The registry contract MUST implement the updatePublisher
function to allow for an existing publisher contract to be updated in the registry. The registry contract MUST emit a PublisherUpdated
event to indicate that the publisher contract was updated.
Removing a Hook
To remove a previously registered Hook, the function removeHook
function must be called on the Registry contract, with the same parameters as the updateHook
function. The registry contract MUST emit a HookRemoved
event with the same parameters as passed to the 'removeHook' function and the msg.sender
value.
Registering a Subscriber
To register a subscriber to a hook, the registerSubscriber
function MUST be called on the registry contract with the following parameters:
- (address) The publisher contract address
- (bytes32) The subscriber contract address
- (uint256) The thread id to subscribe to
- (uint256) The fee that the subscriber is willing to pay to get updates
- (uint256) The maximum gas that the subscriber will allow for updates, to prevent griefing attacks, or 0 to indicate no maximum
- (uint256) The maximum gas price that the subscriber is willing to repay the relayer on top of the fee, or 0 to indicate no rebates
- (uint256) The chain id that the subscriber wants updates from
- (address) The address of the token that the fee will be paid in or 0x0 for the chain's native asset (e.g. ETH, MATIC etc.)
The subscriber contract MAY implement gas refunds on top of the fixed fee per update. Where a subscriber chooses to do this, then they SHOULD specify the maximum gas
and maximum gas price
parameters in order to protect themselves from griefing attacks. This is so that a malicious or careless relay doesn't set an exorbitantly high gas price and ends up draining the subscriber contracts. Subscriber contracts can otherwise choose to set a fee that is estimated to be sufficiently high to cover gas fees.
Note that while the chain id and the token address were not included in the original version of the spec, the simple addition of these two parameters allows for leveraging the relayers for cross chain messages, should the subscriber wish to do this, and also allows for paying relayer fees in various tokens.
Updating a Subscription
To update a subscription, the updateSubscriber
function MUST be called with the same set of parameters as the registerSubscriber
function. This might be done in order to cancel a subscription, or to change the subscription fee. Note that the updateSubscriber
function MUST maintain the same msg.sender
that the registerSubscriber
function was called with.
Removing a Subscription
To remove a previously registered subscription, the function removeSubscriber
MUST be called on the Registry contract, with the same parameters as the updateSubscriber
function, but without the fee
parameter (i.e. publisher and subscriber contract addresses and thread id). The fee will be subsequently set to 0 to indicate that the subscriber no longer wants updates for this subscription. The registry contract MUST emit a SubscriptionRemoved
event with publisher contract address, subscriber contract address and the thread id as topics.
Publishing an Event
A publisher contract SHOULD emit a hook event from at least one function. The emitted event MUST be called Hook
and MUST contain the following parameters:
- uint256 (indexed) - threadId
- uint256 (indexed) - nonce
- bytes32 digest
- bytes payload
- bytes32 checksum
The nonce
value MUST be incremented every time a Hook event is fired by a publisher contract. Every Hook event MUST have a unique nonce
value. The nonce
property is initiated to 1, but the first Hook event ever fired MUST be set to 2. This is to prevent ambiguity between an uninitiated nonce variable and a nonce variable that is explicitly initiated to zero.
The digest
parameter of the event MUST be the keccak256 hash of the payload, and the checksum
MUST be the keccak256 hash of the concatenation of the digest with the current blockheight, e.g.:
bytes32 checksum = keccak256(abi.encodePacked(digest, block.number));
The Hook
event can be triggered by a function call from any EOA or external contract. This allows the payload to be created dynamically within the publisher contract. The subscriber contract SHOULD call the verifyEventHook
function on the publisher contract to verify that the received Hook payload is valid.
The payload MAY be passed to the function firing the Hook event instead of being generated within the publisher contract itself, but if a signature is provided it MUST sign a hash of the payload, and it is strongly recommended to use the EIP-712 standard, and to follow the data structure outlined at the end of this proposal. This signature SHOULD be verified by the subscribers to ensure they are getting authentic events. The signature MUST correspond to the public key that was registered with the event. With this approach, the signature SHOULD be placed at the start of the payload (e.g. bytes 0 to 65 for an ECDSA signature with r, s, v properties). This method of verification can be used for cross-chain Hook events, where subscribers will not be able to call the verifyHookEvent
of the publisher contract on another chain.
The payload MUST be passed to subscribers as a byte array in calldata. The subscriber smart contract SHOULD convert the byte array into the required data type. For example, if the payload is a snark proof, the publisher would need to serialize the variables into a byte array, and the subscriber smart contract would need to deserialize it on the other end, e.g.:
struct SnarkProof {
uint256[2] a;
uint256[2][2] b;
uint256[2] c;
uint256[1] input;
}
SnarkProof memory zkproof = abi.decode(payload, SnarkProof);
Relayers
Relayers are independent parties that listen to Hook
events on publisher smart contracts. Relayers retrieve a list of subscribers for different hooks from the registry, and listen for hook events being fired on the publisher contracts. Once a hook event has been fired by a publisher smart contract, relayers can decide to relay the hook event's payload to the subscriber contracts by broadcasting a transaction that executes the subscriber contract's verifyHook
function. Relayers are incentivised to do this because it is expected that the subscriber contract will remunerate them with ETH, or some other asset.
Relayers SHOULD simulate the transaction locally before broadcasting it to make sure that the subscriber contract has sufficient balance for payment of the fee. This requires subscriber contracts to maintain a balance of ETH (or some asset) in order to provision payment of relayer fees. A subscriber contract MAY decide to revert a transaction based on some logic, which subsequently allows the subscriber contract to conditionally respond to events, depending on the data in the payload. In this case the relayer will simulate the transaction locally and determine not to relay the Hook event to the subscriber contract.
Verifying a Hook Event
The verifyHook
function of the subscriber contracts SHOULD include logic to ensure that they are retrieving authentic events. In the case where the Hook event contains a signature, then subscriber contracts SHOULD create a hash of the required parameters, and SHOULD verify that the signature in the hook event is valid against the derived hash and the publisher's public key (see the reference implemenetation for an example). The hook function SHOULD also verify the nonce of the hook event and record it internally, in order to prevent replay attacks.
For Hook events without signatures, the subscriber contract SHOULD call the verifyHookEvent
on the publisher contract in order to verify that the hook event is valid. The publisher smart contract MUST implement the verifyHookEvent
, which accepts the hash of the payload, the thread id, the nonce, and the block height associated with the Hook event, and returns a boolean value to indicate the Hook event's authenticity.
Interfaces
IRegistry.sol
/// @title IRegistry /// @dev Implements the registry contract interface IRegistry { /// @dev Registers a new hook event by a publisher /// @param publisherContract The address of the publisher contract /// @param threadId The id of the thread these hook events will be fired on /// @param signingKey The public key that corresponds to the signature of externally generated payloads (optional) /// @return Returns true if the hook is successfully registered function registerHook( address publisherContract, uint256 threadId, bytes calldata signingKey ) external returns (bool); /// @dev Verifies a hook with the publisher smart contract before adding it to the registry /// @param publisherAddress The address of the publisher contract /// @param threadId The id of the thread these hook events will be fired on /// @param signingKey The public key used to verify the hook signatures /// @return Returns true if the hook is successfully verified function verifyHook( address publisherAddress, uint256 threadId, bytes calldata signingKey ) external returns (bool); /// @dev Update a previously registered hook event /// @dev Can be used to transfer hook authorization to a new address /// @dev To remove a hook, transfer it to the burn address /// @param publisherContract The address of the publisher contract /// @param threadId The id of the thread these hook events will be fired on /// @param signingKey The public key used to verify the hook signatures /// @return Returns true if the hook is successfully updated function updateHook( address publisherContract, uint256 threadId, bytes calldata signingKey ) external returns (bool); /// @dev Remove a previously registered hook event /// @param publisherContract The address of the publisher contract /// @param threadId The id of the thread these hook events will be fired on /// @param signingKey The public key used to verify the hook signatures /// @return Returns true if the hook is successfully updated function removeHook( address publisherContract, uint256 threadId, bytes calldata signingKey ) external returns (bool); /// @dev Registers a subscriber to a hook event /// @param publisherContract The address of the publisher contract /// @param subscriberContract The address of the contract subscribing to the event hooks /// @param threadId The id of the thread these hook events will be fired on /// @param fee The fee that the subscriber contract will pay the relayer /// @param maxGas The maximum gas that the subscriber allow to spend, to prevent griefing attacks /// @param maxGasPrice The maximum gas price that the subscriber is willing to rebate /// @param chainId The chain id that the subscriber wants updates on /// @param feeToken The address of the token that the fee will be paid in or 0x0 for the chain's native asset (e.g. ETH) /// @return Returns true if the subscriber is successfully registered function registerSubscriber( address publisherContract, address subscriberContract, uint256 threadId, uint256 fee, uint256 maxGas, uint256 maxGasPrice, uint256 chainId, address feeToken ) external returns (bool); /// @dev Registers a subscriber to a hook event /// @param publisherContract The address of the publisher contract /// @param subscriberContract The address of the contract subscribing to the event hooks /// @param threadId The id of the thread these hook events will be fired on /// @param fee The fee that the subscriber contract will pay the relayer /// @return Returns true if the subscriber is successfully updated function updateSubscriber( address publisherContract, address subscriberContract, uint256 threadId, uint256 fee ) external returns (bool); /// @dev Removes a subscription to a hook event /// @param publisherContract The address of the publisher contract /// @param subscriberContract The address of the contract subscribing to the event hooks /// @param threadId The id of the thread these hook events will be fired on /// @return Returns true if the subscriber is subscription removed function removeSubscription( address publisherContract, address subscriberContract, uint256 threadId ) external returns (bool); }
IPublisher.sol
/// @title IPublisher /// @dev Implements a publisher contract interface IPublisher { /// @dev Example of a function that fires a hook event when it is called /// @param payload The actual payload of the hook event /// @param digest Hash of the hook event payload that was signed /// @param threadId The thread number to fire the hook event on function fireHook( bytes calldata payload, bytes32 digest, uint256 threadId ) external; /// @dev Adds / updates a new hook event internally /// @param threadId The thread id of the hook /// @param signingKey The public key associated with the private key that signs the hook events function addHook(uint256 threadId, bytes calldata signingKey) external; /// @dev Called by the registry contract when registering a hook, used to verify the hook is valid before adding /// @param threadId The thread id of the hook /// @param signingKey The public key associated with the private key that signs the hook events /// @return Returns true if the hook is valid and is ok to add to the registry function verifyEventHookRegistration( uint256 threadId, bytes calldata signingKey ) external view returns (bool); /// @dev Returns true if the specified hook is valid /// @param payloadhash The hash of the hook's data payload /// @param threadId The thread id of the hook /// @param nonce The nonce of the current thread /// @param blockheight The blockheight that the hook was fired at /// @return Returns true if the specified hook is valid function verifyEventHook( bytes32 payloadhash, uint256 threadId, uint256 nonce, uint256 blockheight ) external view returns (bool); }
ISubscriber.sol
/// @title ISubscriber /// @dev Implements a subscriber contract interface ISubscriber { /// @dev Example of a function that is called when a hook is fired by a publisher /// @param publisher The address of the publisher contract in order to verify hook event with /// @param payload Hash of the hook event payload that was signed /// @param threadId The id of the thread this hook was fired on /// @param nonce Unique nonce of this hook /// @param blockheight The block height at which the hook event was fired function verifyHook( address publisher, bytes calldata payload, uint256 threadId, uint256 nonce, uint256 blockheight ) external; }
Rationale
The rationale for this design is that it allows smart contract developers to write contract logic that listens and responds to events fired in other smart contracts, without requiring them to run some dedicated off-chain process to achieve this. This best suits any simple smart contract logic that runs relatively infrequently in response to events in other contracts.
This improves on the existing solutions to achieve a pub/sub design pattern. To elaborate: a number of service providers currently offer "webhooks" as a way to subscribe to events emitted by smart contracts, by having some API endpoint called when the events are emitted, or alternatively offer some serverless feature that can be triggered by some smart contract event. This approach works very well, but it does require that some API endpoint or serverless function be always available, which may require some dedicated server / process, which in turn will need to have some private key, and some amount of ETH in order to re-broadcast transactions, no to mention the requirement to maintain an account with some third party provider.
This approach offers a more suitable alternative for when an "always-on" server instance is not desirable, e.g. in the case that it will be called infrequently.
This proposal incorporates a decentralized market-driven relay network, and this decision is based on the fact that this is a highly scalable approach. Conversely, it is possible to implement this functionality without resorting to a market-driven approach, by simply defining a standard for contracts to allow other contracts to subscribe directly. That approach is conceptually simpler, but has its drawbacks, in so far as it requires a publisher contract to record subscribers in its own state, creating an overhead for data management, upgradeability etc. That approach would also require the publisher to call the verifyHook
function on each subscriber contract, which will incur potentially significant gas costs for the publisher contract.
Security Considerations
Griefing Attacks
It is imperative that subscriber contracts trust the publisher contracts not to fire events that hold no intrinsic interest or value for them, as it is possible that malicious publisher contracts can publish a large number of events that will in turn drain the ETH from the subscriber contracts.
Front-running Attacks
It is advised not to rely on signatures alone to validate Hook events. It is important for publishers and subscribers of hooks to be aware that it is possible for a relayer to relay hook events before they are published, by examining the publisher's transaction in the mempool before it actually executes in the publisher's smart contract. The normal flow is for a "trigger" transaction to call a function in the publisher smart contract, which in turn fires an event which is then picked up by relayers. Competitive relayers will observe that it is possible to pluck the signature and payload from the trigger transaction in the public mempool and simply relay it to subscriber contracts before the trigger transaction has been actually included in a block. In fact, it is possible that the subscriber contracts process the event before the trigger transaction is processed, based purely on gas fee dynamics. This can mitigated against by subscriber contracts calling the verifyEventHook
function on the publisher contract when they receive a Hook event.
Another risk from front-running affects relayers, whereby the relayer's transactions to the subscriber contracts can be front-run by generalized MEV searchers in the mempool. It is likely that this sort of MEV capture will occur in the public mempool, and therefore it is advised that relayers use private channels to block builders to mitigate against this issue.
Relayer Competition
By broadcasting transactions to a segregated mempool, relayers protect themselves from front-running by generalized MEV bots, but their transactions can still fail due to competition from other relayers. If two or more relayers decide to start relaying hook events from the same publisher to the same subscribers, then the relay transactions with the highest gas price will be executed before the others. This will result in the other relayer's transactions potentially failing on-chain, by being included later in the same block. For now, there are certain transaction optimization services that will prevent transactions from failing on-chain, which will offer a solution to this problem, though this is out-of-scope for this document.
Optimal Fees
The fees that are paid to relayers are at the discretion of the subscribers, but it can be non-trivial to set fees to their optimal level, especially when considering volatile gas fees and competition between relayers. This will result in subscribers setting fees to a perceived "safe" level, which they are confident will incentivize relayers to relay Hook events. This will inevitably lead to poor price discovery and subscribers over-paying for updates.
The best way to solve this problem is through an auction mechanism that would allow relayers to bid against each other for the right to relay a transaction, which would guarantee that subscribers are paying the optimal price for their updates. Describing an auction mechanism that would satisfy this requirements is out of scope for this proposal, but there exists proposals for general purpose auction mechanisms that can faciliate this without introducing undue latency. One exampe of such as proposal is SUAVE from Flashbots, and there will likely be several others in time.
Without an Auction
In order to cultivate and maintain a reliable relayer market without the use of an auction mechanism, subscriber contracts would need to implement logic to either rebate any gas fees up to a specified limit, (while still allowing for execution of hook updates under normal conditions).
Another approach would be to implement a logical condition that checks the gas price of the transaction that is calling the verifyHook
function, to ensure that the gas price does not effectively reduce the fee to zero. This would require that the subscriber smart contract has some knowledge of the approximate gas used by it's verifyHook
function, and to check that the condition minFee >= fee - (gasPrice * gasUsed)
is true. This will mitigate against competitive bidding that would drive the effective relayer fee to zero, by ensuring that there is some minimum fee below which the effective fee is not allowed to drop. This would mean that the highest gas price that can be paid before the transaction reverts is fee - minFee + ε
where ε ~= 1 gwei
. This will require careful estimation of the gas cost of the verifyHook
function and an awareness that the gas used may change over time as the contract's state changes. The key insight with this approach is that competition between relayers will result in the fee that the subscribers pay always being the maximum, which is why the use of an auction mechanism is preferable.
Relayer Transaction Batching
Another important consideration is with batching of Hook events. Relayers are logically incentivized to batch Hook updates to save on gas, seeing as gas savings amount to 21,000 * n where n is the number of hooks being processed in a block by a single relayer. If a relayer decides to batch multiple Hook event updates to various subscriber contracts into a single transaction, via a multi-call proxy contract, then they increase the risk of the entire batch failing on-chain if even one of the transactions in the batch fails on-chain. For example, if relayer A batches x number of Hook updates, and relayer B batches y number of Hook updates, it is possible that relayer A's batch is included in the same block in front of relayer B's batch, and if both batches contain at least one duplicate, (i.e. the same Hook event to the same subscriber), then this will cause relayer B's batch transaction to revert on-chain. This is an important consideration for relayers, and suggests that relayers should have access to some sort of bundle simulation service to identify conflicting transactions before they occur.
Replay Attacks
When using signature verification, it is advised to use the EIP-712 standard in order to prevent cross network replay attacks, where the same contract deployed on more than one network can have its hook events pushed to subscribers on other networks, e.g. a publisher contract on Polygon can fire a hook event that could be relayed to a subscriber contract on Gnosis Chain. Whereas the keys used to sign the hook events should ideally be unique, in reality this may not always be the case.
For this reason, it is recommended to use ERC-721 Typed Data Signatures. In this case the process that initiates the hook should create the signature according to the following data structure:
const domain = [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, { name: "salt", type: "bytes32" } ] const hook = [ { name: "payload", type: "string" }, { type: "uint256", name: "nonce" }, { type: "uint256", name: "blockheight" }, { type: "uint256", name: "threadId" }, ] const domainData = { name: "Name of Publisher Dapp", version: "1", chainId: parseInt(web3.version.network, 10), verifyingContract: "0x123456789abcedf....publisher contract address", salt: "0x123456789abcedf....random hash unique to publisher contract" } const message = { payload: "bytes array serialized payload" nonce: 1, blockheight: 999999, threadId: 1, } const eip712TypedData = { types: { EIP712Domain: domain, Hook: hook }, domain: domainData, primaryType: "Hook", message: message }
Note: please refer to the unit tests in the reference implmenetation for an example of how a hook event should be constructed properly by the publisher.
Replay attacks can also occur on the same network that the event hook was fired, by simply re-broadcasting an event hook that was already broadcast previously. For this reason, subscriber contracts should check that a nonce is included in the event hook being received, and record the nonce in the contract's state. If the hook nonce is not valid, or has already been recorded, the transaction should revert.
Cross-chain Messaging
There is also the possibility to leverage the chainId
for more than preventing replay attacks, but also for accepting messages from other chains. In this use-case the subscriber contracts should register on the same chain that the subscriber contract is deployed on, and should set the chainId
to the chain it wants to receive hook events from.
Copyright
Copyright and related rights waived via CC0.