Build a USDC Paywall
Learn to design and ship a USDC paywall with a clear plan: problem framing, architecture, phases, and tradeoffs.
Learning Objective
- Translate a product goal into an on-chain design
- Map the system architecture (client + program + token accounts)
- Plan a build with phases, tests, and tradeoffs
What to Expect
- Problem framing and constraints
- Architecture and data flow
- Step-by-step execution checklist
Analyze the Problem
- Goal: user pays USDC to unlock premium content.
- User story: pay once, get access immediately.
- Constraints: minimize fraud, confirm payment, keep UX fast.
- Success: payment verified, access granted, audit trail retained.
- Edge cases: server watcher misses tx, duplicate payments, invalid memo, server restarts mid-invoice, pending payments past 5 minutes.
- Recovery: client stores tx + invoice in localStorage and can trigger a manual recheck endpoint.
System Architecture
- Client: wallet connects and signs a USDC transfer.
- On-chain: Token Program transfers USDC to merchant account.
- Off-chain: server checks transaction signature and grants access.
- Data: record tx signature, buyer wallet, and access window.
User Story: USDC Paywall (UML)
Request-to-pay Sequence Diagram
The system flow when User click pay -> Server return invoice_id -> User pay USDC with invoice_id as Txn Memo.
Server poll blockchain to see if there is any txn with correct information {invoice_id, amount, token=USDC, wallet} -> update wallet as paid in database
Tech Stack
- Client: React.js + Solana Wallet Adapter
- Server: Go + PostgreSQL
- Solana Network: Devnet
- RPC Provider: Hosted RPC (API key)
- Deployment: DigitalOcean droplet (server) + static hosting for client
Implementation Phases
Focus: client code and reading blockchain state.
- User opens the dapp, connects wallet, and sees USDC balance on devnet.
- Admin airdrops USDC devnet to the user.
- User opens the site again, reconnects, and sees the updated balance.
Check out code here
Focus: server + database for premium content gating.
- Build DB tables:
paid_wallet{id, wallet, paid_at, invoice_id}andinvoice{id, wallet, amount, sql_token_address, invoice_id_rndstr, status, txn, expired_at}. - Implement
GET /api/contentwithwalletparam: return content if paid; else false. - User opens dapp, connects wallet, requests
/api/content, receives false, UI prompts to pay.
Check out code here
Focus: payment workflow on client + server.
Client
- Enable Pay only if balance >= required amount; otherwise disable.
- On Pay, call
POST /api/invoicewith{wallet, amount, sql_token_address}; receiveinvoice_id_rndstr. - Create a tx with memo =
invoice_id_rndstrand request signature. - Immediately persist
invoice_id_rndstrbefore sending the tx to avoid a refresh losing state. - After signature, poll
/api/contentevery 15s for 3 minutes. If unlocked, show success + content. Else prompt refresh after 2 minutes. Storetxn+invoice_id_rndstrin localStorage for dispute. - If user refreshes right after signing and
txnis missing, allow recheck by invoice + wallet.
Server
- POST
/api/invoicechecks if wallet already paid; validatesamount+sql_token_address. - Create
invoice_id_rndstrand insert invoice row. - Start a 5-minute watcher polling chain every 15s for a tx matching
{wallet, amount, sql_token_address, invoice_id_rndstr}. - On match, update invoice status and insert row into
paid_wallet. - Return
invoice_id_rndstrto client.
Check out code here
Focus: test coverage, recheck flows, and production polish.
Client
- Persist
txn+invoice_id_rndstrin localStorage after payment. - If server returns “not paid,” show a Recheck Payment button.
- On click, call
POST /api/recheckwith{wallet, txn, invoice_id_rndstr}. - Show retry/pending status and refresh the content on success.
Server
- Add
POST /api/recheckto re-validate a completed tx. - If the 5-minute watcher missed the tx, re-scan by signature + memo.
- Update invoice + paid_wallet rows when recheck succeeds.
Testing & Hardening
- Missing-tx edge case: watcher timeout + successful recheck.
- Double payment: prevent duplicate inserts and access conflicts.
- Invalid memo: reject and keep access locked.
- Partial failures: server restart with pending invoices.
- Rate limiting for recheck endpoint.
Check out code here