Initial capay platform scaffold

这个提交包含在:
cryptocommuniums-afk
2026-02-01 14:52:46 +08:00
当前提交 8a97fe7882
修改 13 个文件,包含 756 行新增0 行删除

24
app/.env 普通文件
查看文件

@@ -0,0 +1,24 @@
APP_PORT=3000
APP_HOST=https://capay.hao.work
PLAN=free
# Low-cost polling for free plan
TX_POLL_INTERVAL_MS=180000
MAX_TX_CHECK_PER_CYCLE=20
MIN_CONFIRMATIONS=1
# Alchemy Ethereum RPC (supports multiple free keys)
ALCHEMY_API_KEYS=P9kZiHB6Q7CLrBlMsUN3n
ALCHEMY_API_KEY=P9kZiHB6Q7CLrBlMsUN3n
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
# Optional: comma-separated RPC URLs (overrides/extends)
# ETHEREUM_RPC_URLS=https://eth-mainnet.g.alchemy.com/v2/KEY1,https://eth-mainnet.g.alchemy.com/v2/KEY2
# Webhook signature secrets (replace in production)
IPN_HMAC_SECRET=replace-with-strong-secret
BTCPAY_WEBHOOK_SECRET=replace-with-strong-secret
X402_WEBHOOK_SECRET=replace-with-strong-secret
# Payment platform
DB_PATH=./data/db.json
WEBHOOK_TIMEOUT_MS=8000

16
app/package.json 普通文件
查看文件

@@ -0,0 +1,16 @@
{
"name": "capay-app",
"version": "0.1.0",
"private": true,
"main": "src/server.js",
"scripts": {
"dev": "node src/server.js",
"start": "node src/server.js"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.19.2",
"ethers": "^6.12.1",
"nanoid": "^5.0.7"
}
}

280
app/src/server.js 普通文件
查看文件

@@ -0,0 +1,280 @@
require('dotenv').config();
const crypto = require('crypto');
const express = require('express');
const { nanoid } = require('nanoid');
const { readDb, withDb } = require('./storage');
const { startEvmWatcher } = require('./watchers/evmWatcher');
const app = express();
app.use(express.json({ limit: '1mb' }));
const PLAN = process.env.PLAN || 'free';
function nowIso() {
return new Date().toISOString();
}
function base64Json(data) {
return Buffer.from(JSON.stringify(data)).toString('base64');
}
function getHeader(req, name) {
const key = Object.keys(req.headers).find((header) => header.toLowerCase() === name.toLowerCase());
return key ? req.headers[key] : undefined;
}
async function requireMerchant(req, res) {
const merchantId = getHeader(req, 'x-merchant-id');
const apiKey = getHeader(req, 'x-api-key');
if (!merchantId || !apiKey) {
res.status(401).json({ error: 'missing merchant auth headers' });
return null;
}
const db = await readDb();
const merchant = db.merchants.find((item) => item.id === merchantId && item.apiKey === apiKey);
if (!merchant) {
res.status(403).json({ error: 'invalid merchant credentials' });
return null;
}
return merchant;
}
async function logEvent(source, payload) {
await withDb((db) => {
db.events.push({
id: nanoid(),
source,
payload,
createdAt: nowIso()
});
});
}
async function notifyMerchant(order, tx) {
if (!order) return;
const db = await readDb();
const merchant = db.merchants.find((item) => item.id === order.merchantId);
if (!merchant || !merchant.webhookUrl) return;
const payload = {
type: 'payment.confirmed',
orderId: order.id,
txHash: tx.txHash,
network: tx.network,
amount: order.amount,
currency: order.currency,
asset: order.asset,
confirmedAt: nowIso()
};
const body = JSON.stringify(payload);
const signature = crypto.createHmac('sha256', merchant.webhookSecret).update(body).digest('hex');
const timeoutMs = Number(process.env.WEBHOOK_TIMEOUT_MS || 8000);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(merchant.webhookUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Capay-Signature': signature
},
body,
signal: controller.signal
});
} catch (error) {
console.warn('[webhook] failed to notify merchant', merchant.id, error.message);
} finally {
clearTimeout(timeout);
}
}
app.get('/health', (req, res) => {
res.json({ status: 'ok', plan: PLAN });
});
app.post('/merchants', async (req, res) => {
const { name, email, webhookUrl } = req.body || {};
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const merchant = {
id: nanoid(),
name,
email,
webhookUrl: webhookUrl || null,
apiKey: nanoid(32),
webhookSecret: nanoid(32),
createdAt: nowIso()
};
await withDb((db) => {
db.merchants.push(merchant);
});
res.status(201).json({
merchantId: merchant.id,
apiKey: merchant.apiKey,
webhookSecret: merchant.webhookSecret
});
});
app.get('/merchants/:id', async (req, res) => {
const db = await readDb();
const merchant = db.merchants.find((item) => item.id === req.params.id);
if (!merchant) return res.status(404).json({ error: 'merchant not found' });
const safe = { ...merchant };
delete safe.apiKey;
delete safe.webhookSecret;
res.json(safe);
});
app.post('/payments/orders', async (req, res) => {
const merchant = await requireMerchant(req, res);
if (!merchant) return;
const { amount, currency, network, asset, description, lockWindowSeconds, slippage, orderId } = req.body || {};
if (!amount || !currency || !network || !asset) {
return res.status(400).json({ error: 'amount, currency, network, asset are required' });
}
const order = {
id: orderId || nanoid(),
merchantId: merchant.id,
amount: String(amount),
currency: String(currency),
network: String(network),
asset: String(asset),
description: description || null,
status: 'pending',
createdAt: nowIso(),
updatedAt: nowIso()
};
await withDb((db) => {
db.orders.push(order);
});
const paymentRequirements = {
requirements: [
{
kind: 'exact',
network: order.network,
asset: order.asset,
amount: order.amount,
currency: order.currency,
description: order.description || 'Capay order'
}
],
lockWindowSeconds: Number(lockWindowSeconds || 1200),
slippage: Number(slippage || 0.02),
orderId: order.id
};
const base64 = base64Json(paymentRequirements);
res.status(201).json({
order,
paymentRequirements,
paymentRequirementsBase64: base64
});
});
app.post('/payments/track', async (req, res) => {
const merchant = await requireMerchant(req, res);
if (!merchant) return;
const { orderId, txHash, network } = req.body || {};
if (!orderId || !txHash || !network) {
return res.status(400).json({ error: 'orderId, txHash, network are required' });
}
const txRecord = {
id: nanoid(),
orderId,
network,
txHash,
status: 'pending',
confirmations: 0,
lastCheckedAt: null,
createdAt: nowIso(),
updatedAt: nowIso()
};
await withDb((db) => {
db.txs.push(txRecord);
});
res.status(201).json({ tracked: true, tx: txRecord });
});
app.get('/payments/orders/:id', async (req, res) => {
const merchant = await requireMerchant(req, res);
if (!merchant) return;
const db = await readDb();
const order = db.orders.find((item) => item.id === req.params.id && item.merchantId === merchant.id);
if (!order) return res.status(404).json({ error: 'order not found' });
res.json(order);
});
app.post('/payments/webhook', async (req, res) => {
const source = (getHeader(req, 'x-source') || '').toLowerCase();
if (!source) {
return res.status(400).json({ error: 'missing X-Source header' });
}
if (source === 'keagate') {
const secret = process.env.IPN_HMAC_SECRET || '';
const sorted = JSON.stringify(req.body, Object.keys(req.body || {}).sort());
const hmac = crypto.createHmac('sha512', secret).update(sorted).digest('hex');
const sig = getHeader(req, 'x-keagate-sig');
if (!sig || sig !== hmac) {
return res.status(403).json({ error: 'invalid keagate signature' });
}
await logEvent('keagate', req.body);
} else if (source === 'btcpay') {
const secret = process.env.BTCPAY_WEBHOOK_SECRET || '';
const payload = JSON.stringify(req.body || {});
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const sig = getHeader(req, 'btcpay-sig') || getHeader(req, 'btcpay-signature');
if (sig && sig !== expected) {
return res.status(403).json({ error: 'invalid btcpay signature' });
}
await logEvent('btcpay', req.body);
} else if (source === 'x402') {
const secret = process.env.X402_WEBHOOK_SECRET || '';
const payload = JSON.stringify(req.body || {});
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const sig = getHeader(req, 'x402-sig');
if (sig && sig !== expected) {
return res.status(403).json({ error: 'invalid x402 signature' });
}
await logEvent('x402', req.body);
} else {
return res.status(400).json({ error: 'unknown source' });
}
const orderId = req.body?.orderId || req.body?.metadata?.orderId;
if (orderId) {
await withDb((db) => {
const order = db.orders.find((item) => item.id === orderId);
if (order) {
order.status = 'paid';
order.updatedAt = nowIso();
}
});
}
res.status(200).json({ received: true });
});
const port = Number(process.env.APP_PORT || 3000);
app.listen(port, () => {
console.log(`capay app listening on :${port}`);
startEvmWatcher({ notifyMerchant });
});

56
app/src/storage.js 普通文件
查看文件

@@ -0,0 +1,56 @@
const fs = require('fs/promises');
const path = require('path');
const DEFAULT_DB = {
merchants: [],
orders: [],
txs: [],
events: []
};
function resolveDbPath() {
const dbPath = process.env.DB_PATH || './data/db.json';
if (path.isAbsolute(dbPath)) return dbPath;
return path.join(process.cwd(), dbPath);
}
async function ensureDbFile() {
const dbPath = resolveDbPath();
await fs.mkdir(path.dirname(dbPath), { recursive: true });
try {
await fs.access(dbPath);
} catch (error) {
await fs.writeFile(dbPath, JSON.stringify(DEFAULT_DB, null, 2));
}
}
async function readDb() {
await ensureDbFile();
const dbPath = resolveDbPath();
const raw = await fs.readFile(dbPath, 'utf-8');
try {
return JSON.parse(raw);
} catch (error) {
return { ...DEFAULT_DB };
}
}
async function writeDb(db) {
const dbPath = resolveDbPath();
const tmpPath = `${dbPath}.tmp`;
await fs.writeFile(tmpPath, JSON.stringify(db, null, 2));
await fs.rename(tmpPath, dbPath);
}
async function withDb(mutator) {
const db = await readDb();
const result = await mutator(db);
await writeDb(db);
return result;
}
module.exports = {
readDb,
writeDb,
withDb
};

50
app/src/utils/rpc.js 普通文件
查看文件

@@ -0,0 +1,50 @@
function parseList(value) {
if (!value) return [];
return value
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function buildAlchemyUrls(keys) {
return keys.map((key) => `https://eth-mainnet.g.alchemy.com/v2/${key}`);
}
function unique(items) {
return Array.from(new Set(items));
}
function getEthereumRpcUrls() {
const urls = [];
const explicitUrls = parseList(process.env.ETHEREUM_RPC_URLS);
urls.push(...explicitUrls);
if (process.env.ETHEREUM_RPC_URL) {
urls.push(process.env.ETHEREUM_RPC_URL);
}
const keyList = parseList(process.env.ALCHEMY_API_KEYS);
if (keyList.length > 0) {
urls.push(...buildAlchemyUrls(keyList));
} else if (process.env.ALCHEMY_API_KEY) {
urls.push(...buildAlchemyUrls([process.env.ALCHEMY_API_KEY]));
}
return unique(urls);
}
function createRoundRobinPicker(items) {
let index = 0;
return function pick() {
if (!items.length) return null;
const item = items[index % items.length];
index = (index + 1) % items.length;
return item;
};
}
module.exports = {
getEthereumRpcUrls,
createRoundRobinPicker
};

查看文件

@@ -0,0 +1,76 @@
const { JsonRpcProvider } = require('ethers');
const { readDb, writeDb } = require('../storage');
const { getEthereumRpcUrls, createRoundRobinPicker } = require('../utils/rpc');
function startEvmWatcher({ notifyMerchant }) {
const rpcUrls = getEthereumRpcUrls();
if (!rpcUrls.length) {
console.warn('[evmWatcher] No Ethereum RPC URLs configured. Skipping watcher.');
return;
}
const pollInterval = Number(process.env.TX_POLL_INTERVAL_MS || 180000);
const maxPerCycle = Number(process.env.MAX_TX_CHECK_PER_CYCLE || 20);
const minConfirmations = Number(process.env.MIN_CONFIRMATIONS || 1);
const pickRpcUrl = createRoundRobinPicker(rpcUrls);
let running = false;
async function poll() {
if (running) return;
running = true;
try {
const db = await readDb();
const pending = db.txs.filter((tx) => tx.network === 'evm:ethereum' && tx.status === 'pending');
if (pending.length === 0) {
running = false;
return;
}
const rpcUrl = pickRpcUrl();
const provider = new JsonRpcProvider(rpcUrl);
const latestBlock = await provider.getBlockNumber();
const now = new Date().toISOString();
const confirmedEvents = [];
for (const tx of pending.slice(0, maxPerCycle)) {
const receipt = await provider.getTransactionReceipt(tx.txHash);
tx.lastCheckedAt = now;
if (!receipt) continue;
const confirmations = latestBlock - receipt.blockNumber + 1;
tx.confirmations = confirmations;
tx.updatedAt = now;
if (receipt.status === 1 && confirmations >= minConfirmations) {
tx.status = 'confirmed';
const order = db.orders.find((item) => item.id === tx.orderId);
if (order) {
order.status = 'paid';
order.updatedAt = now;
confirmedEvents.push({ order, tx });
}
} else if (receipt.status === 0) {
tx.status = 'failed';
}
}
await writeDb(db);
for (const event of confirmedEvents) {
await notifyMerchant(event.order, event.tx);
}
} catch (error) {
console.error('[evmWatcher] poll error', error.message);
} finally {
running = false;
}
}
poll();
setInterval(poll, pollInterval);
console.log(`[evmWatcher] started with interval ${pollInterval}ms`);
}
module.exports = { startEvmWatcher };