출처: https://book.getfoundry.sh/tutorials/testing-eip712
Replay Attack에 대해 알아보다가, 정말 많은 것을 찾아보았다.
혹 EIP-712나 Off-Chain 서명에 대해 낯설다면 아래의 글들을 쭉 정독해보기 바란다.
대부분의 Off-chain 서명은 javascript를 이용하는데 foundry에서는 이것을 solidity로 test 할 수 있다.
한번 ARABOZA! 뿌우뿌우~~
이 코드가 헷갈린다면 ERC-2612 글을 참조하세용
/*//////////////////////////////////////////////////////////////
EIP-2612 LOGIC
//////////////////////////////////////////////////////////////*/
function permit(
address owner,
address spender,
uint256 value,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) public virtual {
require(deadline >= block.timestamp, "PERMIT_DEADLINE_EXPIRED");
// Unchecked because the only math done is incrementing
// the owner's nonce which cannot realistically overflow.
unchecked {
address recoveredAddress = ecrecover(
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
)
)
),
v,
r,
s
);
require(recoveredAddress != address(0) && recoveredAddress == owner, "INVALID_SIGNER");
allowance[recoveredAddress][spender] = value;
}
emit Approval(owner, spender, value);
}
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
keccak256(
"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"
),
owner,
spender,
value,
nonces[owner]++,
deadline
)
)
)
),
v,
r,
s
);
코드가 상당히 복잡해보이지만, EIP-712를 살펴보면 이해할 수 있다.
기본적으로 Off-chain 서명의 verification은 다음과 같이 이루어진다.
Signer는 다음의 3가지를 Verifier에게 전달한다.
v
, r
, s
Verifier는 Signer에게 받은 정보들로 다음의 과정으로 verify를 수행한다.
message hash
와 signature(v, r, s)
를 ecrecover
에 넣어 사용하여 Signer의 주소가 리턴되는지 확인우리는 이
Permit
함수에서 Verify할 signature를 foundry에서 solidity code로 생성하는 것이 목표이다!
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.13;
contract SigUtils {
bytes32 internal DOMAIN_SEPARATOR;
constructor(bytes32 _DOMAIN_SEPARATOR) {
DOMAIN_SEPARATOR = _DOMAIN_SEPARATOR;
}
// keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
bytes32 public constant PERMIT_TYPEHASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9;
struct Permit {
address owner;
address spender;
uint256 value;
uint256 nonce;
uint256 deadline;
}
// computes the hash of a permit
function getStructHash(Permit memory _permit)
internal
pure
returns (bytes32)
{
return
keccak256(
abi.encode(
PERMIT_TYPEHASH,
_permit.owner,
_permit.spender,
_permit.value,
_permit.nonce,
_permit.deadline
)
);
}
// computes the hash of the fully encoded EIP-712 message for the domain, which can be used to recover the signer
function getTypedDataHash(Permit memory _permit)
public
view
returns (bytes32)
{
return
keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
getStructHash(_permit)
)
);
}
}
서명할 message hash
를 만들어줄 함수이다.
생성 시 DOMAIN_SEPERATOR
를 넣어주기만 하면 알아서 짠 하고 만들어줄 것이다.
Signer는 getTypedDataHash
에 struct만 넣어주면 짠 하고 hash를 return해준다.
여기까지는 On-Chain으로 당연히 가능하지만 서명한 message를 생성한 것일 뿐이다.
이후의 서명 과정은 일반적으로 javascript를 통해 진행하지만, 우리의 목표는 이것을 foundry를 이용하여 서명하는 것이다.
foundry에는
vm.sign()
이라는 cheatcode가 있다.
function test_Permit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 0,
deadline: 1 days
});
// 위의 SigUtils contract를 이용하여 digest(서명할 message)를 생성
bytes32 digest = sigUtils.getTypedDataHash(permit);
// vm.sign을 이용하여 서명
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
assertEq(token.allowance(owner, spender), 1e18);
assertEq(token.nonces(owner), 1);
}
위의 경우 정상적으로 verif가 된다는 scenario였고...
실제 test code를 작성하기 위해서 다양한 상황을 설정할 필요가 있다.
function testRevert_ExpiredPermit() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: token.nonces(owner),
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
// deadline인 1 days보다 1초를 더한다
vm.warp(1 days + 1 seconds); // fast forward one second past the deadline
vm.expectRevert("PERMIT_DEADLINE_EXPIRED");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
: ecrecover
의 리턴 주소가 Valid한 Signer가 아닌 경우를 테스트한다.
function testRevert_InvalidSigner() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: token.nonces(owner),
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
// ownerPrivateKey가 아닌 spenderPrivateKey로 서명
(uint8 v, bytes32 r, bytes32 s) = vm.sign(spenderPrivateKey, digest); // spender signs owner's approval
vm.expectRevert("INVALID_SIGNER");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
function testRevert_InvalidNonce() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 1, // owner nonce stored on-chain is 0
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
vm.expectRevert("INVALID_SIGNER");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
function testRevert_SignatureReplay() public {
SigUtils.Permit memory permit = SigUtils.Permit({
owner: owner,
spender: spender,
value: 1e18,
nonce: 0,
deadline: 1 days
});
bytes32 digest = sigUtils.getTypedDataHash(permit);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
vm.expectRevert("INVALID_SIGNER");
token.permit(
permit.owner,
permit.spender,
permit.value,
permit.deadline,
v,
r,
s
);
}
INVALID_SIGNER
메세지가 왜나오나 해서 생각해보니 permit
함수에서 두 번째 호출 시에는 nonce값은 1인데, replay된 signature의 nonce 값은 0이라 address(0)
리턴되어서 그렇다.
사실 vm.sign
을 사용해서 sign을 하는 과정만을 다루려고 하였으나 서치를 하다보니 여러가지 test case도 소개하게 되었다.
EIP-712
관련 글들을 보다보면 대부분 Javascript를 활용하여 서명하게 되는데, foundry를 사용하여 solidity 코드로 작성하는 방법에 대해 다루어 보았다.