Initial capay platform scaffold
这个提交包含在:
83
README.md
普通文件
83
README.md
普通文件
@@ -0,0 +1,83 @@
|
|||||||
|
# Capay 一站式加密货币支付平台(自托管模板)
|
||||||
|
|
||||||
|
本仓库提供:
|
||||||
|
- 统一商户开通(简单 Merchant API)
|
||||||
|
- x402 Facilitator(EVM/Solana)配置模板
|
||||||
|
- BTCPay / Keagate 对接占位
|
||||||
|
- Webhook 聚合 `/payments/webhook`
|
||||||
|
- 低开销交易跟踪(免费套餐默认低频轮询)
|
||||||
|
- Nginx 反向代理与 HTTPS(Let'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`
|
||||||
|
|
||||||
|
证书建议使用 certbot(Let’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
普通文件
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 };
|
||||||
@@ -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
|
||||||
26
infra/docker/keagate/local.json
普通文件
26
infra/docker/keagate/local.json
普通文件
@@ -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
普通文件
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
|
||||||
21
x402/facilitator/networks.json
普通文件
21
x402/facilitator/networks.json
普通文件
@@ -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/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
x402/facilitator/tokens.json
普通文件
21
x402/facilitator/tokens.json
普通文件
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户