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를 통해 블록체인을 조회하지 않고도 서명인지 즉시 알 수 있으며 create2Factory와 factoryCalldata를 사용하면 서명만으로 컨트랙트의 주소를 복구할 수 있는 장점이 있다.
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