[SCH] Smart Contract Hacking 3편 - Token2

0xDave·2023년 3월 19일
0

Ethereum

목록 보기
95/112
post-thumbnail

두 번째 과제


Task1

일반적인 토큰을 만드는 과제다.

// 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() 함수를 만들어주면 끝!


Task2

간단한 디파이 컨트랙트를 만드는 과제다. 일반적인 토큰을 보내면 일드 베어링 토큰을 받고, 컨트랙트에서 받았던 토큰을 다시 보내면 소각 후 토큰을 돌려받는 구조다.

// 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
    
}

빈 공간을 채워보자!


step1

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 변수로 만든다. 유저가 예치하면 보내줄 rTokennew를 이용해 디플로이해준다. 이 때 _underlyingToken에 처음 가져왔던 _token 주소를 넘겨준다.


step2

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는 고려하지 않았다.

step3

    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() 함수와 전체적인 형태는 비슷하지만 마지막에 유저가 넣었던 토큰을 전송하는 것으로 끝난다.


🚨 여기까지 피드백


과연 나는 잘 했을까? 테스트 코드를 작성하기 전에 답을 먼저 확인하고 진행해보자. 답을 보니 내가 놓쳤던 부분이 매우 많다.


  1. rToken 컨트랙트에서 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;
    }
  
  //..
}

  1. public -> external로 변경하는 것이 깔끔하다. 어차피 컨트랙트 내에서 호출할 일은 없으니까.
    function mint(address to, uint256 amount) external onlyOwner {
        _mint(to, amount);
    }

    function burn(address to, uint256 amount) external onlyOwner {
        _burn(to, amount);
    }

  1. TokensDepository는 아예 잘못 작성했다고 봐야한다. 내가 생각했던 것은 각 토큰 주소를 TokensDepository 디플로이할 때 construcor에 넣어서 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을 관리할 수 있는 것이 신기했다.


  1. 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
    
  });


});

getContractAthardhat-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를 뽑아낸다고 한다.


getImpersonatedSignerconnect랑 비슷한 메소드라고 생각하면 될 것 같다. 차이점이라고 하면 connect는 주소가 아니라 Signer를 파라미터로 받고, ethers 뒤에 사용하는 것이 아니라 contract 뒤에 사용한다는 점이다.

ethers.utils.formatUnits는 value를 받아서 string을 리턴해준다.

테스트 코드에서 숫자를 표기할 때 formatUnits, parseEther 등 다양한 메소드가 있다. 너무 헷갈려서 chatGPT한테 정리해달라고 했다.

formatEtherformatUnits는 굳이 사용하지 않을 것 같고

  • parseEther는 평소에 이더를 표현할 때
  • parseUnits는 decimal을 알고 있을 때

사용하면 좋을 것 같다.


step1

  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 토큰의 인스턴스를 만들어준다.


step2

  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);

step3

  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 토큰을 아무도 갖고 있지 않은 것을 확인해주면 끝.


피드백


  1. new rToken() 표현을 통해 컨트랙트가 디플로이 될 때 다른 토큰 컨트랙트를 같이 디플로이할 수 있다는 것을 기억하자.
receiptTokens[_aave] = new rToken(_aave, "Receipt AAVE", "rAave");

  1. 테스트 코드에서 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을 가져올 때 필요하다.


  1. Deploy depository and load receipt tokens 테스트 코드를 작성했을 때 계속 아래와 같은 에러가 났었다. receiptTokens을 불러오는 과정에서 에러가 나는 것 같았는데 분명 코드가 같아서 코드 자체에는 문제가 없는 것 같았다.

답은 mapping 선언에 있었다. public으로 선언이 안 되어있어서 다른 컨트랙트에서 receiptTokens를 읽지 못 했던 것이다.. 변수나 함수, mapping에도 올바른 선언이 적용되도록 신경쓰자.

    mapping(address => rToken) receiptTokens; -> 잘못된 예
    mapping(address => rToken) public receiptTokens; -> 올바른 예!

  1. Deposit 전에 항상 allowance 체크 해주기.

  1. 발행되는 receipt 토큰의 갯수는 underlying 토큰의 갯수와 반드시 같지 않을 수 있다. 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);
profile
Just BUIDL :)

0개의 댓글