짹뚜 스튜디오

ICO 스마트 컨트랙트 구현하기 본문

개발 공부/블록체인

ICO 스마트 컨트랙트 구현하기

짹뚜 2022. 4. 7. 17:14

스마트 컨트랙트로 ICO를 구현해보려고 한다. 일단은 ICO를 구현하기 위해서 필요한 기능들을 보면 다음과 같다.

 

  • ERC20 토큰 스마트 컨트랙트 (ICO를 통해 판매하고자 하는 토큰)
  • 돈을 전송받으면 토큰을 발행해야 한다.
  • 한 명이 모든 토큰을 구매하면 안 되기 때문에 구매할 수 있는 토큰 양에 제한을 둬야 한다.
  • 정해진 시간 내에서만 토큰을 구매할 수 있어야 한다.
  • 목표 모금액을 달성하지 못하면 환불해줘야 한다.
  • ICO가 진행 중일 때는 구매한 토큰을 전송하지 못해야 한다.

해당 글에서는 각 기능에 대해 중요한 부분과 직접 프로그래밍을 하면서 헤매었던 부분들 위주로 설명을 하고 전체 코드는 깃허브에서 확인할 수 있다. 

https://github.com/JJakDDo/ICOPractice

 

GitHub - JJakDDo/ICOPractice

Contribute to JJakDDo/ICOPractice development by creating an account on GitHub.

github.com

Openzeppelin 2.x 버전에서는 Crowdsale과 관련된 컨트랙트를 제공해서 쉽게 구현할 수 있다. 하지만 3.x 버전부터는 해당 컨트랙트들을 더 이상 많은 사람들이 사용하지 않는다고 해서 삭제됐다. 그래서 스마트 컨트랙트 구현하는 방법을 공부할 겸 직접 프로그래밍을 하였다. 

ERC20 토큰 스마트 컨트랙트

Openzeppelin에서 제공하는 ERC20 컨트랙트가 있기 때문에 어렵지 않게 토큰을 발행할 수 있었다.

contract YangTiToken is MintToken  {
  /**
   * @dev constructor create YangTiToken
   * @param _name token name
   * @param _symbol token symbol
   */
  constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol){}
}

MintToken이라는 새로운 컨트랙트를 만들어서 Inherit을 했다. 그 이유는 토큰 mint 함수를 다른 컨트랙트에서도 사용해야 했기 때문이다. 자세한 얘기는 MintToken 컨트랙트를 다른 곳에서 사용할 때 설명할 것이다.

Crowdsale 스마트 컨트랙트

Corwdsale 스마트 컨트랙트에서 ICO를 진행할 때 필요한 모든 기능을 구현했다.

토큰 구매

토큰 구매

 

 function buyTokens(address _buyer) public payable onlyWhileOpen{
    uint256 receivedValue = msg.value;
    // check buyer address and received ether is valid
    require(_buyer != address(0) && receivedValue != 0, "not correct value");
    // check if total funded is not exceeding hardcap
    require(hardCap >= totalFundedInWei + receivedValue, "exceeding hardcap");
    // chekc if each individual's cap is in range
    uint256 currentFundedValue = buyers[_buyer];
    uint256 newFundedValue = currentFundedValue + receivedValue;
    require(newFundedValue >= investorMinCap && newFundedValue <= investorMaxCap, "cannot purchase more");

    buyers[_buyer] = newFundedValue;
    uint256 numberOfTokensToMint = calculateAmountToken(receivedValue);
    totalFundedInWei += receivedValue;
    MintToken(token).mint(_buyer, numberOfTokensToMint);
    //wallet.transfer(msg.value);
    refundVault.deposit{value: msg.value}(msg.sender);
  }

ICO에 참여하고자 하는 사람들이 호출하는 buyTokens 함수이다. 여기서 먼저 토큰 구매가 가능한지 여부를 판단하고 만약 가능하다면 토큰을 minting 하고 전달받은 금액을 일단은 다른 스마트 컨트렉트 (refundVault)에 전송한다. 여기서 다른 스마트 컨트랙트에 전송하는 이유는 나중에 환불 처리를 해야 하는 상황이 있을 수 있기 때문에 임시로 다른 곳에 저장해둔다.

 

이 함수에서 MintToken(token)으로 token 주소를 MintToken 컨트랙트로 타입 캐스팅을 한 다음에 mint 함수를 호출하고 있다. 이렇듯 다른 컨트랙트에서 mint 함수를 호출하기 위해 MintToken이라는 컨트랙트를 하나 더 만들었다.

구매할 수 있는 토큰 양 제한

mapping (address => uint256) public buyers;

현재 개인이 구매한 총금액을 확인할 수 있게 mapping을 사용했다. mapping으로 각 주소에서 전송한 금액을 계속 더하고 일정 금액을 넘어가게 되면 구매가 안되게 require로 확인한다.

제한시간

일반적으로 ICO는 시작시간과 종료시간을 가지고 진행한다. 그래서 이것을 구현하기 위해 openingTime과 closingTime을 만들었고 buyTokens 함수를 호출하는 트랜잭션의 timestamp가 openingTime과 closingTime 사이에 있는지 확인한다. 이것은 modifier를 따로 만들어서 구현했다.

modifier onlyWhileOpen() {
    require(block.timestamp >= openingTime && block.timestamp <= closingTime, "it is not valid time");
    _;
}

환불처리

모금액이 목표금액만큼 모이지 않아서 구매자들에게 다시 환불을 해줘야 하는 경우가 있기 때문에 환불 처리를 구현했다. 일단은 구매자들이 전송한 금액을 한 군데 모아두기 위해 컨트랙트를 하나 만들었다.

contract Refund is Ownable{
  enum State {Open, Close, Refund}

  mapping(address => uint256) public deposited;
  address payable public wallet;
  State public state;

  event RefundClosed();
  event RefundEnabled();
  event Refunded(address to, uint256 amount);

  constructor(address payable _wallet) {
    wallet = _wallet;
    state = State.Open;
  }
  
  ...

전송받은 금액들은 Refund 컨트랙트에 바로 전송이 되고 각 구매자들이 보낸 금액들도 deposit 함수를 통해 mapping으로 저장하고 있다. 그래야 나중에 환불 처리할 때 정확한 금액을 다시 보내줄 수 있다.

  /**
   * @dev keep tracks of each buyer's funds
   * @param _buyer address of buyer
   */
  function deposit(address _buyer) onlyOwner public payable{
    require(state == State.Open);
    deposited[_buyer] += msg.value;
  }

만약  환불을 해야 한다면 각 구매자들이 refund 함수를 호출해야 받을 수 있다.

  /**
   * @dev each individual claims their refunds
   * @param _buyer address of buyer
   */
  function refund(address payable _buyer) public {
    require(state == State.Refund);
    uint256 _deposited = deposited[_buyer];
    deposited[_buyer] = 0;
    _buyer.transfer(_deposited);
    emit Refunded(_buyer, _deposited);
  }

여기서 중요한 것이 금액은 transfer 하기 전에 mapping에 저장된 금액을 0으로 만들어야 한다. 만약 transfer 한 다음 0으로 만든다면 transaction이 처리되는 동안 구매자가 계속해서 refund 함수를 호출해서 의도치 않게 더 많은 돈을 환불받게 될 것이다. 이러한 공격을 Re-entrancy 공격이라고도 한다.

 

아직 구현하지 못한 ICO가 진행 중일 때 토큰 전송 불가 기능은 곧 구현할 예정이다. 잠깐 봤는데 openzeppelin에서 제공하는 컨트랙트 중 ERC20 Pausable이 있다. 이것을 사용해서 테스트를 해봤는데 만약 토큰이 Pause 상태이면 transfer가 되지 않았다. 그러나 mint도 되지 않는다. 내가 원하는 것은 pause 상태일 때 mint는 가능하고 transfer가 되지 않는 것이거나 owner만 pause 상태와 상관없이 mint, transfer가 모두 가능하게 하는 것이다. 이 부분은 좀 더 조사를 해봐야 할 거 같다. 

 

추가로 앞으로 클라이언트 부분도 구현하고 싶다. 스마트 컨트랙트도 어느 정도 완성되었기 때문에 무리 없이 클라이언트를 구현할 수 있을 거 같다.

ICO 종료 후 ETH가 owner에게 전송됨

 

 

 

Comments