Introducing Auctions to Mirror

Today we're excited to officially introduce a new feature to the writers of Mirror: You can now embed reserve auctions for zNFTs into any entry!

The journey to get to this release is full of adventure. A month ago, Mint Fund had an idea to add timed auctions to the Zora Protocol, and decided to crowdfund the effort on Mirror. Mint Fund ran this crowdfund on March 5th, raising 7 ETH to hire a developer to build a reserve auction contract.

Building Auctions

Mirror and Zora worked together to outline a contract that would work with the Zora Protocol. Then, with the funds they raised, Mint Fund commissioned developer Billy Rennekamp to build an implementation of that outline.

This implementation was a promising first pass, but after we reviewed the code closely, we found at least 3 critical bugs and some other issues that needed to be patched before we could say that it was production ready. We did attempt to run the original version with a live auction, but it broke due to an interoperability issue that wasn't handled. Luckily, we were able to exploit a bug in the admin functionality, by creating a mock version of the Zora protocol, updating the Media address, and having the NFT and funds sent to our address, to retrieve the funds and the NFT. All of that is a feverish episode more appropriately described in its own post, but ultimately we were able to recover from the problem without loss of funds, and everyone involved was patient and gracious. It was yet another lesson in building and maintaining smart contracts on Ethereum, which requires extreme diligence.

Following the failed trial run, the Mirror team took full ownership of the auction contract, which involved allocating a few days for intensively rewriting, reviewing, and testing it. We arrived at a new version that we thought was tight from a security and UX perspective (developers: see note at end).

We then ran a high-value auction using this contract for the Generalist, including two animated NFTs by artist Jack Butcher:

The auction was successful, and has been covered by a few mainstream media outlets such as The Hustle, which has over 1.5 million subscribers. This speaks to the power of protocols, the fascinating work that's happening on Ethereum, and how it can touch creators in a real way. We're delighted to be able to build these open source tools for creators to use.

Auction in Action

Here is an example of the Mirror auction block in action. This auction was used for to auction the Mint Fund source code NFT.

Quick Walkthrough of its Design

The auction code is really well documented, and anyone who wants to understand the functionality at a deep level should be able to do so by reading it. Almost every line contains a comment that explains its purpose. Here I'll quickly take you through some of the setup.

It is open-sourced under the GPL3 license, which means anyone can use this code. It is compiled with Solidity version 0.6.8, so that it can be compiled and tested alongside Zora's contracts, and will be compatible.

// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity 0.6.8;
pragma experimental ABIEncoderV2;

A key part of the philosophy of this auction design is that it's immutable — i.e. it's not configurable or upgradable by admins. If we want to change the rules, we'll deploy a new version and that'll exist separately. These configuration settings are all constants declared at the top of the file.

// ============ Constants ============

// The minimum amount of time left in an auction after a new bid is created; 15 min.
uint16 public constant TIME_BUFFER = 900;
// The ETH needed above the current bid for a new bid to be valid; 0.001 ETH.
uint8 public constant MIN_BID_INCREMENT_PERCENT = 10;
// Interface constant for ERC721, to check values in constructor.
bytes4 private constant ERC721_INTERFACE_ID = 0x80ac58cd;
// Allows external read `getVersion()` to return a version for the auction.
uint256 private constant RESERVE_AUCTION_VERSION = 1;

There are also some settings that are configured when the contract is deployed, which allows them to be different based on test networks. One important setting here is the adminRecoveryAddress, which is a multisig that Mirror controls that is able to fish out funds and any NFTs from the auction contract if something goes wrong. This is functionality that will be turned off forever once the auction is proven to be absolutely secure.

// ============ Immutable Storage ============

// The address of the ERC721 contract for tokens auctioned via this contract.
address public immutable nftContract;
// The address of the WETH contract, so that ETH can be transferred via
// WETH if native ETH transfers fail.
address public immutable wethAddress;
// The address that initially is able to recover assets.
address public immutable adminRecoveryAddress;

The auction contract can also be paused, which again allows us to step in if there's anything wrong with the contract once it's running. This will be turned off when all admin functionality is disabled. The last thing we want is for funds or NFTs to be lost due to a bug, and so we want to be explicit about the admin having this capability for the first few auctions. We definitely don't want this capability in the long-run, however.

Finally, you can see that we store a mapping of all of the auctions that are currently running. Auctions are deleted from storage once they have finished running, to free up storage space on the Ethereum network.

// ============ Mutable Storage ============

/**
 * To start, there will be an admin account that can recover funds
 * if anything goes wrong. Later, this public flag will be irrevocably
 * set to false, removing any admin privileges forever.
 *
 * To check if admin recovery is enabled, call the public function `adminRecoveryEnabled()`.
 */
bool private _adminRecoveryEnabled;
/**
 * The account `adminRecoveryAddress` can also pause the contracts
 * while _adminRecoveryEnabled is enabled. This prevents people from using
 * the contract if there is a known problem with it.
 */
bool private _paused;

// A mapping of all of the auctions currently running.
mapping(uint256 => Auction) public auctions;

How Bidding Works

Create bid uses best practices in validating that the amount the user sends is the same amount that they intend to bid with — by adding an amount parameter to the createBid function, and requiring that it equal the implicit ETH value in msg.value. We also use OpenZeppelin's well-tested ReentrancyGuard function to block reentrancy in this function.

function createBid(uint256 tokenId, uint256 amount)
        external
        payable
        nonReentrant
        whenNotPaused
        auctionExists(tokenId)
        auctionNotExpired(tokenId)
    {
        // Validate that the user's expected bid value matches the ETH deposit.
        require(amount == msg.value, "Amount doesn't equal msg.value");
        require(amount > 0, "Amount must be greater than 0");

From there we check if there is already a bid on this auction. If not, we keep track of the time when the first bid was placed. This will allow us to known whether to extend or close the auction later. We also validate that the amount is greater than the auction's reserve price.

if (auctions[tokenId].amount == 0) {
    // If so, it is the first bid.
    auctions[tokenId].firstBidTime = block.timestamp;
    // We only need to check if the bid matches reserve bid for the first bid,
    // since future checks will need to be higher than any previous bid.
    require(
        amount >= auctions[tokenId].reservePrice,
        "Must bid reservePrice or more"
    );
 }

Otherwise, if this bid follows others in the auction, we ensure that it is at least 10% higher in value than the previous highest bid. We then transfer the previous bid amount to the previous bidder. Note: This means that we escrow funds for the highest bid for each auction, which differs from some other auction protocols, like Zora, which just require that there is an allowance on the given token.

} else {
    // Check that the new bid is sufficiently higher than the previous bid, by
    // the percentage defined as MIN_BID_INCREMENT_PERCENT.
    require(
        amount >=
            auctions[tokenId].amount.add(
                // Add 10% of the current bid to the current bid.
                auctions[tokenId]
                    .amount
                    .mul(MIN_BID_INCREMENT_PERCENT)
                    .div(100)
            ),
        "Must bid more than last bid by MIN_BID_INCREMENT_PERCENT amount"
    );

    // Refund the previous bidder.
    transferETHOrWETH(
        auctions[tokenId].bidder,
        auctions[tokenId].amount
    );
}

We then update the auction's current bid amount along with the bidder. If we're within 15 minutes of the end of the auction, this new bid will extend the auction so that it ends in 15 minutes. Therefore, we always keep the auction alive for at least 15 minutes, until there hasn't been a bid for at least 15 minutes.

// Update the current auction.
auctions[tokenId].amount = amount;
auctions[tokenId].bidder = msg.sender;
// Compare the auction's end time with the current time plus the 15 minute extension,
// to see whether we're near the auctions end and should extend the auction.
if (auctionEnds(tokenId) < block.timestamp.add(TIME_BUFFER)) {
    // We add onto the duration whenever time increment is required, so
    // that the auctionEnds at the current time plus the buffer.
    auctions[tokenId].duration += block.timestamp.add(TIME_BUFFER).sub(
        auctionEnds(tokenId)
    );
}

Finally, we emit an event to broadcast that an auction bid has been created.

// Emit the event that a bid has been made.
emit AuctionBid(tokenId, nftContract, msg.sender, amount);

Future Steps for the Zora Protocol

Zora is now going to take this work and expand it in ways that more fully utilize the Zora Protocol. While our version doesn't support bidding in any ERC20 token, it's quite tight and efficient. But many communities might want to run auctions in their own token, and that's where the full features of the Zora Protocol can be quite powerful. We look forward to seeing what Zora ends up deploying on this front.

Using Auctions on Mirror

If you earned and burned your $WRITE token to create a Mirror publication, you're now able to create an auction in your Mirror dashboard for any zNFT that you own. (If you haven't earned a $WRITE token yet, join $WRITE RACE.)

Here's how to do it:

  1. Mint any new or existing post on Mirror as an NFT. If you would like to mint an NFT that isn't a Mirror post, head over to zora.co and mint a zNFT through their UI.
  2. Go to mirror.xyz/dashboard/auctions and click "Create Auction"
  3. Enter parameters like duration, reserve price, recipient address, etc.
  4. Deploy the auction
  5. Grab some tea. Meditate. Answer emails. Ponder existential life questions. After a couple minutes, your auction should be deployed to the Ethereum network.
  6. At this point, you will be redirected to the auction detail page. From here, copy the embed code and paste it in any post on Mirror and voilà! You're ready to go.

Note to developers

Auditing companies such as Open Zeppelin and Trail of Bits are currently booked up until Q4 this year, and so it isn't feasible for us to get this formally audited by a reputable auditing firm in the near term. However, we do have a bug bounty — so if you're up for it, please review our code and report any issues you see, and we will grant rewards in ETH for doing so. The Mirror team will offer up to 10 ETH for bugs that could result in loss of assets.

Subscribe to dev.mirror.xyz
Receive the latest updates directly to your inbox.
Verification
This entry has been permanently stored onchain and signed by its creator.