
출처: 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, sVerifier는 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 코드로 작성하는 방법에 대해 다루어 보았다.