Mastering the Solidity Blind Auction Contract (Example)

5/5 - (1 vote)
Mastering the Solidity Blind Auction Contract (Example)

This article continues on the Solidity Smart Contract Examples series, which implements a somewhat more complex process of a blind auction.

🌍 Recommended Tutorial: Smart Contracts with Solidity — Crash Course

Here, we’re walking through an example of a blind auction (original source).

  • We’ll first lay out the entire smart contract example without the comments for readability and development purposes.
  • Then we’ll dissect it part by part, analyze it and explain it.
  • Following this path, we’ll get a hands-on experience with smart contracts, as well as good practices in coding, understanding, and debugging smart contracts.

Smart contract – Blind Auction

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract BlindAuction {
    struct Bid {
        bytes32 blindedBid;
        uint deposit;
    }

    address payable public beneficiary;
    uint public biddingEnd;
    uint public revealEnd;
    bool public ended;

    mapping(address => Bid[]) public bids;

    address public highestBidder;
    uint public highestBid;

    mapping(address => uint) pendingReturns;

    event AuctionEnded(address winner, uint highestBid);

    error TooEarly(uint time);
    error TooLate(uint time);
    error AuctionEndAlreadyCalled();

    modifier onlyBefore(uint time) {
        if (block.timestamp >= time) revert TooLate(time - block.timestamp);
        _;
    }
    modifier onlyAfter(uint time) {
        if (block.timestamp <= time) revert TooEarly(time - block.timestamp);
        _;
    }

    constructor(
        uint biddingTime,
        uint revealTime,
        address payable beneficiaryAddress
    ) {
        beneficiary = beneficiaryAddress;
        biddingEnd = block.timestamp + biddingTime;
        revealEnd = biddingEnd + revealTime;
    }

    function blind_a_bid(uint value, bool fake, bytes32 secret) 
        public 
        pure 
        returns (bytes32){
        return keccak256(abi.encodePacked(value, fake, secret));
    }

    function bid(bytes32 blindedBid)
        external
        payable
        onlyBefore(biddingEnd)
    {
        bids[msg.sender].push(Bid({
            blindedBid: blindedBid,
            deposit: msg.value
        }));
    }

    function reveal(
        uint[] calldata values,
        bool[] calldata fakes,
        bytes32[] calldata secrets
    )
        external
        onlyAfter(biddingEnd)
        onlyBefore(revealEnd)
    {
        uint length = bids[msg.sender].length;
        require(values.length == length);
        require(fakes.length == length);
        require(secrets.length == length);

        uint refund;
        for (uint i = 0; i < length; i++) {
            Bid storage bidToCheck = bids[msg.sender][i];
            (uint value, bool fake, bytes32 secret) =
                    (values[i], fakes[i], secrets[i]);
            if (bidToCheck.blindedBid != keccak256(abi.encodePacked(value, fake, secret))) {
                continue;
            }
            refund += bidToCheck.deposit;
            if (!fake && bidToCheck.deposit >= value) {
                if (placeBid(msg.sender, value))
                    refund -= value;
            }
            bidToCheck.blindedBid = bytes32(0);
        }
        payable(msg.sender).transfer(refund);
    }

    function withdraw() external {
        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {
            pendingReturns[msg.sender] = 0;
            payable(msg.sender).transfer(amount);
        }
    }

    function auctionEnd()
        external
        onlyAfter(revealEnd)
    {
        if (ended) revert AuctionEndAlreadyCalled();
        emit AuctionEnded(highestBidder, highestBid);
        ended = true;
        beneficiary.transfer(highestBid);
    }

    function placeBid(address bidder, uint value) internal
            returns (bool success)
    {
        if (value <= highestBid) {
            return false;
        }
        if (highestBidder != address(0)) {
            pendingReturns[highestBidder] += highestBid;
        }
        highestBid = value;
        highestBidder = bidder;
        return true;
    }
}

Code breakdown and analysis

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.4;
contract BlindAuction {

The data structure for storing the data on a blinded bidder and his deposit.

    struct Bid {
        bytes32 blindedBid;
        uint deposit;
    }

State variables storing the beneficiary’s address and integer offsets for calculating the bidding end time, best bid reveal end time, and the flag marking the auction end.

    address payable public beneficiary;
    uint public biddingEnd;
    uint public revealEnd;
    bool public ended;

A mapping data structure that stores all the individual blinded bids of each bidder.

    mapping(address => Bid[]) public bids;

The data on the highest bid and the highest bidder.

    address public highestBidder;
    uint public highestBid;

Bids that are overbid will be made available for withdrawal.

    mapping(address => uint) pendingReturns;

The event declarations mark the auction ending, and the errors cover the cases of calling a function too early, too late, or after the auction ended.

    /// The function has been called too early.
    /// Try again at `time`.
    error TooEarly(uint time);
    /// The function has been called too late.
    /// It cannot be called after `time`.
    error TooLate(uint time);
    /// The function auctionEnd has already been called.
    error AuctionEndAlreadyCalled();

We use modifiers to validate the function arguments, i.e. the timestamp of the moment the function is executed. This way, we ensure that the functions are executed in order and at the appropriate phase of the auction, i.e. the contract’s lifecycle.

A modifier works by replacing the merge wildcard _; with the body of the function, and then by executing its original code and the merged code.

The merge wildcard can be placed before, in the middle, or after the modifier code.

    modifier onlyBefore(uint time) {
        if (block.timestamp >= time) revert TooLate(time - block.timestamp);
        _;
    }
    modifier onlyAfter(uint time) {
        if (block.timestamp <= time) revert TooEarly(time - block.timestamp);
        _;
    }

Our familiar friend, the constructor is a special function executed only once for each contract, during its creation/initialization.

The constructor sets the beneficiary address and calculates the end of the bidding phase and the reveal phase.

    constructor(
        uint biddingTime,
        uint revealTime,
        address payable beneficiaryAddress
    ) {
        beneficiary = beneficiaryAddress;
        biddingEnd = block.timestamp + biddingTime;
        revealEnd = biddingEnd + revealTime;
    }

The blind_a_bid function is my addition to the original example from the Solidity documentation. It would otherwise make no sense to have it in the blind auction smart contract, but I put it here to help us calculate blind bids.

    function blind_a_bid(uint value, bool fake, bytes32 secret) 
        public 
        pure 
        returns (bytes32){
        return keccak256(abi.encodePacked(value, fake, secret));
    }

A bid is blinded in two ways:

  1. The bid is represented by a hash produced by the keccak256 hash function, an implementation of the SHA-3 hash algorithm. Among other information, the bid hash hides the bid value. It is not possible to know the bid value until a later time when the bid is revealed. So far, only the deposit is known and required to make the bidder commit by depositing an amount with the bid.
  2. The bidder’s deposit may differ from the enclosed bid value, which might confuse the competition by making them estimate the bid value based on the deposit. The bid value cannot exceed the deposit, but it can go as low as 0. Another hashed information is a fake flag used to make a bid invalid deliberately. The other case when a bid is considered invalid is when it doesn’t exceed the highest bid.
    function bid(bytes32 blindedBid)
        external
        payable

Here we use a modifier onlyBefore with the argument biddingEnd.

        onlyBefore(biddingEnd)
    {

The bid is put into our mapping data structure for later processing. Currently, the bid is still closed and only the deposit is known.

        bids[msg.sender].push(Bid({
            blindedBid: blindedBid,
            deposit: msg.value
        }));
    }

The reveal(...) function is a central function of this smart contract: it reveals the blinded bids and refunds the correctly blinded invalid bids and all bids except for the highest bid.

    /// Reveal your blinded bids. You will get a refund for all
    /// correctly blinded invalid bids and for all bids except for
    /// the totally highest.
    function reveal(

Our arguments are complex ones, therefore we need to explicitly state their memory area, i.e. calldata.

        uint[] calldata values,
        bool[] calldata fakes,
        bytes32[] calldata secrets
    )

The function must be callable after the bidding end and before the revealing end.

        external
        onlyAfter(biddingEnd)
        onlyBefore(revealEnd)
    {

Each bid of a function caller/bidder is processed, and the length of the list of bids (calculated for each bidder) is stored in the variable length.

To process and reveal the bids, we need to have the same amount of clear, unmasked arguments, i.e. variables values, fakes, and secrets.

        uint length = bids[msg.sender].length;
        require(values.length == length);
        require(fakes.length == length);
        require(secrets.length == length);

        uint refund;

Iteration through every bid of the function caller/bidder.

        for (uint i = 0; i < length; i++) {

A reference to a bid is defined for more convenient access.

            Bid storage bidToCheck = bids[msg.sender][i];

A reference to input arguments’ elements is defined for more convenient access.

            (uint value, bool fake, bytes32 secret) =
                    (values[i], fakes[i], secrets[i]);

A received blinded bid is compared with a freshly blinded bid, based on three arguments: a (real bidding) value, a fake flag, and a secret. If the result differs from the received blinded bid, the bid is not revealed and the refund is skipped.

💡 Info: it is not possible to reveal the content behind a hash in a purely mathematical sense, because a hash function result is not reversible, meaning it is not possible to infer the original data from a hash result.

However, we can hash the arguments we assume to represent the original and if we get the same hash result, we know that the original data is correct and matches the data behind the blinded bid.

Theoretically speaking, multiple inputs to the same hash function may produce the same hash result; this property in a hash function is known as hash collision. However, with keccak256, and SHA-3 as a broader family, the likelihood of a hash collision is only theoretical, due to their inherent property of hash collision resistance.

If hashed value, fake, and secret do not match the blinded bid, the bid is not revealed and the refund is skipped.

            if (bidToCheck.blindedBid != keccak256(abi.encodePacked(value, fake, secret))) {
                continue;
            }

In case of a revealed bid, the deposit is added to the total amount for a refund.

            refund += bidToCheck.deposit;

Valid bid values are deducted from the total amount and the remainder will be made available for withdrawal.

            if (!fake && bidToCheck.deposit >= value) {
                if (placeBid(msg.sender, value))
                    refund -= value;
            }

Withdrawn bids are blocked from repeated withdrawal by setting the blinded bid to 0. It’s theoretically impossible for a bidder to send the correct value, fake, and secret arguments to reveal() function to match a blinded bid set to bytes32(0).

            bidToCheck.blindedBid = bytes32(0);
        }

 

Return the refund, i.e. the remainder after a successful bid is deducted, back to the bidder.

        payable(msg.sender).transfer(refund);
    }

Withdraw all the previously valid (highest) bids for a specific bidder.

    /// Withdraw a bid that was overbid.
    function withdraw() external {

Read the pending returns for a bidder (msg.sender) into the amount variable.

        uint amount = pendingReturns[msg.sender];
        if (amount > 0) {

We should set the pendingReturns variable to 0, otherwise, it’d be possible for a bidder/recipient to re-call the withdraw() function before transfer() function returns and, by doing so, request a withdrawal again.

By setting pendingReturns variable to 0, we implement a conditions → actions (effects) → interaction design pattern that prevents such unwanted behavior.

            pendingReturns[msg.sender] = 0;

Transfer the pending returns to the bidder/recipient.

            payable(msg.sender).transfer(amount);
        }
    }

💡 Note: the official Solidity documentation recommends dividing the interacting functions into three functional parts:

  • checking the conditions,
  • performing the actions (effects), and
  • interacting with other contracts.

The auctionEnd() function ends the auction and sends the highest bid to the beneficiary.

    function auctionEnd()
        external
        onlyAfter(revealEnd)
    {

Checking the conditions…

        if (ended) revert AuctionEndAlreadyCalled();

…performing the actions (effects)…

        emit AuctionEnded(highestBidder, highestBid);
        ended = true;

…and interacting with other contracts.

        beneficiary.transfer(highestBid);
    }

The placeBid(...) function is internally called from the reveal(...) function when a bid is valid. Internal function visibility makes the function available only from the inside of the original or derived contract.

    function placeBid(address bidder, uint value) internal
            returns (bool success)
    {

If a bid overbids the highest bid, then it will become the new highest bid. Otherwise, the bid will be skipped.

        if (value <= highestBid) {
            return false;
        }

The previous highest bidder was outbid and his bid is added to his previous bids reserved for a refund. A direct refund is considered a security risk due to the possibility to execute an untrusted contract.

Instead, the bidders (recipients) will withdraw their bids themselves by using withdraw() function below.

        if (highestBidder != address(0)) {
            pendingReturns[highestBidder] += highestBid;
        }

The new highest bidder and his bid are recorded; the event HighestBidIncreased is emitted carrying this information pair.

        highestBid = value;
        highestBidder = bidder;
        return true;
    }
}

Our smart contract example of a blind auction is a somewhat more complex example than the last one, the simple open auction.

The blind auction example enables us to bid an amount of currency to the beneficiary, simultaneously preventing other bidders from seeing the exact amount. We accomplish this by masking the bid, leaving only the deposit publicly visible.

However, the visibility of the bid will be allowed later, during the reveal phase. A bid amount can be lower or equal to the deposit, and the bid can also be deliberately invalidated by setting the fake flag to true.

When the contract instantiates via its constructor, it sets the auction end time, reveals the end time, and its beneficiary, i.e. beneficiary address. The contract has five features, implemented via dedicated functions: bidding, revealing, placing the bids, withdrawing the bids, and ending the auction.

💡 Note: the difference between bidding and placing the bids is that bidding only puts a bid in a bidder’s portfolio, i.e. mapping bids while placing the bid takes only the valid bids and validates them against the highest bid, as it did in the simple open bid example.

A newly placed bid is accepted only if its amount is strictly larger than the current highest bid. A new bid acceptance means that the current highest bid is added to the bidder’s balance for later withdrawal.

The new highest bidder becomes the current highest bidder and the new highest bid becomes the current highest bid.

Bid withdrawing returns all summed previous bids to each bidder (mapping pendingReturns).

Appendix – The Contract Arguments

This section contains additional information for running the contract. We should expect that our example accounts may change with each refresh/reload of Remix.

Our contract creation arguments are the bid phase (in seconds), the reveal phase (in seconds), and the beneficiary address (copy this line when deploying the example):

300, 300, 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

Contract Test Scenario

I utilized this excellent web app for secret generation (64 digits): https://www.browserling.com/tools/random-hex

  • Open auction duration (in seconds): 300
  • Reveal time duration (in seconds): 300
  • Beneficiary: 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

Testing/demonstration steps:

The Bid Phase

  1. Account 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2 blindly bids 10 Wei and deposits 12 Wei:
0xfee8f88b6d146b9c01c8451a51c151d719db0e8965abcd4f27a9c91833e16a6b
  1. Account 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db blindly bids 25 Wei and deposits 30 Wei:
0x108872ad459cb73a884d99cd2ddf8f583cbe77a500888f530b2ea6a806258749
  1. Account 0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB falsely bids 30 Wei and deposits 30 Wei:
0x02e3f010253a21db97ad6c302f7de9fdf65d0eb6931cfb4532f754afa1612528
  1. Account 0x617F2E2fD72FD9D5503197092aC168c91465E7f2 blindly bids 30 Wei and deposits 20 Wei:
0xd6d6a208ad6a4eca9538b70f6d28d6472418c776852d8cab6109969a12e4c6b2

The Reveal Phase

  1. Account 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2 reveals his bids:
[10], [false], [0xc57dae203273d61da1d7d78275618ff13e78022a9ace0a9b43ec3e95377f3ed2]
  1. Account 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db reveals his bids:
[25], [false], [0xeb944b2bab8f46fa9f77d1cd2cb84285b026c4d4038386865457a9df563c54cc]
  1. Account 0x78731D3Ca6b7E34aC0F824c42a7cC18A495cabaB reveals his bids:
[30], [true], [0xa3b1f6694045af12ac5f3cfbf775032e9df24fc337e93ae378886275b8f4cd03]
  1. Account 0x617F2E2fD72FD9D5503197092aC168c91465E7f2 reveals his bids:
[30], [false], [0xc5cf9cfe9230f16beb84ea417453c330366decc7378b9cd78db68ce1fb4a242b]

Conclusion

We continued our smart contract example series with this article that implements a blind auction.

First, we laid out clean source code (without any comments) for readability purposes. Who needs comments, looking at the Matrix is enough…

Second, we dissected the code, analyzed it, and explained each possibly non-trivial segment. Or did we? 😉

🌍 Recommended Tutorial: Blockchain Engineer — Income and Opportunity