Abstract

ERC-1271은 서명을 검증하는 표준이지만, 아직 배포되지 않은 컨트랙트의 서명 검증은 불가능한 한계가 존재

ERC-6492는 미배포 컨트랙트(counterfactual contract)도 서명 검증을 수행할 수 있도록 ERC-1271을 확장한 표준

  • 특정 형식의 서명 래핑(wrapping) 방식을 통해 서명을 검증할 때 컨트랙트를 먼저 배포할 수 있도록 함

Motivation

계정 추상화가 발전하면서, smart account wallet의 첫 트랜잭션 시점에 배포되는 것이 일반적

그러나 대부분 DApp은 로그인이나 인증을 위해 서명을 요구하며, 아직 배포되지 않은 스마트 지갑의 서명 검증이 어려운 한계점을 극복하는 것이 목표

Specification

# ERC-1271
isValidSignature
함수는 서명을 검증하기 위해 호출 할 수 있으며, 상황에 따라 달라질 수 있다.

시간 기반(time based): 특정 시간 내에서만 유효한 서명

  • 상태 기반(state based): 스마트 컨트랙트의 특정 상태에 따라 서명의 유효성이 달라짐
  • EOA 기반: 스마트 월렛 내에서 서명자의 권한 수준에 따라 서명의 유효성이 결정
  • 서명 방식: ECDSA, multisig, BLS 등 다양한 서명 방식 지원

이 함수는 메시지를 서명하고자 하는 컨트랙트(smart account wallet)에 의해 구현되어야 한다.
서명을 지원하려는 app은 서명자가 스마트 컨트랙트인 경우 반드시 이 함수를 호출하여 서명을 검증해야 한다.

 

ERC-1271 기존의 isValidSignature 함수를 그대로 사용하지만, 새로운 형식을 추가한 형태

검증자는 서명이 래핑 형식인지 확인한 후 -> 해당 형식이라면 isValidSignature를 호출하기 전에 반드시 컨트랙트를 배포해야 한다.

 

래핑 형식 여부는 서명의 마지막이 magicBytes 값으로 끝나는지 확인하여 판별한다.

  • magicBytes = 0x6492649264926492649264926492649264926492649264926492649264926492

ERC-6492는 CREATE2와 함께 사용하는 것이 권장

  •  컨트랙트가 배포되기 전에도 예측 가능한 주소를 알 수 있음

Signer Side

컨트랙트가 이미 배포된 경우

  • 일반적인 ERC-1271 서명 형식을 사용하여 서명을 생성

컨트랙트가 아직 배포되지 않은 경우

concat(abi.encode((create2Factory, factoryCalldata, originalERC1271Signature), (address, bytes, bytes)), magicBytes)
  • create2Factory: 컨트랙트를 배포할 팩토리 주소
  • factoryCalldata: 컨트랙트 배포에 필요한 데이터
  • originalERC1271Signature: 기존 ERC-1271 서명
  • magicBytes:0x6492649264926492649264926492649264926492649264926492649264926492

컨트랙트가 배포되었지만, ERC-1271 검증을 수행할 준비가 되지 않은 경우

concat(abi.encode((prepareTo, prepareData, originalERC1271Signature), (address, bytes, bytes)), magicBytes)
  • prepareTo: 컨트랙트를 특정 상태로 변경할 주소
  • prepareData: ERC-1271 검증이 가능하도록 컨트랙트를 준비하는 트랜잭션 데이터 (migrate, update)
  • originalERC1271Signature: 기존 ERC-1271 서명
  • magicBytes: 0x6492649264926492649264926492649264926492649264926492649264926492

 

salt 및 bytecode 대신 factoryCalldata를 전달한다는 점에 유의 

  • 어떤 팩토리 인터페이스에서도 서명 검증이 가능하도록 만들기 위함

create2Factory/salt/bytecode를 기반으로 주소를 계산할 필요가 없음

  • CREATE2를 사용할 경우, 컨트랙트 주소를 미리 알고 있기 때문

 Verifier side

서명 검증은 반드시 다음 순서대로 수행

 

1. 서명이 magic bytes로 끝나는지 확인

  • magic bytes가 감지 -> eth_call을 사용하여 멀티콜 컨트랙트를 실행
  • 멀티콜 컨트랙트는 먼저 factoryCalldata를 통해 팩토리 컨트랙트를 호출 -> smart account가 아직 배포되지 않았다면 배포

2. 해당 주소에 컨트랙트 코드가 존재하는지 확인

  • 코드가 존재 -> 기존 ERC-1271 방식대로 isValidSignature 함수를 호출하여 서명을 검증

3. ERC-1271 검증이 실패했거나, smart account가 이미 배포된 상태여서 배포 과정 넘긴 경우

  • factoryCalldata를 실행하여 추가적으로 필요한 트랜잭션을 수행한 후,  ERC-6452 방식의 isValidSignature 함수를 호출하여 서명을 검증

4.컨트랙트 코드가 없는 경우

  • ecrecover 방식을 사용하여 서명을 검증 (ecrecover 검증은 반드시 ERC-1271 검증보다 후순위)

Rationale

 

서명을 래핑하여 배포 데이터를 포함하는 이유

  • 서명을 래핑하여 배포 데이터를 함께 전달할 수 있도록 하는 것이 가장 깔끔한 해결책이라고 생각함
  • 컨트랙트에 의존하지 않으며 서명 검증이 간단하고 일관되게 수행될 수 있음

magicBytes를 활용하는 이유

 

  • magicBytes의 마지막 값 = 0x92, 이는 ECDSA의 ecrecover 서명(r, s, v)에서 v 값으로 사용할 수 없는 값이기 때문에 magicBytes가 포함된 서명은 ecrecover와 충돌할 가능성이 없음.

또한 magicBytes 자체가 32바이트로  일반적인 ERC-1271 서명과도 충돌할 위험이 없다.

 

Signature Verification Order

 

1. magicBytes 확인을 먼저 수행해야 함

  • 서명이 배포 전후에 일관되게 검증될 수 있도록 보장.

2. magicBytes 확인이 ecrecover 검증보다 먼저 수행되어야 함

  • magicBytes를 먼저 확인하면 ecrecover를 통해 잘못 검증되는 것을 방지 할 수 있음

3. ecrecover 검증은 반드시 ERC-1271 검증 후에 수행해야 함

  • 일부 스마트 컨트랙트가 ecrecover 방식과 유사한 서명 형식을 사용할 수 있기 때문

magicBytes를 통해 블록체인을 조회하지 않고도 서명인지 즉시 알 수 있으며 create2FactoryfactoryCalldata를 사용하면 서명만으로 컨트랙트의 주소를 복구할 수 있는 장점이 있다.

 

 Reference Implementation

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

  • isValidSig(_signer, _hash, _signature)
  • isValidSigWithSideEffects(_signer, _hash, _signature)

Off-chain validation

ValidateSigOffchain 헬퍼 컨트랙트를 사용하면 eth_call을 통해 오프체인에서 서명을 검증할 수 있음

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

Ref

https://eips.ethereum.org/EIPS/eip-6492

 

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

eips.ethereum.org

 

+ Recent posts