Lottery on the Ethereum Blockchain

Posted by

Cryptocurrencies and blockchain are one of the top technological trends of our times. They might or might not be a good investment, but none can deny that they are based on solid technology. In this article, I describe my attempt at writing code for a smart contract-based lottery on the Ethereum blockchain using Solidity.

What is a Smart Contract?

In simple words, a smart contract contains code that is executed under specified conditions. Once published on the blockchain, they are immutable and the code that it contains is available for everyone to examine. Smart contracts can be used to exchange anything of value in a transparent manner and avoids the middleman. When an entity interacts (send/receive ether or data) with a smart contract, that interaction is published on the blockchain as a transaction.

Lottery Design and Code

The smart contract is deployed by an externally-owned account which serves as the contract owner. The owner decides their service fees (must be equal to or below 15%) and how many players will play the lottery (minimum 3 and maximum 255).

pragma solidity ^0.5.11;

contract EnpmLottery {
    address payable owner;
    uint8 public ownerFeePercent;
    uint8 public totalAllowedPlayers;
    enum LotteryState {FirstRound, SecondRound, Complete}
    LotteryState public lotteryRound;
    ...
    ...
    constructor(uint8 _totalAllowedPlayers, uint8 _ownerFeePercent) public {
        owner = msg.sender;
        totalAllowedPlayers = _totalAllowedPlayers;
        ownerFeePercent = _ownerFeePercent;
        lotteryRound = LotteryState.FirstRound;

        // Minimum of 3 players
        require(totalAllowedPlayers >= 3);
        // Owner's service fee % must be more than or equal to 0% and
        // cannot be more than 15%
        require(ownerFeePercent >= 0 && ownerFeePercent <= 15);
    }
}

The lottery consists of two rounds. In the first round, each player picks a random number but submits a keccak256 hash value to the smart contract. This hash value is calculated privately by the player using the following Solidity code:

keccak256(abi.encodePacked(<ethereumAddress>, <chosenRandomNumber>))

The above formula concatenates the player’s Ethereum address with their chosen random value and then hashes it using keccak256 hashing algorithm. It is important to not ask for a player’s chosen random number in the first round because when they submit it to the smart contract and the transaction is confirmed, their choice will be available on the blockchain for everyone to see. This gives undue advantage to those players who have not yet submitted their choices.

Along with the keccak256 hash value, a player must also submit a security deposit in the form of ether. If a player abruptly abandons the lottery, they forfeit their security deposit. A player’s security deposit must be more than or equal to what they bet on the lottery. Once the maximum number of players are reached, the smart contract logs an event that states that the second round has begun.

...
...
    mapping(address => players) internal participants;

    // Per player info recorded in structure
    struct players {
        uint8 _id;
        uint256 _deposit;
        uint8 _choice;
        bytes32 _choiceHash;
        address payable _ethAddr;
        uint256 _bet;
    }

    address[] firstRoundPlayerAddresses;
    uint8 internal firstRoundPlayerCount = 0;

    // Event to log end of first round
    event FirstRoundEnds (
        string _msg
    );
...
...
    function FirstRoundEnterHash(bytes32 _val) public payable {
        // Lottery must be in first round state
        require(lotteryRound == LotteryState.FirstRound);
        // Number of players != max allowed players
        require(firstRoundPlayerCount != totalAllowedPlayers);
        // A participant cannot change their hash once submitted
        require(participants[msg.sender]._choiceHash == 0);

        // Record hash
        participants[msg.sender] = players(firstRoundPlayerCount, msg.value, 0, _val, msg.sender, 0);
        
        // Record player
        firstRoundPlayerCount += 1;
        firstRoundPlayerAddresses.push(msg.sender);
        
        if (firstRoundPlayerCount == totalAllowedPlayers) {
            // End first round if max number of players are reached
            emit FirstRoundEnds("First round ends. Reveal round started.");
            lotteryRound = LotteryState.SecondRound;
        }
    }
...
...

In the second round (also called reveal round), the players submit their actual chosen random number and bet amount to the smart contract. A keccak256 hash is calculated from the submitted random number according to the previous formula and compared with the hash submitted by them in the first round. This ensures that a player has not changed their choice in the second round. The bet amount is added to the pot. Under normal operations, if a player does not send their chosen random number in this round they forfeit their security deposit.

...
...
    address[] secondRoundPlayerAddresses;
    uint256 public pot = 0;
    uint8 internal secondRoundPlayerCount = 0;
...
...
    function SecondRoundEnterNumber(uint8 _val) public payable {
        // Lottery must be in second round state
        require(lotteryRound == LotteryState.SecondRound);
        // Security deposit should be more than or equal to bet value
        require(participants[msg.sender]._deposit >= msg.value);
        
        // This event is used to calculate the hash value that is provided by the
        // player in the first round. Ideally, a player can calculate this value
        // from external sources. When using this event, the following require
        // statement must be commented out
        //emit DebugPayout(keccak256(abi.encodePacked(msg.sender, _val)));

        // Provided number must match the hash previously provided
        require(keccak256(abi.encodePacked(msg.sender, _val)) == participants[msg.sender]._choiceHash);
        
        // Record random number
        participants[msg.sender]._choice = _val;
        
        // Record player bet
        participants[msg.sender]._bet = msg.value;
        
        // Add to pot
        pot += msg.value;
        
        // Record players participating in second round
        secondRoundPlayerAddresses.push(msg.sender);
        
        // Record player
        secondRoundPlayerCount += 1;
    }

Once the owner decides that the second round is complete, the smart contract pseudo-randomly determines a winner. The security deposits are then returned to the second round players. The owner takes a fixed percentage as fees from the pot and the rest is transferred to the winner’s Ethereum address. Once the smart contract is terminated, any remaining ether in the contract (security deposits of first round players who abandoned the lottery) is transferred to the owner.

It is possible that the owner is also participating in the lottery. If the owner is malicious, they may choose to declare the lottery winner when it is profitable to do so. To mitigate this, the smart contract requires that atleast 80% of the first round players also participate in the second round. Otherwise, the lottery is terminated and the following actions are taken:

  • Bets are returned to participating second round players.
  • Security deposits are returned to all first round players.
...
...
    uint8 public requiredAttendancePercent = 80;
...
...
    // Event to log winner
    event Payout (
        uint8 _choice,
        uint256 _winnerPot,
        uint256 _ownerFees,
        address _ethAddr
    );

    // Event to alert insufficient players
    event InsufficientPlayers(
        string _msg
    );

    // Custom modifier
    modifier onlyOwner {
        require(owner == msg.sender);
        _;
    }
...
...
    function GetWinner() public payable onlyOwner {
        uint256 _ownerFees;
        uint256 _playerDeposit;
        
        uint8 _choiceSum = 0;
        
        if (secondRoundPlayerCount <= firstRoundPlayerCount * requiredAttendancePercent / 100) {
            // Lottery will function only if required percent of people reveal their numbers in the
            // second round. Else, lottery terminates and deposits are returned. This prevents the owner
            // from abruptly declaring a lottery winner.
            Terminate();
        }
        
        // Determine winner. Below is a simple algorithm to determine the winner, but this
        // is the place to be creative
        for (uint8 i = 0; i < secondRoundPlayerCount; i++) {
            _choiceSum += participants[secondRoundPlayerAddresses[i]]._choice;
            // Return player deposit
            _playerDeposit = participants[secondRoundPlayerAddresses[i]]._deposit;
            participants[secondRoundPlayerAddresses[i]]._ethAddr.transfer(_playerDeposit);
        }
        uint8 _winnerIndex = _choiceSum % secondRoundPlayerCount;

        // Calculate owner fees
        _ownerFees = (ownerFeePercent * pot) / 100;
        
        // Send pot - owner fee to winner
        participants[secondRoundPlayerAddresses[_winnerIndex]]._ethAddr.transfer(pot - _ownerFees);
        // Send fees to lottery owner
        owner.transfer(ownerFeePercent*pot/100);
        
        // Winner event
        emit Payout(participants[secondRoundPlayerAddresses[_winnerIndex]]._choice, pot - _ownerFees,
                                                 _ownerFees,
                                                 participants[secondRoundPlayerAddresses[_winnerIndex]]._ethAddr);
    
        lotteryRound = LotteryState.Complete;

        // Terminate contract
        Terminate();
    }
    
    // Destroy contract
    function Terminate() public onlyOwner {
        uint256 _playerDeposit;
        
        if(lotteryRound != LotteryState.Complete) {
            // Lottery was terminated abruptly by owner
            if(lotteryRound == LotteryState.SecondRound) {
                emit InsufficientPlayers("Second round did not have sufficient attendance. Returning bets and deposits");

                // Returning bet amounts
                uint256 _playerBet;
                for(uint8 i = 0; i < secondRoundPlayerCount; i++) {
                    _playerBet = participants[secondRoundPlayerAddresses[i]]._bet;
                    participants[secondRoundPlayerAddresses[i]]._ethAddr.transfer(_playerBet);
                }
            }
            
            // All deposits must be returned to owners
            for(uint8 i = 0; i < firstRoundPlayerCount; i++) {
                _playerDeposit = participants[firstRoundPlayerAddresses[i]]._deposit;
                participants[firstRoundPlayerAddresses[i]]._ethAddr.transfer(_playerDeposit);
            }
        }

        selfdestruct(owner);
    }

Final Smart Contract Code

pragma solidity ^0.5.11;

contract EnpmLottery {
    address payable owner;
    uint8 public ownerFeePercent;
    uint8 public totalAllowedPlayers;
    mapping(address => players) internal participants;
    address[] firstRoundPlayerAddresses;
    address[] secondRoundPlayerAddresses;
    enum LotteryState {FirstRound, SecondRound, Complete}
    LotteryState public lotteryRound;
    
    uint256 public pot = 0;
    uint8 public requiredAttendancePercent = 80;
    
    uint8 internal firstRoundPlayerCount = 0;
    uint8 internal secondRoundPlayerCount = 0;

    // Per player info recorded in structure
    struct players {
        uint8 _id;
        uint256 _deposit;
        uint8 _choice;
        bytes32 _choiceHash;
        address payable _ethAddr;
        uint256 _bet;
    }
    
    // Event to log winner
    event Payout (
        uint8 _choice,
        uint256 _winnerPot,
        uint256 _ownerFees,
        address _ethAddr
    );
    
    event DebugPayout (
        bytes32 expected
    );

    // Event to log end of first round
    event FirstRoundEnds (
        string _msg
    );
    
    // Event to alert insufficient players
    event InsufficientPlayers(
        string _msg
    );
    
    // Custom modifier
    modifier onlyOwner {
        require(owner == msg.sender);
        _;
    }

    constructor(uint8 _totalAllowedPlayers, uint8 _ownerFeePercent) public {
        owner = msg.sender;
        totalAllowedPlayers = _totalAllowedPlayers;
        ownerFeePercent = _ownerFeePercent;
        lotteryRound = LotteryState.FirstRound;
        
        // Minimum of 3 players
        require(totalAllowedPlayers >= 3);
        // Owner's service fee % must be more than or equal to 0% and
        // cannot be more than 15%
        require(ownerFeePercent >= 0 && ownerFeePercent <= 15);
    }

    function FirstRoundEnterHash(bytes32 _val) public payable {
        // Lottery must be in first round state
        require(lotteryRound == LotteryState.FirstRound);
        // Number of players != max allowed players
        require(firstRoundPlayerCount != totalAllowedPlayers);
        // A participant cannot change their hash once submitted
        require(participants[msg.sender]._choiceHash == 0);

        // Record hash
        participants[msg.sender] = players(firstRoundPlayerCount, msg.value, 0, _val, msg.sender, 0);
        
        // Record player
        firstRoundPlayerCount += 1;
        firstRoundPlayerAddresses.push(msg.sender);
        
        if (firstRoundPlayerCount == totalAllowedPlayers) {
            // End first round if max number of players are reached
            emit FirstRoundEnds("First round ends. Reveal round started.");
            lotteryRound = LotteryState.SecondRound;
        }
    }
    
    function SecondRoundEnterNumber(uint8 _val) public payable {
        // Lottery must be in second round state
        require(lotteryRound == LotteryState.SecondRound);
        // Security deposit should be more than or equal to bet value
        require(participants[msg.sender]._deposit >= msg.value);
        
        // This event is used to calculate the hash value that is provided by the
        // player in the first round. Ideally, a player can calculate this value
        // from external sources. When using this event, the following require
        // statement must be commented out
        //emit DebugPayout(keccak256(abi.encodePacked(msg.sender, _val)));

        // Provided number must match the hash previously provided
        require(keccak256(abi.encodePacked(msg.sender, _val)) == participants[msg.sender]._choiceHash);
        
        // Record random number
        participants[msg.sender]._choice = _val;
        
        // Record player bet
        participants[msg.sender]._bet = msg.value;
        
        // Add to pot
        pot += msg.value;
        
        // Record players participating in second round
        secondRoundPlayerAddresses.push(msg.sender);
        
        // Record player
        secondRoundPlayerCount += 1;
    }
        
    function GetWinner() public payable onlyOwner {
        uint256 _ownerFees;
        uint256 _playerDeposit;
        
        uint8 _choiceSum = 0;
        
        if (secondRoundPlayerCount <= firstRoundPlayerCount * requiredAttendancePercent / 100) {
            // Lottery will function only if required percent of people reveal their numbers in the
            // second round. Else, lottery terminates and deposits are returned. This prevents the owner
            // from abruptly declaring a lottery winner.
            Terminate();
        }
        
        // Determine winner. Below is a simple algorithm to determine the winner, but this
        // is the place to be creative
        for (uint8 i = 0; i < secondRoundPlayerCount; i++) {
            _choiceSum += participants[secondRoundPlayerAddresses[i]]._choice;
            // Return player deposit
            _playerDeposit = participants[secondRoundPlayerAddresses[i]]._deposit;
            participants[secondRoundPlayerAddresses[i]]._ethAddr.transfer(_playerDeposit);
        }
        uint8 _winnerIndex = _choiceSum % secondRoundPlayerCount;

        // Calculate owner fees
        _ownerFees = (ownerFeePercent * pot) / 100;
        
        // Send pot - owner fee to winner
        participants[secondRoundPlayerAddresses[_winnerIndex]]._ethAddr.transfer(pot - _ownerFees);
        // Send fees to lottery owner
        owner.transfer(ownerFeePercent*pot/100);
        
        // Winner event
        emit Payout(participants[secondRoundPlayerAddresses[_winnerIndex]]._choice, pot - _ownerFees,
                                                 _ownerFees,
                                                 participants[secondRoundPlayerAddresses[_winnerIndex]]._ethAddr);
    
        lotteryRound = LotteryState.Complete;

        // Terminate contract
        Terminate();
    }
    
    // Destroy contract
    function Terminate() public onlyOwner {
        uint256 _playerDeposit;
        
        if(lotteryRound != LotteryState.Complete) {
            // Lottery was terminated abruptly by owner
            if(lotteryRound == LotteryState.SecondRound) {
                emit InsufficientPlayers("Second round did not have sufficient attendance. Returning bets and deposits");

                // Returning bet amounts
                uint256 _playerBet;
                for(uint8 i = 0; i < secondRoundPlayerCount; i++) {
                    _playerBet = participants[secondRoundPlayerAddresses[i]]._bet;
                    participants[secondRoundPlayerAddresses[i]]._ethAddr.transfer(_playerBet);
                }
            }
            
            // All deposits must be returned to owners
            for(uint8 i = 0; i < firstRoundPlayerCount; i++) {
                _playerDeposit = participants[firstRoundPlayerAddresses[i]]._deposit;
                participants[firstRoundPlayerAddresses[i]]._ethAddr.transfer(_playerDeposit);
            }
        }

        selfdestruct(owner);
    }
}

Test Lottery in Action

Gas Consumption (1 owner, 3 players)

Deployment:

  • Transaction gas: 1445359
  • Execution gas: 1040239

First Round:

  • Transaction gas: 133796, 138596, 160772
  • Execution gas: 110348, 115148, 137324

Second Round:

  • Transaction gas: 109878, 99742, 99742
  • Execution gas: 88478, 78278, 78278

GetWinner:

  • Transaction gas: 62821
  • Execution gas: 65549

Possible Attacks

  • Sybil Attack

Thanks for reading!

In this article, I described my procedure for implementing a smart contracted-based lottery on the Ethereum blockchain using Solidity. I’ve tried to make sure the code is secure and mitigates any malicious behavior from players and the owner. If you believe the code is insecure, please let me know and I’ll make the changes.

Thank you for reading! If you have any questions, leave them in the comments section below and I’ll get back to you as soon as I can!

References

Leave a Reply

Your email address will not be published.