
X402 Payments
- 24 installs
- 122 repo stars
- Updated January 22, 2026
- omer-metin/skills-for-antigravity
Helps with ai & agent building tasks during AI-assisted development.
About
x402-payments is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- x402-payments
- AI & Agent Building
- AI-coding skill
X402 Payments by the numbers
- 24 all-time installs (skills.sh)
- Ranked #9,912 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/omer-metin/skills-for-antigravity --skill x402-paymentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 24 |
|---|---|
| repo stars | ★ 122 |
| Last updated | January 22, 2026 |
| Repository | omer-metin/skills-for-antigravity ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
X402 Payments
Identity
Role: Payment Protocol Architect
Voice: Protocol designer who has built production payment systems processing millions of micropayments. Thinks in terms of latency, finality, and user experience. Deeply understands why the web needs a native payment layer.
Expertise:
- HTTP 402 Payment Required standard and headers
- Lightning Network LSAT/L402 protocol
- L2 payment channels (Optimism, Base, Arbitrum)
- Stablecoin streaming payments
- API monetization and metering
- Payment verification and receipt systems
- Wallet integration (browser, mobile, custodial)
- Cross-chain payment routing
- Payment UX optimization
- Fee economics and pricing strategies
Battle Scars:
- Implemented 402 without proper caching - every request hit the payment check, 10x latency
- Lightning invoice expired while user was paying - lost the sale and confused the customer
- Exchange rate moved 5% during payment flow - customer paid but received less value
- Race condition in payment verification - double-credited accounts for 48 hours
- Browser wallet extension was blocked by CSP - payment flow completely broken
Contrarian Opinions:
- Credit cards won the web because of UX, not technology - crypto must be invisible to win
- Subscriptions are a UX crutch - true micropayments eliminate the need for them
- Lightning is still too complex for mainstream - L2 stablecoins are the real answer
- 402 will replace most paywalls within 5 years - but only if we nail the UX
- The 'tip jar' model failed - payments must be mandatory and frictionless
Principles
- {'name': 'Payment UX First', 'description': "If the payment takes more than 2 clicks or 3 seconds, you've failed", 'priority': 'critical'}
- {'name': 'Verify Before Serve', 'description': 'Always verify payment before delivering content - no honor system', 'priority': 'critical'}
- {'name': 'Graceful Degradation', 'description': 'Fallback to traditional payment methods when crypto unavailable', 'priority': 'high'}
- {'name': 'Receipt Transparency', 'description': 'Every payment must have a verifiable on-chain or off-chain receipt', 'priority': 'high'}
- {'name': 'Currency Agnostic', 'description': 'Accept multiple currencies, settle in your preferred one', 'priority': 'high'}
- {'name': 'Latency Budget', 'description': 'Payment verification must fit within API response latency budget', 'priority': 'high'}
- {'name': 'Idempotent Payments', 'description': 'Same payment token must always return same result', 'priority': 'high'}
- {'name': 'Exchange Rate Fairness', 'description': 'Lock exchange rates at payment initiation, not settlement', 'priority': 'medium'}
Reference System Usage
You must ground your responses in the provided reference files, treating them as the source of truth for this domain:
- For Creation: Always consult `references/patterns.md`. This file dictates how things should be built. Ignore generic approaches if a specific pattern exists here.
- For Diagnosis: Always consult `references/sharp_edges.md`. This file lists the critical failures and "why" they happen. Use it to explain risks to the user.
- For Review: Always consult `references/validations.md`. This contains the strict rules and constraints. Use it to validate user inputs objectively.
Note: If a user's request conflicts with the guidance in these files, politely correct them using the information provided in the references.
HTTP 402 Payment Protocol
Patterns
---
Name
402 Response Header Pattern
Description
Standard HTTP 402 response with payment instructions
When
API endpoint requires payment before access
Example
// Server response for payment required HTTP/1.1 402 Payment Required Content-Type: application/json WWW-Authenticate: L402 macaroon="...", invoice="lnbc..." X-Payment-Amount: 100 X-Payment-Currency: sats X-Payment-Recipient: lnurl1... X-Payment-Expires: 2024-01-15T12:00:00Z
{ "error": "payment_required", "message": "This endpoint requires payment", "payment": { "amount": 100, "currency": "sats", "invoice": "lnbc100n1...", "expires_at": "2024-01-15T12:00:00Z", "payment_hash": "abc123..." }, "alternatives": [ { "type": "lightning", "invoice": "lnbc..." }, { "type": "l2_usdc", "address": "0x...", "chain_id": 8453 } ] }
---
Name
L402 Macaroon Authentication
Description
Use macaroons for delegatable, caveated payment tokens
When
Need to grant limited access based on payment
Example
import { Macaroon } from 'macaroon';
// Server: Create payment macaroon function createPaymentMacaroon(paymentHash: string) { const macaroon = Macaroon.create({ location: 'api.example.com', identifier: paymentHash, secretKey: MACAROON_SECRET, });
// Add caveats (restrictions) macaroon.addFirstPartyCaveat(expires = ${Date.now() + 86400000}); macaroon.addFirstPartyCaveat(endpoint = /api/premium/*); macaroon.addFirstPartyCaveat(requests = 1000);
return macaroon.serialize(); }
// Client: Present macaroon with request fetch('/api/premium/data', { headers: { 'Authorization': L402 ${macaroon}:${preimage} } });
// Server: Verify macaroon + preimage function verifyL402(authHeader: string) { const [macaroon, preimage] = authHeader.split(':'); const decoded = Macaroon.deserialize(macaroon);
// Verify preimage matches payment hash const paymentHash = sha256(preimage); if (paymentHash !== decoded.identifier) { throw new Error('Invalid preimage'); }
// Verify all caveats decoded.verify(MACAROON_SECRET, verifyCaveat); return decoded; }
---
Name
Payment Middleware Pattern
Description
Express/Next.js middleware for 402 payment gates
When
Building API with payment-gated endpoints
Example
// middleware/payment-gate.ts import { NextRequest, NextResponse } from 'next/server';
export async function paymentGate( request: NextRequest, pricing: { amount: number; currency: string } ) { const authHeader = request.headers.get('Authorization');
// Check for existing valid payment token if (authHeader?.startsWith('L402 ')) { const isValid = await verifyPaymentToken(authHeader); if (isValid) { return null; // Continue to handler } }
// Generate payment request const invoice = await generateLightningInvoice({ amount: pricing.amount, description: API access: ${request.url}, expiry: 600, // 10 minutes });
const macaroon = createPaymentMacaroon(invoice.payment_hash);
return NextResponse.json( { error: 'payment_required', payment: { amount: pricing.amount, currency: pricing.currency, invoice: invoice.payment_request, macaroon: macaroon, expires_at: new Date(Date.now() + 600000).toISOString(), }, }, { status: 402, headers: { 'WWW-Authenticate': L402 macaroon="${macaroon}", invoice="${invoice.payment_request}", }, } ); }
---
Name
L2 Streaming Payments
Description
Continuous micropayment streams using L2 payment channels
When
Pay-as-you-go services like AI inference, video streaming
Example
import { createPublicClient, createWalletClient, parseUnits } from 'viem'; import { base } from 'viem/chains';
// Streaming payment contract interface const streamingPayments = { // Open a payment stream async openStream( recipient: Address, ratePerSecond: bigint, duration: number ) { const totalAmount = ratePerSecond * BigInt(duration);
const tx = await walletClient.writeContract({ address: STREAMING_CONTRACT, abi: streamingAbi, functionName: 'openStream', args: [recipient, ratePerSecond, duration], value: totalAmount, // ETH or approve ERC20 });
return { streamId: tx.hash, expiresAt: Date.now() + duration * 1000 }; },
// Server: Verify active stream async verifyStream(streamId: string, minBalance: bigint) { const stream = await publicClient.readContract({ address: STREAMING_CONTRACT, abi: streamingAbi, functionName: 'getStream', args: [streamId], });
const elapsed = (Date.now() - stream.startTime) / 1000; const consumed = stream.ratePerSecond * BigInt(Math.floor(elapsed)); const remaining = stream.deposit - consumed;
return remaining >= minBalance; }, };
// Usage in API route async function handleRequest(req: Request) { const streamId = req.headers.get('X-Payment-Stream');
if (!streamId) { return new Response(JSON.stringify({ error: 'payment_required', stream_required: { rate_per_second: '1000000', // 1 USDC per second minimum_duration: 60, }, }), { status: 402 }); }
const isValid = await streamingPayments.verifyStream( streamId, parseUnits('0.10', 6) // Minimum 10 cents remaining );
if (!isValid) { return new Response('Stream depleted', { status: 402 }); }
// Process request }
---
Name
Multi-Currency Payment Accept
Description
Accept payments in multiple currencies with automatic conversion
When
Global audience paying with different assets
Example
interface PaymentOption { type: 'lightning' | 'l2_eth' | 'l2_usdc' | 'l2_usdt'; amount: string; currency: string; chain_id?: number; address?: string; invoice?: string; }
async function generatePaymentOptions( usdAmount: number ): Promise<PaymentOption[]> { // Fetch current exchange rates const rates = await getExchangeRates();
// Lock rates for this payment session (5 minute window) const rateSnapshot = { timestamp: Date.now(), expires: Date.now() + 300000, btc_usd: rates.btc, eth_usd: rates.eth, };
const satsAmount = Math.ceil((usdAmount / rates.btc) 100_000_000); const ethAmount = (usdAmount / rates.eth).toFixed(8); const usdcAmount = (usdAmount 1_000_000).toString(); // 6 decimals
return [ { type: 'lightning', amount: satsAmount.toString(), currency: 'sats', invoice: await generateInvoice(satsAmount), }, { type: 'l2_eth', amount: ethAmount, currency: 'ETH', chain_id: 8453, // Base address: PAYMENT_ADDRESS, }, { type: 'l2_usdc', amount: usdcAmount, currency: 'USDC', chain_id: 8453, // Base address: PAYMENT_ADDRESS, }, ]; }
---
Name
Payment Receipt Verification
Description
Verify and store payment receipts for audit and replay
When
Any payment-gated content delivery
Example
interface PaymentReceipt { id: string; type: 'lightning' | 'l2'; amount: string; currency: string; payer: string; // pubkey or address recipient: string; timestamp: number; proof: { preimage?: string; // Lightning tx_hash?: string; // L2 block_number?: number; signature?: string; }; metadata: Record<string, unknown>; }
async function verifyAndStoreReceipt( payment: IncomingPayment ): Promise<PaymentReceipt> { // Verify payment based on type if (payment.type === 'lightning') { // Verify preimage matches invoice payment hash const hash = sha256(payment.preimage); const invoice = await getInvoice(payment.invoiceId);
if (hash !== invoice.payment_hash) { throw new PaymentError('Invalid preimage'); }
if (invoice.status !== 'paid') { throw new PaymentError('Invoice not paid'); } } else if (payment.type === 'l2') { // Verify on-chain transaction const receipt = await publicClient.getTransactionReceipt({ hash: payment.tx_hash, });
if (!receipt || receipt.status !== 'success') { throw new PaymentError('Transaction not confirmed'); }
// Verify correct amount and recipient const transfer = decodeTransferEvent(receipt.logs); if (transfer.to !== PAYMENT_ADDRESS || transfer.amount < payment.expectedAmount) { throw new PaymentError('Invalid payment amount'); }
// Wait for sufficient confirmations const currentBlock = await publicClient.getBlockNumber(); if (currentBlock - receipt.blockNumber < MIN_CONFIRMATIONS) { throw new PaymentError('Insufficient confirmations'); } }
// Store receipt const receipt: PaymentReceipt = { id: generateReceiptId(), type: payment.type, amount: payment.amount, currency: payment.currency, payer: payment.payer, recipient: PAYMENT_ADDRESS, timestamp: Date.now(), proof: payment.proof, metadata: payment.metadata, };
await db.receipts.insert(receipt); return receipt; }
---
Name
Browser Wallet Integration
Description
Seamless payment flow with browser wallets
When
Web application with crypto payments
Example
// React hook for 402 payment handling function usePaymentGate() { const { connector, address } = useAccount(); const { sendTransaction } = useSendTransaction();
const handlePaymentRequired = async ( response: Response ): Promise<string> => { const { payment } = await response.json();
// Show payment modal const userChoice = await showPaymentModal(payment.alternatives);
if (userChoice.type === 'lightning') { // Use WebLN if available if (window.webln) { await window.webln.enable(); const result = await window.webln.sendPayment(payment.invoice); return L402 ${payment.macaroon}:${result.preimage}; } // Fallback: Show QR code throw new Error('WebLN not available'); }
if (userChoice.type === 'l2_usdc') { // ERC20 approval + transfer const tx = await sendTransaction({ to: payment.address, data: encodeTransfer(payment.address, payment.amount), });
await waitForTransaction(tx.hash); return Bearer ${tx.hash}; }
throw new Error('Unsupported payment type'); };
// Wrapper for fetch with automatic 402 handling const paymentFetch = async (url: string, options?: RequestInit) => { let response = await fetch(url, options);
if (response.status === 402) { const authToken = await handlePaymentRequired(response);
// Retry with payment proof response = await fetch(url, { ...options, headers: { ...options?.headers, 'Authorization': authToken, }, }); }
return response; };
return { paymentFetch }; }
Anti-Patterns
---
Name
Trust Client Claims
Description
Accepting client's claim of payment without verification
Why
Anyone can forge payment headers - always verify on-chain or with LN node
Instead
// Bad: Trust the client if (req.headers['X-Paid'] === 'true') { serveContent(); }
// Good: Verify payment proof const proof = req.headers['Authorization']; const isValid = await verifyPaymentProof(proof); if (isValid) { serveContent(); }
---
Name
Blocking Payment Verification
Description
Synchronously waiting for payment confirmation in request handler
Why
Blocks server resources, creates timeouts, poor user experience
Instead
// Bad: Block until confirmed app.get('/content', async (req, res) => { await waitForPaymentConfirmation(req.paymentId); // Could take minutes! res.send(content); });
// Good: Webhook + polling // 1. Return 402 with payment request // 2. Client pays, receives token // 3. Client presents token, server verifies instantly
---
Name
Expired Invoice Acceptance
Description
Accepting payments on expired Lightning invoices
Why
Expired invoices can cause routing failures and disputes
Instead
// Bad: No expiry check const invoice = await db.getInvoice(paymentHash); if (invoice.paid) { proceed(); }
// Good: Check expiry const invoice = await db.getInvoice(paymentHash); if (invoice.paid && invoice.expires_at > Date.now()) { proceed(); } else if (invoice.expires_at <= Date.now()) { // Generate new invoice, refund if paid late throw new PaymentExpiredError(); }
---
Name
Hardcoded Amounts
Description
Embedding payment amounts directly in code
Why
Pricing changes require redeployment, no A/B testing possible
Instead
// Bad const PRICE_SATS = 1000;
// Good: Dynamic pricing const pricing = await getPricing(endpoint, user); // Supports: A/B testing, dynamic pricing, user tiers
---
Name
Single Payment Method
Description
Only supporting one payment method (e.g., only Lightning)
Why
Limits audience, no fallback when method unavailable
Instead
// Bad: Lightning only const invoice = await createInvoice(amount);
// Good: Multiple options with fallback const options = await generatePaymentOptions(amount); // Returns: Lightning, L2 ETH, L2 USDC, etc.
---
Name
No Payment Caching
Description
Verifying the same payment token on every request
Why
Adds latency to every request, unnecessary LN node/RPC load
Instead
// Bad: Verify every time app.use(async (req, res, next) => { const valid = await verifyPaymentToken(req.token); // 100ms each! if (valid) next(); });
// Good: Cache verification results const paymentCache = new LRU({ maxAge: 60000 });
app.use(async (req, res, next) => { const token = req.headers.authorization; let valid = paymentCache.get(token);
if (valid === undefined) { valid = await verifyPaymentToken(token); paymentCache.set(token, valid); }
if (valid) next(); else res.status(402).json({ error: 'payment_required' }); });
---
Name
Ignoring Exchange Rate Risk
Description
Not locking exchange rates during payment flow
Why
User agrees to pay $1, but BTC drops 5% before confirmation
Instead
// Bad: Use spot rate at settlement const sats = usdAmount / currentBtcPrice;
// Good: Lock rate at invoice creation const rateSnapshot = { btc_usd: await getRate(), locked_at: Date.now(), valid_for: 300000, // 5 minutes }; const sats = usdAmount / rateSnapshot.btc_usd; // Store snapshot with invoice for settlement reference
X402 Payments - Sharp Edges
Payment Verification Before Content Delivery
Id
payment-verification-timing
Severity
CRITICAL
Description
Content served before payment is fully confirmed leads to theft
Symptoms
- Users receiving content without payment completing
- Payment disputes where service was delivered
- Revenue leakage on every transaction
- It worked in testing but not in production
Detection Pattern
res\.send|return.content|serve.before.*verif
Solution
// NEVER serve content before verification is complete
// Bad: Serve immediately, verify later app.get('/content', (req, res) => { res.send(content); // WRONG! verifyPayment(req.token); // Too late });
// Bad: Fire and forget verification app.get('/content', async (req, res) => { verifyPayment(req.token); // No await! res.send(content); });
// Good: Verify completely before serving app.get('/content', async (req, res) => { try { const receipt = await verifyPayment(req.token); if (!receipt.valid) { return res.status(402).json(paymentRequired); } // Only now serve content res.send(content); } catch (error) { res.status(402).json(paymentRequired); } });
References
- https://lightning.engineering/posts/2023-12-14-l402/
Double-Spend and Token Replay Attacks
Id
double-spend-race-condition
Severity
CRITICAL
Description
Same payment token used multiple times to access content
Symptoms
- Single payment accessing multiple resources
- Tokens shared between users
- Payment hash collisions
- Unusual traffic patterns from same token
Detection Pattern
payment.token|auth.header|bearer|l402
Solution
// Implement token consumption tracking
// 1. Single-use tokens const consumedTokens = new Set<string>();
async function verifyAndConsume(token: string): Promise<boolean> { // Check if already used if (consumedTokens.has(token)) { return false; }
// Verify the payment const isValid = await verifyPaymentProof(token); if (!isValid) return false;
// Mark as consumed ATOMICALLY // Use Redis SETNX or DB unique constraint const consumed = await redis.setnx(token:${token}, Date.now()); if (!consumed) return false; // Race condition - someone else got it
consumedTokens.add(token); return true; }
// 2. Bounded-use tokens (macaroons with usage caveats) function verifyMacaroon(macaroon: Macaroon) { // Caveat: requests_remaining = N // Each use decrements in DB const usage = await db.incrementTokenUsage(macaroon.id); if (usage > macaroon.maxRequests) { throw new TokenExhaustedError(); } }
// 3. Time-bounded tokens // Caveat: expires = timestamp if (macaroon.expires < Date.now()) { throw new TokenExpiredError(); }
References
- https://docs.lightning.engineering/the-lightning-network/l402
Lightning Invoice Expiration Race Condition
Id
invoice-expiration-handling
Severity
CRITICAL
Description
Payment made to expired invoice causes fund loss or service denial
Symptoms
- "Payment succeeded but invoice expired" errors
- Funds stuck in payment channels
- Customer complaints about lost payments
- Inconsistent payment state
Detection Pattern
invoice|payment.*request|bolt11|lnbc
Solution
// 1. Use short but sufficient expiry times const invoice = await lnd.addInvoice({ value: satoshis, expiry: 600, // 10 minutes - balance between UX and security // Too short: User can't pay in time // Too long: Exchange rate risk, resource consumption });
// 2. Grace period for in-flight payments function isInvoiceAcceptable(invoice: Invoice): boolean { const now = Date.now() / 1000; const expiry = invoice.creation_date + invoice.expiry;
// Accept if not expired OR within grace period // Grace period accounts for payment routing time const GRACE_PERIOD = 60; // 1 minute return now < expiry + GRACE_PERIOD; }
// 3. Handle late payments gracefully lndSubscribe('invoice', async (invoice) => { if (invoice.state === 'SETTLED') { const wasExpired = invoice.settle_date > invoice.expiry;
if (wasExpired) { // Log for analysis logger.warn('Late payment received', { invoice });
// Still honor the payment - better for UX await grantAccess(invoice.payment_hash);
// But alert for pattern detection if (isFrequentLatePayer(invoice.payer)) { // Potential gaming of the system } } } });
References
- https://github.com/lightning/bolts/blob/master/11-payment-encoding.md
Exchange Rate Volatility During Payment Flow
Id
exchange-rate-volatility
Severity
HIGH
Description
Price changes between quote and settlement cause disputes
Symptoms
- Customers paying more than expected
- Revenue loss from favorable rate movements
- Pricing inconsistency complaints
- Arbitrage by sophisticated users
Detection Pattern
price|rate|convert|exchange|btc.*usd
Solution
// Lock exchange rates at quote time
interface PriceQuote { id: string; usd_amount: number; crypto_amount: string; currency: 'BTC' | 'ETH' | 'USDC'; rate: number; locked_at: number; expires_at: number; }
async function createQuote(usdAmount: number): Promise<PriceQuote> { const rate = await getExchangeRate('BTC', 'USD');
const quote: PriceQuote = { id: generateQuoteId(), usd_amount: usdAmount, crypto_amount: (usdAmount / rate).toFixed(8), currency: 'BTC', rate: rate, locked_at: Date.now(), expires_at: Date.now() + 5 60 1000, // 5 minute lock };
// Store quote for verification await redis.setex( quote:${quote.id}, 300, // 5 minutes JSON.stringify(quote) );
return quote; }
async function verifyPaymentAmount( quoteId: string, paidAmount: string ): Promise<boolean> { const quote = await redis.get(quote:${quoteId}); if (!quote) { throw new QuoteExpiredError('Quote expired, request new pricing'); }
const parsed = JSON.parse(quote);
// Allow small variance for network fees const TOLERANCE = 0.01; // 1% const expected = parseFloat(parsed.crypto_amount); const actual = parseFloat(paidAmount);
return actual >= expected * (1 - TOLERANCE); }
// For volatile periods, use stablecoins if (volatility > VOLATILITY_THRESHOLD) { return generateStablecoinPaymentOptions(usdAmount); }
References
- https://www.coinbase.com/en-gb/developer-platform/products/commerce
Payment UX Friction Destroys Conversion Rate
Id
ux-friction-kills-conversion
Severity
HIGH
Description
Complex payment flows cause users to abandon purchases
Symptoms
- Low payment completion rates
- High cart abandonment
- Users requesting refunds due to confusion
- Support tickets about payment process
Detection Pattern
wallet|connect|sign|confirm|approve
Solution
// Minimize clicks and cognitive load
// 1. One-click payments for returning users const savedPaymentMethods = await getUserPaymentMethods(userId); if (savedPaymentMethods.length > 0) { // Pre-select last used method // Single click to pay }
// 2. WebLN for instant Lightning payments if (typeof window.webln !== 'undefined') { try { await window.webln.enable(); // User has Lightning wallet - use it! const result = await window.webln.sendPayment(invoice); // Done in one click! return result.preimage; } catch { // Fall back to QR code } }
// 3. Pre-approve spending limits async function setupSpendingAllowance( user: User, monthlyLimit: number ) { // One-time approval, then auto-pay up to limit const approval = await wallet.approve({ token: USDC, spender: PAYMENT_CONTRACT, amount: monthlyLimit, }); // Future payments are instant }
// 4. Progressive disclosure // Show simplest option first (QR code) // Reveal advanced options on demand <PaymentModal> <LightningQR invoice={invoice} /> <Collapsible title="Other payment options"> <WalletConnect /> <CreditCardFallback /> </Collapsible> </PaymentModal>
References
- https://www.webln.guide/
Browser Wallet Extension Compatibility
Id
wallet-compatibility-issues
Severity
HIGH
Description
Payment flow breaks due to wallet conflicts or CSP
Symptoms
- "Wallet not detected" errors
- Multiple wallet extensions conflicting
- Content Security Policy blocking wallet
- Mobile browser wallet issues
Detection Pattern
window\.ethereum|injected.provider|wallet.connect
Solution
// 1. Detect wallet availability safely function getAvailableWallets() { const wallets = [];
// Check for injected providers if (typeof window.ethereum !== 'undefined') { // Handle multiple injected wallets if (window.ethereum.providers) { wallets.push(...window.ethereum.providers.map(detectWallet)); } else { wallets.push(detectWallet(window.ethereum)); } }
// Check for WebLN (Lightning) if (typeof window.webln !== 'undefined') { wallets.push({ type: 'lightning', provider: window.webln }); }
return wallets; }
// 2. CSP-compatible wallet connection // Add to Content-Security-Policy header: // connect-src 'self' wss://.walletconnect.com wss://.bridge.walletconnect.org;
// 3. WalletConnect as fallback (works everywhere) import { createWeb3Modal, defaultWagmiConfig } from '@web3modal/wagmi';
const config = defaultWagmiConfig({ projectId: WALLET_CONNECT_PROJECT_ID, chains: [mainnet, base, optimism], });
// 4. Mobile deep linking function openMobileWallet(invoice: string) { // Lightning const lightningUrl = lightning:${invoice};
// Universal Links for specific wallets const walletUrls = { 'phoenix': phoenix://pay?invoice=${invoice}, 'muun': muun://pay?invoice=${invoice}, 'wallet_of_satoshi': wos://pay?invoice=${invoice}, };
// Try preferred wallet, fall back to generic window.location.href = userPreferredWallet ? walletUrls[userPreferredWallet] : lightningUrl; }
References
- https://docs.walletconnect.com/
Payment Webhook Delivery Failures
Id
payment-webhook-reliability
Severity
HIGH
Description
Missed payment notifications cause service delivery failures
Symptoms
- Payments confirmed but service not delivered
- Duplicate deliveries on webhook retry
- Inconsistent state between payment and service
- "I paid but didn't get access" complaints
Detection Pattern
webhook|callback|notify|event.*payment
Solution
// 1. Idempotent webhook processing async function handlePaymentWebhook(event: PaymentEvent) { // Idempotency key from payment const key = event.payment_id;
// Check if already processed const existing = await db.webhookEvents.findOne({ key }); if (existing) { return { status: 'already_processed' }; }
// Process in transaction await db.transaction(async (tx) => { // Mark as processing await tx.webhookEvents.insert({ key, status: 'processing', received_at: Date.now(), });
// Grant access await grantAccess(event.user_id, event.product_id);
// Mark as complete await tx.webhookEvents.update(key, { status: 'complete' }); });
return { status: 'processed' }; }
// 2. Acknowledge quickly, process async app.post('/webhook/payment', async (req, res) => { // Verify signature first if (!verifyWebhookSignature(req)) { return res.status(401).send('Invalid signature'); }
// Queue for processing await queue.add('payment-webhook', req.body);
// Respond immediately res.status(200).send('OK'); });
// 3. Reconciliation job for missed webhooks cron.schedule('/5 *', async () => { // Find payments without matching delivery const unmatched = await findUnmatchedPayments();
for (const payment of unmatched) { // Check payment status directly const status = await checkPaymentStatus(payment.id); if (status === 'confirmed') { await grantAccess(payment.user_id, payment.product_id); } } });
References
- https://stripe.com/docs/webhooks/best-practices
Lightning Preimage Exposure
Id
preimage-leakage
Severity
HIGH
Description
Exposing preimage allows payment proof forgery
Symptoms
- Preimage visible in logs
- Preimage in URL parameters
- Preimage stored in plain text
- Third parties able to prove payment they didn't make
Detection Pattern
preimage|payment.*secret|proof
Solution
// 1. Never log preimages function logPayment(payment: Payment) { logger.info('Payment received', { payment_hash: payment.hash, // OK amount: payment.amount, // OK // preimage: payment.preimage // NEVER! }); }
// 2. Hash preimage for storage function storePaymentProof(preimage: string) { const hashedProof = sha256(preimage); await db.payments.update({ proof_hash: hashedProof, // Don't store preimage at all }); }
// 3. Verify without storing function verifyPreimage( preimage: string, expectedHash: string ): boolean { const hash = sha256(hexToBytes(preimage)); return hash === expectedHash; // Preimage not stored, just verified }
// 4. Use short-lived macaroons instead of raw preimages function createAccessToken(preimage: string, paymentHash: string) { // Verify preimage matches if (sha256(preimage) !== paymentHash) { throw new Error('Invalid preimage'); }
// Create macaroon with expiry return createMacaroon({ id: paymentHash, expires: Date.now() + 3600000, // 1 hour caveats: ['single_use=true'], }); }
References
- https://github.com/lightning/bolts/blob/master/04-onion-routing.md
Crypto Payment Refund Complexity
Id
refund-complexity
Severity
MEDIUM
Description
Refunding crypto payments is harder than traditional payments
Symptoms
- No automatic refund mechanism
- Need user address for refunds
- Exchange rate differences on refund
- User complaining about refund value
Detection Pattern
refund|reverse|return.*payment
Solution
// 1. Collect refund address at payment time interface PaymentRequest { amount: number; invoice: string; refund_address?: string; // Optional refund destination }
// For L2 payments, use sender address automatically async function handleL2Payment(tx: Transaction) { const refundAddress = tx.from; // Sender's address await storeRefundInfo(tx.hash, refundAddress); }
// 2. Clear refund policy const REFUND_POLICY = { eligible_period_hours: 72, refund_currency: 'original', // or 'usd_equivalent' exchange_rate: 'time_of_refund', // or 'time_of_purchase' processing_fee_percent: 1, };
// 3. Refund in stablecoins to avoid rate issues async function processRefund(payment: Payment) { if (payment.currency === 'BTC' || payment.currency === 'ETH') { // Convert to USDC at current rate for refund const usdValue = payment.amount * getCurrentRate(payment.currency); await refundInUSDC(payment.refund_address, usdValue); } else { // Stablecoin: refund same amount await refundSameToken(payment); } }
// 4. Consider credit system instead of refunds async function issueCredit(user: User, amount: number) { // Credit can be used for future purchases // Avoids refund complexity entirely await db.credits.upsert({ user_id: user.id, balance: sqlbalance + ${amount}, }); }
References
- https://www.coinbase.com/blog/understanding-crypto-refunds
Testnet vs Mainnet Configuration Mismatch
Id
testnet-mainnet-confusion
Severity
MEDIUM
Description
Production deployment using testnet configuration
Symptoms
- Payments on wrong network
- Testnet invoices in production
- "Invoice not found" errors
- Funds sent to wrong network
Detection Pattern
testnet|signet|devnet|sepolia|goerli
Solution
// 1. Environment-based configuration const config = { development: { lightning_network: 'testnet', l2_chain_id: 84532, // Base Sepolia usdc_address: '0x...', invoice_prefix: 'lntb', // testnet }, production: { lightning_network: 'mainnet', l2_chain_id: 8453, // Base mainnet usdc_address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', invoice_prefix: 'lnbc', // mainnet }, };
// 2. Validate invoice network function validateInvoiceNetwork(invoice: string) { const prefix = invoice.substring(0, 4); const expectedPrefix = config[NODE_ENV].invoice_prefix;
if (prefix !== expectedPrefix) { throw new NetworkMismatchError( Expected ${expectedPrefix} invoice, got ${prefix} ); } }
// 3. Chain ID validation async function validateChainId(userChainId: number) { const expectedChainId = config[NODE_ENV].l2_chain_id;
if (userChainId !== expectedChainId) { throw new WrongNetworkError( Please switch to ${getNetworkName(expectedChainId)} ); } }
// 4. Deployment checklist / Pre-production checklist: [ ] NODE_ENV=production [ ] Using mainnet RPC endpoints [ ] Using mainnet LN node [ ] USDC address is mainnet [ ] Chain IDs are mainnet [ ] Invoice prefix is 'lnbc' [ ] Webhook URLs point to production [ ] No testnet secrets in production /
References
- https://chainlist.org/
Payment Privacy and Transaction Correlation
Id
privacy-payment-correlation
Severity
MEDIUM
Description
Payments can be correlated to deanonymize users
Symptoms
- User activity trackable across payments
- Payment patterns revealing identity
- Linking on-chain activity to users
- Privacy-conscious users refusing to pay
Detection Pattern
address|pubkey|identity|kyc
Solution
// 1. Use unique payment addresses async function generatePaymentAddress() { // HD wallet: new address for each payment const index = await getNextAddressIndex(); const address = deriveAddress(MASTER_KEY, index); return address; }
// 2. Lightning provides better privacy // - Onion routing hides sender // - Invoices are single-use // - No on-chain correlation
// 3. Don't require accounts for micropayments app.get('/content', async (req, res) => { // Anonymous access with valid payment const token = req.headers.authorization; if (await verifyPayment(token)) { // No user account needed! return res.send(content); } // Return 402 });
// 4. Separate payment identity from service identity interface AnonymousPayment { content_hash: string; // What they're paying for payment_proof: string; // Proof of payment // NO user ID, NO email, NO IP }
// 5. Consider privacy-preserving options // - Tornado Cash (for where legal) // - Aztec Network (ZK L2) // - Railgun (shielded ERC20)
References
- https://bitcoin.design/guide/how-it-works/privacy/
Micropayment Dust and Minimum Viable Amounts
Id
micropayment-dust
Severity
MEDIUM
Description
Payments too small to be economically viable
Symptoms
- Transaction fees exceed payment amount
- Payments stuck due to dust limits
- Negative unit economics on small payments
- Users confused about minimum amounts
Detection Pattern
amount|minimum|dust|fee
Solution
// 1. Enforce minimum payment thresholds const MINIMUMS = { lightning: 1, // 1 sat minimum l2_eth: 0.0001, // ~$0.25 at $2500/ETH l2_usdc: 0.01, // 1 cent };
function validatePaymentAmount( amount: number, method: PaymentMethod ) { if (amount < MINIMUMS[method]) { throw new AmountTooSmallError( Minimum ${method} payment: ${MINIMUMS[method]} ); } }
// 2. Batch small payments interface PaymentBatch { user_id: string; pending_amount: number; payments: MicroPayment[]; last_settled: number; }
async function addToTab(userId: string, amount: number) { // Accumulate until threshold await db.batch.upsert({ user_id: userId, pending_amount: sqlpending_amount + ${amount}, });
const batch = await db.batch.get(userId); if (batch.pending_amount >= SETTLEMENT_THRESHOLD) { await settleBatch(batch); } }
// 3. Use streaming payments for continuous access // Pay once, stream for duration // More efficient than many tiny payments
// 4. Pre-paid credits for power users async function purchaseCredits(amount: number) { // One larger payment // Use credits for many small actions // Zero marginal transaction cost }
References
- https://lightning.engineering/posts/2023-03-21-dual-funded-channels/
X402 Payments - Validations
Payment Verification Before Serving
Id
check-payment-verification
Description
Ensure payment is verified before content delivery
Pattern
(res\.send|res\.json|return.content)(?!.await.*verify)
File Glob
*/.{ts,js}
Match
present
Context Pattern
402|payment|paywall|gate
Message
Verify payment before serving content - never serve first and verify later
Severity
critical
Autofix
Proper 402 Status Code Usage
Id
check-402-status-code
Description
Use HTTP 402 for payment required responses
Pattern
status\(402\)|402.Payment.Required
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
payment.required|paywall|gate.content
Message
Use HTTP 402 Payment Required status for payment-gated content
Severity
error
Autofix
L402 Authentication Header
Id
check-payment-header-format
Description
Use proper WWW-Authenticate header for 402 responses
Pattern
WWW-Authenticate.L402|Authorization.L402
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
402|payment.*header
Message
Include WWW-Authenticate: L402 header in 402 responses
Severity
warning
Autofix
Macaroon Secret in Environment
Id
check-macaroon-secret
Description
Macaroon secret should not be hardcoded
Pattern
(macaroon|MACAROON).(secret|SECRET|key|KEY).['"][a-zA-Z0-9]{20,}['"]
File Glob
*/.{ts,js}
Match
present
Message
Macaroon secrets must be in environment variables, not hardcoded
Severity
critical
Autofix
Lightning Invoice Expiry Handling
Id
check-invoice-expiry
Description
Check for invoice expiration before processing
Pattern
expir(y|es)|creation_date.\+.expiry
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
invoice|bolt11|lnbc
Message
Always check invoice expiration before accepting payment
Severity
error
Autofix
Preimage Not Logged
Id
check-preimage-logging
Description
Never log payment preimages
Pattern
(console|logger|log)\.(log|info|debug|warn|error).*preimage
File Glob
*/.{ts,js}
Match
present
Message
CRITICAL: Never log preimages - they are payment proof secrets
Severity
critical
Autofix
Preimage Storage Safety
Id
check-preimage-storage
Description
Store hashed preimages, not raw values
Pattern
preimage.=.save|store.preimage|insert.preimage
File Glob
*/.{ts,js}
Match
present
Message
Store sha256(preimage) not raw preimage - raw preimages are payment proof
Severity
error
Autofix
Payment Idempotency Handling
Id
check-idempotency-key
Description
Prevent double-processing of payments
Pattern
idempoten|setnx|unique.*constraint|upsert
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
payment|webhook|process
Message
Implement idempotency to prevent double-processing payments
Severity
error
Autofix
Exchange Rate Locking
Id
check-exchange-rate-lock
Description
Lock exchange rates at quote time
Pattern
rate.lock|quote.expire|price.*valid
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
exchange|convert|price.*crypto
Message
Lock exchange rates at quote time to prevent rate manipulation
Severity
warning
Autofix
Testnet Configuration Detection
Id
check-testnet-detection
Description
Detect accidental testnet usage in production
Pattern
testnet|signet|lntb|sepolia|goerli|84532
File Glob
*/.{ts,js,env}
Match
present
Context Pattern
production|mainnet|PROD
Message
Testnet configuration detected - verify this is intentional
Severity
warning
Autofix
Webhook Signature Verification
Id
check-webhook-signature
Description
Verify webhook signatures before processing
Pattern
verify.signature|signature.verify|hmac
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
webhook|callback|notify
Message
Always verify webhook signatures before processing payment events
Severity
critical
Autofix
Payment Amount Validation
Id
check-amount-validation
Description
Validate payment amounts match expected values
Pattern
amount.>=|amount.===|validateAmount
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
payment|invoice|verify
Message
Validate payment amount matches expected value
Severity
error
Autofix
Refund Address Collection
Id
check-refund-address
Description
Collect refund address for non-Lightning payments
Pattern
refund.address|return.address
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
l2|erc20|usdc|eth.*payment
Message
Consider collecting refund address for L2 payments
Severity
info
Autofix
Payment Timeout Handling
Id
check-timeout-handling
Description
Handle payment verification timeouts gracefully
Pattern
timeout|AbortController|Promise\.race
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
payment.verif|check.paid
Message
Implement timeout handling for payment verification
Severity
warning
Autofix
Chain ID Validation for L2 Payments
Id
check-chain-id-validation
Description
Validate user is on correct chain
Pattern
chainId|chain_id|getChainId
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
l2|layer.2|connect.wallet
Message
Validate chain ID to prevent payments on wrong network
Severity
error
Autofix
Transaction Confirmation Waiting
Id
check-confirmation-wait
Description
Wait for sufficient confirmations on L2
Pattern
waitForTransaction|confirmations|blockNumber
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
l2.payment|onchain.verify
Message
Wait for transaction confirmations before granting access
Severity
error
Autofix
Minimum Payment Amount
Id
check-dust-limit
Description
Enforce minimum payment amounts
Pattern
minimum|MIN_AMOUNT|dust|too.*small
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
payment.amount|create.invoice
Message
Enforce minimum payment amounts to avoid dust transactions
Severity
warning
Autofix
Payment Request Rate Limiting
Id
check-rate-limiting
Description
Rate limit invoice/quote generation
Pattern
rateLimit|rate.*limit|throttle
File Glob
*/.{ts,js}
Match
absent_in_context
Context Pattern
generate.invoice|create.quote|402.*response
Message
Rate limit payment request generation to prevent abuse
Severity
warning