블록체인 뿅뿅

체인링크 VRF를 활용한 추첨 컨트랙트 구현하기 (1)

bimppap 2024. 2. 4. 04:35

 

 

 

최근 회사 업무 중 급하게 아래 요구사항을 가진 업무를 받았다.

 

- 신청자를 받은 후 일정 인원의 당첨자를 뽑을 수 있다.
- 당첨자 목록을 확인할 수 있다.
- 위 기능이 블록체인 기술로 구현되어야 한다.
- 당첨차 추첨에는 체인링크 VRF가 사용되어야 한다.

 

즉, 당첨차 추첨 컨트랙트를 만들어 달라는 뜻이었다. 구현이 아주 어려운 건 아니었으나 개발에 필요한 시간이 짧았기에 꽤나 험난한 여정이 되었다. 마무리 단계를 거치고 있는 지금, 정리 겸 회고를 위해 개발 여정을 글로 담아보려고 한다. 글 작성에 관해선 회사에서 허락을 받았으며 혹시나를 대비해 모든 코드를 올리진 않을 예정이다.

 


 

체인링크 VRF에 관해서 아래 게시물들을 통해 충분히 설명을 했기 때문에 간략하게만 얘기를 하겠다.

 

체인링크 VRF 사용해보기 (1) 체인링크란?

체인링크 VRF 사용해보기 (2) Subscription Manager 사용해보기

체인링크 VRF 사용해보기 (3) Subscriptionr과 컨트랙트 연동하기

 

체인링크는 오라클 문제를 해결하기 위한 미들웨어 플랫폼이며, 체인링크에서 제공하는 솔루션 중 하나인 VRF는 Verifiable Random Function의 줄임말로, 검증 가능한 무작위의 난수를 제공하는 기능이다. 검증 가능하다는 뜻은, 조작이 어느 곳에서도 불가능하기 때문에(아주 불가능한 건 아니지만 전세계에서 블록체인에 사용되는 컴퓨터를 과반수 해킹할 수 있는 사람은 존재하지 않는다고 생각한다.) 신뢰할 수 있다는 말과 같다. 당첨자 추첨 컨트랙트는 이 VRF를 활용해 무작위로 당첨자를 뽑을 예정이다.

 

처음 작성한 건 아래 기능들을 가지고 있었다. 체인링크에서 제공하는 VRF 샘플 코드에서 크게 변하지 않았고, 실제론 사용되지 않은 코드이기에 편하게 공유한다. (그나저나 티스토리는 언제쯤 코드 블록에서 Solidity를 지원할까...)

 

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

import "@chainlink/contracts/src/v0.8/interfaces/VRFCoordinatorV2Interface.sol";
import "@chainlink/contracts/src/v0.8/vrf/VRFConsumerBaseV2.sol";
import "hardhat/console.sol";

contract VRFLottery is VRFConsumerBaseV2 {
    uint256 private constant ROLL_IN_PROGRESS = 42;
    VRFCoordinatorV2Interface COORDINATOR;
    uint64 s_subscriptionId;
    
    // mumbai
    address vrfCoordinator = 0x7a1BaC17Ccc5b313516C5E16fb24f7659aA5ebed;
    bytes32 s_keyHash = 0x4b09e658ed251bcafeebbc69400383d49f344ace09b9576fe248bb02c003fe9f;
    
    uint32 callbackGasLimit;
    uint16 requestConfirmations;
    uint32 numWords;
    address s_owner;

    mapping(uint256 => address) private s_rollers;
    mapping(address => uint256[]) private s_results;

    event DiceRolled(uint256 indexed requestId, address indexed roller);
    event DiceLanded(uint256 indexed requestId, uint256[] indexed result);

    address[] public applicants;

    constructor(uint64 subscriptionId, address _vrfCoordinator, bytes32 keyHash) VRFConsumerBaseV2(vrfCoordinator) {
        COORDINATOR = VRFCoordinatorV2Interface(vrfCoordinator);
        s_owner = msg.sender;
        s_subscriptionId = subscriptionId;
        callbackGasLimit = 2500000;
        requestConfirmations = 5;
        numWords = 5;
    }
    
    // VRF 세팅
    function setInfo(uint32 _callbackGasLimit, uint32 _numWords, uint16 _requestConfirmations) public onlyOwner {
        callbackGasLimit = _callbackGasLimit;
        numWords = _numWords;
        requestConfirmations = _requestConfirmations;
    }
    
    // 신청자 세팅
    function setApplicants(address[] memory _applicants) public onlyOwner {
        applicants = _applicants;
    }
    
    // 추첨
    function rollDice(
        address roller
    ) public returns (uint256 requestId) {
        require(s_results[roller].length == 0, "Already rolled");
        requestId = COORDINATOR.requestRandomWords(
            s_keyHash,
            s_subscriptionId,
            requestConfirmations,
            callbackGasLimit,
            numWords
        );

        s_rollers[requestId] = roller;
        s_results[roller] = [ROLL_IN_PROGRESS];

        emit DiceRolled(requestId, roller);
    }
    
    // 난수 생성
    function fulfillRandomWords(
        uint256 requestId,
        uint256[] memory randomWords
    ) internal override {
        uint256[] memory randomNums = new uint256[](randomWords.length);

        for(uint256 i = 0; i < randomWords.length; i++) {
            randomNums[i] = (randomWords[i] % applicants.length + 1);
        }

        s_results[s_rollers[requestId]] = randomNums;

        emit DiceLanded(requestId, randomNums);
    }
    
    // 당첨자 목록 가져오기
    function winners(address player) public view returns (address[] memory) {
        require(s_results[player].length != 0, "Dice not rolled");
        require(!(s_results[player].length == 1 && s_results[player][0] == ROLL_IN_PROGRESS), "Roll in progress");

        return getWinners(s_results[player]);
    }

    function getWinners(uint256[] memory ids) private view returns (address[] memory) {
        address[] memory winnerList = new address[](ids.length);

        for(uint256 i = 0; i < ids.length; i++) {
            winnerList[i] = applicants[ids[i]];
        }
        
        return winnerList;
    }

    modifier onlyOwner() {
        require(msg.sender == s_owner);
        _;
    }
}

 

 

로컬에서도 잘 돌아가고, 테스트넷에서도 잘 작동했다. '아, 너무 쉬운 거 아냐?' 라는 사망플래그를 세우며 약 일주일을 설렁설렁 보냈다. (진짜 설렁설렁은 아니고, 다른 업무들이 많아 그리 신경을 많이 쓰질 못했다...) 그런데 일주일을 보내고, 상용 서비스까지 일주일이 남은 시점에서, 추가 요구사항이 들어왔다.

 

- 신청자는 약 1-2만명을 예상하고 있다.
- 당첨을 확인해야할 분야가 2개 이상이다.
- 신청자는 각 분야에서 자신의 당첨 여부를 확인할 수 있다.

 

 

여기서 예상하지 못한 건, 신청자가 만 명 단위일 줄은 몰랐다는 것이다. 게다가 당첨을 한번만 하는 것이 아니라 여러 개를 해야한다...? 그런데 각 추첨마다 신청자가 자신의 당첨 여부를 확인할 수 있다...? 이거 괜찮을 걸까...? 라는 불안감이 스멀스멀 올라오기 시작했다. 불안감은 금방 현실화가 되었는데, 받은 요구사항에 따라 가스비 계산을 해보니 가스비로 몇백만원이 쓰일 수 있다는 결론에 다다랐다. 때문에 이런 제안도 해보았다.

 

"이러면 폴리곤 네트워크라 하더라도 가스비가 꽤 나올 거에요. 트랜잭션을 여러 번 날려야 해서 시간도 오래 걸릴 테고요. 신청자들에게 미리 추첨번호를 공지한 후, VRF로 랜덤 넘버를 뽑아서 공유하면 되지 않을까요? 이러면 몇백만 원이 아니라 몇천 원만 들게 될 거에요."

 

그러나 더욱 투명하고 공정한 추첨을 위해 온체인에서 모든 게 이루어지도록 하고 싶다, 라는 답변과 함께 제안은 수용되지 않았다. 대신 가스비가 최대한 덜 들도록 개선시켜보라는 답을 받았다. 추가 요구사항과 더불어 가스비 효율에 대해서도 고민해야 하는데 남은 시간은 일주일이었다. 회사를 다니면서 미국행 비행기표를 사고 도망가고 싶다는 생각이 든 건 처음이었다.

 

뭐, 결론부터 말하자면 미국행 비행기표는 사지 않았다. 컨트랙트는 일주일을 꽉 채워서 완성되었고, 가스비는 약 10배를 감량시켰다. 100배 감량을 못 시킨 건 아쉽지만 기간 내에 이 정도로 해낸 것도 잘 했다 생각한다. 어떻게 했는지는 다음 글에서 좀 더 자세히 설명하도록 하겠다.