Alert Source Discuss
Standards Track: ERC

ERC-6492: 预部署合约的签名验证

一种在账户是尚未部署的智能合约时验证签名的方法

Authors Ivo Georgiev (@Ivshti), Agustin Aguilar (@Agusx1211)
Created 2023-02-10
Requires EIP-1271

摘要

合约可以通过 ERC-1271 签署可验证的消息。

然而,如果合约尚未部署,ERC-1271 验证是不可能的,因为你无法在所述合约上调用 isValidSignature 函数。

我们提出了一种标准方法,供任何合约或链下参与者验证代表给定反事实合约(即尚未部署的合约)的签名是否有效。这种标准方法扩展了 ERC-1271

动机

随着账户抽象的日益普及,我们经常发现,合约钱包的最佳用户体验是将合约部署推迟到用户的第一次交易,因此不会在用户可以使用其账户之前给用户增加额外的部署步骤负担。然而,与此同时,许多 dApp 希望获得签名,不仅用于交互,还用于登录。

因此,合约钱包在实际部署之前签署消息的能力受到了限制,而实际部署通常在第一次交易时完成。

此外,无法从反事实合约签署消息一直是 ERC-1271 的一个局限性。

规范

本文档中的关键词“必须 (MUST)”、“禁止 (MUST NOT)”、“必需 (REQUIRED)”、“应该 (SHALL)”、“不应该 (SHALL NOT)”、“应该 (SHOULD)”、“不应该 (SHOULD NOT)”、“推荐 (RECOMMENDED)”、“可以 (MAY)”和“可选 (OPTIONAL)”应按照 RFC 2119 中的描述进行解释。

“验证 (validation)”和“核实 (verification)”这两个词可以互换使用。

引用 ERC-1271

isValidSignature 可以调用任意方法来验证给定的签名,这可能是与上下文相关的(例如,基于时间或基于状态)、与 EOA 相关的(例如,智能钱包内的签名者授权级别)、依赖于签名方案的(例如,ECDSA、多重签名、BLS)等等。

此函数应由希望签署消息的合约来实现(例如,智能合约钱包、DAO、多重签名钱包等)。希望支持合约签名的应用程序应在签名者是合约时调用此方法。

我们使用相同的 isValidSignature 函数,但我们添加了一种新的包装器签名格式,签名合约在部署之前可以使用它,以便支持验证。

如果检测到包装器签名格式,签名验证者必须在尝试调用 isValidSignature 之前执行合约部署。

通过检查签名是否以 magicBytes 结尾来检测包装器格式,而 magicBytes 必须定义为 0x6492649264926492649264926492649264926492649264926492649264926492

建议将此 ERC 与 CREATE2 合约一起使用,因为它们的部署地址始终是可预测的。

签名者

签名合约通常是合约钱包,但它可以是任何实现 ERC-1271 并以反事实方式部署的合约。

  • 如果合约已部署,则生成一个普通的 ERC-1271 签名
  • 如果合约尚未部署,请按如下方式包装签名:concat(abi.encode((create2Factory, factoryCalldata, originalERC1271Signature), (address, bytes, bytes)), magicBytes)
  • 如果合约已部署但尚未准备好使用 ERC-1271 进行验证,请按如下方式包装签名:concat(abi.encode((prepareTo, prepareData, originalERC1271Signature), (address, bytes, bytes)), magicBytes); prepareToprepareData 必须包含使合约准备好使用 ERC-1271 进行验证所需的必要交易(例如,调用 migrateupdate

请注意,我们传递的是 factoryCalldata 而不是 saltbytecode。我们这样做是为了使验证符合任何工厂接口。我们不需要根据 create2Factory/salt/bytecode 计算地址,因为 ERC-1271 验证假定我们已经知道我们正在验证签名的帐户地址。

验证者

必须按以下顺序执行完整的签名验证:

  • 检查签名是否以 magic bytes 结尾,如果是,则对多重调用合约执行 eth_call,该合约将首先使用 factoryCalldata 调用工厂,并在尚未部署合约的情况下部署合约;然后,像往常一样使用解包的签名调用 contract.isValidSignature
  • 检查该地址上是否存在合约代码。如果是,则像往常一样通过调用 isValidSignature 执行 ERC-1271 验证
  • 如果 ERC-1271 验证失败,并且由于钱包已经有代码而跳过了对 factory 的部署调用,则执行 factoryCalldata 交易并再次尝试 isValidSignature
  • 如果该地址上没有合约代码,请尝试 ecrecover 验证

理由

我们认为,以允许传递部署数据的方式包装签名是实现此目的的唯一干净方法,因为它完全与合约无关,而且易于验证。

包装器格式以 magicBytes 结尾,该结尾以 0x92 结尾,如果以 r,s,v 格式打包,则它不可能与有效的 ecrecover 签名冲突,因为 0x92 不是 v 的有效值。为了避免与普通的 ERC-1271 发生冲突,magicBytes 本身也很长 (bytes32)。

确保正确验证的顺序基于以下规则:

  • 必须在通常的 ERC-1271 检查之前进行 magicBytes 检查,以便即使在合约部署后,反事实签名仍然有效
  • 必须在 ecrecover 之前进行 magicBytes 检查,以避免尝试通过 ecrecover 验证反事实合约签名(如果可以明确识别)
  • ERC-1271 验证之前不得进行 ecrecover 检查,因为合约可能使用一种签名格式,该格式也恰好是具有不同地址的 EOA 的有效 ecrecover 签名。一个这样的例子是一个由所述 EOA 控制的钱包合约。

我们无法确定当相应的钱包已经有代码时,为什么签名会用“部署前缀”进行编码。这可能是由于签名是在合约部署之前创建的,或者可能是因为合约已部署但尚未准备好验证签名。因此,我们需要尝试两种选择。

向后兼容性

此 ERC 与之前关于签名验证的工作向后兼容,包括 ERC-1271,并且可以轻松验证所有签名类型,包括 EOA 签名和类型化数据 (EIP-712)。

使用 ERC-6492 进行常规合约签名

此 ERC 中描述的包装器格式可用于所有合约签名,而不是普通的 ERC-1271。这具有以下几个优点:

  • 允许快速识别签名类型:借助 magic bytes,您可以立即知道签名是否为合约签名,而无需检查区块链
  • 允许恢复地址:您可以使用 create2FactoryfactoryCalldata 仅从签名中获取地址,就像 ecrecover 一样

参考实现

下面你可以找到一个通用验证合约的实现,该合约可以在链上和链下使用,旨在部署为单例。它可以验证使用此 ERC、ERC-1271 和传统的 ecrecover 签名的签名。通过扩展也支持 EIP-712,因为我们验证最终摘要 (_hash)。

// As per ERC-1271
// 按照 ERC-1271
interface IERC1271Wallet {
  function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4 magicValue);
}

error ERC1271Revert(bytes error);
error ERC6492DeployFailed(bytes error);

contract UniversalSigValidator {
  bytes32 private constant ERC6492_DETECTION_SUFFIX = 0x6492649264926492649264926492649264926492649264926492649264926492;
  bytes4 private constant ERC1271_SUCCESS = 0x1626ba7e;

  function isValidSigImpl(
    address _signer,
    bytes32 _hash,
    bytes calldata _signature,
    bool allowSideEffects,
    bool tryPrepare
  ) public returns (bool) {
    uint contractCodeLen = address(_signer).code.length;
    bytes memory sigToValidate;
    // The order here is strictly defined in https://eips.ethereum.org/EIPS/eip-6492
    // 这里顺序在 https://eips.ethereum.org/EIPS/eip-6492 中严格定义
    // - ERC-6492 后缀检查和验证优先,同时允许合同已部署的情况;如果合约已部署,我们将根据已部署版本检查 sig,这允许 6492 签名仍然有效,同时考虑到潜在的密钥轮换
    // - 如果存在合约代码,则进行 ERC-1271 验证
    // - 最后,ecrecover
    bool isCounterfactual = bytes32(_signature[_signature.length-32:_signature.length]) == ERC6492_DETECTION_SUFFIX;
    if (isCounterfactual) {
      address create2Factory;
      bytes memory factoryCalldata;
      (create2Factory, factoryCalldata, sigToValidate) = abi.decode(_signature[0:_signature.length-32], (address, bytes, bytes));

      if (contractCodeLen == 0 || tryPrepare) {
        (bool success, bytes memory err) = create2Factory.call(factoryCalldata);
        if (!success) revert ERC6492DeployFailed(err);
      }
    } else {
      sigToValidate = _signature;
    }

    // Try ERC-1271 verification
    // 尝试 ERC-1271 验证
    if (isCounterfactual || contractCodeLen > 0) {
      try IERC1271Wallet(_signer).isValidSignature(_hash, sigToValidate) returns (bytes4 magicValue) {
        bool isValid = magicValue == ERC1271_SUCCESS;

        // retry, but this time assume the prefix is a prepare call
        // 重试,但这次假设前缀是准备调用
        if (!isValid && !tryPrepare && contractCodeLen > 0) {
          return isValidSigImpl(_signer, _hash, _signature, allowSideEffects, true);
        }

        if (contractCodeLen == 0 && isCounterfactual && !allowSideEffects) {
          // if the call had side effects we need to return the
          // 如果调用有副作用,我们需要返回
          // result using a `revert` (to undo the state changes)
          // 使用 `revert` 的结果(撤消状态更改)
          assembly {
           mstore(0, isValid)
           revert(31, 1)
          }
        }

        return isValid;
      } catch (bytes memory err) {
        // retry, but this time assume the prefix is a prepare call
        // 重试,但这次假设前缀是准备调用
        if (!tryPrepare && contractCodeLen > 0) {
          return isValidSigImpl(_signer, _hash, _signature, allowSideEffects, true);
        }

        revert ERC1271Revert(err);
      }
    }

    // ecrecover verification
    // ecrecover 验证
    require(_signature.length == 65, 'SignatureValidator#recoverSigner: invalid signature length');
    bytes32 r = bytes32(_signature[0:32]);
    bytes32 s = bytes32(_signature[32:64]);
    uint8 v = uint8(_signature[64]);
    if (v != 27 && v != 28) {
      revert('SignatureValidator: invalid signature v value');
    }
    return ecrecover(_hash, v, r, s) == _signer;
  }

  function isValidSigWithSideEffects(address _signer, bytes32 _hash, bytes calldata _signature)
    external returns (bool)
  {
    return this.isValidSigImpl(_signer, _hash, _signature, true, false);
  }

  function isValidSig(address _signer, bytes32 _hash, bytes calldata _signature)
    external returns (bool)
  {
    try this.isValidSigImpl(_signer, _hash, _signature, false, false) returns (bool isValid) { return isValid; }
    catch (bytes memory error) {
      // in order to avoid side effects from the contract getting deployed, the entire call will revert with a single byte result
      // 为了避免合约部署的副作用,整个调用将恢复为单字节结果
      uint len = error.length;
      if (len == 1) return error[0] == 0x01;
      // all other errors are simply forwarded, but in custom formats so that nothing else can revert with a single byte in the call
      // 所有其他错误都只是转发,但以自定义格式转发,以便其他任何东西都无法在调用中恢复为单字节
      else assembly { revert(error, len) }
    }
  }
}

// this is a helper so we can perform validation in a single eth_call without pre-deploying a singleton
// 这是一个助手,因此我们可以在单个 eth_call 中执行验证,而无需预先部署单例
contract ValidateSigOffchain {
  constructor (address _signer, bytes32 _hash, bytes memory _signature) {
    UniversalSigValidator validator = new UniversalSigValidator();
    bool isValidSig = validator.isValidSigWithSideEffects(_signer, _hash, _signature);
    assembly {
      mstore(0, isValidSig)
      return(31, 1)
    }
  }
}

链上验证

对于链上验证,你可以使用两个单独的方法:

  • UniversalSigValidator.isValidSig(_signer, _hash, _signature):返回一个布尔值,指示签名是否有效;这是可重入安全的
  • UniversalSigValidator.isValidSigWithSideEffects(_signer, _hash, _signature):这等效于前者 - 它不是可重入安全的,但在某些情况下更节省 gas

如果底层调用恢复,这两种方法都可能会恢复。

链下验证

ValidateSigOffchain 助手允许你在一个 eth_call 中执行通用验证,而无需任何预部署的合约。

以下是如何使用 ethers 库执行此操作的示例:

const isValidSignature = '0x01' === await provider.call({
  data: ethers.utils.concat([
    validateSigOffchainBytecode,
    (new ethers.utils.AbiCoder()).encode(['address', 'bytes32', 'bytes'], [signer, hash, signature])
  ])
})

你还可以使用库来执行通用签名验证,例如 Ambire 的 signature-validator

安全注意事项

ERC-1271 相同的注意事项适用。

然而,部署合约需要 CALL 而不是 STATICCALL,这引入了重入问题。通过使验证方法在存在副作用时始终恢复,并从恢复数据中捕获其实际结果,可以在参考实现中缓解此问题。对于重入不是问题的用例,我们提供了 isValidSigWithSideEffects 方法。

此外,此 ERC 可能更频繁地用于链下验证,因为在许多情况下,在链上验证签名假定钱包已经部署。

一个值得提及的超出范围的安全考虑是,是否将在部署时使用正确的权限设置合约,以便允许有意义的签名验证。根据设计,这取决于实现,但值得注意的是,由于 CREATE2 的工作方式,更改签名中的字节码或构造函数调用码不会允许你升级权限,因为它会更改部署地址,从而使验证失败。

必须注意的是,合约帐户可以动态更改其身份验证方法。此问题在此 EIP 中通过设计得到缓解 - 即使在验证反事实签名时,如果合约已经部署,我们仍然会调用它,并根据合约的当前实时版本进行检查。

与通常的签名一样,在大多数用例中都应实施重放保护。此提案为此增加了一个额外的维度,因为只要 1) 签名在部署时有效 2) 钱包可以使用相同的工厂地址/字节码部署在此不同的网络上,则可以验证在不同网络上已失效的签名(通过更改授权密钥)。

版权

版权和相关权利已通过 CC0 放弃。

Citation

Please cite this document as:

Ivo Georgiev (@Ivshti), Agustin Aguilar (@Agusx1211), "ERC-6492: 预部署合约的签名验证," Ethereum Improvement Proposals, no. 6492, February 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6492.