: EIP-712는 단순한 바이트스트링이 아닌 형식화된 구조물을 해싱하고 서명하기 위한 표준입니다.
EIP-712이 어따가 쓰이는지는 대부분 알 것이라고 생각한다.
사용자가 이런 무작위의 byte string이 아니라
이렇게 readible한 구조화된 데이터에 sign을 하여, 본인이 무엇에 서명을 하는지를 알게끔 하도록 하기 위함이다.
상당히 복잡해보이지만... chat GPT를 돌려 번역해보면 다음과 같다.
서명 가능한 메시지 집합은 트랜잭션과 바이트스트링 𝕋 ∪ 𝔹⁸ⁿ에서 구조화된 데이터 𝕊로 확장됩니다. 이에 따라 새로운 서명 가능한 메시지 집합은 𝕋 ∪ 𝔹⁸ⁿ ∪ 𝕊입니다.
결국 서명 가능한 message의 집합에 𝕊가 추가되었다는 말인데 이게 뭔소리냐면...
RLP_encode(transaction)
의 집합을 의미한다.
즉 일반적인 이더리움 트랜잭션에서 EOA가 sign을 하기 전의 transaction을 말한다.
(raw transaction이 RLP encoding은 되고, sign은 하지 않은 상태)
‖
는 이 글에서 단순히 concat이라고 보아도 무방하니 참고하자!
"\x19Ethereum Signed Message:\n" ‖ len(message) ‖ message
을 의미한다. 이전 EIP-191 글 을 참조하면 알 수 있다.
⁸ⁿ의 의미는 8bit의 배수라는 뜻으로 결국 그냥 1byte 단위라는 의미이다.
참고로 여기서 \x19는 16진법 0x19를 의미하며, 이 prefix를 붙이는 이유는 EIP-191 글 에 설명해두었다.
간략하게 설명하면 "이것은 RLP encoding된 트랜잭션이 아니오 !!"라는 의미이다.
"\x19\x01" ‖ domainSeparator ‖ hashStruct(message)
²⁵⁶의 의미는 256bit (32byte)이며 이것은 keccak256(domainSeparator)
의 결과가 32byte
이기 때문이다.
여기서 domainSeperator
와 hashStruct
는 이 표준의 핵심이며 아래에 설명할 예정이다.
참고로 이것은 EIP-191과 호환되며, version prefix는 0x01
로 고정된다.
(version prefix에 대해서도 해당 글에 설명되어있다. version 0x01
자체가 EIP-712를 의미한다.)
결국 우리가 서명을 하고자 하는 message의 핵심 부분이다. ERC20의 경우에는 예를 들어 transferFrom
에 대한 정보로 누가, 누구에게, 얼마의 토큰을 전송할지에 대한 정보일 수 있다.
struct Mail {
address from;
address to;
string contents;
}
위와 같이 일반적인 solidity의 struct
구조체이다.
아래는 EIP 문서에 나와있는 definition 부분인데, 참고삼아 정리해 두었으나, 그냥 struct를 사용하기 위한 문법과 일치한다고 볼 수 있다.
구조체 타입은 이름으로 식별이 가능하며,
0개 이상의 멤버 변수를 포함한다. (당연히)
멤버 변수는 type과 name을 갖는다. (당연히 변수니까)
그냥 solidity의 원자 타입이다. 다음과 같다.
bytes1
~ bytes32
uint8
~ uint256
int8
~ int256
bool
address
bytes
string
hashStruct(s : 𝕊) = keccak256(typeHash ‖ encodeData(s)) where typeHash = keccak256(encodeType(typeOf(s)))
무섭게 생겼지만 그냥
keccak256(typeHash, encodeData(s))
이다.
여기서 typeHash
는 다음과 같다.
typeHash = keccak256(encodeType(typeOf(s)))
encodeType
과 encodeData(s)
는 아래에서 정의한다.
name ‖ "(" ‖ member₁ ‖ "," ‖ member₂ ‖ "," ‖ … ‖ memberₙ ")"
(각 member는 type ‖ " " ‖ name
로 이루어져 있음)
복잡해 보이지만 별게 아니다. 아래의 예시를 보면 쉽게 이해할 수 있다.
struct Mail {
address from;
address to;
string contents;
}
위에서 보았던 Mail struct는 아래와 같이 encode할 수 있다.
Mail(address from,address to,string contents).
type
과 name
사이에만 공백을 두고 나머지는 다 붙여쓴다.
struct Asset {
address token;
uint256 amount;
}
struct Person {
address wallet;
string name;
}
struct Transaction {
Person from;
Person to;
Asset tx;
}
를 encoding하면 다음과 같다.
Transaction(Person from,Person to,Asset tx)Asset(address token,uint256 amount)Person(address wallet,string name)
여기서 Asset
과 Person
의 순서는 단순히 알파벳 순이다.
enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)
각 value들은 32byte로 통일되어야 한다.
bytes, string의 경우에는 keccak256함수로 32byte로 뽑아내고,
배열의 경우에는 keccak256( arr[0] || arr[1] || arr[2])
처럼 각 원소를 concat하여 해싱한다.
struct의 경우에는 recursive하게 encoding된다. (자세한 예시는 나와있지가 않다)
hashStruct(s : structured data 𝕊) = keccak256(typeHash ‖ encodeData(s))
typeHash = keccak256(encodeType(typeOf(s)))
결국
keccak(
keccak(encodeType)
‖ enc(value₁)
‖ enc(value₂)
‖ …
‖ enc(valueₙ))
)
이렇게 다 합쳐서 다시 keccak을 돌리게 된다.
pragma solidity ^0.8.0;
contract Example {
//structured Data 𝕊
struct Mail {
address from;
address to;
string contents;
}
//1. Calculate TypeHash = keccak256(encodeType(typeOf(s)))
bytes32 public constant MAIL_TYPEHASH = keccak256(
"Mail(address from,address to,string contents)"
);
//2. hashStruct(s) = keccak256(typeHash ‖ encodeData(s))
function hashStruct(Mail mail) internal pure returns (bytes32) {
return keccak256(abi.encode(
// typeHash
MAIL_TYPEHASH,
// 3. encodeData = enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)
mail.from,
mail.to,
// bytes, string 값에는 keccak256 함수를 적용해 32 바이트 길이로 통일
keccak256(bytes(mail.contents))
));
}
}
여기서 abi.encode
를 해주는 이유는 abi.encode
가 256bit에 맞추어 정렬을 해주기 때문이다.
(uint8
이나 bool
같이 256비트가 안되는 것들은 앞에 0으로 패딩을 붙여준다.)
encodeData = enc(value₁) ‖ enc(value₂) ‖ … ‖ enc(valueₙ)
에서 각 값은 32byte
로 통일되어야 한다고 위에서 언급했었다.
위의 예시는 hashStruct(Main mail)
이였다.
domainSeparator = hashStruct(eip712Domain)
struct EIP712Domain {
string name;
string version;
uint256 chainId;
address verifyingContract;
bytes32 salt;
EIP에서는 해당 순서를 지키되 필요없는 것들은 제거하면서 Dapp의 상황에 맞게 사용해야 한다고 한다.
이후의 필드는 알파벳 순서로 정렬하여 위의 struct 하단에 추가하라고 나와있다.
bytes32 public DOMAIN_SEPARATOR;
bytes32 public constant EIP712DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
constructor (string memory name, string memory version) {
//hashStruct(s) = keccak256(typeHash ‖ encodeData(s))
DOMAIN_SEPARATOR = keccak256(
abi.encode(
//typeHash(eip712Domain)
EIP712DOMAIN_TYPEHASH,
//name
keccak256(bytes(name)),
//version
keccak256(bytes(version)),
//chaindid
block.chainid,
//verifyingContract
address(this)
)
);
}
domainSeperator의 경우 값이 고정되어 있지 않기 때문에, 굳이 struct
로 선언하지 않고 이렇게 바로 계산해도 무방하다.
Upgradeable contract의 경우에는
verifyingContract
의 주소가 바뀔 수 있으므로 유의하자.
생성 방법은 hashStruct값과 동일하게
keccak(
ERC721DOMAIN_TYPEHASH,
value1,
value2,
...,
valuen
)
의 형식이며, 동일하게 bytes, string 등의 경우 hash한 값을 사용하고 있다.
또한 address와 같이 32bytes 길이가 되지 않는 것들은 abi.encode
를 통해 앞에 0으로 패딩을 해주고 있다.
function verify(Mail memory mail, uint8 v, bytes32 r<, bytes32 s) internal view returns (bool) {
// Note: we need to use `encodePacked` here instead of `encode`.
bytes32 hash = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
hashStruct(mail)
));
return ecrecover(hash, v, r, s) == mail.from;
}
이 예시를 보면 이해할 수 있다.
뭔가 이것 저것 복잡해보이지만, 사실 EIP-191에서 살펴보았던 매커니즘과 동일하다.
사인하고자 하는 message가 있고, signer가 이것에 서명하여 v, r, s값을 뽑아내는 과정인 것이다.
다만 이 message가 바로 이렇게 이루어져 있는 것이다.
keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
hashStruct(mail)
));
주의할 점은 여기서 실질적으로 바뀔 수 있는 값은 mail
하나 뿐이다.
DOMAIN_SEPERATOR
는 상수로서 contract에 저장되어 있으며 signer도 이것을 그대로 가져다가 해싱한다.
hashStruct의 경우에도 내부에 typedHash는 이미 정의가 되어 있는 상수이고, mail의 값들에 대한 부분만 바뀐다.
DOMAIN_SEPERATOR
의 hash값keccak256(
abi.encode(
EIP712DOMAIN_TYPEHASH,
domain멤버값들
)
)
hashStruct
의 hash값keccak256(
abi.encode(
typeHash,
encodeData 값들 // 지금의 경우 mail의 각 값들 (얘만 변하는 값이다)
)
)
을 다시 keccak256(1번, 2번)
한 값이 최종 message이다.
이것에 sign을 해서 v, r, s 값을 뽑아낸다.
verifier에게는 변하는 값인 mail에 대한 정보와 signature(v, r, s)만 넘겨준다.
verifier는 이미EIP712DOMAIN_TYPEHASH
, domain멤버값들
, typeHash
에 대해 알고있다.
(당연히 verifier에게 먼저 정의된 것을 signer가 가져갔다.)
따라서 mail의 값을 받아서 따로 새로운 message hash를 만들어 낸다.
여기서 verifier가 검증을 위해 새로 생성한 message를 msg
라고 하면,
ecrecover(msg, v, r, s);
를 했을 때 signer의 주소가 return되면 정상적인 verify가 완료된 것이다.
이 글에서는 따로 javascript에서 다루지는 않는다.
domain seperator를 사용하면 다른 컨트랙트의 동일한 함수에 대한 Replay Attack은 막을 수 있겠지만, 동일한 컨트랙트에 여러번 signature가 사용되는 것은 막지 못할 수도 있다.
Uniswap V2 ERC20Permit 함수처럼 owner의 nonce를 증가시키는 등 각 Dapp의 상황에 맞게 로직을 만들어 Replay Attack을 방지하는 것이 필요하다.
EIP-712가 항상 헷갈렸는데 하루종일 이것에 대해 정리하고 생각하면서 이제 확실하게 이해가 된다.
혹시 이 글을 읽는 당신도 헷갈려서 슥슥 스크롤을 내렸다면 몇 시간이 걸리든 다른 자료와 함께 찾아보길 바란다.
이해를 완전히 하고나니 그렇게 어려운 것이 아니였다 ㅠ.ㅠ