Web3 POC - Decentralized Antique Auction

After I finished my first dApp "Decentralized Bookstore", I have been searching for a more web3 native use case. Inspired by this news article, I decided to develop a POC dApp to learn and explore:

  • How to create/sell/buy an NFT linked to a physical asset (e.g. antique)?
  • NFT standards: ERC721 and ERC2981
  • Key business logic and potential user experiences for decentralized auction

Use Cases

The POC dApp is called "Decentralized Antique Auction", allowing anyone to auction/bid for antiques using cryptocurrencies. An NFT is minted the first time when the antique is listed by its initial owner with a starting price, a reserve price, a royalty percentage and the auction end date/time. Once listed, others can place bids before the auction ends. When the auction ends,

  • If the highest bid meets or exceeds the reserve price set by the current owner, the highest bid amount, minus the royalty fee sent to the contract owner, will be transferred to the current owner and, at the same time, the Antique NFT will be transferred to the highest bidder which will enable the new NFT owner to claim the physical antique from the current owner.
  • All unsuccessful bids can be withdrawn by bidders as all bidding amounts are safely kept by AntiqueMarketplace smart contract.

Sell Antique

An antique owner (Account 1) sells a Ceramic Guan Yin Statue from Qing Dynasty at the dApp.

Sell Antique - 1 of 6
Sell Antique - 1 of 6
Sell Antique - 2 of 6
Sell Antique - 2 of 6
Sell Antique - 3 of 6
Sell Antique - 3 of 6
Sell Antique - 4 of 6
Sell Antique - 4 of 6
Sell Antique - 5 of 6
Sell Antique - 5 of 6
Sell Antique - 6 of 6
Sell Antique - 6 of 6

Import NFT

The antique owner imports the minted NFT in the MetaMask wallet.

Import NFT - 1 of 3
Import NFT - 1 of 3
Import NFT - 2 of 3
Import NFT - 2 of 3
Import NFT - 3 of 3
Import NFT - 3 of 3

Bid Antique - Fist Bid

The first bidder (Account 2) bids at starting price.

Bid Antique - Fist Bid - 1 of 4
Bid Antique - Fist Bid - 1 of 4

Bid Antique - Fist Bid - 2 of 4
Bid Antique - Fist Bid - 2 of 4
Bid Antique - Fist Bid - 3 of 4
Bid Antique - Fist Bid - 3 of 4
Bid Antique - Fist Bid - 3 of 4
Bid Antique - Fist Bid - 3 of 4

Bid Antique - Second Bid

The second bidder (Account 3) bids at the reserve price.

Bid Antique - Second Bid - 1 of 4
Bid Antique - Second Bid - 1 of 4
Bid Antique - Second Bid - 2 of 4
Bid Antique - Second Bid - 2 of 4
Bid Antique - Second Bid - 3 of 4
Bid Antique - Second Bid - 3 of 4
Bid Antique - Second Bid - 4 of 4
Bid Antique - Second Bid - 4 of 4

End Auction

At the auction end date, the seller (Account 1) ends the auction, after which the antique is removed from the auction list.

End Auction - 1 of 4
End Auction - 1 of 4
End Auction - 2 of 4
End Auction - 2 of 4
End Auction - 3 of 4
End Auction - 3 of 4
End Auction - 4 of 4
End Auction - 4 of 4

Resell Antique

From My Bids tab, the new antique owner (Account 3) can resell the newly acquired antique. Since the Antique NFT is already minted, its meta data (e.g. name, description, image and sale royalty) cannot be changed.

Resell Antique - 1 of 6
Resell Antique - 1 of 6
Resell Antique - 2 of 6
Resell Antique - 2 of 6
Resell Antique - 3 of 6
Resell Antique - 3 of 6
Resell Antique - 4 of 6
Resell Antique - 4 of 6
Resell Antique - 5 of 6
Resell Antique - 5 of 6
Resell Antique - 6 of 6
Resell Antique - 6 of 6

Withdraw

After auction ends, the failed bidder (Account 2) withdraws the bid amount held by smart contract.

Withdraw - 1 of 3
Withdraw - 1 of 3
Withdraw - 2 of 3
Withdraw - 2 of 3
Withdraw - 3 of 3
Withdraw - 3 of 3

ERC-721 - Non-Fungible Token Standard

What is a Non-Fungible Token?

Fungible means to be the same or interchangeable. For example, Ethereum tokens, all the members of a particular token class, have the same value. The same can be said of Cardano tokens. Fungible tokens are interchangeable 1:1.

With this in mind, NFTs are unique; each one is different. Every single token has unique characteristics and values. The types of things that can be NFTs are collectible cards, artworks, airplane tickets, etc. They are all clearly distinguishable from one another and are not interchangeable. Think of Non-Fungible Tokens (NFTs) as rare collectibles; each has unique characteristics, unusual attributes, and most times, its metadata.

What is ERC-721?

ERC stands for Ethereum Request for Comment, and 721 is the proposal identifier number. ERCs are application-level standards in the Ethereum ecosystem, they can be a smart contract standard for tokens such as ERC-20, the author of an ERC is responsible for building consensus with the Ethereum community and once the proposal is reviewed and approved by the community it becomes a standard. You can track the recent ERC proposal here. ERC-721 was created to propose the functionality to track and transfer NFTs within smart contracts.

ERC-721 is an open standard that describes how to build Non-Fungible tokens on EVM (Ethereum Virtual Machine) compatible blockchains; it is a standard interface for Non-Fungible tokens; it has a set of rules which make it easy to work with NFTs. NFTs are not only of ERC-721 type; they can also be ERC-1155 tokens.

Digital collectibles compatible with the ERC-721 standard have become very popular since the launch of Cryptokitties and have moved forward towards mass adoption in recent years. Here is a brief history of the ERC-721 standard and NFT:

  • September 2017 - Dieter Shirley introduces EIP721.
  • December 2017 - CryptoKitties is so popular that it congests the Ethereum network, causing it to slow down significantly.
  • December 2017 - NFT marketplace OpenSea launches. By 2022 it is the largest NFT marketplace, with $5 billion in monthly sales.
  • June 2018 - ERC-721 is accepted as ‘final’, which means there is a strong consensus among Ethereum developers to accept it as a standard.
  • May 2019 - Nike files and is awarded a patent that utilizes ERC-721 standard.
  • February 2020 - Decentraland, a virtual world built using ERC-721 NFTs to represent land and virtual objects, launches.
  • March 2021 - Beeple’s "EVERYDAYS: THE FIRST 5,000 DAYS" NFT sells for $69.3 million at Christie’s auction house.
  • December 2021 - NFT sales in 2021 reached $25 billion.

How to create an ERC-721 based Antique NFT?

Before creating our NFT, we need to host our art for NFT and create a metadata file; for this, we’ll use IPFS - a peer-to-peer file storing and sharing distributed system. This happens at screenshot “Sell Antique - 3 of 6” after Submit button is clicked, enabled by the following frontend code:

import createNFTMetaDataURIViaPinata from '../libs/createNFTMetaDataURI'

...

const nftData = {
  receiverAddress: this.userData.address,
  metaData: {
    name: this.name,
    description: this.description,
    image: this.file,
    attributes: [
      {
        trait_type: 'creator',
        value: this.userData.address,
      },
      {
        trait_type: 'royalty',
        value: this.royalty + '%',
      },
      {
        trait_type: 'createdAt',
        value: new Date().toUTCString(),
      },
    ],
  },
}
console.log(`nftData: ${JSON.stringify(nftData)}`)
return new Promise((resolve, reject) => {
  createNFTMetaDataURIViaPinata(nftData, (result) => {
    resolve(result)
  }).catch((error) => {
    console.error(error)
    reject(error)
  })
})
import axios from 'axios'
import pinataSDK from '@pinata/sdk'

const PINATA_API_KEY = import.meta.env.VITE_PINATA_API_KEY
const PINATA_SECRET_API_KEY = import.meta.env.VITE_PINATA_SECRET_API_KEY
const pinata = pinataSDK(PINATA_API_KEY, PINATA_SECRET_API_KEY)

const createNFTMetaDataURI = async function (nftData, callback) {
  const nftMetaData = nftData.metaData
  console.log(nftMetaData)

  // convert file into binary
  const data = new FormData()
  data.append('title', nftMetaData.image.name)
  data.append('file', nftMetaData.image)

  const url = 'https://api.pinata.cloud/pinning/pinFileToIPFS'
  // pass binary data into post request
  const result = await axios.post(url, data, {
    maxContentLength: -1,
    headers: {
      'Content-Type': `multipart/form-data; boundary=${data._boundary}`,
      pinata_api_key: PINATA_API_KEY,
      pinata_secret_api_key: PINATA_SECRET_API_KEY,
      path: 'd-antique-auction',
    },
  })

  nftMetaData.image = `ipfs://${result.data.IpfsHash}`

  //Call Pinata to get NFT metadata uri
  const options = {
    pinataMetadata: {
      name: nftMetaData.name,
    },
    pinataOptions: {
      cidVersion: 0,
    },
  }

  pinata
    .pinJSONToIPFS(nftMetaData, options)
    .then((result) => {
      //handle results here
      console.log(`pinJSONToIPFS:\n${JSON.stringify(result, 0, 2)}`)
      const uri = 'ipfs://' + result.IpfsHash
      callback(uri)
    })
    .catch((err) => {
      //handle error here
      console.error(err)
      throw err
    })
}

export default createNFTMetaDataURI 

Once we obtained tokenMetaDataURI, we can mint Antique NFT easily, thanks to OpenZeppelin/openzeppelin-contracts library.

import '@openzeppelin/contracts/token/ERC721/ERC721.sol';
import '@openzeppelin/contracts/utils/Counters.sol';
import '@openzeppelin/contracts/access/Ownable.sol';
import '@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol';

contract AntiqueNFT is
    ERC721URIStorage,
    Ownable
{
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    constructor() ERC721('My Antiques', 'ANTIQUE-NFT') {
    }

    function mintNFT(address recipient, string memory tokenURI)
        public
        override
        returns (uint256)
    {
        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(recipient, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }
}

ERC-2981 - NFT Royalty Standard

What is ERC-2981?

Finalized on July 24, 2021, ERC-2981 is a standard that focuses on signalling to market participants the royalty payment information for a given NFT or set of NFTs. It allows for attaching royalty information to the token itself, thereby making use of a common data store and computation layer that all participants can access — the Ethereum blockchain.

How Does it Work?

It’s pretty simple. The crux of the problem can be solved by overriding a single Solidity function in your smart contract.

interface IERC2981 {

    /// @notice Called with the sale price to determine how much royalty is owed and to whom.
    /// @param _tokenId - the NFT asset queried for royalty information
    /// @param _salePrice - the sale price of the NFT asset specified by _tokenId
    /// @return receiver - address of who should be sent the royalty payment
    /// @return royaltyAmount - the royalty payment amount for _salePrice
    function royaltyInfo(
        uint256 _tokenId,
        uint256 _salePrice
    ) external view returns (
        address receiver,
        uint256 royaltyAmount
    );
}

In this POC I followed this example implementation and implemented Antique NFT royaltyies on a per token basis.

contract AntiqueNFT is
    ERC721URIStorage,
    Ownable,
    ERC2981PerTokenRoyalties
{
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    address public marketContract;

    constructor(address _marketContract) ERC721('My Antiques', 'ANTIQUE-NFT') {
    }

    /// @inheritdoc     ERC165
    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(IERC165, ERC721, ERC2981Base)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }

    /// @notice Mint one token to `to`
    /// @param to the recipient of the token
    /// @param royaltyRecipient the recipient for royalties (if royaltyValue > 0)
    /// @param royaltyValue the royalties asked for (EIP2981)
    function mintNFTWithRoyalty(
        address to,
        address royaltyRecipient,
        uint256 royaltyValue,
        string memory tokenURI
    ) public override returns (uint256) {
        uint256 tokenId = mintNFT(to, tokenURI);

        if (royaltyValue > 0) {
            _setTokenRoyalty(tokenId, royaltyRecipient, royaltyValue);
        }
        return tokenId;
    }
}

Unlike artworks, usually there are no real authors or IP owners for antiques. In the POC, whenever an auction successfully ends, the royalty is received by the contract owner, i.e., the account used to deploy the smart contracts.

function sellAntique(
    string memory tokenURI,
    uint256 startingPrice,
    uint256 reservePrice,
    uint256 royaltyValue,
    uint256 auctionEndTime
) public returns (bool success) {
    AnyNFT anyNFT = AnyNFT(nftContract);
    uint256 tokenId = anyNFT.mintNFTWithRoyalty(
        msg.sender,
        contractOwner,
        royaltyValue,
        tokenURI
    );

    return
        _newAntique(
            tokenId,
            tokenURI,
            startingPrice,
            reservePrice,
            auctionEndTime
        );
}
contract AntiqueNFT is
    ERC721URIStorage,
    Ownable,
    ERC2981PerTokenRoyalties
{
...
    /// @inheritdoc     ERC165
    function supportsInterface(bytes4 interfaceId)
        public
        view
        virtual
        override(IERC165, ERC721, ERC2981Base)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }

    function mintNFT(address recipient, string memory tokenURI)
        public
        override
        returns (uint256)
    {
        _tokenIds.increment();

        uint256 newItemId = _tokenIds.current();
        _mint(recipient, newItemId);
        _setTokenURI(newItemId, tokenURI);

        return newItemId;
    }

    /// @notice Mint one token to `to`
    /// @param to the recipient of the token
    /// @param royaltyRecipient the recipient for royalties (if royaltyValue > 0)
    /// @param royaltyValue the royalties asked for (EIP2981)
    function mintNFTWithRoyalty(
        address to,
        address royaltyRecipient,
        uint256 royaltyValue,
        string memory tokenURI
    ) public override returns (uint256) {
        uint256 tokenId = mintNFT(to, tokenURI);

        if (royaltyValue > 0) {
            _setTokenRoyalty(tokenId, royaltyRecipient, royaltyValue);
        }
        return tokenId;
    }
...
}

In real scenarios, before being listed in AntiqueMarketplace, an antique should have been authenticated by an expert or organization which might be the right royalty receiver. The antique authentication info should be embedded in the token metadata for anyone to verify its authenticity.

Two Smart Contracts - AntiqueMarketplace and AntiqueNFT

As EVM Smart Contract has a size limit of 24K, the backend logic has to be split to two smart contracts: AntiqueMarketplace and AntiqueNFT. To check smart contract sizes, run:

yarn size
  or
$ truffle run contract-size --checkMaxSize
Sample Output of Checking Contract Sizes
Sample Output of Checking Contract Sizes

AntiqueMarketplace and AntiqueNFT have to work closely to properly support the business logic. I use Dependency Injection design pattern to correctly set up both contracts at the deployment time.

AnyNFT.sol:

import '@openzeppelin/contracts/token/ERC721/IERC721.sol';
import './IERC2981Royalties.sol';

abstract contract AnyNFT is IERC721, IERC2981Royalties {
    function mintNFT(address recipient, string memory tokenURI)
        public
        virtual
        returns (uint256);

    function mintNFTWithRoyalty(
        address to,
        address royaltyRecipient,
        uint256 royaltyValue,
        string memory tokenURI
    ) public virtual returns (uint256);
}

abstract contract AnyNFTMarket {
    function NFTContractDeployed(address _nftContract)
        public
        virtual
        returns (bool);

    function endAntiqueAuction(uint256 _antiqueId)
        public
        virtual
        returns (
            address,
            address,
            uint256,
            address,
            uint256
        );
}

AntiqueNFT.sol:

contract AntiqueNFT is
    AnyNFT,
    ERC721URIStorage,
    Ownable,
    ERC2981PerTokenRoyalties
{
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;
    address public marketContract;

    constructor(address _marketContract) ERC721('My Antiques', 'ANTIQUE-NFT') {
        marketContract = _marketContract;
        AnyNFTMarket market = AnyNFTMarket(marketContract);
        market.NFTContractDeployed(address(this));
    }
...
}

AntiqueMarketplace.sol:

contract AntiqueMarketplace is AnyNFTMarket {
    
    address public nftContract;
    
    function NFTContractDeployed(address _nftContract)
        public
        override
        returns (bool)
    {
        nftContract = _nftContract;
        return true;
    }
...
}

2_deploy_contracts.js:

var AntiqueNFT = artifacts.require('./AntiqueNFT.sol')
var AntiqueMarketplaceContract = artifacts.require('./AntiqueMarketplace.sol')

module.exports = function (deployer) {
  deployer.deploy(AntiqueMarketplaceContract).then(function () {
    return deployer.deploy(AntiqueNFT, AntiqueMarketplaceContract.address)
  })
}

Conclusion

Some Thoughts on Antique Auction

  • As all bidding amounts for any antique listing are kept in AntiqueMarketplace smart contract until the auction ends, the smart contract will hold a float of cryptocurrencies, which might generate additional income for the contract owner via DeFi. It would be interesting to see how antique (or physical collectibles in general) industry will leverage web3 and NFT to create new business models in the coming years.
  • For a smoother user experience, the failed bids could have been automatically transferred back to the bidders’ accounts inside the method endAuction of AntiqueMarketplace smart contract. But it might cost significant gas fees for the owner, especially when there are lots of bids. A blockchain with very low gas fees (e.g., Solana) or no gas fee (e.g., Hyperledger Besu) is highly desirable to create a better UX.

Where to find the POC dApp/NFT?

Hope you have enjoyed reading and will find this POC useful. If you have any question/comment on the code, feel free to reach out to me via twitter: @inflaton_sg. Have a good one!

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