Initial capay platform scaffold
这个提交包含在:
24
app/.env
普通文件
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
普通文件
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
普通文件
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
普通文件
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
普通文件
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
|
||||
};
|
||||
76
app/src/watchers/evmWatcher.js
普通文件
76
app/src/watchers/evmWatcher.js
普通文件
@@ -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 };
|
||||
在新工单中引用
屏蔽一个用户