일반적인 토큰을 만드는 과제다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
/**
* @title rToken
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract rToken is ERC20 {
// TODO: Complete this contract functionality
constructor(address _underlyingToken, string memory _name, string memory _symbol)
ERC20(_name, _symbol) {
}
}
빈 공간을 채워보자.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/**
* @title rToken
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract rToken is ERC20, Ownable {
// TODO: Complete this contract functionality
address public underLyingToken;
constructor(address _underlyingToken, string memory _name, string memory _symbol)
ERC20(_name, _symbol) {
underLyingToken = _underlyingToken;
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
function burn(address to, uint256 amount) public onlyOwner {
_burn(to, amount);
}
}
onlyOwner
를 사용하기 위해 openzeppelin에서 Ownable
을 import 했다. constructor에서 받아오는 _underlyingToken
을 변수에 저장해주고 Ownable
를 적용한 mint()
, burn()
함수를 만들어주면 끝!
간단한 디파이 컨트랙트를 만드는 과제다. 일반적인 토큰을 보내면 일드 베어링 토큰을 받고, 컨트랙트에서 받았던 토큰을 다시 보내면 소각 후 토큰을 돌려받는 구조다.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {rToken} from "./rToken.sol";
/**
* @title TokensDepository
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract TokensDepository {
// TODO: Complete this contract functionality
}
빈 공간을 채워보자!
contract TokensDepository {
// TODO: Complete this contract functionality
IERC20 public token;
rToken public rtoken;
constructor(address _token) {
token = IERC20(_token);
rtoken = new rToken(_token, "rToken", "RT");
}
}
컨트랙트에서 유저로부터 받을 토큰 주소를 가져와서 token
변수로 만든다. 유저가 예치하면 보내줄 rToken
을 new
를 이용해 디플로이해준다. 이 때 _underlyingToken
에 처음 가져왔던 _token
주소를 넘겨준다.
contract TokensDepository {
// TODO: Complete this contract functionality
IERC20 public token;
rToken public rtoken;
//mapping 추가
mapping(address => uint256) balance;
constructor(address _token) {
token = IERC20(_token);
rtoken = new rToken(_token, "rToken", "RT");
}
function deposit(uint256 amount) public payable{
require(amount > 0, "Amount must exceed zero");
require(token.balanceOf(msg.sender) >= amount);
balance[msg.sender] += amount;
rtoken.mint(msg.sender, amount);
}
}
deposit()
함수를 만드려고보니 mapping이 필요할 것 같아서 추가했다. 민팅하고자 하는 토큰의 갯수가 0보다 큰지 확인해주고, 유저가 갖고 있는 토큰도 최소 amount 만큼 있는지 확인한다. mapping에 반영 후 rtoken
을 민팅해주면 끝. 일단 Re-entrancy는 고려하지 않았다.
function withdraw(uint256 amount) public {
require(amount > 0, "Amount must exceed zero");
require(rtoken.balanceOf(msg.sender) >= amount);
balance[msg.sender] -= amount;
rtoken.burn(msg.sender, amount);
token.transfer(msg.sender, amount);
}
마지막으로 withdraw
함수를 만들었다. deposit()
함수와 전체적인 형태는 비슷하지만 마지막에 유저가 넣었던 토큰을 전송하는 것으로 끝난다.
과연 나는 잘 했을까? 테스트 코드를 작성하기 전에 답을 먼저 확인하고 진행해보자. 답을 보니 내가 놓쳤던 부분이 매우 많다.
address(0)
확인하는 require문 놓침.contract rToken is ERC20, Ownable {
// TODO: Complete this contract functionality
address public underLyingToken;
constructor(address _underlyingToken, string memory _name, string memory _symbol)
ERC20(_name, _symbol) {
require(_underlyingToken != address(0), "Wrong underlying");
underLyingToken = _underlyingToken;
}
//..
}
function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}
function burn(address to, uint256 amount) external onlyOwner {
_burn(to, amount);
}
rToken
을 만드는 거였다. 그런데 내가 작성한 컨트랙트대로 진행하면 rToken
은 하나만 만들어지고 이를 underlying 하는 토큰은 여러개가 만들어지는 대참사가 벌어진다. 허허.. 따라서 다음과 같이 전면 수정해야 한다.contract TokensDepository {
// TODO: Complete this contract functionality
mapping(address => IERC20) public tokens;
mapping(address => rToken) receiptToken;
constructor(address _aave, address _uni, address _weth) {
tokens[_aave] = IERC20(_aave);
tokens[_uni] = IERC20(_uni);
tokens[_weth] = IERC20(_weth);
receiptToken[_aave] = new rToken(_aave, "Receipt AAVE", "rAave");
receiptToken[_uni] = new rToken(_uni, "Receipt UNI", "rUni");
receiptToken[_weth] = new rToken(_weth, "Receipt WETH", "rWeth");
}
//..
}
애초에 constructor에서 각 토큰 주소를 받아와서 개별적으로 rToken을 디플로이하게 만든다. 이번에 처음 알았는데 mapping에 interface가 들어갈 수 있다. 또한 rToken 형태도 mapping으로 묶어서 receiptToken을 관리할 수 있는 것이 신기했다.
deposit()
, withdraw()
함수에도 부족한 점이 많았다. 앞서 체크했던 address(0)
과 amount는 어차피 _mint()
, _burn()
함수에서 체크하기 때문에 불필요하다. 또한 deposit()
함수에는 유저가 실질적으로 토큰을 보내는 코드가 없었다.. 다시 한 번 역대급 대참사가 벌어질 뻔했다. 코드는 아래처럼 수정할 수 있다. 여기서는 modifier를 이용해서 현재 컨트랙트가 지원하는 토큰인지 확인할 수 있도록 했다. modifier를 사용하니 코드가 훨씬 깔끔하다. modifier isSupported(address _token) {
require(address(tokens[_token]) != address(0), "Not supported token");
_;
}
function deposit(address _token,uint256 _amount) external isSupported(_token){
bool success = tokens[_token].transferFrom(msg.sender, address(this), _amount);
require(success, "transferFrom failed");
receiptToken[_token].mint(msg.sender, _amount);
}
withdraw
함수도 다음과 같이 작성할 수 있다.
function withdraw(address _token, uint256 amount) external isSupported(_token) {
receiptToken[_token].burn(msg.sender, amount);
bool success = tokens[_token].transfer(msg.sender, amount);
require(success, "transfer failed");
}
제공되는 기본 틀은 다음과 같다. 이제 빈 공간을 채워보자.
const { ethers } = require('hardhat');
const { expect } = require('chai');
describe('ERC20 Tokens Exercise 2', function () {
let deployer;
const AAVE_ADDRESS = "0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9"
const UNI_ADDRESS = "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
const WETH_ADDRESS = "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"
const AAVE_HOLDER = "0x2efb50e952580f4ff32d8d2122853432bbf2e204";
const UNI_HOLDER = "0x193ced5710223558cd37100165fae3fa4dfcdc14";
const WETH_HOLDER = "0x741aa7cfb2c7bf2a1e7d4da2e3df6a56ca4131f3";
const ONE_ETH = ethers.utils.parseEther('1');
before(async function () {
/** SETUP EXERCISE - DON'T CHANGE ANYTHING HERE */
[deployer] = await ethers.getSigners();
// Load tokens mainnet contracts
this.aave = await ethers.getContractAt(
"@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20",
AAVE_ADDRESS
);
this.uni = await ethers.getContractAt(
"@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20",
UNI_ADDRESS
);
this.weth = await ethers.getContractAt(
"@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20",
WETH_ADDRESS
);
// Load holders (accounts which hold tokens on Mainnet)
this.aaveHolder = await ethers.getImpersonatedSigner(AAVE_HOLDER);
this.uniHolder = await ethers.getImpersonatedSigner(UNI_HOLDER);
this.wethHolder = await ethers.getImpersonatedSigner(WETH_HOLDER);
// Send some ETH to tokens holders
await deployer.sendTransaction({
to: this.aaveHolder.address,
value: ONE_ETH
});
await deployer.sendTransaction({
to: this.uniHolder.address,
value: ONE_ETH
});
await deployer.sendTransaction({
to: this.wethHolder.address,
value: ONE_ETH
});
this.initialAAVEBalance = await this.aave.balanceOf(this.aaveHolder.address)
this.initialUNIBalance = await this.uni.balanceOf(this.uniHolder.address)
this.initialWETHBalance = await this.weth.balanceOf(this.wethHolder.address)
console.log("AAVE Holder AAVE Balance: ", ethers.utils.formatUnits(this.initialAAVEBalance))
console.log("UNI Holder UNI Balance: ", ethers.utils.formatUnits(this.initialUNIBalance))
console.log("WETH Holder WETH Balance: ", ethers.utils.formatUnits(this.initialWETHBalance))
});
it('Deploy depository and load receipt tokens', async function () {
/** CODE YOUR SOLUTION HERE */
// TODO: Deploy your depository contract with the supported assets
// TODO: Load receipt tokens into objects under `this` (e.g this.rAve)
});
it('Deposit tokens tests', async function () {
/** CODE YOUR SOLUTION HERE */
// TODO: Deposit Tokens
// 15 AAVE from AAVE Holder
// 5231 UNI from UNI Holder
// 33 WETH from WETH Holder
// TODO: Check that the tokens were sucessfuly transfered to the depository
// TODO: Check that the right amount of receipt tokens were minted
});
it('Withdraw tokens tests', async function () {
/** CODE YOUR SOLUTION HERE */
// TODO: Withdraw ALL the Tokens
// TODO: Check that the right amount of tokens were withdrawn (depositors got back the assets)
// TODO: Check that the right amount of receipt tokens were burned
});
});
getContractAt
은 hardhat-ethers plug in에서 찾을 수 있다.
function getContractAt(name: string, address: string, signer?: ethers.Signer): Promise<ethers.Contract>;
function getContractAt(abi: any[], address: string, signer?: ethers.Signer): Promise<ethers.Contract>;
그런데 테스트 코드에서는 파라미터에 "@openzeppelin/contracts/token/ERC20/IERC20.sol:IERC20"
openzeppelin 주소를 넘겨준다. 이해가 잘 안돼서 chatGPT한테 물어봤다.
인터페이스 주소와 이름을 넘겨주면 ethers에서 자동으로 ABI를 뽑아낸다고 한다.
getImpersonatedSigner
는 connect
랑 비슷한 메소드라고 생각하면 될 것 같다. 차이점이라고 하면 connect
는 주소가 아니라 Signer를 파라미터로 받고, ethers
뒤에 사용하는 것이 아니라 contract
뒤에 사용한다는 점이다.
ethers.utils.formatUnits
는 value를 받아서 string을 리턴해준다.
테스트 코드에서 숫자를 표기할 때 formatUnits
, parseEther
등 다양한 메소드가 있다. 너무 헷갈려서 chatGPT한테 정리해달라고 했다.
formatEther
와 formatUnits
는 굳이 사용하지 않을 것 같고
parseEther
는 평소에 이더를 표현할 때parseUnits
는 decimal을 알고 있을 때사용하면 좋을 것 같다.
it("Deploy depository and load receipt tokens", async function () {
/** CODE YOUR SOLUTION HERE */
// TODO: Deploy your depository contract with the supported assets
const contractFactory = await ethers.getContractFactory("TokensDepository", deployer);
this.depository = await contractFactory.deploy(AAVE_ADDRESS, UNI_ADDRESS, WETH_ADDRESS);
// TODO: Load receipt tokens into objects under `this` (e.g this.rAve)
this.rAave = await ethers.getContractAt("rToken", await this.depository.receiptTokens(AAVE_ADDRESS));
this.rUni = await ethers.getContractAt("rToken", await this.depository.receiptTokens(UNI_ADDRESS));
this.rWeth = await ethers.getContractAt("rToken", await this.depository.receiptTokens(WETH_ADDRESS));
});
contractFactory
를 이용해 deploy하고 getContractAt
로 각 receipt 토큰의 인스턴스를 만들어준다.
it("Deposit tokens tests", async function () {
/** CODE YOUR SOLUTION HERE */
// TODO: Deposit Tokens
// 15 AAVE from AAVE Holder
// 5231 UNI from UNI Holder
// 33 WETH from WETH Holder
const amountAAVE = ethers.utils.parseEther("15");
await this.aave.connect(this.aaveHolder).approve(this.depository.address, amountAAVE);
await this.depository.connect(this.aaveHolder).deposit(AAVE_ADDRESS, amountAAVE);
const amountUNI = ethers.utils.parseEther("5231");
await this.uni.connect(this.uniHolder).approve(this.depository.address, amountUNI);
await this.depository.connect(this.uniHolder).deposit(UNI_ADDRESS, amountUNI);
const amountWETH = ethers.utils.parseEther("33");
await this.weth.connect(this.wethHolder).approve(this.depository.address, amountWETH);
await this.depository.connect(this.wethHolder).deposit(WETH_ADDRESS, amountWETH);
// // TODO: Check that the tokens were sucessfuly transfered to the depository
expect(await this.aave.balanceOf(this.depository.address)).to.equal(amountAAVE);
expect(await this.uni.balanceOf(this.depository.address)).to.equal(amountUNI);
expect(await this.weth.balanceOf(this.depository.address)).to.equal(amountWETH);
// // TODO: Check that the right amount of receipt tokens were minted
expect(await this.rAave.totalSupply()).to.equal(amountAAVE);
expect(await this.rUni.totalSupply()).to.equal(amountUNI);
expect(await this.rWeth.totalSupply()).to.equal(amountWETH);
});
parseEther
로 예치 금액을 변수로 만들어주고, 각 토큰 홀더의 권한을 depository 컨트랙트에 넘겨준다. 이후 예치 금액에 맞게 입금하면 끝. 마지막에 receipt 토큰의 발행량을 체크할 때 나는 totalSupply
를 사용했지만 모범답안은 balanceOf
를 이용해 예치한 사람이 얼마나 갖고 있는지 확인하는 방향으로 적었다.
expect(await this.rAave.balanceOf(this.aaveHolder.address)).to.equal(amountAAVE);
expect(await this.rUni.balanceOf(this.uniHolder.address)).to.equal(amountUNI);
expect(await this.rWeth.balanceOf(this.wethHolder.address)).to.equal(amountWETH);
it("Withdraw tokens tests", async function () {
/** CODE YOUR SOLUTION HERE */
// TODO: Withdraw ALL the Tokens
const amountAAVE = ethers.utils.parseEther("15");
const amountUNI = ethers.utils.parseEther("5231");
const amountWETH = ethers.utils.parseEther("33");
await this.depository.connect(this.aaveHolder).withdraw(this.aave.address, amountAAVE);
await this.depository.connect(this.uniHolder).withdraw(this.uni.address, amountUNI);
await this.depository.connect(this.wethHolder).withdraw(this.weth.address, amountWETH);
// TODO: Check that the right amount of tokens were withdrawn (depositors got back the assets)
expect(await this.aave.balanceOf(AAVE_HOLDER)).to.equal(this.initialAAVEBalance);
expect(await this.uni.balanceOf(UNI_HOLDER)).to.equal(this.initialUNIBalance);
expect(await this.weth.balanceOf(WETH_HOLDER)).to.equal(this.initialWETHBalance);
// TODO: Check that the right amount of receipt tokens were burned
expect(await this.rAave.balanceOf(AAVE_HOLDER)).to.equal(0);
expect(await this.rUni.balanceOf(UNI_HOLDER)).to.equal(0);
expect(await this.rWeth.balanceOf(WETH_HOLDER)).to.equal(0);
expect(await this.rAave.balanceOf(this.depository.address)).to.equal(0);
expect(await this.rUni.balanceOf(this.depository.address)).to.equal(0);
expect(await this.rWeth.balanceOf(this.depository.address)).to.equal(0);
});
amount를 미리 변수로 만들어주고 withdraw()
함수를 실행한다. 이후 예치하기 전에 유저가 갖고있던 토큰의 갯수가 일치하는지 확인해준다. 마지막으로 receipt 토큰을 아무도 갖고 있지 않은 것을 확인해주면 끝.
new rToken()
표현을 통해 컨트랙트가 디플로이 될 때 다른 토큰 컨트랙트를 같이 디플로이할 수 있다는 것을 기억하자.receiptTokens[_aave] = new rToken(_aave, "Receipt AAVE", "rAave");
receiptToken
은 함수가 아니라는 오류가 났었다. this.rAave = this.tokenDepository.receiptToken(AAVE_ADDRESS);
모범답안은 다음과 같다. ethers.getContractAt()
을 사용해서 rAve
인스턴스를 만든다. 뒷 부분은 비슷하지만 결과물은 달라진다.
this.rAave = await ethers.getContractAt(
"rAave",
this.tokenDepository.receiptToken(AAVE_ADDRESS)
);
처음 내가 사용한 방법으로 rAve
의 주소를 가져올 수는 있지만 인스턴스를 만들수는 없다. 따라서 ethers.getContractAt()
에 컨트랙트 주소를 넣어줘서 rAve
인스턴스를 만들어줘야 한다. 또한 receiptToken
처럼 다른 컨트랙트의 mapping을 가져올 때 대괄호가 아닌 소괄호를 사용해야 한다는 것도 기억해두자. 대괄호는 컨트랙트 내에서 mapping을 사용할 때 필요하고, 소괄호는 테스트 코드에서 mapping을 가져올 때 필요하다.
Deploy depository and load receipt tokens
테스트 코드를 작성했을 때 계속 아래와 같은 에러가 났었다. receiptTokens
을 불러오는 과정에서 에러가 나는 것 같았는데 분명 코드가 같아서 코드 자체에는 문제가 없는 것 같았다.답은 mapping 선언에 있었다. public
으로 선언이 안 되어있어서 다른 컨트랙트에서 receiptTokens
를 읽지 못 했던 것이다.. 변수나 함수, mapping에도 올바른 선언이 적용되도록 신경쓰자.
mapping(address => rToken) receiptTokens; -> 잘못된 예
mapping(address => rToken) public receiptTokens; -> 올바른 예!
allowance
체크 해주기.withdraw
테스트 코드에서 원래 내가 작성했던 코드는 this.initialAAVEBalance
가 아니라 이전에 유저가 deposit 했던 amountAAVE
양으로 작성했었다. 무의식에서 무조건 넣었던 토큰과 같은 갯수의 토큰이 발행되었을 것이라고 생각했었는데 큰 착각이었다. // TODO: Check that the right amount of tokens were withdrawn (depositors got back the assets)
expect(await this.aave.balanceOf(AAVE_HOLDER)).to.equal(this.initialAAVEBalance);
expect(await this.uni.balanceOf(UNI_HOLDER)).to.equal(this.initialUNIBalance);
expect(await this.weth.balanceOf(WETH_HOLDER)).to.equal(this.initialWETHBalance);