티스토리 뷰
⛓️ 시리즈
2024.01.30 - [Solidity/DeFi] - [Uniswap] V2 Core - UniswapV2ERC20
🦄 IUniswapV2Factory.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
interface IUniswapV2Factory {
event PairCreated(
address indexed token0,
address indexed token1,
address pair,
uint
); // 페어 생성 이벤트
function feeTo() external view returns (address); // 수수료를 받을 주소를 반환
function feeToSetter() external view returns (address); // 수수료를 받을 주소를 설정할 수 있는 주소를 반환
function getPair(
address tokenA,
address tokenB
) external view returns (address pair); // 토큰 A와 토큰 B의 쌍을 입력하면 해당 토큰 쌍의 유동성 풀 주소를 반환
function allPairs(uint) external view returns (address pair); // 모든 토큰 쌍의 유동성 풀 주소를 저장하는 배열에서 index에 해당하는 주소를 반환
function allPairsLength() external view returns (uint); // 유동성 풀의 개수를 반환
function createPair(
address tokenA,
address tokenB
) external returns (address pair); // 토큰 A와 토큰 B의 쌍을 입력하면 해당 토큰 쌍의 유동성 풀을 생성하고, 해당 컨트랙트 주소를 반환
function setFeeTo(address) external; // 수수료를 받을 주소를 설정
function setFeeToSetter(address) external; // 수수료를 받을 주소를 설정할 수 있는 주소를 설정
}
IUniswapV2Factory 인터페이스는 하나의 컨트랙트에서 여러 개의 인스턴스를 생성하고 관리하는 '팩토리 패턴'을 구현하기 위한 인터페이스입니다. 코드 자체에는 명시되어 있지 않지만, 토큰 A와 토큰 B의 유동성 풀을 찍어낸다는 것을 알 수 있습니다.
🦄 UniswapV2Factory.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "./interfaces/IUniswapV2Factory.sol";
import "./UniswapV2Pair.sol";
contract UniswapV2Factory is IUniswapV2Factory {
address public feeTo; // 수수료를 받을 주소
address public feeToSetter; // feeTo를 설정할 수 있는 주소
mapping(address => mapping(address => address)) public getPair; // token0과 token1을 입력하면 해당 토큰 쌍의 UniswapV2Pair 컨트랙트 주소를 반환 (반대로도 찾을 수 있음)
address[] public allPairs; // 모든 토큰 쌍의 UniswapV2Pair 컨트랙트 주소를 저장하는 배열
constructor(address _feeToSetter) {
feeToSetter = _feeToSetter; // feeToSetter를 설정
}
// UniswapV2Pair 인스턴스의 개수를 반환
function allPairsLength() external view returns (uint) {
return allPairs.length;
}
// 토큰 쌍의 UniswapV2Pair 컨트랙트를 생성하고, 해당 컨트랙트 주소를 반환
function createPair(
address tokenA, // 토큰 A의 주소
address tokenB // 토큰 B의 주소
) external returns (address pair) {
require(tokenA != tokenB, "UniswapV2: IDENTICAL_ADDRESSES"); // 토큰 A와 토큰 B의 주소가 같으면 에러
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA); // 토큰 A와 토큰 B의 주소를 오름차순으로 정렬
require(token0 != address(0), "UniswapV2: ZERO_ADDRESS"); // 토큰 A의 주소가 0이면 에러
require(
getPair[token0][token1] == address(0),
"UniswapV2: PAIR_EXISTS"
); // 토큰 A와 토큰 B의 쌍이 이미 존재하면 에러 (반대의 경우는 이미 정렬되어 있으므로 체크할 필요 없음)
bytes memory bytecode = type(UniswapV2Pair).creationCode; // UniswapV2Pair 컨트랙트의 bytecode를 가져옴
bytes32 salt = keccak256(abi.encodePacked(token0, token1)); // 토큰 A와 토큰 B의 주소를 인자로 해시값을 계산하여 salt로 사용
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt) // create2를 사용하여 UniswapV2Pair 컨트랙트를 생성
}
IUniswapV2Pair(pair).initialize(token0, token1); // UniswapV2Pair 컨트랙트의 initialize 함수를 호출하여 토큰 A와 토큰 B의 주소를 설정
getPair[token0][token1] = pair; // mapping에 토큰 A와 토큰 B의 쌍을 저장
getPair[token1][token0] = pair; // mapping에 토큰 B와 토큰 A의 쌍을 저장
allPairs.push(pair); // allPairs 배열에 토큰 A와 토큰 B의 쌍을 저장
emit PairCreated(token0, token1, pair, allPairs.length); // PairCreated 이벤트를 발생
}
// 수수료를 받을 주소를 설정
function setFeeTo(address _feeTo) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN"); // feeToSetter만 호출 가능
feeTo = _feeTo;
}
// 수수료를 받을 주소를 설정할 수 있는 주소를 설정
function setFeeToSetter(address _feeToSetter) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN"); // feeToSetter만 호출 가능
feeToSetter = _feeToSetter;
}
}
UniswapV2Factory는 IUniswapV2Factory 인터페이스를 구현하였습니다. 주석을 제거하고 깔끔하게 하나하나 살펴보겠습니다.
상태 변수
address public feeTo;
address public feeToSetter;
mapping(address => mapping(address => address)) public getPair;
address[] public allPairs;
상태 변수는 네 개가 선언되어 있습니다.
- feeTo: 수수료를 받을 주소
- feeToSetter: feeTo를 설정할 수 있는 주소
- getPair: 토큰 A와 토큰 B (또는 그 반대) 쌍과 매칭되는 UniswapV2Pair 주소를 저장하는 맵
- allPairs: UniswapV2Factory를 통해 생성된 모든 UniswapV2Pair의 주소를 저장하는 저장하는 동적 배열
각 변수는 그 자체로 getter 함수이기 때문에 IUniswapV2Factory 인터페이스에서 다음 네 개의 함수를 구현합니다.
function feeTo() external view returns (address);
function feeToSetter() external view returns (address);
function getPair(
address tokenA,
address tokenB
) external view returns (address pair);
function allPairs(uint) external view returns (address pair);
allPairs(uint)의 경우는 특정 인덱스에 해당하는 주소만 반환하며, 배열의 모든 원소를 한 번에 가져올 수는 없습니다. 따라서 배열의 전체 길이를 반환하는 함수를 선언하고 반복문을 통해 배열의 전체 원소를 읽어올 수 있게끔 allPairsLength를 별도로 선언한 것 같습니다. 물론 배열 그 자체를 반환하도록 할 수도 있지만, 배열의 길이가 너무 길어진다면 블록체인에서 한 번에 전달이 불가능할 수 있으므로 길이만을 확인한 것 같습니다.
function allPairsLength() external view returns (uint) {
return allPairs.length;
}
접근 제어
일부 함수에는 require문을 통해 sender가 feeToSetter여야만 실행되는 로직을 가지고 있습니다.
function setFeeTo(address _feeTo) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN");
feeTo = _feeTo;
}
function setFeeToSetter(address _feeToSetter) external {
require(msg.sender == feeToSetter, "UniswapV2: FORBIDDEN");
feeToSetter = _feeToSetter;
}
feeToSetter는 생성자를 통해 초기화됩니다.
constructor(address _feeToSetter) {
feeToSetter = _feeToSetter;
}
페어 생성
createPair 함수는 tokenA와 tokenB의 주소를 받아 이와 매핑되는 UniswapV2Pair 컨트랙트를 생성하고, 그 주소를 반환합니다. 이 함수는 누구나 호출이 가능하므로, 사용자가 직접 원하는 토큰 페어에 대한 UniswapV2Pair 컨트랙트를 생성할 수 있습니다.
// 토큰 쌍의 UniswapV2Pair 컨트랙트를 생성하고, 해당 컨트랙트 주소를 반환
function createPair(
address tokenA, // 토큰 A의 주소
address tokenB // 토큰 B의 주소
) external returns (address pair) {
require(tokenA != tokenB, "UniswapV2: IDENTICAL_ADDRESSES"); // 토큰 A와 토큰 B의 주소가 같으면 에러
(address token0, address token1) = tokenA < tokenB
? (tokenA, tokenB)
: (tokenB, tokenA); // 토큰 A와 토큰 B의 주소를 오름차순으로 정렬
require(token0 != address(0), "UniswapV2: ZERO_ADDRESS"); // 토큰 A의 주소가 0이면 에러
require(getPair[token0][token1] == address(0), "UniswapV2: PAIR_EXISTS"); // 토큰 A와 토큰 B의 쌍이 이미 존재하면 에러 (반대의 경우는 이미 정렬되어 있으므로 체크할 필요 없음)
bytes memory bytecode = type(UniswapV2Pair).creationCode; // UniswapV2Pair 컨트랙트의 bytecode를 가져옴
bytes32 salt = keccak256(abi.encodePacked(token0, token1)); // 토큰 A와 토큰 B의 주소를 인자로 해시값을 계산하여 salt로 사용
assembly {
pair := create2(0, add(bytecode, 32), mload(bytecode), salt) // create2를 사용하여 UniswapV2Pair 컨트랙트를 생성
}
IUniswapV2Pair(pair).initialize(token0, token1); // UniswapV2Pair 컨트랙트의 initialize 함수를 호출하여 토큰 A와 토큰 B의 주소를 설정
getPair[token0][token1] = pair; // mapping에 토큰 A와 토큰 B의 쌍을 저장
getPair[token1][token0] = pair; // mapping에 토큰 B와 토큰 A의 쌍을 저장
allPairs.push(pair); // allPairs 배열에 토큰 A와 토큰 B의 쌍을 저장
emit PairCreated(token0, token1, pair, allPairs.length); // PairCreated 이벤트를 발생
}
함수 실행 순서는 다음과 같습니다.
- 토큰 A와 토큰 B의 주소가 달라야 합니다.
- 토큰 A와 토큰 B의 주소를 오름차순으로 정렬합니다.
- 토큰 A의 주소가 0x00이 아니어야 합니다. (오름차순 정렬했으므로 B까지 검사는 불필요)
- 토큰 A와 토큰 B의 페어가 팩토리 상에서 아직 존재하지 않아야 합니다.
- UniswapV2Pair 컨트랙트의 바이트코드를 가져옵니다.
- 정렬된 토큰 A와 토큰 B의 주소를 이어 붙인 값을 입력으로 32바이트 해시를 구합니다.
- create2 opcode와 앞서 생성한 해시를 입력으로 사용하여 UniswapV2Pair 컨트랙트를 생성하고 주소를 pair에 할당합니다.
- UniswapV2Pair 컨트랙트의 initialize 함수를 호출하여 정렬된 토큰 A와 토큰 B의 주소를 설정합니다.
- 생성된 UniswapV2Pair 컨트랙트의 주소를 맵과 배열에 저장합니다.
- PairCreated 이벤트를 발생시킵니다.
🔍 테스트
SETUP
setUp 함수에서 feeToSetter와 factory 인스턴스를 생성해 줍니다. PairCreated 이벤트는 createPair 함수에서 발생한 이벤트를 비교대조하기 위해 선언했습니다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;
import "../src/interfaces/IUniswapV2Factory.sol";
import "../src/UniswapV2Factory.sol";
import "../src/UniswapV2Pair.sol";
import "forge-std/Test.sol";
contract UniswapV2FatoryTest is Test {
event PairCreated(
address indexed token0,
address indexed token1,
address pair,
uint
);
UniswapV2Factory public factory;
address feeToSetter;
function setUp() public {
feeToSetter = vm.addr(
0x1234567890123456789012345678901234567890123456789012345678901234
);
factory = new UniswapV2Factory(feeToSetter);
}
...
}
아래쪽에는 토큰 주소를 정렬해 주는 sortToken 함수와 createPair 함수 실행을 통해 생성되는 주소를 계산하는 computePairAddress 함수를 유틸리티 함수로 선언하였습니다.
function sortToken(
address tokenA,
address tokenB
) public pure returns (address token0, address token1) {
if (tokenA < tokenB) {
token0 = tokenA;
token1 = tokenB;
} else {
token0 = tokenB;
token1 = tokenA;
}
}
function computePairAddress(
address _factory,
address tokenA,
address tokenB
) public pure returns (address pair) {
(address token0, address token1) = sortToken(tokenA, tokenB);
bytes32 byteCodeHash = keccak256(type(UniswapV2Pair).creationCode);
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
return
address(
uint160(
uint(
keccak256(
abi.encodePacked(hex"ff", _factory, salt, byteCodeHash)
)
)
)
);
}
CASE 1: 페어 생성에 성공한 경우
function test_CreatePair() public {
address tokenA = vm.addr(1);
address tokenB = vm.addr(2);
(address token0, address token1) = sortToken(tokenA, tokenB);
address expect = computePairAddress(address(factory), token0, token1);
vm.expectEmit(true, true, true, true, address(factory));
emit PairCreated(token0, token1, expect, 1);
address pair = factory.createPair(token0, token1);
assertEq(pair, expect);
assertEq(factory.getPair(token0, token1), pair);
assertEq(factory.getPair(token1, token0), pair);
assertEq(factory.allPairsLength(), 1);
assertEq(factory.allPairs(0), pair);
UniswapV2Pair pairContract = UniswapV2Pair(pair);
assertEq(pairContract.factory(), address(factory));
assertEq(pairContract.token0(), token0);
assertEq(pairContract.token1(), token1);
}
$ forge test -vvv --mt test_CreatePair
[⠔] Compiling...
No files changed, compilation skipped
Running 1 test for test/UniswapV2Factory.sol:UniswapV2FatoryTest
[PASS] test_CreatePair() (gas: 2005243)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 2.58ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
CASE 2: 페어 생성에 실패한 경우
function testRevert_CreatePairWithIdenticalAddresses() public {
address tokenA = vm.addr(1);
address tokenB = vm.addr(1);
vm.expectRevert("UniswapV2: IDENTICAL_ADDRESSES");
factory.createPair(tokenA, tokenB);
}
function testRevert_CreatePairWithZeroAddress() public {
address tokenA = address(0);
address tokenB = vm.addr(2);
vm.expectRevert("UniswapV2: ZERO_ADDRESS");
factory.createPair(tokenA, tokenB);
}
function testRevert_CreatePairWithPairexists() public {
address tokenA = vm.addr(1);
address tokenB = vm.addr(2);
factory.createPair(tokenA, tokenB);
vm.expectRevert("UniswapV2: PAIR_EXISTS");
factory.createPair(tokenA, tokenB);
}
$ forge test -vvv --mt testRevert_CreatePair
[⠔] Compiling...
No files changed, compilation skipped
Running 3 tests for test/UniswapV2Factory.sol:UniswapV2FatoryTest
[PASS] testRevert_CreatePairWithIdenticalAddresses() (gas: 9764)
[PASS] testRevert_CreatePairWithPairexists() (gas: 1991794)
[PASS] testRevert_CreatePairWithZeroAddress() (gas: 9414)
Test result: ok. 3 passed; 0 failed; 0 skipped; finished in 2.37ms
Ran 1 test suites: 3 tests passed, 0 failed, 0 skipped (3 total tests)
🧬CREATE2
CREATE2는 0xf5에 해당하는 opcode로, EIP-1014에서 새롭게 도입되었습니다. CREATE2로 생성되는 스마트컨트랙트의 주소는 다음과 같이 계산됩니다.
keccak256( 0xff ++ address ++ salt ++ keccak256(init_code))[12:]
- 0xff: 1바이트, 접두사
- address: 20바이트, CREATE2를 호출하는 계정의 주소
- salt: 32바이트, 랜덤 데이터
- keccak256(init_code): 32바이트, 생성하고자 하는 컨트랙트 바이트 코드(init_code)의 해시
keccak256 해시 함수의 입력으로 총 85바이트 크기의 입력이 사용됩니다. 마지막으로 해시의 뒷부분 20바이트 만을 잘라내어 컨트랙트의 주소를 구합니다.
solidity 코드에서 assembly를 통해 호출된 create2는 다음과 같습니다.
assembly {
addr := create2(
callvalue(),
add(bytecode, 0x20),
mload(bytecode),
_salt
)
}
- callvalue(): 함수 호출에 포함된 이더(wei 단위)로, 생성된 컨트랙트에게 전달됩니다.
- add(bytecode, 0x20): 컨트랙트 바이트코드의 앞의 32바이트를 건너뛰고 실제 데이터 위치를 가리킵니다. bytes 타입은 앞의 32바이트를 길이를 표현하기 위해 사용합니다.
- mload(bytecode): 길이를 나타내는 32바이트를 포함한 바이트코드의 크기를 가리킵니다.
- _salt: 256비트 정수, 랜덤 한 salt값입니다.
CREATE
기존에는 컨트랙트 생성 트랜잭션을 실행하거나 컨트랙트 안에서 new 또는 create opcode를 사용해서 컨트랙트를 생성하는 것이 일반적이었습니다. 이 방식에서는 다음의 공식을 통해 새로운 컨트랙트의 주소를 계산합니다.
keccak256(rlp([sender, nonce]))
계산 순서는 다음과 같습니다.
- 20바이트 크기의 주소, sender를 rlp 직렬화합니다. 그 결과 1바이트 헤더 0x94 + 20바이트 주소, 총 21바이트 크기의 문자열을 얻습니다.
- 논스값을 rlp 직렬화합니다. 논스가 128보다 작은 경우에는 단일 바이트를 반환하고, 128이상인 경우에는 1바이트 헤더와 논스를 빅 엔디언 바이트열로 변환한 것을 합친 문자열을 반환합니다.
- 직렬화한 주소와 논스값을 연결하고 그 길이를 리스트의 헤더에 추가합니다. 길어봐야 40바이트가 넘지않을 것이기 때문에 헤더는 0xc0 + 페이로드의 길이가 될 것입니다.
- 직렬화한 리스트를 keccak256 함수의 입력으로 사용합니다. 결과 해시의 뒷부분 20바이트를 잘라 주소로 반환합니다.
CREATE2를 사용하는 이유
CREATE는 sender의 논스값을 사용하기 때문에 생성된 컨트랙트의 주소는 항상 결정되어 있습니다. 그러나 CREATE2는 salt라는 랜덤 데이터를 원하는 값으로 조절하여 원하는 컨트랙트 주소를 도출해 낼 수 있습니다.
예를 들어, 내가 배포하려는 DApp에 어울리는 예쁜(?) 컨트랙트 주소를 얻고 싶은 경우, 임의의 salt값을 넣어서 미리 주소를 계산해 보는 식으로 원하는 주소를 얻을 수 있습니다.
이미 존재하는 주소와의 충돌 가능성
충돌이 발생할 것을 고려해 CREATE2의 해시 입력에는 0xff 접두사가 붙습니다. 이는 CREATE에서 입력을 직렬화했을 때 헤더값으로 절대 나올 수 없는 값으로, 아마도 0xff가 헤더값이 되려면 입력값의 길이가 수 페타바이트가 되어야 합니다. 따라서 충돌이 발생할 가능성은 극히 낮습니다.
또한 이미 존재하는 주소를 계산하여 컨트랙트 배포를 시도하더라도 evm 딴에서 반려처리를 해버립니다.
go-ethereum에서 evm 구현 코드를 살펴봅시다. Create2 메서드를 호출하면 컨트랙트 주소를 계산하고 내부 create 메서드를 호출합니다. 이는 Create 메서드를 호출할 때도 동일하게 사용되는 방법입니다.
// Create2 creates a new contract using code as deployment code.
//
// The different between Create2 with Create is Create2 uses keccak256(0xff ++ msg.sender ++ salt ++ keccak256(init_code))[12:]
// instead of the usual sender-and-nonce-hash as the address where the contract is initialized at.
func (evm *EVM) Create2(caller ContractRef, code []byte, gas uint64, endowment *uint256.Int, salt *uint256.Int) (ret []byte, contractAddr common.Address, leftOverGas uint64, err error) {
codeAndHash := &codeAndHash{code: code}
contractAddr = crypto.CreateAddress2(caller.Address(), salt.Bytes32(), codeAndHash.Hash().Bytes())
return evm.create(caller, codeAndHash, gas, endowment, contractAddr, CREATE2)
}
create 메서드에서는 다음과 같이 이미 해당 주소에 컨트랙트가 배포되어 있는 경우, ErrContractAddressCollision 오류를 반환합니다. 따라서 이미 컨트랙트가 배포되어 있는 주소로는 새로운 컨트랙트를 배포할 수 없습니다.
// create creates a new contract using code as deployment code.
func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *uint256.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {
...
// Ensure there's no existing contract already at the designated address
contractHash := evm.StateDB.GetCodeHash(address)
if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != types.EmptyCodeHash) {
return nil, common.Address{}, 0, ErrContractAddressCollision
}
...
}
self-destructed 된 주소로 다른 컨트랙트를 재배포
그럼에도 공격가능한 케이스가 있는데, 이는 링크를 첨부하는 것으로 대체하겠습니다.
어셈블리로 컨트랙트 주소 계산
어셈블리는 아직 저한테는 너무 어려운 것 같습니다... 이 부분은 날 잡고 나중에 다시 정리해 보도록 하죠.
function computePairAddressInAssembly(
address _factory,
address tokenA,
address tokenB
) public pure returns (address pair) {
(address token0, address token1) = sortToken(tokenA, tokenB);
bytes32 byteCodeHash = keccak256(type(UniswapV2Pair).creationCode);
bytes32 salt = keccak256(abi.encodePacked(token0, token1));
assembly {
let ptr := mload(0x40)
mstore(add(ptr, 0x40), byteCodeHash)
mstore(add(ptr, 0x20), salt)
mstore(ptr, _factory)
let start := add(ptr, 0x0b)
mstore8(start, 0xff)
pair := keccak256(start, 85)
}
}
function test_ComputePairAddress() public {
address tokenA = vm.addr(1);
address tokenB = vm.addr(2);
(address token0, address token1) = sortToken(tokenA, tokenB);
address p1 = computePairAddress(address(factory), token0, token1);
address p2 = computePairAddressInAssembly(address(factory), token0, token1);
assertEq(p1, p2);
}
$ forge test -vvv --mt test_ComputePairAddress
[⠔] Compiling...
No files changed, compilation skipped
Running 1 test for test/UniswapV2Factory.sol:UniswapV2FatoryTest
[PASS] test_ComputePairAddress() (gas: 15232)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.27ms
Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)
😎 마무리
Uniswap V2 Core 컨트랙트는 주석은 안 달아놓느니만 못하게 달아놔서 카피 닌자도 이게 무슨 소리인가 싶을 것 같습니다. 하지만 닌자는 뒤의 뒤를 읽어야 하는 법. UniswapV2Pair 컨트랙트로 다시 돌아오겠습니다.
🥷 전체 코드
📖 참고
https://github.com/ethereum/go-ethereum/blob/master/core/vm/evm.go
'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 - UniswapV2ERC20 (1) | 2024.01.30 |