Alert Source Discuss
Standards Track: ERC

ERC-6492: Signature Validation for Predeploy Contracts

A way to verify a signature when the account is a smart contract that has not been deployed yet

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

Abstract

Contracts can sign verifiable messages via ERC-1271.

However, if the contract is not deployed yet, ERC-1271 verification is impossible, as you can’t call the isValidSignature function on said contract.

We propose a standard way for any contract or off-chain actor to verify whether a signature on behalf of a given counterfactual contract (that is not deployed yet) is valid. This standard way extends ERC-1271.

Motivation

With the rising popularity of account abstraction, we often find that the best user experience for contract wallets is to defer contract deployment until the first user transaction, therefore not burdening the user with an additional deploy step before they can use their account. However, at the same time, many dApps expect signatures, not only for interactions, but also just for logging in.

As such, contract wallets have been limited in their ability to sign messages before their de-facto deployment, which is often done on the first transaction.

Furthermore, not being able to sign messages from counterfactual contracts has always been a limitation of ERC-1271.

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.

The words “validation” and “verification” are used interchangeably.

Quoting ERC-1271,

isValidSignature can call arbitrary methods to validate a given signature, which could be context dependent (e.g. time based or state based), EOA dependent (e.g. signers authorization level within smart wallet), signature scheme Dependent (e.g. ECDSA, multisig, BLS), etc.

This function should be implemented by contracts which desire to sign messages (e.g. smart contract wallets, DAOs, multisignature wallets, etc.) Applications wanting to support contract signatures should call this method if the signer is a contract.

We use the same isValidSignature function, but we add a new wrapper signature format, that signing contracts MAY use before they’re deployed, in order to allow support for verification.

The signature verifier MUST perform a contract deployment before attempting to call isValidSignature if the wrapper signature format is detected.

The wrapper format is detected by checking if the signature ends in magicBytes, which MUST be defined as 0x6492649264926492649264926492649264926492649264926492649264926492.

It is RECOMMENDED to use this ERC with CREATE2 contracts, as their deploy address is always predictable.

Signer side

The signing contract will normally be a contract wallet, but it could be any contract that implements ERC-1271 and is deployed counterfactually.

  • If the contract is deployed, produce a normal ERC-1271 signature
  • If the contract is not deployed yet, wrap the signature as follows: concat(abi.encode((create2Factory, factoryCalldata, originalERC1271Signature), (address, bytes, bytes)), magicBytes)
  • If the contract is deployed but not ready to verify using ERC-1271, wrap the signature as follows: concat(abi.encode((prepareTo, prepareData, originalERC1271Signature), (address, bytes, bytes)), magicBytes); prepareTo and prepareData must contain the necessary transaction that will make the contract ready to verify using ERC-1271 (e.g. a call to migrate or update)

Note that we’re passing factoryCalldata instead of salt and bytecode. We do this in order to make verification compliant with any factory interface. We do not need to calculate the address based on create2Factory/salt/bytecode, because ERC-1271 verification presumes we already know the account address we’re verifying the signature for.

Verifier side

Full signature verification MUST be performed in the following order:

  • check if the signature ends with magic bytes, in which case do an eth_call to a multicall contract that will call the factory first with the factoryCalldata and deploy the contract if it isn’t already deployed; Then, call contract.isValidSignature as usual with the unwrapped signature
  • check if there’s contract code at the address. If so perform ERC-1271 verification as usual by invoking isValidSignature
  • if the ERC-1271 verification fails, and the deploy call to the factory was skipped due to the wallet already having code, execute the factoryCalldata transaction and try isValidSignature again
  • if there is no contract code at the address, try ecrecover verification

Rationale

We believe that wrapping the signature in a way that allows to pass the deploy data is the only clean way to implement this, as it’s completely contract agnostic, but also easy to verify.

The wrapper format ends in magicBytes, which ends with a 0x92, which makes it is impossible for it to collide with a valid ecrecover signature if packed in the r,s,v format, as 0x92 is not a valid value for v. To avoid collisions with normal ERC-1271, magicBytes itself is also quite long (bytes32).

The order to ensure correct verification is based on the following rules:

  • checking for magicBytes MUST happen before the usual ERC-1271 check in order to allow counterfactual signatures to be valid even after contract deployment
  • checking for magicBytes MUST happen before ecrecover in order to avoid trying to verify a counterfactual contract signature via ecrecover if such is clearly identifiable
  • checking ecrecover MUST NOT happen before ERC-1271 verification, because a contract may use a signature format that also happens to be a valid ecrecover signature for an EOA with a different address. One such example is a contract that’s a wallet controlled by said EOA.

We can’t determine the reason why a signature was encoded with a “deploy prefix” when the corresponding wallet already has code. It could be due to the signature being created before the contract was deployed, or it could be because the contract was deployed but not ready to verify signatures yet. As such, we need to try both options.

Backwards Compatibility

This ERC is backward compatible with previous work on signature validation, including ERC-1271 and allows for easy verification of all signature types, including EOA signatures and typed data (EIP-712).

Using ERC-6492 for regular contract signatures

The wrapper format described in this ERC can be used for all contract signatures, instead of plain ERC-1271. This provides several advantages:

  • allows quick recognition of the signature type: thanks to the magic bytes, you can immediately know whether the signature is a contract signature without checking the blockchain
  • allows recovery of address: you can get the address only from the signature using create2Factory and factoryCalldata, just like ecrecover

Reference Implementation

Below you can find an implementation of a universal verification contract that can be used both on-chain and off-chain, intended to be deployed as a singleton. It can validate signatures signed with this ERC, ERC-1271 and traditional ecrecover. EIP-712 is also supported by extension, as we validate the final digest (_hash).

// As per 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
    // - ERC-6492 suffix check and verification first, while being permissive in case the contract is already deployed; if the contract is deployed we will check the sig against the deployed version, this allows 6492 signatures to still be validated while taking into account potential key rotation
    // - ERC-1271 verification if there's contract code
    // - finally, 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
    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)
          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
    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
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)
    }
  }
}

On-chain validation

For on-chain validation, you could use two separate methods:

  • UniversalSigValidator.isValidSig(_signer, _hash, _signature): returns a bool of whether the signature is valid or not; this is reentrancy-safe
  • UniversalSigValidator.isValidSigWithSideEffects(_signer, _hash, _signature): this is equivalent to the former - it is not reentrancy-safe but it is more gas-efficient in certain cases

Both methods may revert if the underlying calls revert.

Off-chain validation

The ValidateSigOffchain helper allows you to perform the universal validation in one eth_call, without any pre-deployed contracts.

Here’s example of how to do this with the ethers library:

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

You may also use a library to perform the universal signature validation, such as Ambire’s signature-validator.

Security Considerations

The same considerations as ERC-1271 apply.

However, deploying a contract requires a CALL rather than a STATICCALL, which introduces reentrancy concerns. This is mitigated in the reference implementation by having the validation method always revert if there are side-effects, and capturing its actual result from the revert data. For use cases where reentrancy is not a concern, we have provided the isValidSigWithSideEffects method.

Furthermore, it is likely that this ERC will be more frequently used for off-chain validation, as in many cases, validating a signature on-chain presumes the wallet has been already deployed.

One out-of-scope security consideration worth mentioning is whether the contract is going to be set-up with the correct permissions at deploy time, in order to allow for meaningful signature verification. By design, this is up to the implementation, but it’s worth noting that thanks to how CREATE2 works, changing the bytecode or contructor callcode in the signature will not allow you to escalate permissions as it will change the deploy address and therefore make verification fail.

It must be noted that contract accounts can dynamically change their methods of authentication. This issue is mitigated by design in this EIP - even when validating counterfactual signatures, if the contract is already deployed, we will still call it, checking against the current live version of the contract.

As per usual with signatures, replay protection should be implemented in most use cases. This proposal adds an extra dimension to this, because it may be possible to validate a signature that has been rendered invalid (by changing the authorized keys) on a different network as long as 1) the signature was valid at the time of deployment 2) the wallet can be deployed with the same factory address/bytecode on this different network.

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Ivo Georgiev (@Ivshti), Agustin Aguilar (@Agusx1211), "ERC-6492: Signature Validation for Predeploy Contracts," Ethereum Improvement Proposals, no. 6492, February 2023. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-6492.