Case study · 2026
EventMint
A proof-of-attendance app for in-person events. Attendees scan a time-limited QR code at the venue and, if they’re inside the event’s area, mint a non-transferable NFT to their wallet.
- Role
- Built the Flutter app, the API and the Solana program
- Timeline
- Feb 21 – Mar 3, 2026
- Platform
- Android · Solana devnet
- Stack
- Flutter, Riverpod, FastAPI, Supabase, Anchor (Rust)
- Context
- Built for the Superteam Nepal Solana Mini-Hack 2026
The hosted backend is currently offline, so there’s no live version to try.
The problem
Event check-ins are easy to fake and hard to verify later. A screenshot of a ticket or a name on an organizer’s spreadsheet doesn’t prove someone was in the room, and that record lives in one organizer’s system.
EventMint gives each attendee a token in their own wallet. The app only lets them claim it at the venue, during the event, once.
How it fits together
EventMint has three parts, all in one repository:
- Flutter app. Organizers create events and show QR codes. Attendees scan and claim. The app builds every Solana transaction itself and talks to Phantom through deep links.
- FastAPI service. Stores events and claims in Supabase, uploads images to ImageKit and NFT metadata to IPFS through Pinata, signs QR codes and checks each claim before the app builds a transaction.
- Anchor program. Creates an account for each event on Solana devnet and mints one locked token per attendee.
The claim flow
Creating an event
- The organizer fills in the event: image, name, supply, time window, radius and a location pinned on a map. The app uploads the image, and the API pins the NFT metadata to IPFS and saves the event as pending.
- The app builds the
create_eventtransaction and Phantom signs it. The app broadcasts it to devnet and the API records the signature. Events that never get one are deleted after 60 seconds.
Claiming at the venue
- The organizer’s screen shows a QR code generated by the API. It carries the event ID and a timestamp, signed with HMAC-SHA256, and expires after a few minutes.
- The attendee scans it. The app sends the code, their wallet address and the device’s location to the API, which checks the signature, the expiry, the event’s time window, the distance from the venue, the remaining supply and earlier claims, then reserves the claim.
- The app generates a fresh mint key pair, builds
claim_popand signs it with the mint key. Phantom adds the attendee’s signature. - The app broadcasts the transaction. The program mints one token, writes Metaplex metadata and a master edition, records a receipt and freezes the token account. Claims that are never signed are released after 60 seconds.
Engineering decisions
Solana transactions built in Dart, without an SDK
The app doesn’t depend on a Solana SDK. It builds transactions byte by byte: it merges duplicate accounts, orders them by signer and writable flags, writes compact-u16 lengths and encodes instruction data the way Anchor expects. That means an 8-byte discriminator taken from sha256("global:<instruction>"), followed by little-endian integers and length-prefixed strings.
Program addresses are derived the way the Solana runtime does it: hash the seeds with each bump from 255 down, and keep the first result that isn’t a point on the ed25519 curve. claim_pop needs two signatures, so the app finds the mint’s signer slot and fills it before handing the transaction to Phantom. Event coordinates go on-chain as integers in millionths of a degree.
The Phantom handshake
Phantom’s mobile deep-link protocol encrypts everything passed between the two apps. EventMint generates an X25519 key pair on first launch, sends the public key with its connect request, and decrypts Phantom’s reply with a NaCl box using Phantom’s public key and the returned nonce. The session from that reply is saved and sent, encrypted the same way, with every signing request.
Replies come back to eventmint://phantom/…. One handler processes them whether the app was already running (uriLinkStream) or was launched by the link (getInitialLink).
One token per wallet, locked to it
Each claim creates a receipt account whose address is derived from the event and the attendee’s wallet. The program creates it with init, so a second claim for the same pair fails before anything is minted. Minting also stops at the event’s supply limit.
After minting, the program freezes the attendee’s token account. The event’s program-derived address is the freeze authority and the program has no thaw instruction, so the token stays where it was minted. The master edition is created with a maximum supply of zero, so no prints can be made from it.
QR and location checks
QR codes are signed with HMAC-SHA256 and compared in constant time. Before the app builds a claim transaction, the API rejects expired codes and claims outside the event’s time window, measures the distance to the venue with the haversine formula against the event’s radius, and checks supply and earlier claims. Pending events and claims without a signature are cleaned up after 60 seconds, and a released claim gives its supply back.
How Riverpod state is organized
Wallet state lives in one AsyncNotifier. On start it restores the app’s key pair and any saved Phantom session, then listens for deep links. Smaller providers derive from it (connection status, public key, session), so screens only rebuild for what they read.
Created events, claimed tokens and wallet stats are separate AsyncNotifiers that watch the wallet’s public key, so they reload when the wallet changes. The service that builds transactions only exists once a Phantom session does.
Limitations and next steps
- The venue, time-window and supply rules run in the API. The next step is for the program to require the API’s approval on every claim, so the same rules hold on-chain.
- Location comes from the attendee’s device. Stronger proof of presence needs more than GPS.
- Secret handling, key storage and debug logging need a hardening pass before any mainnet release.
- There are no automated tests yet. Program tests for creating events, claiming, double claims and sold-out events come first.
- It runs on Solana devnet, and the hosted backend is currently offline.