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 <noreply@anthropic.com>
This commit is contained in:
parent
0609754ab3
commit
9361f9e4e2
17
invoices.json
Normal file
17
invoices.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
193
payments.js
Normal file
193
payments.js
Normal file
@ -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
|
||||||
|
};
|
||||||
101
server.js
101
server.js
@ -5,6 +5,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { marked } = require('marked');
|
const { marked } = require('marked');
|
||||||
|
const payments = require('./payments');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome';
|
const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/google-chrome';
|
||||||
@ -596,10 +597,110 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return;
|
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.');
|
sendError(res, 404, 'Not found. Visit / for API documentation.');
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
console.log(`Screenshot API running at http://localhost:${PORT}`);
|
console.log(`Screenshot API running at http://localhost:${PORT}`);
|
||||||
console.log(`API keys: ${Object.keys(apiKeys).length} configured`);
|
console.log(`API keys: ${Object.keys(apiKeys).length} configured`);
|
||||||
|
// Start payment polling (check every 30 seconds)
|
||||||
|
payments.startPolling(30000);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user