[Ethereum] Staking dApp 만들기

0xDave·2022년 9월 18일
0

Ethereum

목록 보기
12/112
post-thumbnail

스테이킹은 여러 토큰들이 매도 압력을 덜어내기 위해 주로 사용하는 방식이다. 토큰을 스테이킹한 기간을 기준으로 일정 APY 만큼의 토큰을 분배해주고, 그 기간에는 사용자가 토큰을 팔지 못하도록 한다. 보상 기준은 얼마나 많은 사람들이 스테이킹에 참여했는지, 해당 토큰의 토크노믹스 상 생태계 참여자들에게 할당된 비율이 얼마인지 등에 따라 달라진다. 이번에는 아주 간단한 Staking 컨트랙트를 만들어보자.


🏗 Scaffold-eth


Scaffold-eth는 말그대로 개발자가 이더리움에서 프로젝트를 만들 때 빠르고 간편하게 빌딩할 수 있도록 도와주는 툴이다. 이 덕분에 처음부터 하나하나 만들지 않아도 되며, 시간을 절약할 수 있다는 장점이 있다. 이번 예제에서는 SpeedRunEthereum.comChallenge 1: Decentralized Staking App 코드로 구현했다.


🏃‍♀️ SpeedRunEthereum


Scaffold-eth를 만든 Austin Griffith 형님의 또 다른 프로젝트다. 이더리움을 배우려고 하는 나 같은 사람들을 위해 프로젝트 기반으로 이더리움을 배울 수 있게 만들었다. Token Vendor 만들기, DEX 만들기 등의 챌린지들이 있으며 재밌는 것들이 많아 보인다. 추후에 여기 있는 것들도 해보면서 글로 남겨봐야겠다.


1. Scaffold-eth 세팅


git clone https://github.com/scaffold-eth/scaffold-eth-challenges.git challenge-1-decentralized-staking

cd challenge-1-decentralized-staking

git checkout challenge-1-decentralized-staking

yarn install

위 명령어로 Scaffold-eth를 다운 받는다. 그 다음 터미널을 총 3개 열어서 각 터미널에 아래 명령어를 하나씩 입력한다.

yarn start

첫 번째 터미널은 프론트엔드를 실행시켜주고

yarn chain

두 번째 터미널은 Hardhat으로 백엔드를 실행켜준다.

yarn deploy

마지막 터미널은 contracts 폴더에 있는 컨트랙트를 컴파일, 디플로이 해준다.

실행이 완료되면 http://localhost:3000/에 다음과 같은 화면이 로드된다.


2. Staker.sol


컨트랙트를 작성하기 전에 dApp의 컨셉부터 살펴보자. Staking dApp은 총 2가지 기능이 있다. 첫 번째는 입금(Staking) 기능이다. 사용자는 컨트랙트가 배포된 시점으로 부터 2분까지 자신의 토큰을 입금할 수 있다. 두 번째는 출금(withdrawal) 기능이다. 입금기간이 지난 시점으로 부터 2분까지(컨트랙트가 최초로 배포되고 나서 +4분) 사용자는 이자와 함께 자신이 입금했던 자금을 인출할 수 있다. 이 시간이 지나면 사용자는 출금을 할 수 없으며, 남은 자금은 다른 컨트랙트로 보내져 잠긴다(locked). 이제 본격적으로 컨트랙트를 살펴보자.


// SPDX-License-Identifier: MIT
pragma solidity 0.8.4;  //Do not change the solidity version as it negativly impacts submission grading

import "hardhat/console.sol";
import "./ExampleExternalContract.sol";

contract Staker {
  //lock된 자금을 보낼 외부 컨트랙트 가져오기
  ExampleExternalContract public exampleExternalContract;

  //주소별 금액과 입금 시간 저장
  mapping(address => uint256) public balances;
  mapping(address => uint256) public depositTimestamps;

  uint256 public constant rewardRatePerBlock = 0.1 ether;
  uint256 public withdrawlDeadline = block.timestamp + 120 seconds;
  uint256 public claimDeadline = block.timestamp + 240 seconds;
  uint256 public currentBlock = 0;

사용자에게 지급할 블록 별 보상으로는 0.1 이더로 설정했다. 위에서 설명한대로 사용자는 2분 안에 입금해야 하며, 4분 안에 출금해야 한다. 이를 위해 block.timestamp를 이용해서 기한을 설정했다.


  event Stake(address indexed sender, uint256 amount);
  event Received(address, uint);
  event Execute(address indexed sender, uint256 amount);

  constructor(address exampleExternalContractAddress) {
      exampleExternalContract = ExampleExternalContract(exampleExternalContractAddress);
  }

각 기능들이 정상적으로 수행됐는지 알기 위해 event를 설정해주고 constructor 부분에는 자금을 송금할 외부 컨트랙트의 주소를 파라미터로 설정한다.

  //출금까지 남은 시간 체크
  function withdrawlTimeLeft() public view returns (uint256 withdrawlTimeLeft) {
    if (block.timestamp >= withdrawlDeadline) {
      return (0);
    } else {
      return (withdrawlDeadline - block.timestamp);
    }
  }

  //보상 클레임까지 남은 시간 체크
  function claimPeriodLeft() public view returns (uint256 claimPeriodLeft) {
    if (block.timestamp >= claimDeadline) {
      return (0);
    } else {
      return (claimDeadline - block.timestamp);
    }
  }

출금과 이자 클레임까지 남은 시간을 체크하는 함수를 만들어준다.


  modifier withdrawlDeadlineReached( bool requireReached ) {
    uint256 timeRemaining = withdrawlTimeLeft();
    if (requireReached) {
      require (timeRemaining == 0, "Withdrawal period is not reached yet");
    } else {
      require(timeRemaining > 0, "Withdrawal period has been reached");
    }
    _;
  }
  
  modifier claimDeadlineReached( bool requireReached ) {
    uint256 timeRemaining = claimPeriodLeft();
    if (requireReached) {
      require (timeRemaining == 0, "WClaim deadline is not reached yet");
    } else {
      require(timeRemaining > 0, "Claim deadline has been reached");
    }
    _;
  }

  modifier notCompleted() {
    bool completed = exampleExternalContract.completed();
    require(!completed, "Stake already completed!");
    _;
  }

modifier를 이용해 함수가 실행될 수 있는 필요조건을 만들어준다. withdrawlDeadlineReached가 리턴하는 boolean으로 통해 출금기한이 끝났는지 확인하고 claimDeadlineReached를 통해 이자 claim 기간이 지났는지 확인한다. 마지막으로 notCompleted는 외부 컨트랙트의 complete 함수가 실행됐는지 알려준다.

Note: 외부 컨트랙트의 변수 값을 가져오기 위해서는 변수명 뒤에 괄호를 붙여줘야 한다!


참고로 외부 컨트랙트의 코드는 다음과 같으며, 나중에 사용자가 출금하지 않은 토큰들을 받아서 잠그는 기능을 한다.

// SPDX-License-Identifier: MIT
pragma solidity 0.8.4;  //Do not change the solidity version as it negativly impacts submission grading

contract ExampleExternalContract {

  bool public completed;

  function complete() public payable {
    completed = true;
  }

}

하지만 실제로 토큰을 잠그는 함수는 컨트랙트 내에 존재하지 않는다. 그냥 boolean을 반환할 뿐.. 이번 예제는 스테이킹에 초점을 맞춰서 락 기능은 생략된 것 같다.


  function stake() public payable withdrawlDeadlineReached(false) claimDeadlineReached(false) {
    balances[msg.sender] = balances[msg.sender] + msg.value;
    depositTimestamps[msg.sender] = block.timestamp;
    emit Stake(msg.sender, msg.value);
  }


  function withdraw() public withdrawlDeadlineReached(true) claimDeadlineReached(false) notCompleted {
    require(balances[msg.sender] > 0 , "You have no balance to withdraw!");
    uint256 individualBalance = balances[msg.sender];
    uint256 indBalanceReward = individualBalance + ((block.timestamp - depositTimestamps[msg.sender]) * rewardRatePerBlock);
    balances[msg.sender] = 0;

    (bool sent, bytes memory data) = msg.sender.call{value: indBalanceReward}("");
    require(sent, "RIP, withdrawl failed :( ");
  }

이번 예제의 핵심 기능인 stake 함수와 withdraw 함수다.

stake 함수는 컨트랙트가 배포된 이후 2분, 4분이 지나지 않은 것을 확인하고 입력받은 이더(msg.value)만큼 balances에 지갑 주소와 함께 저장한다. 이후 이자 계산을 쉽게 하기위해 사용자가 언제 staking 했는지 기록한다.

withdraw 함수는 컨트랙트가 배포된 지 2분과 4분 사이임을 확인하고, 아직 락 기능이 실행되지 않았는지 확인한다. 이후 예치한 자금이 0을 초과하는지 체크한 다음(0만큼 스테이킹 해놓고 출금할 수 없도록) 사용자가 예치한 기간과 APY에 맞춰서 인출할 수 있도록 한다.


//App.jsx
<div style={{ padding: 8 }}>
  <Button
   type={balanceStaked ? "success" : "primary"}
   onClick={() => {
      tx(writeContracts.Staker.stake({ value: ethers.utils.parseEther("0.5") }));
   }}
  >
    🥩 Stake 0.5 ether!
  </Button>
</div>

실제 stake 함수가 실행될 때는 다음과 같이 버튼을 눌렀을 때 value 값과 함께 함수가 실행되도록 한다.


  function execute() public claimDeadlineReached(true) notCompleted {
    uint256 contractBalance = address(this).balance;
    exampleExternalContract.complete{value: address(this).balance}();
  }

  receive() external payable {
      emit Received(msg.sender, msg.value);
  }

마지막으로 락 기능을 수행할 함수와 컨트랙트가 이더를 받을 수 있도록 하는 receive 함수를 작성한다. 4분이 지났는지 확인하고 락 기능이 실행되지 않았는지 확인한다. 이후 Staker 컨트랙트에 있는 이더를 외부 컨트랙트로 송금한다. receive 함수는 아래에서 자세히 살펴보자.


🌪️ fallback 함수와 receive 함수


fallback 함수는 다음과 같은 목적으로 사용된다.

  1. 스마트컨트랙트에 이더를 받을 수 있게 한다.
  2. 외부 컨트랙트에서 함수를 호출했는데 함수가 존재하지 않을 경우에 실행되도록 한다.

fallback 함수는 솔리디티 0.6버전 이후로 fallback 함수와 receive 함수로 나눠졌다. receive 함수는 순수하게 이더를 받을 때 사용되며, fallback 함수는 존재하지 않는 함수가 호출될 때 사용된다. 또한 fallback 함수는 이더를 받음과 동시에 특정 동작을 수행할 수 있다.

Solidity by Example의 Sending Ether (transfer, send, call) 예제를 보면 언제 어떤 함수가 사용되는지 잘 나와있다. 일반적인 과정은 다음과 같다. 외부에서 컨트랙트로 이더를 받는다고 했을 때

  1. msg.data가 없을 경우, fallback() 함수가 이더를 받는다.
  2. msg.data가 있지만 receive() 함수가 없을 경우, fallback() 함수가 이더를 받는다.
  3. msg.data가 있고 receive() 함수도 있을 경우, receive() 함수가 이더를 받는다.
  4. 둘 다 컨트랙트 내에 존재하지 않는다면 이더는 사용자에게 반환된다.

🔥 한계점


  1. 보통 스테이킹 이율은 사용자들이 얼마나 예치했는지에 따라 달라지지만 예제에서는 0.1이더로 하드코딩되었다. 실제 프로젝트에선 어떻게 APY를 구현하는지 살펴볼 필요가 있다.

  2. 2분이 지나고 withdraw 할 때 에러가 뜨면서 출금이 안 됐었다. 알고보니 컨트랙트에 충분한 이더가 없었기 때문.(블록마다 0.1이더를 이자로 지급하는데 120초 동안의 이자를 지급하려면 12이더나 필요하다..) 따라서 컨트랙트에 이더를 송금해주거나 디플로이할 때 컨트랙트에 이더를 넣어줘야 한다.


🤷‍♂️ 의문점


stake 함수와 withdraw 함수를 비교했을 때, stake 함수에는 payable이 있지만 withdraw 함수에는 없다.

처음에는 당연히 withdraw 함수에도 payable이 있어야 하는게 아닌가?라고 생각했었다. 이더를 주거나 받을 때 payable이 사용되어야 한다고 알고 있었는데 완전히 잘못 알고 있었다.

Ethereum Stackexchange의 답변을 빌리자면, payable 은 이더를 받을 때만 사용된다. 우리가 사용했던 withdraw 함수는 명백히 말하자면 이더를 받는 기능을 하지 않는다. 예치했던 사용자에게 이자와 예치자금을 보낼 뿐이다. 역시 내가 알고 있는 것들도 제대로 알고 있는지 다시 확인해보는 과정이 꼭 필요하다.


참고자료 및 출처


  1. 솔리디티 강좌 35강 - fallback / receive 함수
  2. Receive Ether Function
  3. How to Build a Staking Dapp
  4. what is the best way to access smart contract public variables using HardHat?
  5. How to receive ethers inside a smart contract
  6. Payable
  7. Sending Ether (transfer, send, call)
profile
Just BUIDL :)

0개의 댓글