[번역/DeFi] Uniswap V1 톺아보기 (2)

Diana
7 min readDec 4, 2022

--

이전편에서는 유니스왑이 무엇인지 그리고 특징은 무엇이 있는지에 대해 가볍게 다루어 보았습니다. 이번편에서는 실제 스마트 컨트랙트 개발을 진행합니다!

원문: https://jeiwan.net/posts/programming-defi-uniswap-1/

스마트 컨트랙트 개발

Uniswap의 복사본을 만들어 나가면서 Uniswap이 어떻게 작동하는지 알아볼 겁니다. 우리는 솔리디티(Solidity)로 스마트 컨트랙트를 작성할 것이며 개발 환경으로 하드햇(HardHat)을 사용할 것 입니다. HardHat은 개발, 테스팅, 스마트 컨트랙트 배포 등을 매우 간단하게 만들어주는 좋은 도구입니다.

스마트 컨트랙트 개발이 처음이라면 이 코스를 듣고 오는 것을 강력하게 추천합니다.

프로젝트 설정하기

우선 빈 폴더를 생성하고 cd 명령어를 통해 만든 폴더로 이동합니다. 그리고 HardHat을 설치하세요.

$ mkdir zuniswap && cd $_
$ yarn init
$ yarn add -D hardhat

토큰 컨트랙트도 필요하기 때문에 OpenZeppelin 에서 제공되는 ERC20 컨트랙트를 사용할 겁니다.

$ yarn add -D @openzeppelin/contracts

HardHat 프로젝트를 초기화하고 contract, script, test 폴더 안에 파일들을 깔끔하게 지워줍시다.

$ yarn hardhat
...follow the instructions...
$ rm ...
$ tree -a
.
├── .gitignore
├── contracts
├── hardhat.config.js
├── scripts
└── test

마지막으로, 이 글을 작성하는 시점 기준 가장 최근의 Solidity 버전을 사용하고 있습니다(0.8.4). hardhat.config.js 파일을 열어서 Solidity 버전을 업데이트 해주세요.

토큰 컨트랙트 (Token Contract)

유니스왑 V1은 ETH-토큰 간 스왑만 지원합니다. 이를 가능하게 하기 위해서는 ERC20 토큰 컨트랙트가 필요합니다. 한번 작성해 봅시다!

pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract Token is ERC20 {
constructor(
string memory name,
string memory symbol,
uint256 initialSupply
) ERC20(name, symbol) {
_mint(msg.sender, initialSupply);
}
}

OpenZeppelin에서 제공한 ERC20 컨트랙트를 확장하여 토큰 이름, 심볼, 최초 발행량을 설정하는 우리만의 생성자(constructor)를 정의합니다. 이 생성자는 initialSupply 만큼의 토큰을 발행(mint)하여 토큰을 만든 사람의 주소로 보냅니다.

이제 진짜 재미있는 부분이 시작됩니다!

거래소 컨트랙트 (Exchange Contract)

Uniswap V1에는 FactoryExchange 두 가지 컨트랙트 밖에 없습니다.

Factory는 등록 컨트랙트(registry contract)로써 거래소(exchange)를 만들고 모든 배포된 거래소를 계속해서 추적하는 역할을 합니다. 토큰 주소로 거래소 주소를 찾거나 그 역도 가능합니다.

거래소 컨트랙트는 거래 로직을 실제로 정의하고 있습니다. 각각의 페어(ETH-토큰)들은 거래소 컨트랙트로써 배포가 되어있고, ETH→토큰 또는 토큰→ETH 이런식으로 단 하나의 토큰과만 교환이 가능합니다.

Factory 컨트랙트는 다음 글에서 다루도록 하고, 우선 거래소 컨트랙트 먼저 만들어 봅시다.

빈 컨트랙트를 생성합시다.

// contracts/Exchange.sol
pragma solidity ^0.8.0;

contract Exchange {
}

각각의 거래소는 한 종류의 토큰만 교환을 지원하므로 거래소와 토큰 주소를 연결해야 합니다.

contract Exchange {
address public tokenAddres;

constructor(address _token) {
require(_token != address(0), "invalid token address");
}

tokenAddress = _token;
}

토큰 주소는 상태 변수로써 다른 컨트랙트 함수가 접근할 수 있게 만들어야 합니다. public을 선언함으로써 사용자나 개발자가 토큰 주소를 읽거나 이 거래소와 연결된 토큰이 무엇인지 찾을 수 있습니다. 생성자에서는 제공된 토큰 주소가 유효한 주소인지 체크한 후 (제로 어드레스가 아닌지) 상태 변수에 저장합니다.

유동성 공급하기

이전에 이미 배웠듯이 유동성은 거래를 가능하게 해 줍니다. 그러므로 Exchange 컨트랙트에 유동성을 공급할 방법이 필요합니다.

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract Exchange {
...
function addLiquidity(uint256 _tokenAmount) public payable {
IERC20 token = IERC20(tokenAddress);
token.transferFrom(msg.sender, address(this), _tokenAmount);
}
}

기본적으로 컨트랙트는 ETH를 받을 수 없기 때문에 payable 제어자(modifier)로 함수에서 ETH를 받을 수 있도록 수정해야 합니다. 함수 호출과 함께 전송된 ETH는 컨트랙트의 잔액에 쌓입니다.

토큰을 예치하는 것은 다릅니다. 토큰의 잔액이 토큰 컨트랙트에 쌓이기 때문에, (ERC20 표준에 선언되어 있는 것 처럼) transferFrom 함수를 사용해서 트랜젝션을 보내는 자의 주소에서 컨트랙트로 토큰을 전송합니다. 또한, 트랜젝션을 보내는 사람은 거래소 컨트랙트가 토큰을 받게 하기 위해서 토큰 컨트랙트의 approve 함수를 호출해야 할 수도 있습니다.

addLiquidity 의 구현은 아직 완성되지 않았습니다. 가격 책정 함수에 집중하기 위해 의도적으로 그렇게 한 것이며 이후에 빈 부분을 채워나갈 것 입니다.

거래소의 토큰 잔액을 반환하는 helper 함수도 추가합시다.

function getReserve() public view returns (uint256) {
return IERC20(tokenAddress).balanceOf(address(this));
}

이제 모든게 올바른지 확인하기 위해 addLiquidity 를 테스트 할 수 있게 되었습니다!

describe("addLiquidity", async () => {
it("adds liquidity", async () => {
await token.approve(exchange.address, toWei(200));
await exchange.addLiquidity(toWei(200), { value: toWei(100) });

expect(await getBalance(exchange.address)).to.equal(toWei(100));
expect(await exchange.getReserve()).to.equal(toWei(200));
});
});

먼저 거래소 컨트랙트가 200개의 토큰을 사용할 수 있게 approve 를 호출합니다. 그 후, addLiquidity 호출을 통해 200개의 토큰을 예치하고 (거래소 컨트랙트는 이걸 가져가기 위해 transferFrom 을 호출합니다), 100 ETH는 함수 호출과 함께 전송됩니다. 그러고 나서 우리는 실제로 토큰들을 받았는지 확인합니다.

간결함을 위해 테스트 보일러플레이트 코드에서 많은 부분들을 생략했습니다. 분명하지 않은 부분들이 있다면 전체 소스코드 를 참고해주세요.

--

--