Alert Source Discuss
🚧 Stagnant Standards Track: ERC

ERC-5902: 智能合约事件钩子

这种格式允许合约通过使用公共中继网络作为消息总线,来半自主地响应其他合约发出的事件

Authors Simon Brown (@orbmis)
Created 2022-11-09
Discussion Link https://ethereum-magicians.org/t/idea-smart-contract-event-hooks-standard/11503
Requires EIP-712

摘要

本 EIP 提出了一种创建“钩子”的标准,该标准允许通过使用公共中继网络作为消息总线,自动调用智能合约函数以响应由另一个合约触发的触发器。

虽然已经存在许多类似的解决方案,但本提案描述了一个简单但强大的原语,可以由许多应用程序以开放、无需许可和去中心化的方式使用。

它依赖于两个接口,一个用于发布者合约,一个用于订阅者合约。发布者合约发出由“中继者”拾取的事件,这些中继者是独立的实体,它们订阅发布者合约上的“钩子”事件,并在发布者合约触发钩子事件时,调用各自订阅者合约上的函数。每当一个中继者使用由发布者合约发出的钩子事件的详细信息调用各自订阅者的合约时,他们都会收到订阅者支付的费用。发布者合约和订阅者合约都注册在一个中央注册表智能合约中,中继者可以使用该合约来发现钩子。

动机

存在许多用例,需要一些链下参与者来监视链并通过广播交易来响应链上事件。这种情况通常需要一些链下进程与以太坊节点一起运行,以便订阅智能合约发出的事件,然后执行一些逻辑作为响应,并随后将交易广播到网络。这需要一个以太坊节点和一个开放的 websocket 连接到一些可能不经常使用的长期运行的进程,从而导致资源利用率欠佳。

本提案将允许一个智能合约包含其响应事件所需的逻辑,而无需将该逻辑存储在某些链下进程中。该智能合约可以订阅其他智能合约触发的事件,并且仅在需要时执行所需的逻辑。此方法适用于任何不需要链下计算的合约逻辑,但通常需要链下进程来监视链状态。通过这种方法,订阅者不需要他们自己的专用链下进程来监视和响应合约事件。相反,单个受激励的中继者可以代表多个不同的订阅者合约订阅许多不同的事件。

受益于此方案的用例示例包括:

抵押贷款协议

抵押贷款协议或稳定币可以在每次收到价格预言机更新时发出事件,这将允许借款人自动“补充”其未平仓头寸以避免清算。

例如,Maker 使用“medianizer”智能合约,该合约维护一个允许发布价格更新的价格馈送合约白名单。 每次收到新的价格更新时,所有馈送价格的中位数都会重新计算,并且中值会更新。 在这种情况下,medianizer 智能合约可以触发一个钩子事件,允许订阅者合约决定重新抵押其 CDP。

自动化做市商

每当添加或删除流动性时,AMM 流动性池都可以触发一个钩子事件。 这可以允许订阅者智能合约在总池流动性达到某个点后添加或删除流动性。

AMM 可以在交易对中进行交易时触发一个钩子,通过一个钩子事件发出时间加权价格预言机更新。 订阅者可以使用它来创建一个自动限价单簿类型的合约,以便在资产的现货价格突破预先指定的阈值后买入/卖出代币。

DAO 投票

DAO 治理合约可以发出钩子事件,以表明提案已发布、已投票、已通过或已否决,并且允许任何订阅者合约自动做出相应的响应。 例如,在特定提案通过时执行一些智能合约函数,例如批准支付资金。

计划函数调用

可以创建一个调度服务,订阅者可以在其中注册一个计划函数调用,这可以使用 unix cron 格式完成,并且该服务可以从一个智能合约在单独的线程上触发事件。 订阅者合约可以订阅各自的线程,以便订阅特定的时间表(例如,每天、每周、每小时等),甚至可以注册客户 cron 时间表。

定期付款

服务提供商可以触发 Hook 事件,这将允许订阅者合约定期自动支付其服务费。 订阅者合约收到钩子事件后,他们可以调用服务提供商合约上的一个函数来转移到期的资金。

通过委托进行协调

Hook 事件有效负载可以包含任何任意数据,这意味着您可以使用像 Delegatable 框架这样的东西来签署链下委托,这可以促进一系列授权实体发布有效的 Hook 事件。 您还可以使用像 BLS 阈值签名这样的东西,以促进多个链下发布者授权触发 Hook。

规范

本文档中的关键词“必须”、“不得”、“必需”、“应该”、“不应”、“推荐”、“不推荐”、“可以”和“可选”应按照 RFC 2119 中的描述进行解释。

注册发布者

发布者合约和订阅者合约必须在特定的注册表合约中注册,类似于智能合约在 ERC-1820 合约中注册接口的方式。注册表合约必须使用确定性部署机制,即使用工厂合约和特定的盐。

要注册发布者合约的钩子,必须在注册表合约上调用 registerHook 函数。需要提供的参数是:

  • (地址)发布者合约地址
  • (uint256)钩子事件将引用的线程 id(单个合约可以触发具有任意数量线程的钩子事件,订阅者可以选择订阅哪些线程)
  • (bytes)与钩子事件关联的公钥(可选)

当在注册表合约上调用 registerHook 函数时,注册表合约必须通过调用发布者合约的 verifyEventHookRegistration 函数,并使用与传递给注册表合约上的 registerHook 函数相同的参数,来向下游调用发布者合约地址。发布者合约中的 verifyEventHookRegistration 函数必须返回 true,以表明该合约将允许其自身作为发布者添加到注册表中。注册表合约必须发出一个 HookRegistered 事件,以表明添加了一个新的发布者合约。

更新钩子

发布者可能希望更新与 Hook 事件关联的详细信息,或者确实完全删除对 Hook 事件的支持。注册表合约必须实现 updatePublisher 函数,以允许在注册表中更新现有的发布者合约。注册表合约必须发出一个 PublisherUpdated 事件,以表明发布者合约已更新。

删除钩子

要删除先前注册的 Hook,必须在注册表合约上调用函数 removeHook,并使用与 updateHook 函数相同的参数。注册表合约必须使用与传递给 removeHook 函数相同的参数和 msg.sender 值发出一个 HookRemoved 事件。

注册订阅者

要将订阅者注册到钩子,必须使用以下参数在注册表合约上调用 registerSubscriber 函数:

  • (地址)发布者合约地址
  • (bytes32)订阅者合约地址
  • (uint256)要订阅的线程 id
  • (uint256)订阅者愿意支付的更新费用
  • (uint256)订阅者允许更新的最大 gas,以防止恶意攻击,或 0 表示没有最大值
  • (uint256)订阅者愿意在费用之上偿还中继者的最大 gas 价格,或 0 表示没有回扣
  • (uint256)订阅者想要接收更新的链 id
  • (address)将支付费用的代币地址,或 0x0 表示链的原生资产(例如,ETH、MATIC 等)

订阅者合约可以在每次更新的固定费用之上实施 gas 退款。如果订阅者选择这样做,那么他们应该指定 maximum gasmaximum gas price 参数,以保护自己免受恶意攻击。这是为了防止恶意或粗心的中继设置过高的 gas 价格,最终耗尽订阅者合约。否则,订阅者合约可以选择设置一个估计足以支付 gas 费用的费用。

请注意,虽然链 id 和代币地址未包含在规范的原始版本中,但简单地添加这两个参数允许利用中继者进行跨链消息传递,如果订阅者希望这样做,并且还允许以各种代币支付中继者费用。

更新订阅

要更新订阅,必须使用与 registerSubscriber 函数相同的参数集调用 updateSubscriber 函数。 这可能是为了取消订阅,或更改订阅费用。 请注意,updateSubscriber 函数必须维护调用 registerSubscriber 函数时使用的相同 msg.sender

删除订阅

要删除先前注册的订阅,必须在注册表合约上调用函数 removeSubscriber,并使用与 updateSubscriber 函数相同的参数,但不带 fee 参数(即,发布者和订阅者合约地址以及线程 id)。 该费用将随后设置为 0,以表明订阅者不再希望接收此订阅的更新。 注册表合约必须使用发布者合约地址、订阅者合约地址和线程 id 作为主题发出一个 SubscriptionRemoved 事件。

发布事件

发布者合约应该从至少一个函数中发出一个钩子事件。 发出的事件必须称为 Hook,并且必须包含以下参数:

  • uint256 (indexed) - threadId
  • uint256 (indexed) - nonce
  • bytes32 digest
  • bytes payload
  • bytes32 checksum

每次发布者合约触发 Hook 事件时,必须递增 nonce 值。 每个 Hook 事件必须具有唯一的 nonce 值。 nonce 属性初始化为 1,但第一个触发的 Hook 事件必须设置为 2。 这是为了防止未初始化的 nonce 变量与显式初始化为零的 nonce 变量之间存在歧义。

事件的 digest 参数必须是有效负载的 keccak256 哈希,并且 checksum 必须是摘要与当前区块高度的连接的 keccak256 哈希,例如:

bytes32 checksum = keccak256(abi.encodePacked(digest, block.number));

Hook 事件可以由来自任何 EOA 或外部合约的函数调用触发。 这允许在发布者合约中动态创建有效负载。 订阅者合约应该调用发布者合约上的 verifyEventHook 函数,以验证接收到的 Hook 有效负载是否有效。

有效负载可以传递给触发 Hook 事件的函数,而不是在发布者合约内生成,但是如果提供了签名,则必须对有效负载的哈希进行签名,并且强烈建议使用 EIP-712 标准,并遵循本提案末尾概述的数据结构。 订阅者应该验证此签名,以确保他们获得真实的事件。 签名必须与在事件中注册的公钥相对应。 通过这种方法,签名应该放置在有效负载的开头(例如,对于具有 r、s、v 属性的 ECDSA 签名,则为字节 0 到 65)。 这种验证方法可以用于跨链 Hook 事件,其中订阅者将无法在另一个链上调用发布者合约的 verifyHookEvent

有效负载必须作为 calldata 中的字节数组传递给订阅者。 订阅者智能合约应该将字节数组转换为所需的数据类型。 例如,如果有效负载是 snark 证明,则发布者需要将变量序列化为字节数组,并且订阅者智能合约需要在另一端反序列化它,例如:

struct SnarkProof {
    uint256[2] a;
    uint256[2][2] b;
    uint256[2] c;
    uint256[1] input;
}

SnarkProof memory zkproof = abi.decode(payload, SnarkProof);

中继者

中继者是独立的参与者,他们侦听发布者智能合约上的 Hook 事件。 中继者从注册表中检索不同钩子的订阅者列表,并侦听在发布者合约上触发的钩子事件。 发布者智能合约触发钩子事件后,中继者可以通过广播执行订阅者合约的 verifyHook 函数的交易来决定将钩子事件的有效负载中继到订阅者合约。 中继者有动力这样做,因为预计订阅者合约将以 ETH 或其他资产向他们支付报酬。

中继者应该在本地模拟交易,然后再广播它,以确保订阅者合约具有足够的余额来支付费用。 这要求订阅者合约维护 ETH(或某些资产)的余额,以便提供中继者费用的支付。 订阅者合约可以根据某些逻辑来还原交易,这随后允许订阅者合约根据有效负载中的数据有条件地响应事件。 在这种情况下,中继者将本地模拟交易,并确定不将 Hook 事件中继到订阅者合约。

验证 Hook 事件

订阅者合约的 verifyHook 函数应该包含逻辑,以确保他们正在检索真实的事件。 如果 Hook 事件包含签名,那么订阅者合约应该创建所需参数的哈希,并且应该根据派生的哈希和发布者的公钥验证 Hook 事件中的签名(有关示例,请参见参考实现)。 钩子函数应该还验证钩子事件的 nonce 并将其记录在内部,以防止重放攻击。

对于没有签名的 Hook 事件,订阅者合约应该调用发布者合约上的 verifyHookEvent,以验证钩子事件是否有效。 发布者智能合约必须实现 verifyHookEvent,它接受有效负载的哈希、线程 id、nonce 和与 Hook 事件关联的区块高度,并返回一个布尔值以指示 Hook 事件的真实性。

接口

IRegistry.sol

/// @title IRegistry
/// @dev 实现注册表合约
interface IRegistry {
    /// @dev 注册发布者的新钩子事件
    /// @param publisherContract 发布者合约的地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @param signingKey 与外部生成的有效负载的签名相对应的公钥(可选)
    /// @return 如果钩子成功注册,则返回 true
    function registerHook(
        address publisherContract,
        uint256 threadId,
        bytes calldata signingKey
    ) external returns (bool);

    /// @dev 在将钩子添加到注册表之前,使用发布者智能合约验证钩子
    /// @param publisherAddress 发布者合约的地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @param signingKey 用于验证钩子签名的公钥
    /// @return 如果钩子成功验证,则返回 true
    function verifyHook(
        address publisherAddress,
        uint256 threadId,
        bytes calldata signingKey
    ) external returns (bool);

    /// @dev 更新先前注册的钩子事件
    /// @dev 可用于将钩子授权转移到新地址
    /// @dev 要删除钩子,请将其转移到刻录地址
    /// @param publisherContract 发布者合约的地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @param signingKey 用于验证钩子签名的公钥
    /// @return 如果钩子成功更新,则返回 true
    function updateHook(
        address publisherContract,
        uint256 threadId,
        bytes calldata signingKey
    ) external returns (bool);

    /// @dev 删除先前注册的钩子事件
    /// @param publisherContract 发布者合约的地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @param signingKey 用于验证钩子签名的公钥
    /// @return 如果钩子成功更新,则返回 true
    function removeHook(
        address publisherContract,
        uint256 threadId,
        bytes calldata signingKey
    ) external returns (bool);

    /// @dev 将订阅者注册到钩子事件
    /// @param publisherContract 发布者合约的地址
    /// @param subscriberContract 订阅事件钩子的合约地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @param fee 订阅者合约将支付给中继者的费用
    /// @param maxGas 订阅者允许花费的最大 gas,以防止恶意攻击
    /// @param maxGasPrice 订阅者愿意返还的最大 gas 价格
    /// @param chainId 订阅者想要接收更新的链 id
    /// @param feeToken 将支付费用的代币地址,或 0x0 表示链的原生资产(例如,ETH)
    /// @return 如果订阅者成功注册,则返回 true
    function registerSubscriber(
        address publisherContract,
        address subscriberContract,
        uint256 threadId,
        uint256 fee,
        uint256 maxGas,
        uint256 maxGasPrice,
        uint256 chainId,
        address feeToken
    ) external returns (bool);

    /// @dev 将订阅者注册到钩子事件
    /// @param publisherContract 发布者合约的地址
    /// @param subscriberContract 订阅事件钩子的合约地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @param fee 订阅者合约将支付给中继者的费用
    /// @return 如果订阅者成功更新,则返回 true
    function updateSubscriber(
        address publisherContract,
        address subscriberContract,
        uint256 threadId,
        uint256 fee
    ) external returns (bool);

    /// @dev 删除对钩子事件的订阅
    /// @param publisherContract 发布者合约的地址
    /// @param subscriberContract 订阅事件钩子的合约地址
    /// @param threadId 将在其上触发这些钩子事件的线程的 id
    /// @return 如果订阅者的订阅已删除,则返回 true
    function removeSubscription(
        address publisherContract,
        address subscriberContract,
        uint256 threadId
    ) external returns (bool);
}

IPublisher.sol

/// @title IPublisher
/// @dev 实现发布者合约
interface IPublisher {
    /// @dev 在调用时触发钩子事件的函数示例
    /// @param payload 钩子事件的实际有效负载
    /// @param digest 已签名的钩子事件有效负载的哈希
    /// @param threadId 在其上触发钩子事件的线程号
    function fireHook(
        bytes calldata payload,
        bytes32 digest,
        uint256 threadId
    ) external;

    /// @dev 在内部添加/更新新的钩子事件
    /// @param threadId 钩子的线程 id
    /// @param signingKey 与签署钩子事件的私钥关联的公钥
    function addHook(uint256 threadId, bytes calldata signingKey) external;

    /// @dev 在注册钩子时由注册表合约调用,用于在添加之前验证钩子是否有效
    /// @param threadId 钩子的线程 id
    /// @param signingKey 与签署钩子事件的私钥关联的公钥
    /// @return 如果钩子有效并且可以添加到注册表中,则返回 true
    function verifyEventHookRegistration(
        uint256 threadId,
        bytes calldata signingKey
    ) external view returns (bool);

    /// @dev 如果指定的钩子有效,则返回 true
    /// @param payloadhash 钩子数据有效负载的哈希
    /// @param threadId 钩子的线程 id
    /// @param nonce 当前线程的 nonce
    /// @param blockheight 触发钩子的区块高度
    /// @return 如果指定的钩子有效,则返回 true
    function verifyEventHook(
        bytes32 payloadhash,
        uint256 threadId,
        uint256 nonce,
        uint256 blockheight
    ) external view returns (bool);
}

ISubscriber.sol

/// @title ISubscriber
/// @dev 实现订阅者合约
interface ISubscriber {
    /// @dev 当发布者触发钩子时调用的函数示例
    /// @param publisher 发布者合约的地址,以便使用验证钩子事件
    /// @param payload 已签名的钩子事件有效负载的哈希
    /// @param threadId 在其上触发此钩子的线程的 id
    /// @param nonce 此钩子的唯一 nonce
    /// @param blockheight 触发钩子事件的区块高度
    function verifyHook(
        address publisher,
        bytes calldata payload,
        uint256 threadId,
        uint256 nonce,
        uint256 blockheight
    ) external;
}

理由

这种设计的理由是,它允许智能合约开发人员编写侦听和响应其他智能合约中触发的事件的合约逻辑,而无需他们运行一些专用的链下进程来实现这一点。 这最适合于响应其他合约中的事件而相对不频繁地运行的任何简单智能合约逻辑。

这改进了实现发布/订阅设计模式的现有解决方案。 要详细说明:许多服务提供商目前提供“webhooks”作为订阅智能合约发出的事件的一种方式,方法是在发出事件时调用一些 API 端点,或者提供一些可以通过智能合约事件触发的无服务器功能。 这种方法效果很好,但它确实要求某些 API 端点或无服务器函数始终可用,这可能需要一些专用服务器/进程,这反过来将需要一些私钥和一些 ETH 以重新广播交易,更不用说需要维护与某些第三方提供商的帐户。

对于不希望使用“始终在线”服务器实例的情况,例如,如果它将被不经常调用,则此方法提供了一个更合适的替代方案。

本提案纳入了一个去中心化的市场驱动中继网络,并且此决策基于以下事实:这是一种高度可扩展的方法。 相反,可以通过简单地定义合约的标准来允许其他合约直接订阅,而无需诉诸市场驱动的方法。 这种方法在概念上更简单,但有其缺点,因为它要求发布者合约在其自身状态中记录订阅者,从而为数据管理、可升级性等创建开销。 这种方法还需要发布者调用每个订阅者合约上的 verifyHook 函数,这将给发布者合约带来潜在的显着 gas 成本。

安全注意事项

恶意攻击

订阅者合约必须信任发布者合约不会触发对他们没有任何内在利益或价值的事件,因为恶意的发布者合约可能会发布大量事件,进而耗尽订阅者合约中的 ETH。

抢先交易攻击

建议不要仅依靠签名来验证 Hook 事件。 钩子的发布者和订阅者必须意识到,中继者有可能在发布事件之前中继钩子事件,方法是在发布者的交易实际在发布者的智能合约中执行之前,检查 mempool 中的发布者的交易。 正常的流程是“触发”交易调用发布者智能合约中的一个函数,该函数反过来触发一个事件,然后由中继者拾取。 有竞争力的中继者会观察到,可以从公共 mempool 中的触发交易中获取签名和有效负载,并且只需在触发交易实际包含在区块之前将其转发到订阅者合约。 事实上,根据 gas 费用动态,订阅者合约有可能在触发交易处理之前处理事件。 这可以通过订阅者合约在收到 Hook 事件时调用发布者合约上的 verifyEventHook 函数来缓解。

另一种来自抢先交易的风险会影响中继者,即中继者到订阅者合约的交易可能会被 mempool 中的通用 MEV 搜索者抢先交易。 这种 MEV 捕获很可能会在公共 mempool 中发生,因此建议中继者使用私人通道来阻止构建者,以缓解此问题。

中继者竞争

通过将交易广播到隔离的 mempool,中继者可以保护自己免受通用 MEV 机器人的抢先交易,但由于来自其他中继者的竞争,他们的交易仍然可能会失败。 如果两个或多个中继者决定开始将来自同一发布者的钩子事件中继到同一订阅者,那么 gas 价格最高的交易将在其他交易之前执行。 这将导致其他中继者的交易可能在链上失败,原因是在同一区块中稍后包含。 目前,有一些交易优化服务可以防止交易在链上失败,这将为此问题提供解决方案,但这超出了本文档的范围。

最佳费用

支付给中继者的费用由订阅者自行决定,但将费用设置为最佳水平可能并非易事,尤其是在考虑波动的 gas 费用和中继者之间的竞争时。 这将导致订阅者将费用设置为他们认为“安全”的水平,他们有信心这将激励中继者中继 Hook 事件。 这将不可避免地导致不良的价格发现和订阅者为更新支付过高的费用。

解决此问题的最佳方法是通过拍卖机制,该机制将允许中继者相互竞标中继交易的权利,这将保证订阅者为其更新支付最佳价格。 描述一种满足此要求的拍卖机制超出了本提案的范围,但存在一些通用拍卖机制的提案,可以促进这一点,而不会引入不必要的延迟。 Flashbots 的 SUAVE 就是这样一个提案的示例,并且随着时间的推移,可能会有其他几个提案。

没有拍卖

为了在不使用拍卖机制的情况下培养和维持可靠的中继者市场,订阅者合约需要实施逻辑以返还任何 gas 费用,最高至指定限额,(同时仍然允许在正常情况下执行钩子更新)。

另一种方法是实施一个逻辑条件,检查调用 verifyHook 函数的交易的 gas 价格,以确保 gas 价格不会有效地将费用降至零。 这将要求订阅者智能合约了解其 verifyHook 函数使用的大约 gas,并检查条件 minFee >= fee - (gasPrice * gasUsed) 是否为真。 这将通过确保存在一些最低费用,低于该最低费用不允许实际费用下降,从而缓解竞争性竞标,从而将有效的中继者费用推为零。 这意味着在交易还原之前可以支付的最高 gas 价格是 fee - minFee + ε,其中 ε ~= 1 gwei。 这将需要仔细估计 verifyHook 函数的 gas 成本,并意识到随着合约状态的变化,使用的 gas 可能会随着时间的推移而变化。 这种方法的关键见解是,中继者之间的竞争将导致订阅者支付的费用始终是最高的,这就是为什么最好使用拍卖机制。

中继者交易批处理

另一个重要的考虑因素是 Hook 事件的批处理。 从逻辑上讲,中继者有动力对 Hook 更新进行批处理以节省 gas,因为 gas 节省量相当于 21,000 * n,其中 n 是单个中继者在一个区块中处理的钩子数。 如果中继者决定通过 multi-call 代理合约将多个 Hook 事件更新批处理到各种订阅者合约中,那么如果批处理中的任何一个交易在链上失败,他们会增加整个批处理在链上失败的风险。 例如,如果中继者 A 批处理 x 个 Hook 更新,而中继者 B 批处理 y 个 Hook 更新,则中继者 A 的批处理有可能在中继者 B 的批处理之前包含在同一区块中,并且如果两个批处理都包含至少一个重复项(即,到同一订阅者的同一 Hook 事件),那么这将导致中继者 B 的批处理交易在链上还原。 这对于中继者来说是一个重要的考虑因素,并表明中继者应该可以访问某种捆绑模拟服务,以在发生冲突之前识别冲突的交易。

重放攻击

当使用签名验证时,建议使用 EIP-712 标准来防止跨网络重放攻击,其中部署在多个网络上的相同合约可以将其钩子事件推送到其他网络上的订阅者,例如,Polygon 上的发布者合约可以触发一个钩子事件,该事件可以中继到 Gnosis Chain 上的订阅者合约。 尽管用于签署钩子事件的密钥理想情况下应该是唯一的,但实际上可能并非总是如此。

因此,建议使用 ERC-721 类型化数据签名。 在这种情况下,启动钩子的过程应该根据以下数据结构创建签名:

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: "发布者 Dapp 的名称",
  version: "1",
  chainId: parseInt(web3.version.network, 10),
  verifyingContract: "0x123456789abcedf....发布者合约地址",
  salt: "0x123456789abcedf....发布者合约独有的随机哈希"
}
 
const message = {
  payload: "字节数组序列化有效负载"
  nonce: 1,
  blockheight: 999999,
  threadId: 1,
}
 
const eip712TypedData = {
  types: {
    EIP712Domain: domain,
    Hook: hook
  },
  domain: domainData,
  primaryType: "Hook",
  message: message
}

注意:请参阅参考实现中的单元测试,以获取发布者应如何正确构建钩子事件的示例。

重放攻击也可能发生在触发事件钩子的同一网络上,方法是简单地重新广播先前已广播的事件钩子。 因此,订阅者合约应检查接收到的事件钩子中是否包含 nonce,并在合约的状态中记录 nonce。 如果钩子 nonce 无效或已被记录,则应回滚交易。

跨链消息传递

还可以利用 chainId,不仅可以防止重放攻击,还可以接受来自其他链的消息。 在此用例中,订阅者合约应该在部署订阅者合约的同一链上注册,并且应该将 chainId 设置为它想要接收钩子事件的链。

版权

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Simon Brown (@orbmis), "ERC-5902: 智能合约事件钩子 [DRAFT]," Ethereum Improvement Proposals, no. 5902, November 2022. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-5902.