Skip to main content

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

tokenCounter
uint256
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)
authorizedMinters
mapping(address => bool)
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
string
required
Name of the POA collection (e.g., “DevCon 2024 - Proof of Attendance”)
symbol
string
required
Symbol for the collection (e.g., “POA”)
eventContract
address
required
Address of the EventTicket contract (automatically authorized to mint)
What happens:
  1. Sets the collection name and symbol
  2. Authorizes the EventTicket contract to mint POAs
  3. Sets default base URI to https://api.passmint.com/poa/metadata/
  4. 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)
attendee
address
required
Address to receive the POA (the ticket holder)
ticketId
uint256
required
Original ticket ID that was checked in
return
uint256
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:
  1. Generates a new POA token ID
  2. Links POA to original ticket ID in both mappings
  3. Mints the soulbound NFT to the attendee
  4. Emits POAMinted event
  5. 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)
ticketId
uint256
required
Original ticket ID to check
exists
bool
True if a POA has been minted for this ticket
poaTokenId
uint256
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)
poaTokenId
uint256
required
POA token ID to query
return
uint256
Original ticket ID that was checked in
Requirements:
  • POA must exist (will revert if not)
Example:
JavaScript
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)
return
uint256
Total number of POAs minted for this event
Example:
JavaScript
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)
return
string
Full URI for POA metadata (baseURI + tokenId)
Example Metadata Structure:
POA Metadata (tokenURI)
{
  "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
minter
address
required
Address to authorize (typically EventTicket contracts)
Use Cases:
  • Add additional event contracts
  • Enable manual minting from admin interface
  • Grant temporary minting access
Example:
JavaScript
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
minter
address
required
Address to revoke authorization from
Example:
JavaScript
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
newBaseURI
string
required
New base URI for token metadata
Example:
JavaScript
// Update to custom metadata server
const tx = await proofOfAttendance.setBaseURI(
  'https://metadata.myevent.com/poa/'
);
await tx.wait();

Events

POAMinted
event
event POAMinted(address indexed attendee, uint256 indexed poaTokenId, uint256 indexed ticketId);
Emitted when a new POA is minted
MinterAuthorized
event
event MinterAuthorized(address indexed minter);
Emitted when an address is authorized to mint POAs
MinterRevoked
event
event MinterRevoked(address indexed minter);
Emitted when minting authorization is revoked

Complete Workflow

1

Event Creation

EventTicket contract automatically deploys a POA contract during initialization
2

Ticket Purchase

Attendee buys ticket NFT from EventTicket contract
3

Event Day

Attendee arrives at event with their ticket NFT
4

Check-In

Attendee calls checkIn() on EventTicket contract
5

POA Minting

EventTicket calls mintPOA() on ProofOfAttendance contract
6

Soulbound NFT

POA is minted to attendee’s wallet and permanently locked

Usage Examples

Query POA by Wallet

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}`);
});

Metadata Best Practices

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

Build docs developers (and LLMs) love