Build a Web3 Ticketing System and Disrupt Online Ticketing
Blockchains such as Ethereum can guarantee authenticity, solving the problem of counterfeit tickets. Learn how to build a ticketing solution in this tutorial.
Join the DZone community and get the full member experience.
Join For FreeA popular and practical use case for web3 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 API. We’ll deploy a smart contract that acts as a ticketing service and creates tickets as ERC-20/ERC-721 digital tokens. 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 a Ticketing System on Ethereum
The basic architecture of our system is intended to create a smart contract that issues our tickets as digital ERC-20/ERC-721 tokens. These are perfect for what we want to build. They are probably 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 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 the 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 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 for fast, reliable, and easy access to the Ethereum blockchain.
Sign up for a free account. Once you’ve created your account, navigate to the dashboard and select Create New Key.
For the 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 ticketing && cd ticketing
$ npm init -y
We will be using Truffle, a long-trusted development environment and testing framework for EVM smart contracts, to build and deploy our 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 digital 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
Open the project repository in a code editor (for example, VS Code). In the contracts
folder, create a new file called Ticketing.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:
- 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.
- Airdropping: The owner will be able to airdrop tickets to a list of wallet addresses.
- 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 Ticketing.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 Ticketing 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 Tickets", "MNT") {}
// Generate 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%'>Ticket #",
Strings.toString(tokenId),
"</tspan></text></svg>"
));
string memory json = Base64.encode(
bytes(
string(
abi.encodePacked(
'{"name": "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 reserveTickets(uint _count) public onlyOwner {
uint nextId = _tokenIds.current();
require(nextId + _count < MAX_SUPPLY, "Not enough tickets left to reserve");
for (uint i = 0; i < _count; i++) {
string memory metadata = generateMetadata(nextId + i);
_mintSingleTicket(msg.sender, metadata);
}
}
// Airdrop
function airDropTickets(address[] calldata _wAddresses) public onlyOwner {
uint nextId = _tokenIds.current();
uint count = _wAddresses.length;
require(nextId + count < MAX_SUPPLY, "Not enough Tickets left to reserve");
for (uint i = 0; i < count; i++) {
string memory metadata = generateMetadata(nextId + i);
_mintSingleTicket(_wAddresses[i], metadata);
}
}
// Set Sale state
function setSaleState(bool _activeState) public onlyOwner {
saleIsActive = _activeState;
}
// Allow public to mint tickets
function mintTickets(uint _count) public payable {
uint nextId = _tokenIds.current();
require(nextId + _count < MAX_SUPPLY, "Not enough tickets left!");
require(_count > 0 && _count <= MAX_PER_MINT, "Cannot mint specified number of tickets.");
require(saleIsActive, "Sale is not currently active!");
require(msg.value >= price * _count, "Not enough ether to purchase tickets.");
for (uint i = 0; i < _count; i++) {
string memory metadata = generateMetadata(nextId + i);
_mintSingleTicket(msg.sender, metadata);
}
}
// Mint a single ticket
function _mintSingleTicket(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:
- Define a Solidity mapping that acts as an allowlist for wallets that can hold more than one ticket.
- Create a function that allows the owner to add addresses to this allowlist.
- 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 Tickets.
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 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 contract
const ticketContract = artifacts.require("Ticketing");
module.exports = async function (deployer) {
// Deploy the contract
await deployer.deploy(ticketContract);
const contract = await ticketContract.deployed();
// Mint 5 tickets
await contract.reserveTickets(5);
console.log("5 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 an 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 'Ticketing'
-----------------------
> 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:
- A website (with a great user experience) where public users can pay and mint their tickets.
- 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.
- A tool that verifies that a person has a particular ticket both online and IRL.
Building these systems from scratch is out of the scope of this tutorial, but we will leave you with a few resources and tips.
- 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.
- 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 API
One more hint: once you have your smart contract and front end (or even before your front end is complete and you want to prove that everything works), you can use the Infura API to verify that your new ticket exists. The Infura API is a quick way to replace a lot of code with a single API call.
For example, the information we need to show ownership of our ticket 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: <contract address>,
tokenId: '0',
supply: '1',
type: 'ERC20',
metadata: [Object]
},
…
]
}
Conclusion
In this tutorial, we deployed a fully functional ticketing service using Truffle, Infura, and the Infura 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 ticketing platform, hopefully, you’ve learned a little about web3 in the process.
Published at DZone with permission of Michael Bogan. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments