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 URI
  • verifyPayment() — 6-point on-chain verification (confirmed, recipient, amount, mint, reference, unexpired)
  • PaymentButton — React component (wallet adapter required)
  • usePaymentStatus() — polls for payment confirmation
  • withPaymentGate() — optional x402 pay-per-request middleware
  • SQL migration: payments and subscriptions tables
Mainnet vs. devnet: The playground on the block detail page runs on devnet with simulated USDC. Production use requires 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 PaymentButton component)

3. Install

  1. Copy the file from the block detail page and paste it into lib/solana-payments.ts.
  2. Remove @ts-nocheck from line 1.
  3. Install peer dependencies:
bash
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-wallets

4. Environment

VariableRequiredDefaultPurpose
NEXT_PUBLIC_SUPABASE_URLyesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYyesService role key — server-side only
SOLANA_CLUSTERyesmainnet-betaMust be mainnet-beta in production. Never devnet.
SOLANA_RPC_URLyesMainnet RPC endpoint. The public endpoint is rate-limited; use Helius or QuickNode.
TREASURY_WALLET_ADDRESSyesPublic key of the wallet that receives USDC payments.
USDC_MINTyesEPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1vUSDC mint address on mainnet. Do not change unless you know what you are doing.
PAYMENT_EXPIRY_SECONDSno600How 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 guard
  • subscriptions — 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):

app/api/payment/create/route.ts
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):

app/api/payment/verify/route.ts
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):

app/checkout/page.tsx
'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

  1. Call createPaymentRequest({ amount: 1, userId: 'test' }) in a test route. Confirm a row appears in the payments table with status pending.
  2. Use the devnet playground to test the full payment flow before spending real USDC on mainnet.
  3. On mainnet: send the exact USDC amount to the treasury address and callverifyPayment() with the tx signature. Confirm thepayments row transitions to verified.
  4. 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 payments table 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() returns verified: 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_ADDRESS and SUPABASE_SERVICE_ROLE_KEY server-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.