Solana
Solana Payments (USDC)
Reference-keyed USDC payments with 6-point on-chain verification, idempotency, a subscription scaffold, and optional x402 pay-per-request middleware. Devnet playground available on the block detail page — this guide is for mainnet-beta production use.
Read the Getting Access guideif you haven't yet.
1. Get the file
Sign in at marrowstack.dev, open the Solana Payments block, and click Copy all files. Paste lib/solana-payments.ts (~500 lines) into your project. Exports:
createPaymentRequest()— generates a reference keypair and a payment URIverifyPayment()— 6-point on-chain verification (confirmed, recipient, amount, mint, reference, unexpired)PaymentButton— React component (wallet adapter required)usePaymentStatus()— polls for payment confirmationwithPaymentGate()— optional x402 pay-per-request middleware- SQL migration:
paymentsandsubscriptionstables
SOLANA_CLUSTER=mainnet-beta and the real USDC mint (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v). These configs must never be mixed.2. Prerequisites
- Next.js 14 or 15, App Router
- Supabase project
- A Solana treasury wallet (the public key that receives USDC)
- A mainnet-beta RPC endpoint (Helius, QuickNode, or similar)
- Wallet adapter (if using the
PaymentButtoncomponent)
3. Install
- Copy the file from the block detail page and paste it into
lib/solana-payments.ts. - Remove
@ts-nocheckfrom line 1. - Install peer dependencies:
npm install @solana/web3.js @solana/spl-token bs58
# If using PaymentButton (wallet adapter):
npm install @solana/wallet-adapter-base @solana/wallet-adapter-react \
@solana/wallet-adapter-react-ui @solana/wallet-adapter-wallets4. Environment
| Variable | Required | Default | Purpose |
|---|---|---|---|
| NEXT_PUBLIC_SUPABASE_URL | yes | — | Your Supabase project URL |
| SUPABASE_SERVICE_ROLE_KEY | yes | — | Service role key — server-side only |
| SOLANA_CLUSTER | yes | mainnet-beta | Must be mainnet-beta in production. Never devnet. |
| SOLANA_RPC_URL | yes | — | Mainnet RPC endpoint. The public endpoint is rate-limited; use Helius or QuickNode. |
| TREASURY_WALLET_ADDRESS | yes | — | Public key of the wallet that receives USDC payments. |
| USDC_MINT | yes | EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v | USDC mint address on mainnet. Do not change unless you know what you are doing. |
| PAYMENT_EXPIRY_SECONDS | no | 600 | How long a payment request is valid before expiry. Default is 10 minutes. |
5. Database
Run the MIGRATION constant from solana-payments.ts in Supabase SQL Editor.
Creates:
payments— reference key, amount, status, tx signature, idempotency guardsubscriptions— optional recurring payment scaffold with renewal tracking
RLS on both tables: service role has full access; anon is blocked.
6. Wire it in
Create a payment request (server-side):
import { createPaymentRequest } from '@/lib/solana-payments'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { amount, userId } = await req.json()
// amount is in USDC (e.g. 49 for $49)
const payment = await createPaymentRequest({
amount,
userId,
description: 'MarrowStack block purchase',
})
// payment.referenceKey — unique key for this payment
// payment.paymentUri — Solana Pay URI for QR codes
// payment.expiresAt — ISO timestamp
return NextResponse.json(payment)
}Verify payment (server-side, called after buyer pays):
import { verifyPayment } from '@/lib/solana-payments'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const { paymentId, txSignature } = await req.json()
const result = await verifyPayment({ paymentId, txSignature })
// result.verified — boolean
// result.error — string if verification failed
if (!result.verified) {
return NextResponse.json({ error: result.error }, { status: 422 })
}
// Mark order as paid, trigger delivery, etc.
return NextResponse.json({ ok: true })
}Drop in the PaymentButton (client-side):
'use client'
import { PaymentButton } from '@/lib/solana-payments'
export default function CheckoutPage({ paymentUri }: { paymentUri: string }) {
return (
<PaymentButton
paymentUri={paymentUri}
onSuccess={(txSig) => fetch('/api/payment/verify', {
method: 'POST',
body: JSON.stringify({ txSignature: txSig }),
})}
onError={(err) => console.error(err)}
/>
)
}7. Verify it works
- Call
createPaymentRequest({ amount: 1, userId: 'test' })in a test route. Confirm a row appears in thepaymentstable with statuspending. - Use the devnet playground to test the full payment flow before spending real USDC on mainnet.
- On mainnet: send the exact USDC amount to the treasury address and call
verifyPayment()with the tx signature. Confirm thepaymentsrow transitions toverified. - Attempt to verify the same tx signature a second time.
verifyPayment()must return{ verified: false, error: 'Already processed' }(idempotency guard).
8. Failure modes & fixes
Transaction not found
Cause: The tx signature was queried before the transaction finalized on-chain.
Fix: Add a 2–5 second delay and retry once. Transactions on mainnet typically confirm within 1–2 slots (~800ms) but can take longer under load.
Amount mismatch: expected 49.00, received 48.51
Cause: Buyer sent slightly less than the required amount (wallet fee deducted from the transfer amount).
Fix: Set the transfer amount to USDC (stable, no fee deduction). If using SOL, the tolerance band in verifyPayment() handles this — widen it in the config if needed.
Wrong recipient: expected <treasury>, got <other>
Cause: Buyer sent USDC to a different address.
Fix: Ensure the UI clearly displays the treasury address. Surface the exact address as a copy field — never rely on the buyer to remember it.
Mint mismatch
Cause: Buyer sent a different SPL token (not USDC).
Fix: verifyPayment() checks the mint against USDC_MINT. The block rejects non-USDC transfers. Show the USDC mint address in the payment UI.
PaymentExpiredError
Cause: The payment was not completed within the PAYMENT_EXPIRY_SECONDS window.
Fix: Create a new payment request. The expiry window is configurable. For large amounts, consider increasing PAYMENT_EXPIRY_SECONDS.
RPC 429 Too Many Requests
Cause: Using the public Solana RPC endpoint, which has aggressive rate limits.
Fix: Switch to a dedicated mainnet endpoint: Helius (https://helius.dev) or QuickNode. Set SOLANA_RPC_URL accordingly.
9. Security responsibilities
What this block guarantees:
- 6-point on-chain verification: confirmed finality, exact recipient, exact amount (within tolerance), correct USDC mint, unique reference key, within expiry window.
- Reference keys are single-use. The
paymentstable has a unique constraint on reference keys — double-payment is rejected. - Verification runs server-side. The client submits a tx signature; the server does not trust the client's claim that payment was made.
- All verification happens before any value is delivered. Never deliver before
verifyPayment()returnsverified: true.
What you must ensure:
- Verify payment server-side before delivering value — this is the most important rule. Call
verifyPayment()in your API route, not on the client. - Set
SOLANA_CLUSTER=mainnet-beta. Never use devnet config in production. - Keep
TREASURY_WALLET_ADDRESSandSUPABASE_SERVICE_ROLE_KEYserver-side only. - Use a dedicated RPC endpoint in production. Public endpoints are rate-limited and unreliable under load.
- Display the USDC mint address to users so they can verify they are sending the correct token.