A complete system for event attendance proof using Soul Bound Tokens (SBT) with the ability to upgrade to tradable Premium NFTs. Built and deployed on Base Sepolia testnet with signature-based public minting and dynamic event creation for maximum scalability.
The system now supports permissionless event creation - any user can create events on-chain with automatic code generation.
Previous Flow (β): Only admin can register events
New Flow (β
): Any user creates event β Backend auto-generates codes β Infinitely scalable
Key Features:
- π Permissionless - Anyone can create events
- π€ Automatic - Backend generates 100 claim codes per event
- πΌοΈ IPFS Images - Upload images to permanent storage
- π Full Control - Creators can update/deactivate their events
- π« Max Attendees - Set limits or unlimited capacity
- π On-chain Registry - All event data stored on Base Sepolia
User uploads image β IPFS (via backend)
β
User creates event on-chain (pays gas)
β
EventCreated emitted β Backend listener detects
β
Backend auto-generates 100 claim codes β
β
Codes ready for distribution
| Contract | Address | Status |
|---|---|---|
| BasicMerch | 0xaD3d265112967c52a9BE48F4a61b89B48a5098F1 |
β Verified |
| PremiumMerch | 0x139894eB07f6cFDd10f36D1Af31EeB236C03443B |
β Verified |
| EASIntegration | 0x985eCaBA2B222971fc018983004C226076fBf723 |
β Verified |
| MerchManager | 0xD71F654c7B9C15A54B2617262369fA219c15fe24 |
β Verified |
Deployment Date: January 23, 2025
Network: Base Sepolia (Chain ID: 84532)
Explorer: https://sepolia.basescan.org/
// ANY user can create events
function createEvent(
string memory name,
string memory description,
string memory imageURI, // IPFS hash
uint256 maxAttendees // 0 = unlimited
) external returns (bytes32);// Users pay gas, backend signs (free)
function mintSBTWithAttestation(
address _to,
string memory _tokenURI,
bytes32 _eventId,
bytes memory _signature // From backend API
) external returns (uint256, bytes32);// Only creator can update
function updateEvent(bytes32 eventId, string memory name, string memory description, string memory imageURI) external;
// Only creator can activate/deactivate
function setEventStatus(bytes32 eventId, bool isActive) external;
// View functions
function getEvent(bytes32 eventId) external view returns (...);
function getAllEvents() external view returns (bytes32[] memory);
function getEventsByCreator(address creator) external view returns (bytes32[] memory);
function getRemainingSpots(bytes32 eventId) external view returns (uint256);import { ethers } from 'ethers';
async function createEvent(eventData) {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
// Step 1: Upload image to IPFS
const formData = new FormData();
formData.append('image', eventData.imageFile);
const uploadResponse = await fetch('https://api.yourbackend.com/api/events/upload-image', {
method: 'POST',
body: formData
});
const { imageUri } = await uploadResponse.json();
// imageUri = "ipfs://QmXxx..."
// Step 2: Create event on-chain
const merchManager = new ethers.Contract(
'0xD71F654c7B9C15A54B2617262369fA219c15fe24',
[
'function createEvent(string,string,string,uint256) external returns (bytes32)'
],
signer
);
const tx = await merchManager.createEvent(
eventData.name,
eventData.description,
imageUri, // From step 1
eventData.maxAttendees // 0 = unlimited
);
const receipt = await tx.wait();
// Step 3: Backend automatically detects event and generates codes
console.log('β
Event created! Backend generating codes...');
return receipt.transactionHash;
}async function claimMerch(claimCode) {
const provider = new ethers.BrowserProvider(window.ethereum);
const signer = await provider.getSigner();
const userAddress = await signer.getAddress();
// 1. Get signature from backend API
const response = await fetch('https://api.yourbackend.com/api/verify-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-KEY': 'your_api_key'
},
body: JSON.stringify({
code: claimCode,
walletAddress: userAddress
})
});
const { eventId, tokenURI, signature } = await response.json();
// 2. User calls contract with signature (pays gas)
const merchManager = new ethers.Contract(
'0xD71F654c7B9C15A54B2617262369fA219c15fe24',
[
'function mintSBTWithAttestation(address,string,bytes32,bytes) external returns (uint256,bytes32)'
],
signer
);
const tx = await merchManager.mintSBTWithAttestation(
userAddress,
tokenURI,
eventId,
signature
);
const receipt = await tx.wait();
console.log('β
NFT minted!');
return receipt.transactionHash;
}async function getMyEvents(userAddress) {
const provider = new ethers.JsonRpcProvider('https://sepolia.base.org');
const merchManager = new ethers.Contract(
'0xD71F654c7B9C15A54B2617262369fA219c15fe24',
[
'function getEventsByCreator(address) external view returns (bytes32[])',
'function getEvent(bytes32) external view returns (string,string,string,address,bool,uint256,uint256,uint256)'
],
provider
);
// Get event IDs
const eventIds = await merchManager.getEventsByCreator(userAddress);
// Get details for each event
const events = await Promise.all(
eventIds.map(async (eventId) => {
const [name, description, imageURI, creator, isActive, createdAt, totalAttendees, maxAttendees]
= await merchManager.getEvent(eventId);
return {
eventId,
name,
description,
imageURI,
creator,
isActive,
createdAt: Number(createdAt),
totalAttendees: Number(totalAttendees),
maxAttendees: Number(maxAttendees)
};
})
);
return events;
}const express = require('express');
const multer = require('multer');
const FormData = require('form-data');
const fetch = require('node-fetch');
const app = express();
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
// Upload image to IPFS (Pinata)
app.post('/api/events/upload-image', upload.single('image'), async (req, res) => {
try {
const formData = new FormData();
formData.append('file', req.file.buffer, {
filename: req.file.originalname,
contentType: req.file.mimetype
});
const response = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PINATA_JWT}`
},
body: formData
});
const data = await response.json();
res.json({
success: true,
storage: 'ipfs',
imageUri: `ipfs://${data.IpfsHash}`,
ipfsHash: data.IpfsHash,
gatewayUrl: `https://gateway.pinata.cloud/ipfs/${data.IpfsHash}`
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});const { ethers } = require('ethers');
class EventListenerService {
constructor() {
this.provider = new ethers.JsonRpcProvider('https://sepolia.base.org');
this.contract = new ethers.Contract(
'0xD71F654c7B9C15A54B2617262369fA219c15fe24',
[
'event EventCreated(bytes32 indexed eventId, address indexed creator, string name, string description, string imageURI, uint256 maxAttendees, uint256 timestamp)'
],
this.provider
);
}
startListening() {
console.log('π Listening for events...');
this.contract.on('EventCreated', async (eventId, creator, name, description, imageURI, maxAttendees, timestamp) => {
console.log('π NEW EVENT DETECTED!');
console.log(' Event ID:', eventId);
console.log(' Creator:', creator);
console.log(' Name:', name);
console.log(' Image:', imageURI);
// Generate 100 claim codes automatically
await generateCodesForEvent(eventId, name, description, imageURI, 100);
console.log('β
Event processed!\n');
});
}
}
// Start listener
const listener = new EventListenerService();
listener.startListening();const express = require('express');
const { ethers } = require('ethers');
const app = express();
const backendWallet = new ethers.Wallet(process.env.BACKEND_ISSUER_PRIVATE_KEY);
app.post('/api/verify-code', async (req, res) => {
const { code, walletAddress } = req.body;
// Verify claim code
const claim = await db.getClaim(code);
if (!claim || claim.used) {
return res.status(400).json({ error: 'Invalid or used code' });
}
// Mark as used
await db.markAsUsed(code, walletAddress);
// Generate signature (FREE - no transaction)
const messageHash = ethers.solidityPackedKeccak256(
['address', 'uint256', 'string'],
[walletAddress, claim.eventId, claim.tokenURI]
);
const signature = await backendWallet.signMessage(ethers.getBytes(messageHash));
res.json({
eventId: claim.eventId,
tokenURI: claim.tokenURI,
signature,
is_valid: true
});
});
app.listen(3000);git clone https://github.com/your-repo/merch-contracts
cd merch-contracts
forge installcp .env.example .env
nano .envRequired variables:
PRIVATE_KEY=your_deployer_private_key
BASE_SEPOLIA_RPC_URL=https://sepolia.base.org
BASESCAN_API_KEY=your_basescan_api_key (optional)
BACKEND_ISSUER_ADDRESS=0x648a3e5510f55B4995fA5A22cCD62e2586ACb901
TREASURY_ADDRESS=0x648a3e5510f55B4995fA5A22cCD62e2586ACb901# Deploy all contracts
forge script script/DeployMerchMVP.s.sol:DeployMerchMVP \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--broadcast \
--verify
# Test dynamic events
forge script script/TestDynamicEvents.s.sol:TestDynamicEvents \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--broadcast# Run all tests
forge test -vvv
# Run specific test
forge test --match-test testCreateEventByAnyUser -vvvv
# Coverage
forge coverage| Metric | Admin Model | Dynamic Events |
|---|---|---|
| Who creates events | Admin only | Anyone |
| Backend setup | Manual | Automatic |
| Code generation | Manual | <1 second |
| Event creation cost | $0 | ~$0.001 (Base) |
| Scalability | Limited | Infinite |
| Backend minting cost | $0 | $0 (signatures) |
| Feature | Status |
|---|---|
| Permissionless event creation | β |
| IPFS image upload | β |
| Automatic code generation | β |
| Event update/deactivate | β |
| Max attendees limit | β |
| Event queries (by creator, all, etc.) | β |
| Signature-based minting | β |
| Soul Bound Tokens (SBT) | β |
| Premium NFT upgrade | β |
| EAS Attestations | β |
- β Signature Verification - ECDSA with ecrecover (EIP-191)
- β Access Control - Only creators can update their events
- β Max Attendees - Enforce capacity limits on-chain
- β ReentrancyGuard - All state-changing functions protected
- β Duplicate Prevention - One SBT per user per event
- β Event Deactivation - Creators can pause minting
- β No Backend Funds - Backend issuer needs $0 ETH
- β Comprehensive Tests - Full test coverage
Backend Security:
- Store private key in environment variables
- NEVER commit credentials to Git
- Rotate issuer wallet periodically
- Use rate limiting on API endpoints
merch-contracts/
βββ src/
β βββ BasicMerch.sol # SBT (ERC-4973)
β βββ PremiumMerch.sol # Premium NFT (ERC-721)
β βββ EASIntegration.sol # Attestation system
β βββ MerchManager.sol # β Main coordinator (Dynamic Events)
βββ script/
β βββ DeployMerchMVP.s.sol # Full deployment
β βββ TestDynamicEvents.s.sol # Test event creation
β βββ VerifyContracts.s.sol # BaseScan verification
βββ test/
β βββ BasicMerch.t.sol
β βββ PremiumMerch.t.sol
β βββ MerchMVPIntegration.t.sol # β Full system tests
βββ deployments/
βββ base-sepolia.json # Deployed addresses
# All tests
forge test -vvv
# Specific contract
forge test --match-contract MerchMVPIntegrationTest -vvv
# Specific test
forge test --match-test testCreateEventByAnyUser -vvvv
# Gas report
forge test --gas-report
# Coverage
forge coverage// Test: Anyone can create events
function testCreateEventByAnyUser() public {
vm.prank(user1);
bytes32 eventId = merchManager.createEvent(
"My Event",
"Description",
"ipfs://QmTest",
100
);
assertTrue(merchManager.isEventRegistered(eventId));
assertTrue(merchManager.isEventActive(eventId));
}
// Test: Max attendees enforcement
function testMaxAttendeesLimit() public {
bytes32 eventId = merchManager.createEvent(
"Small Event",
"Only 2 spots",
"ipfs://test",
2
);
// Mint for user1 and user2 (OK)
mintSBT(user1, eventId);
mintSBT(user2, eventId);
// Try to mint for user3 (should fail)
vm.expectRevert(MerchManager.EventFull.selector);
mintSBT(user3, eventId);
}# Get all events
cast call $MERCH_MANAGER "getAllEvents()" --rpc-url $BASE_SEPOLIA_RPC_URL
# Get events by creator
cast call $MERCH_MANAGER \
"getEventsByCreator(address)" \
$YOUR_ADDRESS \
--rpc-url $BASE_SEPOLIA_RPC_URL
# Get event details
cast call $MERCH_MANAGER \
"getEvent(bytes32)" \
$EVENT_ID \
--rpc-url $BASE_SEPOLIA_RPC_URL
# Get remaining spots
cast call $MERCH_MANAGER \
"getRemainingSpots(bytes32)" \
$EVENT_ID \
--rpc-url $BASE_SEPOLIA_RPC_URLcast send $MERCH_MANAGER \
"createEvent(string,string,string,uint256)" \
"My Event" \
"Event Description" \
"ipfs://QmXxx..." \
100 \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEYcast send $MERCH_MANAGER \
"updateEvent(bytes32,string,string,string)" \
$EVENT_ID \
"New Name" \
"New Description" \
"ipfs://NewImage" \
--rpc-url $BASE_SEPOLIA_RPC_URL \
--private-key $PRIVATE_KEYFor complete documentation:
- Deployment Guide - What to update
- Scripts Guide - Deployment scripts
- Verification Guide - Contract verification
- Implementation Summary - Visual flow
- Conference Attendance - Proof of attendance with exclusive perks
- Workshop Certificates - Verifiable skill badges
- Community Meetups - Membership tokens with benefits
- Hackathon Participation - Team achievements and prizes
- Course Completion - Educational credentials
- VIP Events - Access control and collectibles
Contributions are welcome! Please:
- Fork the repository
- Create a feature branch
- Add tests for new features
- Submit a pull request
MIT License - see LICENSE file for details
- BaseScan Explorer: https://sepolia.basescan.org/
- Base Docs: https://docs.base.org/
- Foundry Book: https://book.getfoundry.sh/
- EAS Documentation: https://docs.attest.sh/
For questions or issues:
- Open an issue on GitHub
- Check existing documentation
- Review test files for examples
Happy Building! π
With dynamic events and signature-based minting, your system is ready to scale to millions of users at near-zero cost.