Initial capay platform scaffold

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

83
README.md 普通文件
查看文件

@@ -0,0 +1,83 @@
# Capay 一站式加密货币支付平台(自托管模板)
本仓库提供:
- 统一商户开通(简单 Merchant API
- x402 FacilitatorEVM/Solana配置模板
- BTCPay / Keagate 对接占位
- Webhook 聚合 `/payments/webhook`
- 低开销交易跟踪(免费套餐默认低频轮询)
- Nginx 反向代理与 HTTPSLet's Encrypt示例
## 目录结构
```
app/ # 统一业务 API
infra/nginx/sites-enabled/ # Nginx 配置
infra/docker/ # BTCPay/Keagate 占位
x402/facilitator/ # x402 配置模板
```
## 快速开始App
```bash
cd app
npm install
npm run dev
```
服务默认监听 `:3000`
### 免费套餐低频监控
`app/.env` 中已设置:
- `TX_POLL_INTERVAL_MS=180000`3 分钟)
- `MAX_TX_CHECK_PER_CYCLE=20`
- `MIN_CONFIRMATIONS=1`
交易跟踪采用“待确认 tx 列表 + 轮询 receipt”的低开销方式。
支持多个 Alchemy 免费 key使用 `ALCHEMY_API_KEYS=key1,key2`,系统会按轮询周期进行轮转调用,降低单 key 的频率。
## API 说明(简化版)
### 1) 开通商户
`POST /merchants`
```json
{ "name": "Acme", "email": "ops@acme.com", "webhookUrl": "https://merchant.example.com/webhook" }
```
返回:`merchantId``apiKey``webhookSecret`
### 2) 创建订单
`POST /payments/orders`
Headers: `x-merchant-id`, `x-api-key`
```json
{ "amount": "9.99", "currency": "USD", "network": "evm:ethereum", "asset": "USDC", "description": "API Access" }
```
返回:订单信息与 `paymentRequirementsBase64`
### 3) 追踪链上交易(低开销)
`POST /payments/track`
Headers: `x-merchant-id`, `x-api-key`
```json
{ "orderId": "ORD123", "txHash": "0x...", "network": "evm:ethereum" }
```
### 4) Webhook 聚合入口
`POST /payments/webhook`
- `X-Source: keagate | btcpay | x402`
- Keagate 校验:`x-keagate-sig`sha512 HMAC
- BTCPay 校验:`btcpay-sig`sha256 HMAC
- x402 校验:`x402-sig`sha256 HMAC
## Nginx + HTTPS免费
配置在:`infra/nginx/sites-enabled/capay.conf`
证书建议使用 certbotLet’s Encrypt
```bash
apt-get update && apt-get install -y certbot python3-certbot-nginx
certbot --nginx -d capay.hao.work -d pay.capay.hao.work -d btc.capay.hao.work -d doge.capay.hao.work
```
## x402 Facilitator
配置文件在:`x402/facilitator/`,已绑定 `pay.capay.hao.work`,并写入 Alchemy ETH RPC。
## 生产建议
-`.env` 中的密钥替换为强随机值
- 生产环境建议使用数据库PostgreSQL/MySQL替换本地 JSON
- 重要订单建议提高 `MIN_CONFIRMATIONS`
- Webhook 需做好幂等与重试

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 };

查看文件

@@ -0,0 +1,6 @@
BTCPAY_HOST=btc.capay.hao.work
LETSENCRYPT_EMAIL=ops@capay.hao.work
BTCPAYGEN_EXCLUDE_FRAGMENTS=nginx-https
BTCPAYGEN_ADDITIONAL_FRAGMENTS=opt-save-storage,bitcoin
BTCPAYGEN_LIGHTNING=lnd
NBITCOIN_NETWORK=mainnet

查看文件

@@ -0,0 +1,26 @@
{
"HOST": "https://doge.capay.hao.work",
"PORT": 8081,
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
"MONGO_KEAGATE_DB": "keagate",
"IP_WHITELIST": ["127.0.0.1"],
"TRANSACTION_TIMEOUT": 1200000,
"TRANSACTION_MIN_REFRESH_TIME": 30000,
"TRANSACTION_SLIPPAGE_TOLERANCE": 0.02,
"SEED": "hex-128-bit-seed",
"KEAGATE_API_KEY": "replace-api-key",
"INVOICE_ENC_KEY": "hex-32-bytes",
"IPN_HMAC_SECRET": "replace-hmac-secret",
"DOGE": {
"ADMIN_PUBLIC_KEY": "DogePublicAddress",
"ADMIN_PRIVATE_KEY": null
},
"SOL": {
"ADMIN_PUBLIC_KEY": "SolPublicAddress",
"ADMIN_PRIVATE_KEY": null
}
}

查看文件

@@ -0,0 +1,80 @@
server {
listen 80;
server_name capay.hao.work pay.capay.hao.work btc.capay.hao.work doge.capay.hao.work;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name capay.hao.work;
# ssl_certificate /etc/letsencrypt/live/capay.hao.work/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/capay.hao.work/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
server_name pay.capay.hao.work;
# ssl_certificate /etc/letsencrypt/live/pay.capay.hao.work/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/pay.capay.hao.work/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location /verify {
proxy_pass http://127.0.0.1:4020/verify;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /settle {
proxy_pass http://127.0.0.1:4020/settle;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
server_name btc.capay.hao.work;
# ssl_certificate /etc/letsencrypt/live/btc.capay.hao.work/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/btc.capay.hao.work/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:23000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl http2;
server_name doge.capay.hao.work;
# ssl_certificate /etc/letsencrypt/live/doge.capay.hao.work/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/doge.capay.hao.work/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf;
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

17
x402/facilitator/.env 普通文件
查看文件

@@ -0,0 +1,17 @@
PORT=4020
HOST=https://pay.capay.hao.work
LOG_LEVEL=info
FACILITATOR_JWT_SECRET=replace-with-strong-secret
PAYMENT_HMAC_SECRET=replace-with-strong-secret
# Alchemy Ethereum RPC
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
# Optional BSC RPC if needed
BSC_RPC_URL=https://bsc-dataseed.binance.org
# Solana RPC (optional)
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
REDIS_URL=redis://127.0.0.1:6379

查看文件

@@ -0,0 +1,21 @@
{
"evm": {
"ethereum": {
"chainId": 1,
"rpcEnv": "ETHEREUM_RPC_URL",
"explorer": "https://etherscan.io/tx/"
},
"bsc": {
"chainId": 56,
"rpcEnv": "BSC_RPC_URL",
"explorer": "https://bscscan.com/tx/"
}
},
"svm": {
"solana": {
"cluster": "mainnet-beta",
"rpcEnv": "SOLANA_RPC_URL",
"explorer": "https://solscan.io/tx/"
}
}
}

查看文件

@@ -0,0 +1,21 @@
{
"evm": {
"ethereum": {
"USDC": { "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "decimals": 6 },
"USDT": { "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7", "decimals": 6 },
"ETH": { "native": true, "decimals": 18 }
},
"bsc": {
"USDC": { "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", "decimals": 18 },
"USDT": { "address": "0x55d398326f99059fF775485246999027B3197955", "decimals": 18 },
"BNB": { "native": true, "decimals": 18 }
}
},
"svm": {
"solana": {
"USDC": { "mint": "EPjFWdd5AufqSSqeM2qE1z3vY2Z9K5xkqkQ3yqC4wR5Z", "decimals": 6 },
"USDT": { "mint": "Es9vMFrzaCERzmxEtpmJieE5s3bD4ZjbFj9a2yq6VQ8G", "decimals": 6 },
"SOL": { "native": true, "decimals": 9 }
}
}
}