티스토리 뷰
안녕하세요. piatoss입니다.
Ethernaut 문제를 계속 푸는 와중에 Dex(Decentralized Exchange) 관련 문제가 나와서 찾아보다가 'Damn Vulnerable DeFi'라는 문제 사이트를 발견해서 그것도 조금 풀어보려는데 무슨 소리인지 하나도 안 들어오더라고요. 그래서 solidity를 계속 공부하려면 근본적으로 DEX가 무엇이고 어떻게 돌아가는지 이해가 필요할 것 같아서 Uniswap V2를 들고 와 봤습니다.
V2면 V1도 있을 텐데 왜 V2부터 하냐고 물어보신다면, V1은 solidity가 아닌 vyper로 작성되어 있기 때문에 제가 읽을 수가 없습니다. 그래서 굳이 V1부터 시작하기보다는 가장 유명하고 여러 개선점들도 돋보이는 V2를 기준으로 시작해 볼까 합니다. 이론적인 부분도 중요하지만, 일단은 코드를 살펴보면서 관련된 개념들을 정리하고 마무리할 때 다시 정리하는 식으로 작성하고자 합니다. 그럼 시작하겠습니다.
solidity 버전 0.5.x로 작성된 코드를 버전 0.8.19에서 다시 작성하는 작업을 포함합니다.
🦄 IUniswapV2ERC20.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
// 유동성 예치 시에 제공되는 LP 토큰의 ERC20 표준 인터페이스
interface IUniswapV2ERC20 {
event Approval(address indexed owner, address indexed spender, uint value); // Approval 이벤트 (owner가 spender에게 value만큼의 토큰을 인출할 수 있도록 허가)
event Transfer(address indexed from, address indexed to, uint value); // Transfer 이벤트 (from에서 to로 value만큼의 토큰을 전송)
function name() external pure returns (string memory); // 토큰 이름 (getter)
function symbol() external pure returns (string memory); // 토큰 심볼 (getter)
function decimals() external pure returns (uint8); // 토큰 소수점 자리수 / ether의 경우 18 (getter)
function totalSupply() external view returns (uint); // 토큰 총 발행량 (getter)
function balanceOf(address owner) external view returns (uint); // owner의 토큰 잔액 (getter)
function allowance(
address owner,
address spender
) external view returns (uint); // owner가 spender에게 인출을 허가한 토큰의 잔액 (getter)
function approve(address spender, uint value) external returns (bool); // spender에게 value만큼의 토큰을 인출할 수 있도록 허가
function transfer(address to, uint value) external returns (bool); // to에게 value만큼의 토큰을 전송
function transferFrom(
address from,
address to,
uint value
) external returns (bool); // from에서 to에게 value만큼의 토큰을 전송
function DOMAIN_SEPARATOR() external view returns (bytes32); // EIP-2612 permit()을 위한 도메인 분리자
function PERMIT_TYPEHASH() external pure returns (bytes32); // EIP-2612 permit()을 위한 타입 해시
function nonces(address owner) external view returns (uint); // EIP-2612 permit()을 위한 nonce
function permit(
address owner,
address spender,
uint value,
uint deadline,
uint8 v,
bytes32 r,
bytes32 s
) external; // EIP-2612 permit()을 통한 토큰 인출 허가
}
IUniswapV2ERC20 인터페이스는 대체 가능한 토큰을 위한 표준인 ERC20의 인터페이스와 크게 다를 바가 없습니다.
눈에 띄는 부분은 아래쪽에 정의된 네 가지 함수입니다.
- DOMAIN_SEPARATOR
- PERMIT_TYPEHASH
- nonces
- permit
이 함수들은 ERC-2612에 정의되어 있는 것들입니다. 즉, 이 인터페이스는 ERC-20와 ERC-2612를 포함하고 있습니다. ERC-20은 귀에 못이 박히도록 들어봤는데, ERC-2612는 무엇일까요?
📜 ERC-2612
ERC-20 표준에서 transferFrom을 통해 토큰을 전송하는 방식은 일반적으로 다음과 같습니다.
- A 계정에서 직접 approve 함수를 호출하여 B가 100개의 토큰을 사용하도록 허용합니다.
- B 또는 다른 누군가가(A 포함) transferFrom 함수를 호출하여 A의 계좌로부터 B에게 100개의 토큰을 전송합니다.
이 방법은 두 개의 트랜잭션을 각각 실행해야 하기 때문에 추가적인 가스비가 필요하다는 단점이 있습니다. 또한 approve 함수는 내부 함수를 호출할 때 msg.sender를 사용하므로 토큰의 소유자가 직접 호출하는 수밖에 없습니다. 이러한 이유로 제삼자가 소유자 대신 토큰을 전송하는 것은 불가능한 일(일반적으로)이었습니다.
ERC-2612는 이러한 문제점을 보완하기 위한 EIP-20에 대한 확장으로, 'permit'이라는 새로운 함수를 사용해 allowance를 수정하는 데에 msg.sender대신 서명된 메시지를 사용합니다.
사양
ERC-2612를 준수하는 컨트랙트는 반드시 아래 함수들을 구현해야 합니다.
function permit(address owner, address spender, uint value, uint deadline, uint8 v, bytes32 r, bytes32 s) external
function nonces(address owner) external view returns (uint)
function DOMAIN_SEPARATOR() external view returns (bytes32)
DOMAIN_SEPARATOR
DOMAIN_SEPARATOR 함수는 EIP-712 표준에 정의된 도메인 구분자를 반환합니다. 생성 과정에 체인 ID와 현 도메인(컨트랙트)에 관한 정보를 포함하고 있기 때문에 다른 체인 또는 컨트랙트에서 서명이 재사용되는 것을 방지할 수 있으며, 사용자 에이전트에서 올바른 도메인을 구분하는 것을 돕습니다.
DOMAIN_SEPARATOR 생성에 일반적으로 다음 값들을 직렬화하여 해시 함수의 입력으로 사용합니다.
- 첫 번째, 32바이트 해시: EIP712Domain 구조체 필드를 나타내는 시그니처의 해시
- 두 번째, 32바이트 해시: DApp 이름과 같이 서명 도메인의 이름의 해시
- 세 번째, 32바이트 해시: 서명 도메인의 버전의 해시
- 네 번째, 체인 ID: 컨트랙트가 활성화되어 있는 체인의 ID
- 다섯 번째, 검증 컨트랙트 주소: 서명을 검증하는 컨트랙트의 주소
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256('EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)'),
keccak256(bytes(name)),
keccak256(bytes(version)),
chainid,
address(this)
));
nonces
owner의 논스값을 반환합니다. permit 함수 호출 시에 서명 데이터를 재구성하는 과정에서 논스값을 사용하고 서명이 검증되면 다음 permit 함수를 호출할 때는 1만큼 늘어난 논스값을 사용해야 합니다. 이는 permit 요청이 중복되는 것을 방지하기 위해 사용됩니다.
permit
permit 함수 호출은 'nonces[owner]'를 1 증가시키고 'allowance[owner][spender]'를 'value'로 수정합니다. 그리고 approve 함수와 같이 Approval 이벤트를 발생시킵니다. 'deadline'은 서명이 유효한 기한을 나타내므로 유효 기한이 지난 서명으로 permit 함수 호출하는 것을 제한할 수 있습니다.
permit 함수는 아래와 같은 조건에서만 정상적으로 실행됩니다.
- 현재 블록의 타임스탬프가 'deadline'보다 작다.
- 'owner'가 '0x00'이 아니다.
- 'nonces[owner]'가 서명 데이터에 사용된 논스와 동일해야 한다.
- 서명 (r,s,v)가 서명 데이터에 대해 유효한 서명이어야 한다.
이하는 서명 데이터를 재구성하는 코드입니다. '0x1901', 32바이트 도메인 구분자 그리고 32바이트의 해시(입력값을 직렬화한 값의 해시)를 이어 붙인 총 66바이트의 값을 keccak256 해시 함수의 입력으로 사용합니다. (0x1901은 ERC-191에서 정한 서명 데이터 표준의 0x19 접두사와 버전이 구조화된 데이터;0x01 임을 나타냅니다.)
keccak256(abi.encodePacked(
hex"1901",
DOMAIN_SEPARATOR,
keccak256(abi.encode(
keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"),
owner,
spender,
value,
nonce,
deadline))
))
이렇게 구한 서명 데이터와 서명 (v, r, s)를 solidity의 내장 함수 ecrecover의 입력으로 사용하여 서명자의 공개키(주소)를 복원합니다. 이렇게 복원된 주소는 owner와 비교하여 동일하지 않다면 오류를 반환하고 동일하다면 유효한 서명으로 간주하고 내부 _approve 함수를 실행합니다.
ecrecover(bytes32 hash, uint8 v, bytes32 r, bytes32 s) returns (address)
ERC-2612의 이점
- 기존의 approve와 transferFrom을 나누어서 실행해야 했던 것보다 가스비를 절약할 수 있습니다. owner는 서명을 생성하고 제삼자에게 함수 호출을 맡길 수 있습니다.
- 1을 기반으로 일괄 처리가 더 간단해졌습니다.
- 기존의 approve 방식은 별다른 제한이 없었던 것에 비해, permit은 서명에 deadline을 설정하여 보안적인 요소를 추가하였습니다.
ERC-2612의 한계
- ERC-2612를 구현하지 않은 기존의 ERC-20 토큰 컨트랙트는 해당 기능을 사용할 수 없습니다.
- 서명을 생성하는 과정이 복잡합니다.
🦄 UniswapV2ERC20.sol
ERC-2612를 구현하기 위해 32바이트 크기의 DOMAIN_SEPARATOR, PERMIT_TYPEHASH 그리고 각 주소의 논스값을 저장하는 맵이 선언되어 있습니다. 여기서 PERMIT_TYPEHASH는 앞서 살펴본 서명 메시지를 재구성하는 과정에서 사용되는 값입니다.
bytes32 public DOMAIN_SEPARATOR;
bytes32 public constant PERMIT_TYPEHASH =
0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; // keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");
mapping(address => uint) public nonces;
DOMAIN_SEPARATOR는 생성자에서 다음과 같이 초기화됩니다.
constructor() {
uint chainId;
assembly {
chainId := chainid()
}
DOMAIN_SEPARATOR = keccak256(
abi.encode(
keccak256(
"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
),
keccak256(bytes(name)), // name
keccak256(bytes("1")), // version
chainId, // chainId
address(this) // verifyingContract
)
); // EIP2612 domain separator (replay attack 방지)
}
permit 함수는 다음과 같습니다. 서명 데이터를 재구성해서 서명자의 주소를 복원하고 일치하는 경우 내부 _approve 함수를 실행합니다.
// owner가 spender에게 토큰을 사용할 수 있도록 허락
// (approve는 소유 계정에서 직접 호출해야 한다면, permit은 서명을 통해 유효성을 검증할 수 있으므로 제삼자가 호출할 수 있다.)
function permit(
address owner, // 서명자
address spender, // 허락 받는자
uint value, // 허락하는 토큰의 양
uint deadline, // 허락 기한 (type(uint).max: 무제한)
uint8 v, // 서명의 v
bytes32 r, // 서명의 r
bytes32 s // 서명의 s
) external {
require(deadline >= block.timestamp, "UniswapV2: EXPIRED"); // 기한이 현 시각보다 이전인 경우 예외 처리
bytes32 digest = keccak256(
abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(
abi.encode(
PERMIT_TYPEHASH,
owner,
spender,
value,
nonces[owner]++, // 논스 증가
deadline
)
)
)
); // 서명 데이터
address recoveredAddress = ecrecover(digest, v, r, s); // 서명자 주소 복원 (= digest에 서명한 비공개 키의 공개 키)
require(
recoveredAddress != address(0) && recoveredAddress == owner, // 서명자 주소가 0이 아니고 서명자가 서명자 주소와 같아야 한다.
"UniswapV2: INVALID_SIGNATURE"
);
_approve(owner, spender, value); // owner가 spender에게 value만큼의 토큰을 사용할 수 있도록 허락
}
이미 다 살펴보았기 때문에 조금은 밋밋하지 않으신가요? 그래서 테스트를 준비했습니다...
🔍 테스트
UniswapV2ERC20 컨트랙트 자체에는 mint 함수가 없어서 테스트용으로 UniswapV2ERC20를 래핑한 컨트랙트를 사용했습니다.
contract UniswapV2ERC20WithMint is UniswapV2ERC20 {
function mint(address to, uint256 value) public {
_mint(to, value);
}
}
SETUP
테스트는 Foundry를 사용했습니다. javascript를 사용하지 않고(굉장히 큼) solidity 자체를 사용해서 테스트를 작성할 수 있다는 것과 속도가 굉장히 빠르다는 장점이 있습니다.
setup 함수는 각 테스트가 실행되기 전에 매번 실행되는 함수로 테스트 환경을 설정해 줍니다. permit 함수를 실행하기 위해 owner와 spender를 생성하고 owner에게 초기자금을 보내주었습니다.
contract UniswapV2ERC20Test is Test {
UniswapV2ERC20WithMint public token;
uint256 ownerPrivateKey;
uint256 spenderPrivateKey;
address owner;
address spender;
function setUp() public {
token = new UniswapV2ERC20WithMint();
ownerPrivateKey = 0x1234567890123456789012345678901234567890123456789012345678901234;
owner = vm.addr(ownerPrivateKey);
spenderPrivateKey = 0x1234567890123456789012345678901234567890123456789012345678904321;
spender = vm.addr(spenderPrivateKey);
token.mint(owner, 1e18);
}
...
}
서명 데이터를 생성하는 유틸리티 함수는 계속 재사용할 것이므로 다음과 같이 별도로 정의해 주었습니다.
function calcDigest(
address _owner,
address _spender,
uint256 value,
uint256 nonce,
uint256 deadline
) public view returns (bytes32) {
return
keccak256(
abi.encodePacked(
"\x19\x01",
token.DOMAIN_SEPARATOR(),
keccak256(
abi.encode(
token.PERMIT_TYPEHASH(),
_owner,
_spender,
value,
nonce,
deadline
)
)
)
);
}
CASE 1: 서명이 유효한 경우
function test_Permit() public {
bytes32 digest = calcDigest(
owner,
spender,
1e9,
token.nonces(owner),
1 days
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(owner, spender, 1e9, 1 days, v, r, s);
assertEq(token.allowance(owner, spender), 1e9);
assertEq(token.nonces(owner), 1);
}
function test_PermitAndTransferFrom() public {
bytes32 digest = calcDigest(
owner,
spender,
1e9,
token.nonces(owner),
1 days
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(owner, spender, 1e9, 1 days, v, r, s);
assertEq(token.balanceOf(owner), 1e18);
assertEq(token.balanceOf(spender), 0);
vm.prank(spender);
token.transferFrom(owner, spender, 1e9);
assertEq(token.balanceOf(spender), 1e9);
assertEq(token.balanceOf(owner), 1e18 - 1e9);
}
$ forge test -vvv --mt "test_Permit()"
[⠔] Compiling...
No files changed, compilation skipped
Running 2 tests for test/UniswapV2ERC20.t.sol:UniswapV2ERC20Test
[PASS] test_Permit() (gas: 74056)
[PASS] test_PermitAndTransferFrom() (gas: 88136)
Test result: ok. 2 passed; 0 failed; 0 skipped; finished in 3.90ms
Ran 1 test suites: 2 tests passed, 0 failed, 0 skipped (2 total tests)
여기서 조금 특이한 부분은 test_PermitAndTransferFrom 함수에서 transferFrom 함수를 실행하기 전에 'vm.prank(spender)'로 트랜잭션 서명자를 spender로 변경한 것입니다.
이는 UniswapV2ERC20 컨트랙트의 transferFrom 함수 안에서 allowance를 체크할 때 msg.sender를 사용합니다. 그래서 to에 해당하는 msg.sender가 아닌 경우에는 잘못된 값이 반환되어서 빼기 연산에서 오버플로우가 발생할 수 있습니다. 저도 왜 오버플로우가 발생하는지 이유를 모르겠어서 코드를 살펴보다가 발견했습니다.
// from에서 to로 value만큼의 토큰을 전송
function transferFrom(
address from,
address to,
uint value
) external returns (bool) {
if (allowance[from][msg.sender] != type(uint256).max) {
// 무제한 허가가 아닌 경우 (허가량이 제한되어 있는 경우)
allowance[from][msg.sender] -= value; // 허가량 감소
}
_transfer(from, to, value); // from의 잔고에서 to로 value만큼의 토큰 전송
return true;
}
CASE 2: 서명이 유효하지 않은 경우 - 서명 만료
deadline이 지난 서명을 사용할 경우, 서명이 만료되었다는 사유로 revert됩니다.
function testRevert_ExpiredPermit() public {
bytes32 digest = calcDigest(
owner,
spender,
1e9,
token.nonces(owner),
1 days
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
vm.warp(1 days + 1 seconds);
vm.expectRevert("UniswapV2: EXPIRED");
token.permit(owner, spender, 1e9, 1 days, v, r, s);
}
$ forge test -vvv --mt testRevert_ExpiredPermit
[⠔] Compiling...
[⠃] Compiling 5 files with 0.8.23
[⠊] Solc 0.8.23 finished in 2.25s
Compiler run successful!
Running 1 test for test/UniswapV2ERC20.t.sol:UniswapV2ERC20Test
[PASS] testRevert_ExpiredPermit() (gas: 23784)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 4.28ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
CASE 3: 서명이 유효하지 않은 경우 - 잘못된 논스값
컨트랙트에서 추적하고 있는 계정의 논스값과 다른 값인 경우, 서명이 유효하지 않다는 사유로 revert됩니다.
function testRevert_InvalidNonce() public {
bytes32 digest = calcDigest(
owner,
spender,
1e9,
token.nonces(owner) + 1,
1 days
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
vm.expectRevert("UniswapV2: INVALID_SIGNATURE");
token.permit(owner, spender, 1e9, 1 days, v, r, s);
}
$ forge test -vvv --mt testRevert_InvalidNonce
[⠔] Compiling...
No files changed, compilation skipped
Running 1 test for test/UniswapV2ERC20.t.sol:UniswapV2ERC20Test
[PASS] testRevert_InvalidNonce() (gas: 47717)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.37ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
CASE 4: 서명을 재사용하는 경우 - Replay Attack
이미 서명을 사용한 경우 계정의 논스값이 1 증가했으므로, 이전의 논스값에 대한 서명을 재사용할 수 없습니다.
function testRevert_SignatureReplay() public {
bytes32 digest = calcDigest(
owner,
spender,
1e9,
token.nonces(owner),
1 days
);
(uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPrivateKey, digest);
token.permit(owner, spender, 1e9, 1 days, v, r, s);
vm.expectRevert("UniswapV2: INVALID_SIGNATURE");
token.permit(owner, spender, 1e9, 1 days, v, r, s);
}
$ forge test -vvv --mt testRevert_SignatureReplay
[⠔] Compiling...
[⠆] Compiling 1 files with 0.8.23
[⠰] Solc 0.8.23 finished in 1.74s
Compiler run successful!
Running 1 test for test/UniswapV2ERC20.t.sol:UniswapV2ERC20Test
[PASS] testRevert_SignatureReplay() (gas: 77840)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.48ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
😎 마무리
공부를 하다 보면 조금만 더 일찍 정신 차리고 시작할 걸 이라는 생각이 끊임없이 듭니다. 이게 하나 공부를 하려면 ERC니 EIP니 하는 엮여 있는 것들이 너무 많아서 정말 머리가 아프네요. 그러나 중요한 것은 꺾여도 계속하는 마음.
이상으로 UniswapV2ERC20 코드 분석을 마치겠습니다. 다음은 UniswapV2Factory 컨트랙트로 돌아오겠습니다.
🥷 전체 코드
📖 참고
'Solidity > DeFi' 카테고리의 다른 글
[Uniswap] V2 Oracle 예제 (0) | 2024.02.21 |
---|---|
[Uniswap] V2 Router (0) | 2024.02.20 |
[Uniswap] V2 Core 보충 자료 - 백서 읽기 (0) | 2024.02.05 |
[Uniswap] V2 Core - UniswapV2Pair (0) | 2024.01.31 |
[Uniswap] V2 Core - UniswapV2Factory (1) | 2024.01.31 |