Build a Web3 Ticketing System with NFTs and Disrupt Ticketmaster

Introduction

A popular and practical use case for NFTs is generating tickets to live events. Blockchains such as Ethereum can guarantee the ownership, originator, and authenticity of a digital item, effectively solving the problem of counterfeit tickets. While major players such as Ticketmaster struggle to mitigate scalpers (trying desperately to control who can resell tickets, where, and for how much) and ticket fraud—web3 already has a solution. The ticketing industry is ripe for disruption.

In this tutorial, we’ll look at how to create such a ticketing solution using ConsenSys Truffle, Infura, and the Infura NFT API. We’ll deploy a smart contract that acts as a ticketing service and creates tickets as ERC-20 non-fungible tokens (NFTs). We’ll also walk through a few architectures of potential frontends that could interface with the contract, and together function as an integrated, full-stack, web3 ticketing system.

Let’s get building!

Create an NFT ticketing system on Ethereum

The basic architecture of our system is intended to create a smart contract that issues our tickets as non-fungible tokens (NFTs). NFTs are perfect for what we want to build. They are provably unique digital tokens that allow us to ensure that every ticket is unique and cannot be copied or forged. This not only guarantees a secure ticketing experience for concertgoers, but also empowers artists (and event organizers) with greater control over ticket distribution, pricing, and resale. Using smart contracts and NFTs even allows for new revenue streams such as royalty payments and revenue sharing!

(If you need background info on any of these terms, blockchain technology, or web3 in general, check out this article on Learning to Become a Web3 Developer by Exploring the Web3 Stack).

Step 1: Install MetaMask

The first thing we’re going to do is set up a MetaMask wallet and add the Sepolia test network to it. MetaMask is the world’s most popular, secure, and easy to use self-custodial digital wallet.

First, download the MetaMask extension. After you install the extension, MetaMask will set up the wallet for you. In the process, you will be given a secret phrase. Keep that safe, and under no circumstances should you make it public.

Once you’ve set up MetaMask, click on the Network tab on the top-right. You will see an option to show/hide test networks.

Once you turn test networks on, you should be able to see the Sepolia test network in the drop-down menu. We want to use the Sepolia network so that we can deploy and test our system without spending any real money.

Step 2: Get some test ETH

In order to deploy our smart contract and interact with it, we will require some free test ETH. You can obtain free Sepolia ETH from the Sepolia faucet.

Once you fund your wallet, you should see a non-zero balance when you switch to the Sepolia test network on MetaMask.

Step 3: Install NPM and Node

Like all Ethereum dapps, we will build our project using node and npm. In case you don't have these installed on your local machine, you can do so here.

To ensure everything is working correctly, run the following command:

$ node -v

If all goes well, you should see a version number for Node.

Step 4: Sign up for an Infura account

In order to deploy our contract to the Sepolia network, we will need an Infura account. Infura gives us access to RPC endpoints which allow for fast, reliable, and easy access to the blockchain of our choice.

Sign up for a free account. Once you’ve created your account, navigate to the dashboard and select Create New Key.

For network, choose Web3 API and name it Ticketing System, or something of your choosing.

Once you click on Create, Infura will generate an API key for you and give you RPC endpoints to Ethereum, Goerli, Sepolia, L2s, and non-EVM L1s (and their corresponding testnets) automatically.

For this tutorial, we are only interested in the Sepolia RPC endpoint. This URL is of the form https://sepolia.infura.io/v3/←API KEY→

Step 5: Create a Node project and install necessary packages

Let's set up an empty project repository by running the following commands:

$ mkdir nft-ticketing && cd nft-ticketing
$ npm init -y

We will be using Truffle, a world-class development environment and testing framework for EVM smart contracts, to build and deploy our cryptocurrency smart contract. Install Truffle by running:

$ npm install —save truffle

We can now create a barebones Truffle project by running the following command:

$ npx truffle init

To check if everything works properly, run:

$ npx truffle test

We now have Truffle successfully configured. Let us next install the OpenZeppelin contracts package. This package will give us access to the ERC-721 base implementation (the standard for non-fungible tokens) as well as a few helpful additional functionalities.

$ npm install @openzeppelin/contracts

To allow Truffle to use our MetaMask wallet, sign transactions, and pay gas on our behalf, we will require another package called hdwalletprovider. Install it by using the following command:

$ npm install @truffle/hdwallet-provider

Finally, in order to keep our sensitive wallet information safe, we will use the dotenv package.

$ npm install dotenv

Step 6: Create the ticketing smart contract for the NFT

Open the project repository in a code editor (for example: VS Code). In the contracts folder, create a new file called NftTicketing.sol.

Our ticketing contract will inherit all functionality offered by the ERC721Enumerable implementation of OpenZeppelin. This includes transfers, metadata tracking, ownership data, etc.

We will implement the following features from scratch:

  1. Public Primary Sale: Our contract will give its owner the power to sell tickets at a particular price. The owner will have the power to open and close sales, update ticket prices, and withdraw any money sent to the contract for ticket purchases. The public will have the opportunity to mint tickets at sale price whenever the sale is open and tickets are still in supply.

  2. Airdropping: The owner will be able to airdrop tickets to a list of wallet addresses.

  3. Reservation: The owner will also be able to reserve tickets for himself/herself without having to pay the public sale price.

Add the following code to NftTicketing.sol.

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/utils/Base64.sol";
import "@openzeppelin/contracts/utils/Strings.sol";

contract NftTicketing is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable  {
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIds;

    // Total number of tickets available for the event
    uint public constant MAX_SUPPLY = 10000;

    // Number of tickets you can book at a time; prevents spamming
    uint public constant MAX_PER_MINT = 5;

    string public baseTokenURI;

    // Price of a single ticket
    uint public price = 0.05 ether;

    // Flag to turn sales on and off
    bool public saleIsActive = false;

    // Give collection a name and a ticker
    constructor() ERC721("My NFT Tickets", "MNT") {}

    // Generate NFT metadata
    function generateMetadata(uint tokenId) public pure returns (string memory) {
        string memory svg = string(abi.encodePacked(
            "<svg xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinyMin meet' viewBox='0 0 350 350'>",
            "<style>.base { fill: white; font-family: serif; font-size: 25px; }</style>",
            "<rect width='100%' height='100%' fill='red' />",
            "<text x='50%' y='40%' class='base' dominant-baseline='middle' text-anchor='middle'>",
            "<tspan y='50%' x='50%'>NFT Ticket #",
            Strings.toString(tokenId),
            "</tspan></text></svg>"
        ));

        string memory json = Base64.encode(
            bytes(
                string(
                    abi.encodePacked(
                        '{"name": "NFT Ticket #',
                        Strings.toString(tokenId),
                        '", "description": "A ticket that gives you access to a cool event!", "image": "data:image/svg+xml;base64,',
                        Base64.encode(bytes(svg)),
                        '", "attributes": [{"trait_type": "Type", "value": "Base Ticket"}]}'
                    )
                )
            )
        );

        string memory metadata = string(
            abi.encodePacked("data:application/json;base64,", json)
        );
        return metadata;
    }

    // Reserve tickets to creator wallet
    function reserveNfts(uint _count) public onlyOwner {
        uint nextId = _tokenIds.current();

        require(nextId + _count < MAX_SUPPLY, "Not enough NFTs left to reserve");

        for (uint i = 0; i < _count; i++) {
            string memory metadata = generateMetadata(nextId + i);
            _mintSingleNft(msg.sender, metadata);
        }
    }

    // Airdrop NFTs
    function airDropNfts(address[] calldata _wAddresses) public onlyOwner {
        uint nextId = _tokenIds.current();
        uint count = _wAddresses.length;

        require(nextId + count < MAX_SUPPLY, "Not enough NFTs left to reserve");

        for (uint i = 0; i < count; i++) {
            string memory metadata = generateMetadata(nextId + i);
            _mintSingleNft(_wAddresses[i], metadata);
        }
    }

    // Set Sale state
    function setSaleState(bool _activeState) public onlyOwner {
        saleIsActive = _activeState;
    }

    // Allow public to mint NFTs
    function mintNfts(uint _count) public payable {

        uint nextId = _tokenIds.current();

        require(nextId + _count < MAX_SUPPLY, "Not enough NFT tickets left!");
        require(_count > 0 && _count <= MAX_PER_MINT, "Cannot mint specified number of NFT tickets.");
        require(saleIsActive, "Sale is not currently active!");
        require(msg.value >= price * _count, "Not enough ether to purchase NFTs.");

        for (uint i = 0; i < _count; i++) {
            string memory metadata = generateMetadata(nextId + i);
            _mintSingleNft(msg.sender, metadata);
        }
    }

    // Mint a single NFT ticket
    function _mintSingleNft(address _wAddress, string memory _tokenURI) private {
        // Sanity check for absolute worst case scenario
        require(totalSupply() == _tokenIds.current(), "Indexing has broken down!");
        uint newTokenID = _tokenIds.current();
        _safeMint(_wAddress, newTokenID);
        _setTokenURI(newTokenID, _tokenURI);
        _tokenIds.increment();
    }

    // Update price
    function updatePrice(uint _newPrice) public onlyOwner {
        price = _newPrice;
    }

    // Withdraw ether
    function withdraw() public payable onlyOwner {
        uint balance = address(this).balance;
        require(balance > 0, "No ether left to withdraw");

        (bool success, ) = (msg.sender).call{value: balance}("");
        require(success, "Transfer failed.");
    }

    // Get tokens of an owner
    function tokensOfOwner(address _owner) external view returns (uint[] memory) {

        uint tokenCount = balanceOf(_owner);
        uint[] memory tokensId = new uint256[](tokenCount);

        for (uint i = 0; i < tokenCount; i++) {
            tokensId[i] = tokenOfOwnerByIndex(_owner, i);
        }
        return tokensId;
    }

    // The following functions are overrides required by Solidity.
    function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
        internal
        override(ERC721, ERC721Enumerable)
    {
        super._beforeTokenTransfer(from, to, tokenId, batchSize);
    }

    function _burn(uint256 tokenId) internal override(ERC721, ERC721URIStorage) {
        super._burn(tokenId);
    }

    function tokenURI(uint256 tokenId)
        public
        view
        override(ERC721, ERC721URIStorage)
        returns (string memory)
    {
        return super.tokenURI(tokenId);
    }

    function supportsInterface(bytes4 interfaceId)
        public
        view
        override(ERC721, ERC721Enumerable)
        returns (bool)
    {
        return super.supportsInterface(interfaceId);
    }
}

Make sure the contract is compiling correctly by running:

npx truffle compile

Our contract is pretty complex already, but it is possible to add some extra features as you see fit.

For example, you can implement an anti-scalping mechanism within your contract. The steps to do so would be as follows:

  1. Define a Solidity mapping that acts as an allowlist for wallets that can hold more than one ticket.

  2. Create a function that allows the owner to add addresses to this allowlist.

  3. Introduce a check in _beforeTokenTransfer that allows mint or transfer to a wallet already holding a ticket only if it is in the allowlist.

Add the following snippet below the contract’s constructor:

mapping(address => bool) canMintMultiple;

    // Function that allowlists addresses to hold multiple NFTs.
    function addToAllowlist(address[] calldata _wAddresses) public onlyOwner {
        for (uint i = 0; i < _wAddresses.length; i++) {
            canMintMultiple[_wAddresses[i]] = true;
        }
    }

Finally, modify the _beforeTokenTranfer function to the following:

// The following functions are overrides required by Solidity.
    function _beforeTokenTransfer(address from, address to, uint256 tokenId, uint256 batchSize)
        internal
        override(ERC721, ERC721Enumerable)
    {
        if (balanceOf(to) > 0) {
            require(to == owner() || canMintMultiple[to], "Not authorized to hold more than one ticket");
        }
        super._beforeTokenTransfer(from, to, tokenId, batchSize);
    }

Compile the contract once again using the Truffle command above.

Step 7: Update Truffle config and create a .env file

Create a new file in the project’s root directory called .env and add the following contents:

INFURA_API_KEY = "https://sepolia.infura.io/v3/<Your-API-Key>"
MNEMONIC = "<Your-MetaMask-Secret-Recovery-Phrase>"

Next, let’s add information about our wallet, the Infura RPC endpoint, and the Sepolia network to our Truffle config file. Replace the contents of truffle.config.js with the following:

require('dotenv').config();
const HDWalletProvider = require('@truffle/hdwallet-provider');
const { INFURA_API_KEY, MNEMONIC } = process.env;

module.exports = {
  networks: {
    development: {
      host: "127.0.0.1",
      port: 8545,
      network_id: "*"
    },
    sepolia: {
      provider: () => new HDWalletProvider(MNEMONIC, INFURA_API_KEY),
      network_id: '5',
    }
  }
};

Step 8: Deploy the NFT Smart Contract

Let us now write a script to deploy our contract to the Sepolia blockchain.

In the migrations folder, create a new file called 1_deploy_contract.js and add the following code:

// Get instance of the NFT contract
const nftContract = artifacts.require("NftTicketing");

module.exports = async function (deployer) {
    // Deploy the contract
    await deployer.deploy(nftContract);
    const contract = await nftContract.deployed();

    // Mint 5 tickets
    await contract.reserveNfts(5);
    console.log("5 NFT Tickets have been minted!")
};

We’re all set! Deploy the contract by running the following command:

truffle migrate --network sepolia

If all goes well, you should see output (containing the contract address) that looks something like this:

Starting migrations...
======================
> Network name:    'sepolia'
> Network id:      5
> Block gas limit: 30000000 (0x1c9c380)


1_deploy_contract.js
====================

   Deploying 'NftTicketing'
   -----------------------
   > transaction hash:    …
   > Blocks: 2            Seconds: 23
    …
   > Saving artifacts
   -------------------------------------
   > Total cost:     0.1201 ETH
Summary
=======
> Total deployments:   1
> Final cost:          0.1201 ETH

You can search for your contract address on Sepolia etherscan and see it live.

Congratulations! You’ve successfully deployed the contract to Sepolia.

Step 9: Interface with the smart contract

We have our smart contract! The next step is to deploy frontends that interface with the contract and allow anyone to call the mint function to make a donation and mint a ticket for themselves.

For a fully functional ticketing service, you would typically need the following frontends:

  1. A website (with a great user experience) where public users can pay and mint their tickets.

  2. An admin portal where the owner can reserve and airdrop tickets, update pricing, transfer admin role to another wallet, withdraw sales revenue, open and close sale, etc.

  3. A tool which verifies that a person has a particular ticket both online and IRL.

Building these systems from scratch is out of scope for this tutorial, but we will leave you with a few resources and tips.

  1. For the frontend minting website, check out the frontend I built in the Thank You NFT tutorial as a starting point.

  2. If you verify your contract on Etherscan, it will automatically give you an admin portal where you can call any function on your contract. This is a good first step before you decide on building a custom solution.

  3. Verifying that a wallet has a ticket from your collection is extremely simple using the balanceOf function. If someone can prove that they own a wallet containing one of our tickets, it’s basically proof that they have a ticket. This can be achieved using digital signatures.

Verification using the Infura NFT API

One more hint: once you have your smart contract and frontend (or even before your frontend is complete and you want to prove out that everything works), you can use the Infura NFT API to verify that your new NFT exists. The Infura NFT API is a quick way to replace a lot of NFT-related code with a single API call.

For example, the information we need to show ownership of our NFT is easily available to us through the API. All we need to supply is the wallet address. The code would look something like this:

const walletAddress = <your wallet address>
const chainId = "1"

const baseUrl = "https://nft.api.infura.io"
const url = `${baseUrl}/networks/${chainId}/accounts/${walletAddress}/assets/nfts`

// API request
const config = {
    method: 'get',
    url: url,
    auth: {
        username: '<-- INFURA_API_KEY –>',
        password: '<-- INFURA_API_SECRET –>',
    }
};

// API Request
axios(config)
    .then(response => {
        console.log(response['data'])
    })
    .catch(error => console.log('error', error));

Run it …

$ node <filename>.js

And you should see something like this:

{
  total: 1,
  pageNumber: 1,
  pageSize: 100,
  network: 'ETHEREUM',
  account: <account address>,
  cursor: null,
  assets: [
    {
      contract: <NFT contract address>,
      tokenId: '0',
      supply: '1',
      type: 'ERC20',
      metadata: [Object]
    },
   …
  ]
}

Conclusion

In this tutorial, we deployed a fully functional NFT ticketing service using Truffle, Infura, and the Infura NFT API. It’s obviously not everything you would need to disrupt Ticketmaster—but it’s a solid start and a great proof of concept! Even if you don’t take this code and start your own NFT ticketing platform, hopefully you’ve learned a little about web3 in the process.