chore: update app, infra configs, assets
这个提交包含在:
10
README.md
10
README.md
@@ -56,6 +56,10 @@ Headers: `x-merchant-id`, `x-api-key`
|
||||
```json
|
||||
{ "orderId": "ORD123", "txHash": "0x...", "network": "evm:ethereum" }
|
||||
```
|
||||
Bitcoin 示例:
|
||||
```json
|
||||
{ "orderId": "ORD123", "txHash": "btc_txid", "network": "btc:mainnet" }
|
||||
```
|
||||
|
||||
### 4) Webhook 聚合入口
|
||||
`POST /payments/webhook`
|
||||
@@ -70,12 +74,16 @@ Headers: `x-merchant-id`, `x-api-key`
|
||||
证书建议使用 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
|
||||
certbot --nginx -d capay.hao.work -d pay.capay.hao.work -d btc.capay.hao.work
|
||||
```
|
||||
|
||||
## x402 Facilitator
|
||||
配置文件在:`x402/facilitator/`,已绑定 `pay.capay.hao.work`,并写入 Alchemy ETH RPC。
|
||||
|
||||
## Bitcoin 节点(Alchemy)
|
||||
默认使用 `ALCHEMY_BTC_API_KEY(S)` 或 `BTC_RPC_URL(S)` 来访问 Bitcoin 主网 RPC。
|
||||
`/payments/track` 的 `network` 统一使用 `btc:mainnet`。
|
||||
|
||||
## 生产建议
|
||||
- 把 `.env` 中的密钥替换为强随机值
|
||||
- 生产环境建议使用数据库(PostgreSQL/MySQL)替换本地 JSON
|
||||
|
||||
8
app/.env
8
app/.env
@@ -1,6 +1,7 @@
|
||||
APP_PORT=3001
|
||||
APP_HOST=https://capay.hao.work
|
||||
PLAN=free
|
||||
ADMIN_API_KEY=whoami139
|
||||
|
||||
# Low-cost polling for free plan
|
||||
TX_POLL_INTERVAL_MS=180000
|
||||
@@ -14,6 +15,13 @@ 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
|
||||
|
||||
# Bitcoin RPC via Alchemy (optional)
|
||||
ALCHEMY_BTC_API_KEYS=P9kZiHB6Q7CLrBlMsUN3n
|
||||
ALCHEMY_BTC_API_KEY=P9kZiHB6Q7CLrBlMsUN3n
|
||||
# Optional: full RPC URL with auth (overrides/extends)
|
||||
# BTC_RPC_URL=https://alchemy:YOUR_KEY@bitcoin-mainnet.g.alchemy.com/v2/YOUR_KEY
|
||||
# BTC_RPC_URLS=https://alchemy:KEY1@bitcoin-mainnet.g.alchemy.com/v2/KEY1,https://alchemy:KEY2@bitcoin-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
|
||||
|
||||
221
app/src/public/app.js
普通文件
221
app/src/public/app.js
普通文件
@@ -0,0 +1,221 @@
|
||||
const adminKeyInput = document.getElementById('admin-key');
|
||||
const loginForm = document.getElementById('login-form');
|
||||
const loginError = document.getElementById('login-error');
|
||||
const loginSection = document.getElementById('login-section');
|
||||
const appSection = document.getElementById('app-section');
|
||||
const statusText = document.getElementById('status-text');
|
||||
const statusPill = document.getElementById('status-pill');
|
||||
const logoutButton = document.getElementById('logout');
|
||||
const clearKeyButton = document.getElementById('clear-key');
|
||||
const refreshButton = document.getElementById('refresh-merchants');
|
||||
const merchantRows = document.getElementById('merchant-rows');
|
||||
const merchantEmpty = document.getElementById('merchant-empty');
|
||||
const merchantForm = document.getElementById('merchant-form');
|
||||
const merchantResult = document.getElementById('merchant-result');
|
||||
const tabs = document.querySelectorAll('.tabs button');
|
||||
const panels = document.querySelectorAll('.tab-panel');
|
||||
|
||||
const curlCreateMerchant = document.getElementById('curl-create-merchant');
|
||||
const curlCreateOrder = document.getElementById('curl-create-order');
|
||||
const curlTrackTx = document.getElementById('curl-track-tx');
|
||||
const webhookTip = document.getElementById('webhook-tip');
|
||||
|
||||
const STORAGE_KEY = 'capay_admin_key';
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
function setStatus(text, ok) {
|
||||
statusText.textContent = text;
|
||||
statusPill.textContent = ok ? '已连接' : '未连接';
|
||||
statusPill.style.borderColor = ok ? 'rgba(52, 211, 153, 0.6)' : 'rgba(248, 113, 113, 0.6)';
|
||||
statusPill.style.color = ok ? '#34d399' : '#f87171';
|
||||
}
|
||||
|
||||
function showLoginError(message) {
|
||||
loginError.textContent = message;
|
||||
loginError.classList.toggle('hidden', !message);
|
||||
}
|
||||
|
||||
function getAdminKey() {
|
||||
return localStorage.getItem(STORAGE_KEY) || '';
|
||||
}
|
||||
|
||||
function setAdminKey(value) {
|
||||
localStorage.setItem(STORAGE_KEY, value);
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = options.headers ? { ...options.headers } : {};
|
||||
const adminKey = getAdminKey();
|
||||
if (adminKey) {
|
||||
headers['x-admin-key'] = adminKey;
|
||||
}
|
||||
const res = await fetch(path, { ...options, headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let message = text;
|
||||
try {
|
||||
message = JSON.parse(text).error || text;
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `request failed (${res.status})`);
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const health = await request('/health', { headers: {} });
|
||||
setStatus(`服务正常 · 计划 ${health.plan}`, true);
|
||||
} catch (error) {
|
||||
setStatus('无法连接服务', false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMerchants(merchants) {
|
||||
merchantRows.innerHTML = '';
|
||||
if (!merchants || merchants.length === 0) {
|
||||
merchantEmpty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
merchantEmpty.classList.add('hidden');
|
||||
merchants.forEach((merchant) => {
|
||||
const row = document.createElement('tr');
|
||||
const apiKey = merchant.apiKey || '-';
|
||||
const webhookSecret = merchant.webhookSecret || '-';
|
||||
row.innerHTML = `
|
||||
<td>${merchant.id}</td>
|
||||
<td>${merchant.name}</td>
|
||||
<td>${merchant.email}</td>
|
||||
<td>${merchant.webhookUrl || '-'}</td>
|
||||
<td><span class="chip" data-value="${apiKey}">${apiKey}</span></td>
|
||||
<td><span class="chip" data-value="${webhookSecret}">${webhookSecret}</span></td>
|
||||
<td>${merchant.createdAt || '-'}</td>
|
||||
`;
|
||||
row.querySelectorAll('.chip').forEach((chip) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.textContent = '复制';
|
||||
btn.className = 'ghost copy-btn';
|
||||
btn.addEventListener('click', () => {
|
||||
navigator.clipboard.writeText(chip.dataset.value || '');
|
||||
btn.textContent = '已复制';
|
||||
setTimeout(() => (btn.textContent = '复制'), 1200);
|
||||
});
|
||||
chip.appendChild(btn);
|
||||
});
|
||||
merchantRows.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMerchants() {
|
||||
try {
|
||||
const data = await request('/admin/merchants');
|
||||
renderMerchants(data.merchants || []);
|
||||
} catch (error) {
|
||||
renderMerchants([]);
|
||||
showLoginError(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updateGuide() {
|
||||
const adminKey = getAdminKey() || '<ADMIN_API_KEY>';
|
||||
curlCreateMerchant.textContent = `curl -X POST ${baseUrl}/admin/merchants \\\n -H \"Content-Type: application/json\" \\\n -H \"X-Admin-Key: ${adminKey}\" \\\n -d '{\"name\":\"Acme\",\"email\":\"ops@acme.com\",\"webhookUrl\":\"https://merchant.example.com/webhook\"}'`;
|
||||
|
||||
curlCreateOrder.textContent = `curl -X POST ${baseUrl}/payments/orders \\\n -H \"Content-Type: application/json\" \\\n -H \"x-merchant-id: <merchantId>\" \\\n -H \"x-api-key: <apiKey>\" \\\n -d '{\"amount\":\"9.99\",\"currency\":\"USD\",\"network\":\"evm:ethereum\",\"asset\":\"USDC\",\"description\":\"API Access\"}'`;
|
||||
|
||||
curlTrackTx.textContent = `curl -X POST ${baseUrl}/payments/track \\\n -H \"Content-Type: application/json\" \\\n -H \"x-merchant-id: <merchantId>\" \\\n -H \"x-api-key: <apiKey>\" \\\n -d '{\"orderId\":\"<orderId>\",\"txHash\":\"<txHash>\",\"network\":\"btc:mainnet\"}'`;
|
||||
|
||||
webhookTip.textContent = `Webhook 入口: ${baseUrl}/payments/webhook\n签名头:\n- Keagate: x-keagate-sig (sha512 HMAC)\n- BTCPay: btcpay-sig (sha256 HMAC)\n- x402: x402-sig (sha256 HMAC)\n商户侧使用 webhookSecret 验签后处理。`;
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
loginSection.classList.add('hidden');
|
||||
appSection.classList.remove('hidden');
|
||||
showLoginError('');
|
||||
updateGuide();
|
||||
loadHealth();
|
||||
loadMerchants();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
loginSection.classList.remove('hidden');
|
||||
appSection.classList.add('hidden');
|
||||
}
|
||||
|
||||
loginForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const key = adminKeyInput.value.trim();
|
||||
if (!key) {
|
||||
showLoginError('请输入管理员密钥');
|
||||
return;
|
||||
}
|
||||
setAdminKey(key);
|
||||
try {
|
||||
await request('/admin/merchants');
|
||||
showApp();
|
||||
} catch (error) {
|
||||
showLoginError(error.message || '登录失败');
|
||||
}
|
||||
});
|
||||
|
||||
clearKeyButton.addEventListener('click', () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
adminKeyInput.value = '';
|
||||
showLoginError('');
|
||||
});
|
||||
|
||||
logoutButton.addEventListener('click', () => {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
adminKeyInput.value = '';
|
||||
showLogin();
|
||||
});
|
||||
|
||||
refreshButton.addEventListener('click', () => {
|
||||
loadMerchants();
|
||||
});
|
||||
|
||||
merchantForm.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
merchantResult.classList.add('hidden');
|
||||
const name = document.getElementById('merchant-name').value.trim();
|
||||
const email = document.getElementById('merchant-email').value.trim();
|
||||
const webhookUrl = document.getElementById('merchant-webhook').value.trim();
|
||||
try {
|
||||
const result = await request('/admin/merchants', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, email, webhookUrl: webhookUrl || null })
|
||||
});
|
||||
merchantResult.textContent = `创建成功:merchantId=${result.merchantId}`;
|
||||
merchantResult.classList.remove('hidden');
|
||||
merchantForm.reset();
|
||||
await loadMerchants();
|
||||
} catch (error) {
|
||||
merchantResult.textContent = `创建失败:${error.message}`;
|
||||
merchantResult.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
tabs.forEach((item) => item.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
const target = tab.dataset.tab;
|
||||
panels.forEach((panel) => {
|
||||
panel.classList.toggle('hidden', panel.id !== `tab-${target}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
(function init() {
|
||||
const key = getAdminKey();
|
||||
if (key) {
|
||||
adminKeyInput.value = key;
|
||||
showApp();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
loadHealth();
|
||||
})();
|
||||
62
app/src/public/docs/index.html
普通文件
62
app/src/public/docs/index.html
普通文件
@@ -0,0 +1,62 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Capay API Docs</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<style>
|
||||
.docs-header {
|
||||
max-width: 1180px;
|
||||
margin: 24px auto 0;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.docs-header a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.swagger-ui .topbar {
|
||||
display: none;
|
||||
}
|
||||
.swagger-ui {
|
||||
max-width: 1180px;
|
||||
margin: 12px auto 60px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.swagger-ui .info .title {
|
||||
font-family: "Fraunces", "Times New Roman", serif;
|
||||
}
|
||||
.swagger-ui .opblock-tag {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="docs-header">
|
||||
<div>
|
||||
<h2>Capay API Docs</h2>
|
||||
<p class="muted">Swagger-style documentation with live requests.</p>
|
||||
</div>
|
||||
<a class="pill link" href="/">Back to Admin</a>
|
||||
</div>
|
||||
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
url: "/openapi.json",
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
presets: [SwaggerUIBundle.presets.apis],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
136
app/src/public/index.html
普通文件
136
app/src/public/index.html
普通文件
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Capay 管理后台</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="hero">
|
||||
<div>
|
||||
<h1>Capay 管理后台</h1>
|
||||
<p>商户管理 · REST 配置 · 接入指导</p>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="pill">API Admin</span>
|
||||
<span class="pill" id="status-pill">未连接</span>
|
||||
<a class="pill link" href="/docs/">API Docs</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="login-section" class="card">
|
||||
<h2>管理员登录</h2>
|
||||
<p class="muted">使用 ADMIN_API_KEY 进入管理后台(默认:whoami139)。</p>
|
||||
<form id="login-form">
|
||||
<label>
|
||||
管理员密钥
|
||||
<input id="admin-key" type="password" placeholder="X-Admin-Key" autocomplete="current-password" />
|
||||
</label>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary">登录</button>
|
||||
<button type="button" id="clear-key" class="ghost">清除</button>
|
||||
</div>
|
||||
<p id="login-error" class="error hidden"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="app-section" class="hidden">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span id="status-text" class="muted">连接中...</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button id="refresh-merchants" class="ghost">刷新商户</button>
|
||||
<button id="logout" class="ghost">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="tabs">
|
||||
<button data-tab="merchants" class="active">商户列表</button>
|
||||
<button data-tab="create">新增商户</button>
|
||||
<button data-tab="guide">REST 管理配置与教程</button>
|
||||
</nav>
|
||||
|
||||
<div id="tab-merchants" class="tab-panel card">
|
||||
<h2>已接入商户</h2>
|
||||
<p class="muted">支持多商户并行接入,复制 API Key / Secret 交付对应商户即可。</p>
|
||||
<div id="merchant-table" class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>名称</th>
|
||||
<th>邮箱</th>
|
||||
<th>Webhook</th>
|
||||
<th>API Key</th>
|
||||
<th>Secret</th>
|
||||
<th>创建时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="merchant-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p id="merchant-empty" class="muted hidden">暂无商户,请先新增。</p>
|
||||
</div>
|
||||
|
||||
<div id="tab-create" class="tab-panel card hidden">
|
||||
<h2>新增商户</h2>
|
||||
<form id="merchant-form">
|
||||
<div class="form-grid">
|
||||
<label>
|
||||
商户名称
|
||||
<input id="merchant-name" type="text" required />
|
||||
</label>
|
||||
<label>
|
||||
邮箱
|
||||
<input id="merchant-email" type="email" required />
|
||||
</label>
|
||||
<label class="span-2">
|
||||
Webhook URL(可选)
|
||||
<input id="merchant-webhook" type="url" placeholder="https://merchant.example.com/webhook" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="primary">创建商户</button>
|
||||
<button type="reset" class="ghost">重置</button>
|
||||
</div>
|
||||
<p id="merchant-result" class="success hidden"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="tab-guide" class="tab-panel card hidden">
|
||||
<h2>REST 管理配置与教程</h2>
|
||||
<div class="guide">
|
||||
<ol>
|
||||
<li>使用管理员密钥创建商户,获取 merchantId / apiKey / webhookSecret。</li>
|
||||
<li>将 apiKey 与 merchantId 提供给商户,用于创建订单与跟踪交易。</li>
|
||||
<li>商户创建订单后,把链上 txHash 回传到 /payments/track 进行确认。</li>
|
||||
<li>Webhook 由平台签名,商户侧自行验签并处理订单状态。</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h3>1. 创建商户(管理员)</h3>
|
||||
<pre><code id="curl-create-merchant"></code></pre>
|
||||
|
||||
<h3>2. 创建订单(商户)</h3>
|
||||
<pre><code id="curl-create-order"></code></pre>
|
||||
|
||||
<h3>3. 追踪链上交易</h3>
|
||||
<pre><code id="curl-track-tx"></code></pre>
|
||||
|
||||
<h3>4. Webhook 验签提示</h3>
|
||||
<pre><code id="webhook-tip"></code></pre>
|
||||
|
||||
<div class="callout">
|
||||
<strong>多商户接入</strong>
|
||||
<p>每个商户都会得到独立的 merchantId/apiKey/webhookSecret。重复创建即可支持不同商户并行接入。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
365
app/src/public/openapi.json
普通文件
365
app/src/public/openapi.json
普通文件
@@ -0,0 +1,365 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Capay API",
|
||||
"version": "0.1.0",
|
||||
"description": "Capay merchant payment API with admin controls."
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "/"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{ "name": "Admin", "description": "Admin merchant management" },
|
||||
{ "name": "Merchant", "description": "Merchant order & tracking APIs" },
|
||||
{ "name": "Webhook", "description": "Unified webhook entry" }
|
||||
],
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"adminKey": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "X-Admin-Key"
|
||||
},
|
||||
"merchantId": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "x-merchant-id"
|
||||
},
|
||||
"merchantApiKey": {
|
||||
"type": "apiKey",
|
||||
"in": "header",
|
||||
"name": "x-api-key"
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"MerchantCreateRequest": {
|
||||
"type": "object",
|
||||
"required": ["name", "email"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"email": { "type": "string", "format": "email" },
|
||||
"webhookUrl": { "type": "string", "format": "uri", "nullable": true }
|
||||
}
|
||||
},
|
||||
"MerchantCredentials": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"merchantId": { "type": "string" },
|
||||
"apiKey": { "type": "string" },
|
||||
"webhookSecret": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"MerchantPublic": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"email": { "type": "string" },
|
||||
"webhookUrl": { "type": "string", "nullable": true },
|
||||
"createdAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"MerchantFull": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/components/schemas/MerchantPublic" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"apiKey": { "type": "string" },
|
||||
"webhookSecret": { "type": "string" }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"OrderRequest": {
|
||||
"type": "object",
|
||||
"required": ["amount", "currency", "network", "asset"],
|
||||
"properties": {
|
||||
"amount": { "type": "string" },
|
||||
"currency": { "type": "string" },
|
||||
"network": { "type": "string" },
|
||||
"asset": { "type": "string" },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"lockWindowSeconds": { "type": "number" },
|
||||
"slippage": { "type": "number" },
|
||||
"orderId": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"TrackRequest": {
|
||||
"type": "object",
|
||||
"required": ["orderId", "txHash", "network"],
|
||||
"properties": {
|
||||
"orderId": { "type": "string" },
|
||||
"txHash": { "type": "string" },
|
||||
"network": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"Order": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"merchantId": { "type": "string" },
|
||||
"amount": { "type": "string" },
|
||||
"currency": { "type": "string" },
|
||||
"network": { "type": "string" },
|
||||
"asset": { "type": "string" },
|
||||
"description": { "type": "string", "nullable": true },
|
||||
"status": { "type": "string" },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"updatedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
},
|
||||
"TrackResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tracked": { "type": "boolean" },
|
||||
"tx": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"orderId": { "type": "string" },
|
||||
"network": { "type": "string" },
|
||||
"txHash": { "type": "string" },
|
||||
"status": { "type": "string" },
|
||||
"confirmations": { "type": "number" },
|
||||
"lastCheckedAt": { "type": "string", "format": "date-time", "nullable": true },
|
||||
"createdAt": { "type": "string", "format": "date-time" },
|
||||
"updatedAt": { "type": "string", "format": "date-time" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"summary": "Health check",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "string" },
|
||||
"plan": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/merchants": {
|
||||
"post": {
|
||||
"tags": ["Admin"],
|
||||
"summary": "Create merchant (admin)",
|
||||
"security": [{ "adminKey": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/MerchantCreateRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/MerchantCredentials" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/merchants/{id}": {
|
||||
"get": {
|
||||
"tags": ["Admin"],
|
||||
"summary": "Get merchant public profile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/MerchantPublic" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/merchants": {
|
||||
"get": {
|
||||
"tags": ["Admin"],
|
||||
"summary": "List merchants (admin)",
|
||||
"security": [{ "adminKey": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"merchants": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/components/schemas/MerchantFull" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Admin"],
|
||||
"summary": "Create merchant (admin)",
|
||||
"security": [{ "adminKey": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/MerchantCreateRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/MerchantCredentials" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/payments/orders": {
|
||||
"post": {
|
||||
"tags": ["Merchant"],
|
||||
"summary": "Create order",
|
||||
"security": [{ "merchantId": [], "merchantApiKey": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/OrderRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"order": { "$ref": "#/components/schemas/Order" },
|
||||
"paymentRequirements": { "type": "object" },
|
||||
"paymentRequirementsBase64": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/payments/orders/{id}": {
|
||||
"get": {
|
||||
"tags": ["Merchant"],
|
||||
"summary": "Get order",
|
||||
"security": [{ "merchantId": [], "merchantApiKey": [] }],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/Order" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/payments/track": {
|
||||
"post": {
|
||||
"tags": ["Merchant"],
|
||||
"summary": "Track transaction",
|
||||
"security": [{ "merchantId": [], "merchantApiKey": [] }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/TrackRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "$ref": "#/components/schemas/TrackResponse" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/payments/webhook": {
|
||||
"post": {
|
||||
"tags": ["Webhook"],
|
||||
"summary": "Unified webhook entry",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "X-Source",
|
||||
"in": "header",
|
||||
"required": true,
|
||||
"schema": { "type": "string" }
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "object" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
292
app/src/public/styles.css
普通文件
292
app/src/public/styles.css
普通文件
@@ -0,0 +1,292 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Fraunces:wght@500;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b0f19;
|
||||
--panel: #0f172a;
|
||||
--panel-strong: #111d35;
|
||||
--text: #e2e8f0;
|
||||
--muted: #9aa4b2;
|
||||
--accent: #38bdf8;
|
||||
--accent-2: #f97316;
|
||||
--stroke: rgba(148, 163, 184, 0.25);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 12% 20%, rgba(56, 189, 248, 0.2), transparent 45%),
|
||||
radial-gradient(circle at 90% 10%, rgba(249, 115, 22, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.9), rgba(11, 15, 25, 0.98));
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
opacity: 0.35;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 36px 24px 90px;
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 28px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(145deg, rgba(15, 23, 42, 0.95), rgba(17, 29, 53, 0.95));
|
||||
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||
box-shadow: 0 24px 60px rgba(2, 6, 23, 0.6);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0 0 8px;
|
||||
font-family: "Fraunces", "Times New Roman", serif;
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
border: 1px solid var(--stroke);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pill.link {
|
||||
text-decoration: none;
|
||||
border-color: rgba(56, 189, 248, 0.45);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(15, 23, 42, 0.95);
|
||||
border: 1px solid var(--stroke);
|
||||
border-radius: 20px;
|
||||
padding: 26px;
|
||||
box-shadow: 0 18px 50px rgba(2, 6, 23, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: #0b1224;
|
||||
color: var(--text);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(56, 189, 248, 0.6);
|
||||
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.2);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
padding: 10px 18px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-family: "IBM Plex Sans", sans-serif;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: linear-gradient(135deg, #38bdf8, #0ea5e9);
|
||||
color: #03121f;
|
||||
box-shadow: 0 14px 28px rgba(14, 165, 233, 0.25);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(148, 163, 184, 0.4);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
background: #0b1224;
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
border-color: rgba(56, 189, 248, 0.7);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th, td {
|
||||
text-align: left;
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
}
|
||||
|
||||
th {
|
||||
color: #cbd5f5;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
font-size: 12px;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0b1224;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
overflow-x: auto;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
|
||||
.guide ol {
|
||||
margin: 0 0 16px 20px;
|
||||
}
|
||||
|
||||
.callout {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(56, 189, 248, 0.08);
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
}
|
||||
@@ -3,13 +3,17 @@ require('dotenv').config();
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const { nanoid } = require('nanoid');
|
||||
const path = require('path');
|
||||
const { readDb, withDb } = require('./storage');
|
||||
const { startEvmWatcher } = require('./watchers/evmWatcher');
|
||||
const { startBtcWatcher } = require('./watchers/btcWatcher');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
const PLAN = process.env.PLAN || 'free';
|
||||
const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'whoami139';
|
||||
|
||||
function nowIso() {
|
||||
return new Date().toISOString();
|
||||
@@ -40,6 +44,15 @@ async function requireMerchant(req, res) {
|
||||
return merchant;
|
||||
}
|
||||
|
||||
function requireAdmin(req, res) {
|
||||
const adminKey = getHeader(req, 'x-admin-key');
|
||||
if (!adminKey || adminKey !== ADMIN_API_KEY) {
|
||||
res.status(403).json({ error: 'invalid admin key' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function logEvent(source, payload) {
|
||||
await withDb((db) => {
|
||||
db.events.push({
|
||||
@@ -96,10 +109,45 @@ app.get('/health', (req, res) => {
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.status(200).send('Capay API online. Use /health for status.');
|
||||
res.status(200).sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
app.get('/admin/merchants', async (req, res) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const db = await readDb();
|
||||
res.json({ merchants: db.merchants });
|
||||
});
|
||||
|
||||
app.post('/admin/merchants', async (req, res) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
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.post('/merchants', async (req, res) => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const { name, email, webhookUrl } = req.body || {};
|
||||
if (!name || !email) {
|
||||
return res.status(400).json({ error: 'name and email are required' });
|
||||
@@ -281,4 +329,5 @@ const port = Number(process.env.APP_PORT || 3000);
|
||||
app.listen(port, () => {
|
||||
console.log(`capay app listening on :${port}`);
|
||||
startEvmWatcher({ notifyMerchant });
|
||||
startBtcWatcher({ notifyMerchant });
|
||||
});
|
||||
|
||||
@@ -10,6 +10,10 @@ function buildAlchemyUrls(keys) {
|
||||
return keys.map((key) => `https://eth-mainnet.g.alchemy.com/v2/${key}`);
|
||||
}
|
||||
|
||||
function buildAlchemyBitcoinUrls(keys) {
|
||||
return keys.map((key) => `https://alchemy:${key}@bitcoin-mainnet.g.alchemy.com/v2/${key}`);
|
||||
}
|
||||
|
||||
function unique(items) {
|
||||
return Array.from(new Set(items));
|
||||
}
|
||||
@@ -34,6 +38,26 @@ function getEthereumRpcUrls() {
|
||||
return unique(urls);
|
||||
}
|
||||
|
||||
function getBitcoinRpcUrls() {
|
||||
const urls = [];
|
||||
|
||||
const explicitUrls = parseList(process.env.BTC_RPC_URLS);
|
||||
urls.push(...explicitUrls);
|
||||
|
||||
if (process.env.BTC_RPC_URL) {
|
||||
urls.push(process.env.BTC_RPC_URL);
|
||||
}
|
||||
|
||||
const keyList = parseList(process.env.ALCHEMY_BTC_API_KEYS);
|
||||
if (keyList.length > 0) {
|
||||
urls.push(...buildAlchemyBitcoinUrls(keyList));
|
||||
} else if (process.env.ALCHEMY_BTC_API_KEY) {
|
||||
urls.push(...buildAlchemyBitcoinUrls([process.env.ALCHEMY_BTC_API_KEY]));
|
||||
}
|
||||
|
||||
return unique(urls);
|
||||
}
|
||||
|
||||
function createRoundRobinPicker(items) {
|
||||
let index = 0;
|
||||
return function pick() {
|
||||
@@ -46,5 +70,6 @@ function createRoundRobinPicker(items) {
|
||||
|
||||
module.exports = {
|
||||
getEthereumRpcUrls,
|
||||
getBitcoinRpcUrls,
|
||||
createRoundRobinPicker
|
||||
};
|
||||
|
||||
103
app/src/watchers/btcWatcher.js
普通文件
103
app/src/watchers/btcWatcher.js
普通文件
@@ -0,0 +1,103 @@
|
||||
const { readDb, writeDb } = require('../storage');
|
||||
const { getBitcoinRpcUrls, createRoundRobinPicker } = require('../utils/rpc');
|
||||
|
||||
const BTC_NETWORK = 'btc:mainnet';
|
||||
|
||||
async function callRpc(url, method, params = []) {
|
||||
const body = JSON.stringify({
|
||||
jsonrpc: '1.0',
|
||||
id: 'capay',
|
||||
method,
|
||||
params
|
||||
});
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`rpc ${method} failed (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (payload.error) {
|
||||
const message = payload.error.message || JSON.stringify(payload.error);
|
||||
throw new Error(`rpc ${method} error: ${message}`);
|
||||
}
|
||||
|
||||
return payload.result;
|
||||
}
|
||||
|
||||
function startBtcWatcher({ notifyMerchant }) {
|
||||
const rpcUrls = getBitcoinRpcUrls();
|
||||
if (!rpcUrls.length) {
|
||||
console.warn('[btcWatcher] No Bitcoin 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 === BTC_NETWORK && tx.status === 'pending');
|
||||
if (pending.length === 0) {
|
||||
running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcUrl = pickRpcUrl();
|
||||
const now = new Date().toISOString();
|
||||
const confirmedEvents = [];
|
||||
|
||||
for (const tx of pending.slice(0, maxPerCycle)) {
|
||||
try {
|
||||
const result = await callRpc(rpcUrl, 'getrawtransaction', [tx.txHash, true]);
|
||||
const confirmations = Number(result?.confirmations || 0);
|
||||
|
||||
tx.confirmations = confirmations;
|
||||
tx.lastCheckedAt = now;
|
||||
tx.updatedAt = now;
|
||||
|
||||
if (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 });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
tx.lastCheckedAt = now;
|
||||
console.warn('[btcWatcher] rpc error', tx.txHash, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await writeDb(db);
|
||||
|
||||
for (const event of confirmedEvents) {
|
||||
await notifyMerchant(event.order, event.tx);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[btcWatcher] poll error', error.message);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
poll();
|
||||
setInterval(poll, pollInterval);
|
||||
console.log(`[btcWatcher] started with interval ${pollInterval}ms`);
|
||||
}
|
||||
|
||||
module.exports = { startBtcWatcher };
|
||||
1
infra/docker/btcpay-src
子模块
1
infra/docker/btcpay-src
子模块
子模块 infra/docker/btcpay-src 已添加到 3bd29ae5a4
@@ -0,0 +1,43 @@
|
||||
version: "3"
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.6.5
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- "postgres_datadir:/var/lib/postgresql/data"
|
||||
|
||||
nbxplorer:
|
||||
image: nicolasdorier/nbxplorer:1.0.2.31
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NBXPLORER_NETWORK: mainnet
|
||||
NBXPLORER_BIND: 0.0.0.0:32838
|
||||
NBXPLORER_CHAINS: "btc"
|
||||
NBXPLORER_BTCRPCURL: https://alchemy:P9kZiHB6Q7CLrBlMsUN3n@bitcoin-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
||||
NBXPLORER_BTCNODEENDPOINT: bitcoin-mainnet.g.alchemy.com:443
|
||||
NBXPLORER_BTCRPCUSER: "alchemy"
|
||||
NBXPLORER_BTCRPCPASSWORD: "P9kZiHB6Q7CLrBlMsUN3n"
|
||||
volumes:
|
||||
- "nbxplorer_datadir:/datadir"
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
btcpayserver:
|
||||
image: nicolasdorier/btcpayserver:1.0.2.106
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "23000:49392"
|
||||
environment:
|
||||
BTCPAY_POSTGRES: User ID=postgres;Host=postgres;Port=5432;Database=btcpayservermainnet
|
||||
BTCPAY_NETWORK: mainnet
|
||||
BTCPAY_BIND: 0.0.0.0:49392
|
||||
BTCPAY_EXTERNALURL: https://btc.capay.hao.work/
|
||||
BTCPAY_ROOTPATH: /
|
||||
BTCPAY_BTCEXPLORERURL: http://nbxplorer:32838/
|
||||
depends_on:
|
||||
- postgres
|
||||
- nbxplorer
|
||||
|
||||
volumes:
|
||||
postgres_datadir:
|
||||
nbxplorer_datadir:
|
||||
1
infra/docker/keagate-src
子模块
1
infra/docker/keagate-src
子模块
子模块 infra/docker/keagate-src 已添加到 0581525abb
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"HOST": "https://doge.capay.hao.work",
|
||||
"HOST": "https://capay.hao.work",
|
||||
"PORT": 8081,
|
||||
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
|
||||
"MONGO_KEAGATE_DB": "keagate",
|
||||
@@ -14,11 +14,6 @@
|
||||
"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
|
||||
|
||||
1
infra/docker/x402-rs
子模块
1
infra/docker/x402-rs
子模块
子模块 infra/docker/x402-rs 已添加到 4b0134acb1
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name capay.hao.work pay.capay.hao.work btc.capay.hao.work doge.capay.hao.work;
|
||||
server_name capay.hao.work pay.capay.hao.work btc.capay.hao.work;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -61,20 +61,3 @@ server {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ LOG_LEVEL=info
|
||||
|
||||
FACILITATOR_JWT_SECRET=replace-with-strong-secret
|
||||
PAYMENT_HMAC_SECRET=replace-with-strong-secret
|
||||
EVM_PRIVATE_KEY=da31295a02cb4bf55be60827d72be87c60d7c40efc9b10f6f04dd87e97735da5
|
||||
SOLANA_PRIVATE_KEY=fQ6DNzNNJmwiuB9VQSCn5UnwrJktp1ZKsgkmds8NsQowgswv58TGibnJpQkKcLzQtHztizvshfcQVoCJBZrfWsB
|
||||
|
||||
# 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
|
||||
# Optional BSC RPC (Alchemy BNB Mainnet)
|
||||
BSC_RPC_URL=https://bnb-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
||||
|
||||
# Solana RPC (optional)
|
||||
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
|
||||
# Solana RPC (Alchemy)
|
||||
SOLANA_RPC_URL=https://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
||||
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
49
x402/facilitator/config.json
普通文件
49
x402/facilitator/config.json
普通文件
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"port": 4020,
|
||||
"host": "0.0.0.0",
|
||||
"chains": {
|
||||
"eip155:1": {
|
||||
"eip1559": true,
|
||||
"signers": ["$EVM_PRIVATE_KEY"],
|
||||
"rpc": [
|
||||
{
|
||||
"http": "https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n",
|
||||
"rate_limit": 20
|
||||
}
|
||||
]
|
||||
},
|
||||
"eip155:56": {
|
||||
"eip1559": false,
|
||||
"signers": ["$EVM_PRIVATE_KEY"],
|
||||
"rpc": [
|
||||
{
|
||||
"http": "https://bnb-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n",
|
||||
"rate_limit": 20
|
||||
}
|
||||
]
|
||||
},
|
||||
"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp": {
|
||||
"signer": "$SOLANA_PRIVATE_KEY",
|
||||
"rpc": "https://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n",
|
||||
"pubsub": "wss://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n"
|
||||
}
|
||||
},
|
||||
"schemes": [
|
||||
{
|
||||
"id": "v1-eip155-exact",
|
||||
"chains": "eip155:*"
|
||||
},
|
||||
{
|
||||
"id": "v2-eip155-exact",
|
||||
"chains": "eip155:*"
|
||||
},
|
||||
{
|
||||
"id": "v1-solana-exact",
|
||||
"chains": "solana:*"
|
||||
},
|
||||
{
|
||||
"id": "v2-solana-exact",
|
||||
"chains": "solana:*"
|
||||
}
|
||||
]
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户