Overview
ProofOfAttendance (POA) is an ERC721 NFT contract that issues soulbound (non-transferable) tokens as verifiable proof that a wallet holder attended an event. POAs are automatically minted when tickets are checked in.
Contract Location : src/packages/contracts/src/ProofOfAttendance.solInherits : ERC721, Ownable, ReentrancyGuardSpecial Property : Soulbound (non-transferable after minting)
What are POAs?
Proof of Attendance NFTs serve as permanent, on-chain records that someone attended an event. Unlike tickets (which are transferable), POAs are soulbound - they stay with the original attendee forever.
Verifiable Proof Immutable on-chain proof of attendance
Non-Transferable POAs cannot be sold or transferred after minting
Unique Memories Each POA links to custom event metadata and artwork
Reputation Building Build an on-chain history of events attended
Key Features
Soulbound Tokens
POAs implement a modified ERC721 that prevents transfers:
function _update (
address to ,
uint256 tokenId ,
address auth
) internal override returns ( address ) {
address from = _ownerOf (tokenId);
// Allow minting (from == address(0)) but prevent transfers
if (from != address ( 0 ) && to != address ( 0 )) {
revert ( "POAs are soulbound and non-transferable" );
}
return super . _update (to, tokenId, auth);
}
POAs cannot be transferred, sold, or moved after minting. They remain with the original attendee forever.
Automatic Minting
POAs are automatically minted when attendees check in to an event:
Ticket Linkage
Each POA is linked to the original ticket that was checked in:
originalTicketId[poaTokenId] maps POA to original ticket
ticketToPOA[ticketId] maps ticket to POA (prevents duplicate POAs)
Contract State
Public Variables
Counter for the next POA token ID to be minted (starts at 1)
Mappings
originalTicketId
mapping(uint256 => uint256)
Maps POA token ID to the original ticket ID that was checked in
ticketToPOA
mapping(uint256 => uint256)
Maps ticket ID to POA token ID (0 if no POA minted yet)
Addresses authorized to mint POAs (typically EventTicket contracts)
Constructor
constructor (
string memory name ,
string memory symbol ,
address eventContract
) ERC721 (name, symbol) Ownable (msg.sender)
Name of the POA collection (e.g., “DevCon 2024 - Proof of Attendance”)
Symbol for the collection (e.g., “POA”)
Address of the EventTicket contract (automatically authorized to mint)
What happens:
Sets the collection name and symbol
Authorizes the EventTicket contract to mint POAs
Sets default base URI to https://api.passmint.com/poa/metadata/
Initializes token counter to 1
The POA contract is automatically deployed by the EventTicket contract during initialization. You typically don’t deploy it manually.
Core Functions
Mint POA
Mint a Proof of Attendance NFT (called by authorized minters only).
function mintPOA (
address attendee ,
uint256 ticketId
) external onlyAuthorizedMinter nonReentrant returns ( uint256 )
Address to receive the POA (the ticket holder)
Original ticket ID that was checked in
The newly minted POA token ID
Requirements:
Caller must be an authorized minter (EventTicket contract)
attendee cannot be zero address
No POA already exists for this ticketId
What Happens:
Generates a new POA token ID
Links POA to original ticket ID in both mappings
Mints the soulbound NFT to the attendee
Emits POAMinted event
Returns the new POA token ID
Example (called from EventTicket):
// Inside EventTicket.checkIn()
uint256 poaTokenId = proofOfAttendance. mintPOA ( msg.sender , tokenId);
emit ProofOfAttendanceMinted ( msg.sender , tokenId, poaTokenId);
Events Emitted:
event POAMinted ( address indexed attendee , uint256 indexed poaTokenId , uint256 indexed ticketId );
View Functions
Get POA for Ticket
Check if a POA exists for a given ticket ID.
function getPOAForTicket ( uint256 ticketId ) external view returns ( bool exists , uint256 poaTokenId )
Original ticket ID to check
True if a POA has been minted for this ticket
The POA token ID (0 if doesn’t exist)
Example:
const [ exists , poaTokenId ] = await proofOfAttendance . getPOAForTicket ( 42 );
if ( exists ) {
console . log ( `Ticket #42 has POA # ${ poaTokenId } ` );
} else {
console . log ( 'Ticket #42 has not been checked in yet' );
}
Get Original Ticket ID
Get the original ticket ID that was checked in for a POA.
function getOriginalTicketId ( uint256 poaTokenId ) external view returns ( uint256 )
Original ticket ID that was checked in
Requirements:
POA must exist (will revert if not)
Example:
const originalTicketId = await proofOfAttendance . getOriginalTicketId ( 123 );
console . log ( `POA #123 was minted from ticket # ${ originalTicketId } ` );
Total Minted
Get the total number of POAs minted.
function totalMinted () external view returns ( uint256 )
Total number of POAs minted for this event
Example:
const total = await proofOfAttendance . totalMinted ();
console . log ( ` ${ total } attendees have checked in` );
Token URI
Get metadata URI for a POA token.
function tokenURI ( uint256 tokenId ) public view override returns ( string memory )
Full URI for POA metadata (baseURI + tokenId)
Example Metadata Structure:
{
"name" : "DevCon 2024 - Proof of Attendance #123" ,
"description" : "This NFT proves that the holder attended DevCon 2024" ,
"image" : "https://api.passmint.com/poa/images/123.png" ,
"attributes" : [
{
"trait_type" : "Event" ,
"value" : "DevCon 2024"
},
{
"trait_type" : "Date" ,
"value" : "2024-03-15"
},
{
"trait_type" : "Location" ,
"value" : "San Francisco, CA"
},
{
"trait_type" : "Original Ticket ID" ,
"value" : "42"
},
{
"trait_type" : "Checked In At" ,
"value" : "1710345600"
}
]
}
Administrative Functions
Authorize Minter
Authorize an address to mint POAs (owner only).
function authorizeMinter ( address minter ) external onlyOwner
Address to authorize (typically EventTicket contracts)
Use Cases:
Add additional event contracts
Enable manual minting from admin interface
Grant temporary minting access
Example:
const tx = await proofOfAttendance . authorizeMinter ( newEventContract );
await tx . wait ();
Events Emitted:
event MinterAuthorized ( address indexed minter );
Revoke Minter
Revoke minting authorization (owner only).
function revokeMinter ( address minter ) external onlyOwner
Address to revoke authorization from
Example:
const tx = await proofOfAttendance . revokeMinter ( oldEventContract );
await tx . wait ();
Events Emitted:
event MinterRevoked ( address indexed minter );
Set Base URI
Update the base URI for metadata (owner only).
function setBaseURI ( string memory newBaseURI ) external onlyOwner
New base URI for token metadata
Example:
// Update to custom metadata server
const tx = await proofOfAttendance . setBaseURI (
'https://metadata.myevent.com/poa/'
);
await tx . wait ();
Events
event POAMinted ( address indexed attendee , uint256 indexed poaTokenId , uint256 indexed ticketId );
Emitted when a new POA is minted
attendee: Address that received the POA
poaTokenId: The newly minted POA token ID
ticketId: Original ticket ID that was checked in
event MinterAuthorized ( address indexed minter );
Emitted when an address is authorized to mint POAs
event MinterRevoked ( address indexed minter );
Emitted when minting authorization is revoked
Complete Workflow
Event Creation
EventTicket contract automatically deploys a POA contract during initialization
Ticket Purchase
Attendee buys ticket NFT from EventTicket contract
Event Day
Attendee arrives at event with their ticket NFT
Check-In
Attendee calls checkIn() on EventTicket contract
POA Minting
EventTicket calls mintPOA() on ProofOfAttendance contract
Soulbound NFT
POA is minted to attendee’s wallet and permanently locked
Usage Examples
Query POA by Wallet
Check User's POAs
Check User's POAs
const { ethers } = require ( 'ethers' );
const poaContract = new ethers . Contract ( poaAddress , poaABI , provider );
const eventTicketContract = new ethers . Contract ( ticketAddress , ticketABI , provider );
// Check if user has a ticket
const ticketBalance = await eventTicketContract . balanceOf ( userAddress );
if ( ticketBalance > 0 ) {
// Get user's first ticket
const ticketId = await eventTicketContract . tokenOfOwnerByIndex ( userAddress , 0 );
// Check if they have a POA
const [ exists , poaTokenId ] = await poaContract . getPOAForTicket ( ticketId );
if ( exists ) {
// Get POA metadata
const tokenURI = await poaContract . tokenURI ( poaTokenId );
const response = await fetch ( tokenURI );
const metadata = await response . json ();
console . log ( 'User has attended!' );
console . log ( 'POA Metadata:' , metadata );
} else {
console . log ( 'User has ticket but has not checked in' );
}
} else {
console . log ( 'User does not have a ticket' );
}
Build Attendance History
Build User's Event History
async function getUserEventHistory ( userAddress ) {
// Array to store all events attended
const eventsAttended = [];
// Get all POA contracts (from your database)
const poaContracts = await database . getAllPOAContracts ();
for ( const poaAddress of poaContracts ) {
const poa = new ethers . Contract ( poaAddress , poaABI , provider );
const balance = await poa . balanceOf ( userAddress );
if ( balance > 0 ) {
// User has POA from this event
const tokenId = await poa . tokenOfOwnerByIndex ( userAddress , 0 );
const tokenURI = await poa . tokenURI ( tokenId );
const metadata = await ( await fetch ( tokenURI )). json ();
eventsAttended . push ({
event: metadata . name ,
date: metadata . attributes . find ( a => a . trait_type === 'Date' ). value ,
poaAddress ,
tokenId
});
}
}
return eventsAttended ;
}
// Usage
const history = await getUserEventHistory ( '0xUserAddress' );
console . log ( `User has attended ${ history . length } events:` );
history . forEach ( event => {
console . log ( `- ${ event . event } on ${ event . date } ` );
});
Generate unique POA images that:
Include event branding
Display event date and location
Are optimized for NFT marketplaces (1:1 aspect ratio, 1000x1000px)
Include the POA token ID for uniqueness
Include these key attributes: {
"trait_type" : "Event" ,
"value" : "Event Name"
},
{
"trait_type" : "Date" ,
"value" : "2024-03-15"
},
{
"trait_type" : "Location" ,
"value" : "City, Country"
},
{
"trait_type" : "Original Ticket ID" ,
"value" : "42"
},
{
"trait_type" : "Check-In Time" ,
"value" : "1710345600"
}
Consider storing POA metadata and images on IPFS for decentralization: const baseURI = 'ipfs://QmYourIPFSHash/' ;
await proofOfAttendance . setBaseURI ( baseURI );
Use Cases
Event Attendance Verify someone attended a conference, concert, or meetup
Gated Communities Grant Discord roles or access based on POA ownership
Loyalty Programs Reward frequent attendees with airdrops or perks
Reputation Systems Build on-chain reputation based on events attended
Alumni Networks Create verifiable alumni communities for events
Achievement System Gamify event attendance with collectible POAs
Security Considerations
Soulbound : POAs cannot be transferred after minting (intentional design)
Authorization : Only authorized minters can mint POAs
One POA per Ticket : Duplicate POAs for the same ticket are prevented
Reentrancy : Protected by ReentrancyGuard on minting
Limitations
POAs are permanently bound to the original recipient. Consider:
Lost wallets = lost POAs
Cannot gift or sell POAs
Cannot recover POAs if wallet is compromised
These are intentional design decisions to maintain attendance authenticity.
EventTicket Parent contract that mints POAs during check-in
Contract Overview High-level architecture documentation