7. Solidity(Smart Contract 복권)

정예찬·2022년 8월 2일
0

solidity

목록 보기
9/13
post-custom-banner

본 글은 freeCodeCamp.Org의 Youtube 영상 'Solidity, Blockchain, and Smart Contract Course – Beginner to Expert Python Tutorial'와 관련 코드인 SmartContract의 Github 코드를 기초로 작성되었다.

Youtube 영상 링크: https://www.youtube.com/watch?v=M576WGiDBdQ&t=10336s
Github 코드 링크: https://github.com/smartcontractkit/full-blockchain-solidity-course-py
오늘의 코드: https://github.com/PatrickAlphaC/smartcontract-lottery

이번 포스팅은 유튜브 영상 06:11:38~08:21:02에 해당하는 내용이다.

이번 시간에는 스마트 컨트랙트 복권 어플리케이션을 만들어보자.
먼저 demos 폴더에 smartcontract-lottery라는 폴더를 만들고 그 폴더를 brownie로 initialize하자.

mkdir smartcontract-lottery
cd smartcontract-lottery
brownie init

코드를 본격적으로 작성하기 전 여느 때처럼 필요한 작업들을 해주자.
.env를 만들어 다음 내용을 입력해주자.

export PRIVATE_KEY = 0xb5e857091a491a306f8a13ac49ed53655f51c2d780db0e9faf0305878b3f8fe5
export WEB3_INFURA_PROJECT_ID=a9c5bc0ec75c4a83a3e48086df81acfe
export ETHERSCAN_TOKEN=BEF1HMKFS34JWYP6RQACG8S3MX6G4FN4N4

매번 필자의 메타마스크 개인키를 올리고 있는데 포스팅용으로만 사용하는 지갑이라 그렇다. 여러분은 개인키가 노출되지 않도록 조심하라!

다음으로 brownie-config.yaml를 만들고 다음 내용을 입력하자.

dependencies:
  - smartcontractkit/chainlink-brownie-contracts@1.1.1
  - OpenZeppelin/openzeppelin-contracts@3.4.0
compiler:
  solc:
    remappings:
      - '@chainlink=smartcontractkit/chainlink-brownie-contracts@1.1.1'
      - '@openzeppelin=OpenZeppelin/openzeppelin-contracts@3.4.0'
dotenv: .env
networks:
  default: development
  development:
    keyhash: '0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311'
    fee: 100000000000000000
  rinkeby:
    vrf_coordinator: '0xb3dCcb4Cf7a26f6cf6B120Cf5A73875B7BBc655B'
    eth_usd_price_feed: '0x8A753747A1Fa494EC906cE90E9f37563A8AF630e'
    link_token: '0x01BE23585060835E02B77ef475b0Cc51aA1e0709'
    keyhash: '0x2ed0feb3e7fd2022120aa84fab1945545a9f2ffc9076fd6156fa96eaff4c1311'
    fee: 100000000000000000
    verify: True
  mainnet-fork:
    eth_usd_price_feed: '0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419'
    verify: False
wallets:
  from_key: ${PRIVATE_KEY}

chainlink와 openzeppelin으로부터 dependency를 가져오고 있다.
networks에서 몇 가지 추가된 점이 있다.
먼저 keyhash가 추가되었다. keyhash는 추후 사용할 chainlink node를 고유성을 검증한다.
fee는 거래 생성 수수료이다. 수수료는 0.1 LINK인데(Ethereum에서 Gas를 사용한다면 Chainlink에서는 LINK를 이용한다.), 단위를 10^18로 맞춰주었다.
다음으로 rinkeby에 vrf_coordinator와 link_token이 새롭게 추가되었다. 해당 주소는 아래 링크에서 가져올 수 있다.
https://docs.chain.link/docs/vrf-contracts/
vrf_coordinator는 chainlinknode가 반환한 값이 순수하게 랜덤성이 보장되는지 검증한다.
link_token은 chainlink token의 rinkeby network 주소이다. 즉 이 주소는 rinkeby network 상에서 LINK가 이용되도록 한다.

복권 규칙
1. 이용자들은 USD 요금으로 나타낸 ETH로 복권에 참여할 수 있다.
2. 관리자가 복권이 마감되는 시점을 결정한다.
3. 랜덤으로 당첨자를 뽑는다.

복권 규칙은 위와 같다. 그러면 복권 계약을 작성해보자! 먼저 Lottery.sol파일을 contracts 폴더에 만들어주고 아래 내용을 입력해주자.(코드가 길어서 잘라서 설명하겠다.)

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.6;

import "@chainlink/contracts/src/v0.6/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@chainlink/contracts/src/v0.6/VRFConsumerBase.sol";

contract Lottery is VRFConsumerBase, Ownable {
    address payable[] public players;
    address payable public recentWinner;
    uint256 public randomness;
    uint256 public usdEntryFee;
    AggregatorV3Interface internal ethUsdPriceFeed;
    enum LOTTERY_STATE {
        OPEN,
        CLOSED,
        CALCULATING_WINNER
    }
    LOTTERY_STATE public lottery_state;
    uint256 public fee;
    bytes32 public keyhash;
    event RequestedRandomness(bytes32 requestId);

AggregatorV3Interface, Ownable, VRFConsumerBase를 import하고 있다.
Lottery 계약에서는 VRFConsumerBase와 Ownable을 상속받고 있다. VRFConsumerBase는 chainlink에서 randomnumber(난수)를 가져오게 해준다. VRFConsumerBase.sol의 코드를 직접 확인하고 싶다면 아래 링크로 들어가면 된다.
https://github.com/smartcontractkit/chainlink-brownie-contracts/blob/main/contracts/src/v0.6/VRFConsumerBase.sol

Lottery 계약 내용을 살펴보자.
참가자들의 주소 배열을 players라는 이름으로 선언하고 있다.
최근 당첨자를 저장하기 위한 recentWinner라는 이름의 주소 변수를 선언하고 있다.
players와 recentWinner는 모두 ETH 입출금이 이루어지는 주소이기에 payable 키워드가 붙었다.
다음으로 난수 저장을 위한 randomness와 USD로 나타낸 복권 참여 금액인 usdEntryFee가 선언되었다.
다음으로 enum을 활용한 열거형 선언이 이루어졌다. 'enum 열거형이름 {상수1, 상수2, 상수3, ...}'에서 상수1, 상수2, 상수3, ...에는 각각 0, 1, 2, ...이 할당된다. 상n에는 '열거형이름.상수n' 형식으로 접근이 가능하다. LOTTERY_STATE의 OPEN, CLOSED, CALCULATING_WINNER에 각각 0, 1, 2가 저장되었다.
LOTTERY_STATE을 public 키워드를 붙여 lottery_state로 선언하고 있다.
fee와 keyhash를 각각 양의 정수와 바이트 형식으로 선언하고 있다.
다음으로 event라는 새로운 데이터 타입이 등장하였다. transaction이 발생하는 과정 동안 블록체인 상에서 여러 event가 발생한다. event는 transaction에 담긴 데이터 조각이다. event를 정의하고 방출하기 위해 event 데이터 타입이 활용된다. event를 방출할 때는 emit 데이터 타입을 쓴다.

  constructor(
        address _priceFeedAddress,
        address _vrfCoordinator,
        address _link,
        uint256 _fee,
        bytes32 _keyhash
    ) public VRFConsumerBase(_vrfCoordinator, _link) {
        usdEntryFee = 50 * (10**18);
        ethUsdPriceFeed = AggregatorV3Interface(_priceFeedAddress);
        lottery_state = LOTTERY_STATE.CLOSED;
        fee = _fee;
        keyhash = _keyhash;
    }

이어서 코드를 보자. constructor를 설정하고 있는데, 눈여겨볼 점은 VRFConsumerBase에도 _vrfCoordinator, _link를 입력값으로 넣어주고 있는 점이다. 이는 VRFConsumerBase의 contructor에 두 변수를 넣는 작업이다. 이처럼 상속을 해준 대상의 constructor 또한 설정이 가능하다.
constructor의 입력값은 가격피드주소, vrfCoordinator 주소, link토큰 주소, 요금, keyhash(chainlink 노드의 고유성 검증에 사용)이다.
이 중 vrfCoordinator 주소, link토큰 주소는 VRFConsumerBase의 constructor 입력값으로 들어간다.
USD로 나타낸 입장 요금은 50달러로 설정하고 있다.
가격피드 주소를 입력값으로 AggregatorV3Interface를 호출하여 ETH/USD 가격 피드를 설정해주고 있다.
복권은 닫아놓는다.
fee와 keyhash에 각각 입력값으로 받아온 요금과 keyhash를 저장한다.

  function enter() public payable {
        // $50 minimum
        require(lottery_state == LOTTERY_STATE.OPEN);
        require(msg.value >= getEntranceFee(), "Not enough ETH!");
        players.push(payable(msg.sender));
    }

enter함수, 즉 복권에 입장하는 함수를 구현하고 있다. ETH가 투입되는 함수이니 payable 키워드가 활용되었다.
복권이 열려 있고, 입장료(getEntranceFee())보다 투입값(msg.value)이 커야만 복권에 참여할 수 있다.
위 2가지 조건을 만족하는 경우 players 배열에 입장자(msg.sender)를 넣는다.(msg.sender가 송금 주소이므로 payable로 감싸주었다.)

 function getEntranceFee() public view returns (uint256) {
        (, int256 price, , , ) = ethUsdPriceFeed.latestRoundData();
        uint256 adjustedPrice = uint256(price) * 10**10; // 18 decimals
        // $50, $2,000 / ETH
        // 50/2,000
        // 50 * 100000 / 2000
        uint256 costToEnter = (usdEntryFee * 10**18) / adjustedPrice;
        return costToEnter;
    }

입장료를 반환하는 getEntranceFee 함수를 보자. 자주 봐서 익숙한 코드이다.
이때 USD로 나타낸 입장료를 usdEntryFee로 설정했다
adjustedPrice는 ETH/USD 가격 비율을 Wei 단위로 맞추어준 값이다.
따라서 costToEnter는 USD 입장료를 ETH로 환산한 값임을 알 수 있다.
이 함수는 costToEnter를 반환하며 종료된다.

 function startLottery() public onlyOwner {
        require(
            lottery_state == LOTTERY_STATE.CLOSED,
            "Can't start a new lottery yet!"
        );
        lottery_state = LOTTERY_STATE.OPEN;
    }

복권을 시작하는 함수이다. 복권 시작은 관리자에 의해서만 가능해야 하므로 onlyOwner modifier를 추가해주었다. 앞서 import한 Ownable에 onlyOwner가 있다. onlyOwner를 사용하기 위해 Ownable을 import하였다.
복권을 시작하기 위해서는 복권이 닫혀있어야 한다.
복권이 닫혀있음이 확인되면 복권을 연다.

function endLottery() public onlyOwner {
        lottery_state = LOTTERY_STATE.CALCULATING_WINNER;
        bytes32 requestId = requestRandomness(keyhash, fee);
        emit RequestedRandomness(requestId);
    }

복권을 종료하는 함수이다. 복권 종료 또한 관리자에 의해서만 가능하기에 onlyOwner가 추가되었다.
복권을 승자 결정 중인 상태로 변경한다.
keyhash와 fee를 입력값으로 랜덤 숫자를 요청하는 requestRandomness함수를 호출하여 그 반환값을 requestId에 저장하고 있다. requestRandomness함수를 이해하기 위해 chainlink를 이용해 난수를 받아오는 과정을 살펴보자.

chainlink를 이용해 난수를 받아오는 일은 아래 사진과 같이 4가지 단계로 이루어진다.
1. 스마트 컨트랙트를 이용해 chainlink node에 난수를 요청한다.
2. chainlink node가 난수를 생성하여 VRF contract에 무작위성 증명을 보낸다.
3. VRF contract가 난수의 무작위성을 검증한다.
4. 검증된 난수를 요청자가 받는다.

사진 출처: AZCoin News

requestRandomness는 chainlink node의 chainlink node에 난수를 요청하는 함수이다. 이때 입력값인 keyhash는 chainlink node의 고유성을 검증하고, fee는 난수 요청 요금이 된다.
emit으로 인해 RequestedRandomness라는 event가 방출된다.

 function fulfillRandomness(bytes32 _requestId, uint256 _randomness)
        internal
        override
    {
        require(
            lottery_state == LOTTERY_STATE.CALCULATING_WINNER,
            "You aren't there yet!"
        );
        require(_randomness > 0, "random-not-found");
        uint256 indexOfWinner = _randomness % players.length;
        recentWinner = players[indexOfWinner];
        recentWinner.transfer(address(this).balance);
        // Reset
        players = new address payable[](0);
        lottery_state = LOTTERY_STATE.CLOSED;
        randomness = _randomness;
    }
}

fulfillRandomness는 chainlink로부터 무작위성 증명을 받은 VRF가 무작위성을 검증하기 위해 호출하는 함수이다. 이 함수는 따라서 난수를 생성하고 받아오는 과정에서 자동으로 호출된다.
VRFConsumerBase.sol에 정의된 fulfillRandomness는 다음과 같다.

function fulfillRandomness(bytes32 requestId, uint256 randomness)
    internal virtual;

이 함수에 VRF만 접근할 수 있도록 internal 키워드를 더하고 있다.
virtual 키워드는 이후 이 함수에 virtual 대신 override라는 키워드를 더할 수 있도록 정의된 키워드이다. 그렇다면 override 키워드는 무엇일까?
원래 우리가 살펴보던 Lottery.sol의 fulfillRandomness로 다시 돌아오자. virtual 키워드 대신 override 키워드가 추가되었음이 보인다. 기존 함수(VRFConsumerBase에서의 fulfillRandomness)를 override 키워드를 추가하여 새롭게 정의하면(Lottery에서의 fulfillRandomness) 함수 내용(중괄호 안의 내용: require~ .. ~ _randomness;)을 더할 수 있다. 즉, override 키워드는 기존 함수에 내용을 추가하게 해주는 키워드이다.
추가한 내용을 하나하나 살펴보자.
먼저 lottery가 CALCULATING_WINNER, 즉 승자를 고르는 상태인지 확인하여 이 함수를 실행할 상황인지 검증한다.
다음으로 난수가 0인지 검증한다.
2가지 검증을 통과한 난수를 참여자 수로 나누어 그 나머지를 참여자 index에 저장하고 있다.
그 index에 해당하는 player를 복권 당첨자로 정한 후 이 계약의 잔고, 즉 지금까지 받은 복권 참여금을 전부 당첨자에게 주고 있다.
복권 추첨이 끝났으므로 복권 초기화를 위해 players 배열을 초기화하고, lottery state를 닫힘으로 변경하고 있다.
마지막으로 randomness 변수에 받아온 난수를 저장해준다.
Lottery.sol 작성이 끝났다.

Lottery를 deploy하기 전에 필요한 작업을 한 가지 해두자. testnet에서 난수를 가져오기 위해서는 testnet link가 필요하다. 이를 메타마스크 지갑에 받기 위해 다음 링크에 접속하자.
https://faucets.chain.link/

Connet wallet을 눌러 Metamask 지갑을 연결해주자.

20 test LINK에 대해 Send Request를 하자.

발생한 contract의 address(rinkeby etherscan에서 쉽게 찾을 수 있다.) 복사->metamask에서 '토큰 가져오기'->복사한 address '토큰 계약 주소'에 붙여넣기->맞춤형 토큰 추가 클릭
위 작업을 완료하면 Rinkeby LINK가 지갑에 추가되었음을 확인할 수 있다.

다음으로 brownie에 계좌를 추가하는 방법을 알아보자.
지금까지 get_account 함수에서는 local 환경에서 계좌를 가져오거나 지갑에서 가져왔다. 이번 시간에는 brownie에 저장된 계좌에까지도 접근 가능하게 함수를 변경하므로 brownie에 계좌를 추가해보자.

brownie accounts generate 계좌id

계좌id를 정해 터미널에 입력하면 계좌가 생성된다. 이미 존재하는 계좌를 brownie에 추가하고 싶다면 터미널에 다음 코드를 입력하면 된다.

brownie accounts new 계좌id

위 코드를 입력하고 private key를 입력하라는 명령이 나오면 입력해주면 된다.

brownie accounts list

brownie에 추가된 계좌를 확인하고 싶다면 위 코드를 입력하면 된다.

다음으로 deploy와 mocking을 위해 필요한 여러 계약을 contracts 폴더에 test폴더를 만들어 그 안에 추가해주자. 아래 링크의 파일 4가지(LinkToken.sol, MockOracle.sol, MockV3Aggregator.sol, VRFCoordinatorMock.sol)의 제목과 내용을 모두 추가해주자.
https://github.com/PatrickAlphaC/smartcontract-lottery/tree/e3cd8e3b52311c27a557147c494d5bd1d9d3e753/contracts/test
다음으로 interfaces 폴더에 들어가 LinkTokenInterface.sol을 추가해주자. 아래 링크에 들어가면 해당 코드를 가져올 수 있다.
https://github.com/PatrickAlphaC/smartcontract-lottery/blob/e3cd8e3b52311c27a557147c494d5bd1d9d3e753/interfaces/LinkTokenInterface.sol

이제 deploy를 위해 scripts로 넘어가자. scripts 간 import를 위해 __init__.py를 scrpits 폴더에 추가해주자. 다음으로 helpfulscripts.py를 추가하여 스크립트 코드를 작성해보자.

from brownie import (
    accounts,
    network,
    config,
    MockV3Aggregator,
    VRFCoordinatorMock,
    LinkToken,
    Contract,
    interface,
)

FORKED_LOCAL_ENVIRONMENTS = ["mainnet-fork", "mainnet-fork-dev"]
LOCAL_BLOCKCHAIN_ENVIRONMENTS = ["development", "ganache-local"]

import된 대상을 보자. 기존에 다룬 대상들을 제외하고 살펴보자.
contracts 폴더에서 우리가 새로 추가한 VRFCoordinatorMock, LinkToken을 추가하고 있다.
abi로부터 계약을 deploy하기 위해 Contract, 인터페이스 활용을 위해 interface를 import하였다.

def get_account(index=None, id=None):
    # accounts[0]
    # accounts.add("env")
    # accounts.load("id")
    if index:
        return accounts[index]
    if id:
        return accounts.load(id)
    if (
        network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS
        or network.show_active() in FORKED_LOCAL_ENVIRONMENTS
    ):
        return accounts[0]
    return accounts.add(config["wallets"]["from_key"])

get_account 함수를 보자. index나 id는 입력해도, 안 해도 되게 했다.
index를 입력한 경우 accounts[index]가, id를 입력한 경우 계좌id에 해당하는 계좌가 반환된다. accounts.load(id)는 brownie로부터 id에 해당하는 계좌를 가져온다.
둘 다 입력되지 않은 경우는 우리가 지금까지 해왔던 방식과 동일하게 계좌를 반환한다. local 체인이나 forked 체인이 활성화된 경우 그 체인에 계좌를 하나 할당하여 반환한다. 나머지 경우, 즉 test network가 활성화된 경우에는 그 네트워크에 해당하는 계좌 중 우리가 입력한 비밀키의 계좌를 반환한다.

contract_to_mock = {
    "eth_usd_price_feed": MockV3Aggregator,
    "vrf_coordinator": VRFCoordinatorMock,
    "link_token": LinkToken,
}

contract_to_mock 안에서는 우리가 사용할 contract에 이름을 할당하고 있다.

def get_contract(contract_name):
      contract_type = contract_to_mock[contract_name]
    if network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        if len(contract_type) <= 0:
            deploy_mocks()
        contract = contract_type[-1]
    else:
        contract_address = config["networks"][network.show_active()][contract_name]
        # address
        # ABI
        contract = Contract.from_abi(
            contract_type._name, contract_address, contract_type.abi
        )
    return contract

get_contract에서는 contract 이름을 받아 그 이름에 해당하는 contract를 contract_type에 저장하고 있다.
local 체인이 활성화되어있는 경우에 모의 계약이 deploy되어 있지 않다면 모의 계약을 deploy하고 그 계약을 contract에 저장하고 있다.(deploy_mocks 함수에 대해서는 추후 자세히 살펴보자.)
나머지 경우, 즉 test network가 활성화되어있는 경우에는 그 네트워크의 주소를 가져와 그 주소, contract 이름과 contract abi를 입력값으로 하여 Contract.from_abi(Contract함수는 contract의 정보를 받아 해당 contract를 deploy한다.)로 contract를 deploy하고 있다. deploy된 contract를 contract에 저장하고 있다.
get_contract 함수는 contract를 반환하며 마무리된다.

DECIMALS = 8
INITIAL_VALUE = 200000000000


def deploy_mocks(decimals=DECIMALS, initial_value=INITIAL_VALUE):
    account = get_account()
    MockV3Aggregator.deploy(decimals, initial_value, {"from": account})
    link_token = LinkToken.deploy({"from": account})
    VRFCoordinatorMock.deploy(link_token.address, {"from": account})
    print("Deployed!")

이 함수에서 살펴볼 점은 MockV3Aggregator, LinkToken, VRFCoordinatorMock가 deploy된다는 점이다. 각 contract의 입력값은 각 contract 코드의 constructor가 갖는 입력값이다. 어떠한 입력값을 넣어주어야할지 모르겠다면 contract 코드를 살펴보면 된다.

def fund_with_link(
    contract_address, account=None, link_token=None, amount=100000000000000000
): 
    account = account if account else get_account()
    link_token = link_token if link_token else get_contract("link_token")
    tx = link_token.transfer(contract_address, amount, {"from": account})
    # link_token_contract = interface.LinkTokenInterface(link_token.address)
    # tx = link_token_contract.transfer(contract_address, amount, {"from": account})
    tx.wait(1)
    print("Fund contract!")
    return tx

fund_with_link는 계좌에서 contract 주소로 링크(0.1 LINK)를 보내는 함수이다.(0.1 LINK는 난수 생성에 필요한 수수료이다.) 계좌와 link token은 각각 지정할 수도, 하지 않을 수도 있다.
account를 지정하지 않으면 get_account함수를 호출하여 그 반환값을 account에 저장한다.
link_token을 지정하지 않으면 get_contract("link_token")의 반환값을 link_token에 저장한다.
amount만큼의 LINK를 account에서 contract_address로 보내고 있다. 이 거래를 tx에 저장한다.
주석 처리된 두 줄은 interface를 활용하여 link transaction을 만드는 방법이다. 실행 결과는 주석 처리된 두 줄 위의 두 줄과 동일하다.
interface.인터페이스명(계약 주소)를 하면 계약 주소에 해당하는 계약이 반환된다.
tx를 반환하며 함수가 종료된다.

helpfulscripts의 작성이 끝났다. 다음으로 deploy_lottery.py를 작성하자.

from scripts.helpful_scripts import get_account, get_contract, fund_with_link
from brownie import Lottery, network, config
import time

import 대상은 time 제외하고 다 다루었다. time은 이후 time.sleep(초) 함수를 사용하기 위해 가져왔다. 이 함수는 입력된 초만큼 함수 실행을 정지했다가 재실행한다.

def deploy_lottery():
    account = get_account()
    lottery = Lottery.deploy(
        get_contract("eth_usd_price_feed").address,
        get_contract("vrf_coordinator").address,
        get_contract("link_token").address,
        config["networks"][network.show_active()]["fee"],
        config["networks"][network.show_active()]["keyhash"],
        {"from": account},
        publish_source=config["networks"][network.show_active()].get("verify", False),
    )
    print("Deployed lottery!")
    return lottery

deploy_lottery 함수, 즉 Lottery 계약을 deploy하는 함수를 살펴보자.
계좌를 get_account로 하나 가져온다.
다음으로 Lottery를 deploy하여 이를 lottery에 저장한다. Lottery.deploy constructor에는 가격피드주소, vrfcoordinator 주소, link token주소 , fee, keyhash가 들어간다. 각각을 get_contract와 config를 적절히 활용하여 넣어주었다.
실행 계좌를 입력하고 publish 요청까지 이루어졌다. verify를 확인하였는데 값이 없으면 False를 default로 설정하였다.(get("verify", False)의 의미)
lottery 반환으로 함수가 종료된다.

def start_lottery():
    account = get_account()
    lottery = Lottery[-1]
    starting_tx = lottery.startLottery({"from": account})
    starting_tx.wait(1)
    print("The lottery is started!")


def enter_lottery():
    account = get_account()
    lottery = Lottery[-1]
    value = lottery.getEntranceFee() + 100000000
    tx = lottery.enter({"from": account, "value": value})
    tx.wait(1)
    print("You entered the lottery!")

start_lottery와 enter_lottery는 각각 get_account와 최근 실행된 lottery를 호출하여 함수 내용을 진행하고 있다.
거래.wait(n)은 n회의 block confirmation이 발생할 때까지 기다리는 함수이다.
위 내용 이외에는 코드 설명이 따로 필요하지 않기에 생략하겠다.

def end_lottery():
    account = get_account()
    lottery = Lottery[-1]
    # fund the contract
    # then end the lottery
    tx = fund_with_link(lottery.address)
    tx.wait(1)
    ending_transaction = lottery.endLottery({"from": account})
    ending_transaction.wait(1)
    time.sleep(180)
    print(f"{lottery.recentWinner()} is the new winner!")


def main():
    deploy_lottery()
    start_lottery()
    enter_lottery()
    end_lottery()

end_lottery 또한 start_lottery와 enter_lottery와 겹치는 부분이 많고 코드가 간단하여 time.sleep(180) 이외에는 설명이 필요한 내용이 없다.
end_lottery에서 lottery.endLottery를 호출한 후 180초를 쉬는 이유는 앞서 설명한 난수 발생과 관련이 있다. 난수 발생을 요청하면 chainlink node의 난수 발생과 VRF의 난수 무작위성 검증이 이루어지는데, 이 과정에 시간이 좀 소요된다. 이 과정이 끝나고 복권 당첨자가 선출되기까지의 시간을 충분히 기다렸다가 마지막 문장을 실행하기 위해 함수를 180초 간 정지한다.(180초 너무 길다. 영상처럼 60초만 해도 충분하다.)

main함수에서는 앞서 다룬 네 함수(deploy_lottery(), start_lottery(), enter_lottery(), end_lottery())를 호출하고 있다.

deploy_lottery.py 작성을 마쳤다. 이번에는 deploy_mocks.py 작성을 진행하자.

from scripts.helpful_scripts import deploy_mocks


def main():
    deploy_mocks()

deploy_mocks 함수는 이미 helpful_scripts에 정의되어서 메인 함수에서 호출하기만 하면 끝이다.

다음으로 test를 진행하자. 여러 test를 통해 deploy script가 잘 작동하는지 살펴보자.
test에는 2가지가 있다. 코드의 부분을 보는 unit 테스트와 시스템 간 상호작용을 보는 integration test가 있다. 보통 unit test는 코드의 일부를 local 환경에서 테스트하는 반면 integration test는 test network 상의 코드 작동을 시험한다.

먼저 tests 폴더에 unit test를 위한 'test_lottery_unit.py'를 추가하고 코드를 채워넣어보자.

from scripts.helpful_scripts import (
    get_account,
    fund_with_link,
    get_contract,
    LOCAL_BLOCKCHAIN_ENVIRONMENTS,
)
from brownie import exceptions, network
from web3 import Web3
import pytest


def test_get_entrance_fee(lottery_contract):
    # Arrange
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()

    # Act
    # 2,000 eth / usd
    # usdEntryFee is 50
    # 2000/1 == 50/x == 0.025
    expected_entrance_fee = Web3.toWei(0.025, "ether")
    entrance_fee = lottery_contract.getEntranceFee()
    # Assert
    assert expected_entrance_fee == entrance_fee

import되는 요소 중 새로운 요소는 없다.

test_get_entrance_fee 함수를 보자. 이는 복권 계약의 entrance fee가 제대로 설정되었는지 확인하는 함수로, 복권 계약(lottery_contract)를 입력값을 받는다.
Arrange 단계에서는 local 환경에서만 test가 작동하도록 해주고 있다.
Act 단계를 보자. local 환경에서 1 ETH의 가격을 2000 USD로 설정을 했다. USD로 나타낸 entrance fee는 50이기에 이에 해당하는 ETH는 50/2000=0.025이다.
따라서 우리가 예측한 entrancefee를 나타내는 변수인 expected_entrance_fee에 0.025 ETH를 Wei로 변환하여 저장한다.
복권 계약(lottery_contract)에서 getEntranceFee를 호출하여 그 반환값을 entrance_fee에 저장한다.
Assert 단계에서 expected_entrance_fee와 entrance_fee가 일치하는지 확인한다.

def test_cant_enter_unless_started(lottery_contract):
    # Arrange
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()

    # Act / Assert
    with pytest.raises(exceptions.VirtualMachineError):
        lottery_contract.enter(
            {"from": get_account(), "value": lottery_contract.getEntranceFee()}
        )


def test_can_start_and_enter_lottery(lottery_contract):
    # Arrange
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()

    account = get_account()
    lottery_contract.startLottery({"from": account})
    # Act
    lottery_contract.enter(
        {"from": account, "value": lottery_contract.getEntranceFee()}
    )
    # Assert
    assert lottery_contract.players(0) == account

다음으로 lottery가 시작되기 전에 입장할 수 없는지 확인하는 함수를 보자.
Arrange 단계에서는 역시 local 체인 상의 작업인지 확인한다.
Act 및 Assert 단계에서는 lottery 시작 함수를 호출하지 않은 상태에서 lottery_contract.enter를 시도하고, 에러를 일으키는지 확인한다.

또한 lottery가 시작된 후 입장이 가능한지 확인하는 함수를 보자.
Arrange 단계에서는 먼저 local 체인 상의 작업인지 확인한다. 그 후 계좌를 하나 호출하여 그 계좌로 Lottery를 시작한다.
Act 단계에서는 lottery_contract에 입장(enter)한다.
Assert 단계에서는 lottery_contract의 players의 첫 번째 요소에 account가 입력되었는지, 즉 입장이 되었는지 검증한다.

def test_can_end_lottery(lottery_contract):
    # Arrange
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()

    account = get_account()
    lottery_contract.startLottery({"from": account})
    lottery_contract.enter(
        {"from": account, "value": lottery_contract.getEntranceFee()}
    )
    fund_with_link(lottery_contract)
    #Act
    lottery_contract.endLottery({"from": account})
    #Assert
    assert lottery_contract.lottery_state() == 2

이번에는 lottery를 종료할 수 있는지 확인하는 test를 보자.
Arrange 단계에서 local 환경인지 확인한다.
다음으로 Lottery를 시작하고 입장한다.
lottery 종료를 위해 fund_with_link 함수를 호출하였다. endLottery에서는 난수를 요청하고 받아오는데, 난수 수령에 필요한 수수료가 fund_with_link를 통해 전송된다.
Act 단계에서 endLottery 함수를 실행한다.
endLottery가 호출되면 일련의 과정을 거쳐(이 과정이 생각이 안 나면 Lottery.sol을 다시 살펴보자.) 당첨자가 선정되고 복권이 초기화된다. 이때 복권 상태(lottery_state)가 CLOSED==2로 바뀐다.
Assert 단계에서는 lottery_state가 CLOSED인지 검증한다.

def test_can_pick_winner_correctly(lottery_contract):
    if network.show_active() not in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()
	account = get_account()
    lottery_contract.startLottery({"from": account})
    lottery_contract.enter(
        {"from": account, "value": lottery_contract.getEntranceFee()}
    )
    lottery_contract.enter(
        {"from": get_account(index=1), "value": lottery_contract.getEntranceFee()}
    )
    lottery_contract.enter(
        {"from": get_account(index=2), "value": lottery_contract.getEntranceFee()}
    )
    fund_with_link(lottery_contract)
    starting_balance_of_account = account.balance()
    balance_of_lottery = lottery_contract.balance()
    transaction = lottery_contract.endLottery({"from": account})
    request_id = transaction.events["RequestedRandomness"]["requestId"]
    STATIC_RNG = 777
    get_contract("vrf_coordinator").callBackWithRandomness(
        request_id, STATIC_RNG, lottery_contract.address, {"from": account}
    )
    # 777 % 3 = 0
    assert lottery_contract.recentWinner() == account
    assert lottery_contract.balance() == 0
    assert account.balance() == starting_balance_of_account + balance_of_lottery

unit test의 마지막으로 Lottery의 당첨자가 규칙대로 뽑히는지 확인하는 test를 보자. 즉, 이 test는 Lottery.sol의 fulfillRandomness가 잘 작동하는지 확인하는 test이다.
lottery를 시작(start)하고 lottery에 account, index=1인 계좌, index=2인 계좌 총 3개의 계좌를 입장(enter)시키고 있다.
난수 발생을 위해 fund_with_link를 호출한다.
account의 잔고, lottery의 잔고를 확인하여 각각 starting_balance_of_account와 balance_of_lottery에 입력하고 있다.
다음으로 Lottery를 종료(end)하고, 이 거래를 transaction에 저장한다.
transaction.events에는 transaction의 event들이 다 담겨 있다. 이 중 우리가 event와 emit을 통해 기록 및 방출했던 RequestedRandomness에서 requestId를 가져와 request_id에 저장한다.
test이므로 우리가 난수를 STATIC_RNG라는 이름의 상수(777)로 지정할 수 있다.
vrf_coordinator를 입력값으로 계약을 호출하여 그 안의 callBackWithRandomness 함수를 실행하고 있다. callBackWithRandomness는 VRFCoordinatorMock에 있는 함수이다. 이 함수는 원래 chainlink node가 난수 검증을 위해 호출하는 함수이다. 그러나 test 진행을 위해 우리가 chainlink node 대신 이 함수를 호출하자. 이 함수는 requestId, randomness, consumerContract를 입력값으로 받고 있다.(VRFCoordinatorMock.sol에서 함수를 구체적으로 확인 가능하다.)
requestId에는 event 선언을 통해 가져온 request_id를 ,난수로는 777을, consumerContract에는 lottery계약의 주소를 넣어줬다.
777%3은 0이다. 따라서 account가 당첨자가 되어야 한다. 이를 확인해보는 assert 문장들을 보자.
먼저 lottery의 recentWinner와 account가 일치하는지 확인한다.
다음으로 lottery의 잔고가 0인지 확인한다.
마지막으로 account의 잔고가 lottery의 잔고만큼 늘었는지 확인한다.(초기 잔고에 lottery의 잔고를 더한 값이 계좌의 현재 잔고와 일치하는지 확인한다.)

unit test 코드 작성이 종료되었다. 이번에는 integration test를 위해 test_lottery_integration.py를 tests 폴더에 추가하고 스크립트를 채워주자.

from scripts.helpful_scripts import (
    get_account,
    fund_with_link,
    LOCAL_BLOCKCHAIN_ENVIRONMENTS,
)
import time
from brownie import network
import pytest


def test_can_pick_winner(lottery_contract):
    if network.show_active() in LOCAL_BLOCKCHAIN_ENVIRONMENTS:
        pytest.skip()

    account = get_account()
    lottery_contract.startLottery({"from": account})
    lottery_contract.enter(
        {"from": account, "value": lottery_contract.getEntranceFee()}
    )
    lottery_contract.enter(
        {"from": account, "value": lottery_contract.getEntranceFee()}
    )
    fund_with_link(lottery_contract)
    lottery_contract.endLottery({"from": account})
    time.sleep(180)
    assert lottery_contract.recentWinner() == account
    assert lottery_contract.balance() == 0

integration test는 아까 말했듯이 testnet에서 실행하기 위한 test이다.
test_can_pick_winner의 코드 내용을 살펴보자. 먼저 local 환경이 아님을, 즉 testnet이 활성화되어있음을 확인하고 testnet이 활성화된 경우에만 나머지 코드를 실행한다.
getaccount()로 받아온 계좌로 Lottery를 시작, 입장, 종료하고 있다.
Lottery 종료에는 앞서 말했듯 난수 발생으로 인해 시간이 조금 걸리므로 time.sleep을 추가해주었다.
account가 recentWinner에 해당하는지, lottery의 잔고가 0인지를 확인하여 당첨자(winner)가 제대로 선출되었음을 검증하며 함수가 종료된다.

마지막으로 tests폴더에 conftest.py를 추가하여 다음 코드를 입력해주자.

from scripts.helpful_scripts import (
    LOCAL_BLOCKCHAIN_ENVIRONMENTS,
)
from brownie import network
from scripts.deploy_lottery import deploy_lottery
import pytest


@pytest.fixture()
def lottery_contract():
    return deploy_lottery()

영상에서는 conftest.py를 채우지도, 충분히 설명해주지도 않는다. 필자가 알아보니 conftest.py 파일을 만들어 @pytest.fixture에 함수를 선언해주면 그 함수를 테스트 함수의 인자로 넣어 사용할 수 있다고 한다. conftest는 보다 간편하게 test를 진행할 수 있는 도구 정도로 이해하면 될 듯하다.

2시간 10분 분량의 포스팅을 마쳤다. 시작할 때는 암담했는데 막상 시작해서 열심히 해보니까 할 만했다. 잘 이해가 되지 않는 내용을 찾아가며 공부하는 재미도 느꼈다. 포스팅을 진행하며 스마트 컨트랙트 개발자가 갖추어야 할 역량에 대해 조금씩 이해해가고 있다. 포스팅을 통해 내가 기대했던 효과 이상을 느끼고 있어서 보람이 많이 느껴진다.
일이든, 공부든, 사람이든, 대상에 대한 사랑에서 열정이 나오고 그 열정에서 비롯되는 행동을 통해 배우고 느끼고 성장하는 듯하다. 밖에 비가 많이 오고 시간도 많이 늦었는데 다들 예쁜 빗소리 들으면서 빗소리만큼이나 예쁜 꿈 꾸길 바란다!

MIT License

Copyright (c) 2021 SmartContract

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
profile
BlockChain Researcher
post-custom-banner

0개의 댓글