From 9361f9e4e28b98145f5c052bd65ef60abdda9534 Mon Sep 17 00:00:00 2001 From: OpSpawn Date: Fri, 6 Feb 2026 07:49:05 +0000 Subject: [PATCH] Add USDC payment integration (Polygon, direct wallet monitoring) - payments.js: Invoice creation, blockchain polling, payment matching - Endpoints: POST /api/subscribe, GET /api/subscribe/:id, GET /api/pricing - Plans: Pro ($10/mo, 1000 captures), Enterprise ($50/mo, 10000 captures) - Zero fees: Direct USDC transfer monitoring on Polygon - Unique cent offsets per invoice for payment reconciliation - Auto-generates API keys on payment confirmation Co-Authored-By: Claude Opus 4.6 --- invoices.json | 17 +++++ payments.js | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 101 ++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 invoices.json create mode 100644 payments.js diff --git a/invoices.json b/invoices.json new file mode 100644 index 0000000..ebe9e4a --- /dev/null +++ b/invoices.json @@ -0,0 +1,17 @@ +{ + "79965d9149a43546": { + "id": "79965d9149a43546", + "plan": "pro", + "email": "test@example.com", + "amount": 10.41, + "amount_raw": 10410000, + "status": "pending", + "wallet": "0x7483a9F237cf8043704D6b17DA31c12BfFF860DD", + "network": "Polygon", + "token": "USDC", + "created_at": "2026-02-06T07:41:21.954Z", + "expires_at": "2026-02-06T08:41:21.954Z", + "tx_hash": null, + "api_key": null + } +} \ No newline at end of file diff --git a/payments.js b/payments.js new file mode 100644 index 0000000..4c41c2b --- /dev/null +++ b/payments.js @@ -0,0 +1,193 @@ +/** + * USDC Payment Module for SnapAPI + * + * Monitors Polygon USDC transfers to the OpSpawn wallet. + * Creates invoices with unique amounts for reconciliation. + * Auto-generates API keys upon payment confirmation. + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const INVOICES_FILE = path.join(__dirname, 'invoices.json'); +const WALLET_ADDRESS = '0x7483a9F237cf8043704D6b17DA31c12BfFF860DD'; +const USDC_CONTRACT = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'; +const POLYGON_RPC = 'https://polygon-rpc.com'; + +// USDC Transfer event signature: Transfer(address,address,uint256) +const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'; + +// Pricing (in USDC) +const PLANS = { + pro: { price: 10.00, limit: 1000, name: 'Pro', period: 'month' }, + enterprise: { price: 50.00, limit: 10000, name: 'Enterprise', period: 'month' } +}; + +// Load invoices +let invoices = {}; +try { invoices = JSON.parse(fs.readFileSync(INVOICES_FILE, 'utf8')); } catch {} + +function saveInvoices() { + fs.writeFileSync(INVOICES_FILE, JSON.stringify(invoices, null, 2)); +} + +/** + * Create a payment invoice with a unique amount. + * We add a small random offset (0.01-0.99 cents) to distinguish payers. + */ +function createInvoice(plan, email) { + if (!PLANS[plan]) throw new Error(`Unknown plan: ${plan}`); + + const planData = PLANS[plan]; + const invoiceId = crypto.randomBytes(8).toString('hex'); + + // Create a unique amount by adding cents offset based on invoice ID + // This helps match payments to invoices when multiple are pending + const offset = (parseInt(invoiceId.slice(0, 4), 16) % 99 + 1) / 100; + const amount = planData.price + offset; + + // Round to 2 decimal places (USDC has 6 decimals, but we think in dollars) + const roundedAmount = Math.round(amount * 100) / 100; + + invoices[invoiceId] = { + id: invoiceId, + plan, + email: email || null, + amount: roundedAmount, + amount_raw: Math.round(roundedAmount * 1e6), // USDC has 6 decimals + status: 'pending', + wallet: WALLET_ADDRESS, + network: 'Polygon', + token: 'USDC', + created_at: new Date().toISOString(), + expires_at: new Date(Date.now() + 3600000).toISOString(), // 1 hour + tx_hash: null, + api_key: null + }; + + saveInvoices(); + return invoices[invoiceId]; +} + +/** + * Check if an invoice has been paid by querying Polygon USDC Transfer events. + */ +async function checkPayment(invoiceId) { + const invoice = invoices[invoiceId]; + if (!invoice) return null; + if (invoice.status === 'paid') return invoice; + + // Check if expired + if (new Date(invoice.expires_at) < new Date()) { + invoice.status = 'expired'; + saveInvoices(); + return invoice; + } + + // Query USDC Transfer events to our wallet + try { + const fromBlock = '0x' + Math.max(0, await getBlockNumber() - 5000).toString(16); // ~2.5 hours of blocks + const toAddress = '0x' + WALLET_ADDRESS.slice(2).toLowerCase().padStart(64, '0'); + + const response = await fetchRPC('eth_getLogs', [{ + fromBlock, + toBlock: 'latest', + address: USDC_CONTRACT, + topics: [ + TRANSFER_TOPIC, + null, // from (any) + toAddress // to (our wallet) + ] + }]); + + if (response.result && Array.isArray(response.result)) { + for (const log of response.result) { + // Amount is in the data field (uint256, 6 decimals for USDC) + const amountRaw = parseInt(log.data, 16); + + // Check if this matches our invoice amount (within 0.001 USDC tolerance) + if (Math.abs(amountRaw - invoice.amount_raw) < 1000) { + invoice.status = 'paid'; + invoice.tx_hash = log.transactionHash; + invoice.paid_at = new Date().toISOString(); + + // Generate API key + const apiKey = 'pro_' + crypto.randomBytes(16).toString('hex'); + invoice.api_key = apiKey; + + saveInvoices(); + return invoice; + } + } + } + } catch (err) { + console.error('Payment check error:', err.message); + } + + return invoice; +} + +/** + * Get all invoices (for admin) + */ +function listInvoices() { + return Object.values(invoices); +} + +/** + * Get current block number + */ +async function getBlockNumber() { + const resp = await fetchRPC('eth_blockNumber', []); + return parseInt(resp.result, 16); +} + +/** + * JSON-RPC call to Polygon + */ +async function fetchRPC(method, params) { + const response = await fetch(POLYGON_RPC, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); + return response.json(); +} + +/** + * Background poller - checks all pending invoices periodically + */ +let pollInterval = null; + +function startPolling(intervalMs = 30000) { + if (pollInterval) return; + console.log(`Payment poller started (every ${intervalMs / 1000}s)`); + + pollInterval = setInterval(async () => { + const pending = Object.values(invoices).filter(i => i.status === 'pending'); + if (pending.length === 0) return; + + console.log(`Checking ${pending.length} pending invoice(s)...`); + for (const inv of pending) { + await checkPayment(inv.id); + } + }, intervalMs); +} + +function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } +} + +module.exports = { + PLANS, + WALLET_ADDRESS, + createInvoice, + checkPayment, + listInvoices, + startPolling, + stopPolling +}; diff --git a/server.js b/server.js index 1ed4513..a3e23c3 100644 --- a/server.js +++ b/server.js @@ -5,6 +5,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { marked } = require('marked'); +const payments = require('./payments'); const PORT = process.env.PORT || 3001; const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome'; @@ -596,10 +597,110 @@ const server = http.createServer(async (req, res) => { return; } + // --- Payment Endpoints --- + + // Create subscription invoice + if (parsed.pathname === '/api/subscribe' && req.method === 'POST') { + try { + const body = JSON.parse(await readBody(req)); + const plan = body.plan || 'pro'; + const email = body.email || null; + + const invoice = payments.createInvoice(plan, email); + + sendJson(res, 201, { + invoice_id: invoice.id, + plan: invoice.plan, + amount: invoice.amount, + token: 'USDC', + network: 'Polygon', + wallet: invoice.wallet, + expires_at: invoice.expires_at, + instructions: `Send exactly ${invoice.amount} USDC to ${invoice.wallet} on Polygon network. The unique amount helps us identify your payment.`, + check_url: `/api/subscribe/${invoice.id}` + }); + } catch (err) { + sendError(res, 400, err.message); + } + return; + } + + // Check invoice status + if (parsed.pathname.startsWith('/api/subscribe/') && req.method === 'GET') { + const invoiceId = parsed.pathname.split('/api/subscribe/')[1]; + if (!invoiceId) return sendError(res, 400, 'Missing invoice ID'); + + try { + const invoice = await payments.checkPayment(invoiceId); + if (!invoice) return sendError(res, 404, 'Invoice not found'); + + const response = { + invoice_id: invoice.id, + status: invoice.status, + plan: invoice.plan, + amount: invoice.amount + }; + + if (invoice.status === 'paid') { + response.api_key = invoice.api_key; + response.tx_hash = invoice.tx_hash; + response.message = 'Payment confirmed! Your API key is ready to use.'; + + // Also register the key in apiKeys + if (invoice.api_key && !apiKeys[invoice.api_key]) { + const planData = payments.PLANS[invoice.plan]; + apiKeys[invoice.api_key] = { + name: invoice.email || invoice.plan, + tier: invoice.plan, + limit: planData.limit, + used: 0, + resetMonth: new Date().toISOString().slice(0, 7), + created: new Date().toISOString() + }; + fs.writeFileSync(API_KEYS_FILE, JSON.stringify(apiKeys, null, 2)); + } + } else if (invoice.status === 'pending') { + response.wallet = invoice.wallet; + response.network = 'Polygon'; + response.message = `Waiting for ${invoice.amount} USDC payment to ${invoice.wallet}`; + } else if (invoice.status === 'expired') { + response.message = 'Invoice expired. Create a new one at POST /api/subscribe'; + } + + sendJson(res, 200, response); + } catch (err) { + sendError(res, 500, err.message); + } + return; + } + + // List plans/pricing + if (parsed.pathname === '/api/pricing') { + sendJson(res, 200, { + plans: Object.entries(payments.PLANS).map(([id, plan]) => ({ + id, + name: plan.name, + price: plan.price, + currency: 'USDC', + network: 'Polygon', + limit: plan.limit, + period: plan.period, + features: id === 'pro' + ? ['1,000 captures/month', 'All formats (PNG, JPEG, PDF)', 'Markdown conversion', 'Priority support'] + : ['10,000 captures/month', 'All formats (PNG, JPEG, PDF)', 'Markdown conversion', 'Priority support', 'Higher rate limits'] + })), + wallet: payments.WALLET_ADDRESS, + subscribe_url: 'POST /api/subscribe' + }); + return; + } + sendError(res, 404, 'Not found. Visit / for API documentation.'); }); server.listen(PORT, () => { console.log(`Screenshot API running at http://localhost:${PORT}`); console.log(`API keys: ${Object.keys(apiKeys).length} configured`); + // Start payment polling (check every 30 seconds) + payments.startPolling(30000); });