chore: update app, infra configs, assets
这个提交包含在:
10
README.md
10
README.md
@@ -56,6 +56,10 @@ Headers: `x-merchant-id`, `x-api-key`
|
|||||||
```json
|
```json
|
||||||
{ "orderId": "ORD123", "txHash": "0x...", "network": "evm:ethereum" }
|
{ "orderId": "ORD123", "txHash": "0x...", "network": "evm:ethereum" }
|
||||||
```
|
```
|
||||||
|
Bitcoin 示例:
|
||||||
|
```json
|
||||||
|
{ "orderId": "ORD123", "txHash": "btc_txid", "network": "btc:mainnet" }
|
||||||
|
```
|
||||||
|
|
||||||
### 4) Webhook 聚合入口
|
### 4) Webhook 聚合入口
|
||||||
`POST /payments/webhook`
|
`POST /payments/webhook`
|
||||||
@@ -70,12 +74,16 @@ Headers: `x-merchant-id`, `x-api-key`
|
|||||||
证书建议使用 certbot(Let’s Encrypt):
|
证书建议使用 certbot(Let’s Encrypt):
|
||||||
```bash
|
```bash
|
||||||
apt-get update && apt-get install -y certbot python3-certbot-nginx
|
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
|
||||||
配置文件在:`x402/facilitator/`,已绑定 `pay.capay.hao.work`,并写入 Alchemy ETH RPC。
|
配置文件在:`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` 中的密钥替换为强随机值
|
- 把 `.env` 中的密钥替换为强随机值
|
||||||
- 生产环境建议使用数据库(PostgreSQL/MySQL)替换本地 JSON
|
- 生产环境建议使用数据库(PostgreSQL/MySQL)替换本地 JSON
|
||||||
|
|||||||
8
app/.env
8
app/.env
@@ -1,6 +1,7 @@
|
|||||||
APP_PORT=3001
|
APP_PORT=3001
|
||||||
APP_HOST=https://capay.hao.work
|
APP_HOST=https://capay.hao.work
|
||||||
PLAN=free
|
PLAN=free
|
||||||
|
ADMIN_API_KEY=whoami139
|
||||||
|
|
||||||
# Low-cost polling for free plan
|
# Low-cost polling for free plan
|
||||||
TX_POLL_INTERVAL_MS=180000
|
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)
|
# 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
|
# 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)
|
# Webhook signature secrets (replace in production)
|
||||||
IPN_HMAC_SECRET=replace-with-strong-secret
|
IPN_HMAC_SECRET=replace-with-strong-secret
|
||||||
BTCPAY_WEBHOOK_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 crypto = require('crypto');
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { nanoid } = require('nanoid');
|
const { nanoid } = require('nanoid');
|
||||||
|
const path = require('path');
|
||||||
const { readDb, withDb } = require('./storage');
|
const { readDb, withDb } = require('./storage');
|
||||||
const { startEvmWatcher } = require('./watchers/evmWatcher');
|
const { startEvmWatcher } = require('./watchers/evmWatcher');
|
||||||
|
const { startBtcWatcher } = require('./watchers/btcWatcher');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
const PLAN = process.env.PLAN || 'free';
|
const PLAN = process.env.PLAN || 'free';
|
||||||
|
const ADMIN_API_KEY = process.env.ADMIN_API_KEY || 'whoami139';
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -40,6 +44,15 @@ async function requireMerchant(req, res) {
|
|||||||
return merchant;
|
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) {
|
async function logEvent(source, payload) {
|
||||||
await withDb((db) => {
|
await withDb((db) => {
|
||||||
db.events.push({
|
db.events.push({
|
||||||
@@ -96,10 +109,45 @@ app.get('/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/', (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) => {
|
app.post('/merchants', async (req, res) => {
|
||||||
|
if (!requireAdmin(req, res)) return;
|
||||||
const { name, email, webhookUrl } = req.body || {};
|
const { name, email, webhookUrl } = req.body || {};
|
||||||
if (!name || !email) {
|
if (!name || !email) {
|
||||||
return res.status(400).json({ error: 'name and email are required' });
|
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, () => {
|
app.listen(port, () => {
|
||||||
console.log(`capay app listening on :${port}`);
|
console.log(`capay app listening on :${port}`);
|
||||||
startEvmWatcher({ notifyMerchant });
|
startEvmWatcher({ notifyMerchant });
|
||||||
|
startBtcWatcher({ notifyMerchant });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ function buildAlchemyUrls(keys) {
|
|||||||
return keys.map((key) => `https://eth-mainnet.g.alchemy.com/v2/${key}`);
|
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) {
|
function unique(items) {
|
||||||
return Array.from(new Set(items));
|
return Array.from(new Set(items));
|
||||||
}
|
}
|
||||||
@@ -34,6 +38,26 @@ function getEthereumRpcUrls() {
|
|||||||
return unique(urls);
|
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) {
|
function createRoundRobinPicker(items) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
return function pick() {
|
return function pick() {
|
||||||
@@ -46,5 +70,6 @@ function createRoundRobinPicker(items) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getEthereumRpcUrls,
|
getEthereumRpcUrls,
|
||||||
|
getBitcoinRpcUrls,
|
||||||
createRoundRobinPicker
|
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,
|
"PORT": 8081,
|
||||||
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
|
"MONGO_CONNECTION_STRING": "mongodb://localhost:27017",
|
||||||
"MONGO_KEAGATE_DB": "keagate",
|
"MONGO_KEAGATE_DB": "keagate",
|
||||||
@@ -14,11 +14,6 @@
|
|||||||
"INVOICE_ENC_KEY": "hex-32-bytes",
|
"INVOICE_ENC_KEY": "hex-32-bytes",
|
||||||
"IPN_HMAC_SECRET": "replace-hmac-secret",
|
"IPN_HMAC_SECRET": "replace-hmac-secret",
|
||||||
|
|
||||||
"DOGE": {
|
|
||||||
"ADMIN_PUBLIC_KEY": "DogePublicAddress",
|
|
||||||
"ADMIN_PRIVATE_KEY": null
|
|
||||||
},
|
|
||||||
|
|
||||||
"SOL": {
|
"SOL": {
|
||||||
"ADMIN_PUBLIC_KEY": "SolPublicAddress",
|
"ADMIN_PUBLIC_KEY": "SolPublicAddress",
|
||||||
"ADMIN_PRIVATE_KEY": null
|
"ADMIN_PRIVATE_KEY": null
|
||||||
|
|||||||
1
infra/docker/x402-rs
子模块
1
infra/docker/x402-rs
子模块
子模块 infra/docker/x402-rs 已添加到 4b0134acb1
@@ -1,6 +1,6 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
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;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,20 +61,3 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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
|
FACILITATOR_JWT_SECRET=replace-with-strong-secret
|
||||||
PAYMENT_HMAC_SECRET=replace-with-strong-secret
|
PAYMENT_HMAC_SECRET=replace-with-strong-secret
|
||||||
|
EVM_PRIVATE_KEY=da31295a02cb4bf55be60827d72be87c60d7c40efc9b10f6f04dd87e97735da5
|
||||||
|
SOLANA_PRIVATE_KEY=fQ6DNzNNJmwiuB9VQSCn5UnwrJktp1ZKsgkmds8NsQowgswv58TGibnJpQkKcLzQtHztizvshfcQVoCJBZrfWsB
|
||||||
|
|
||||||
# Alchemy Ethereum RPC
|
# Alchemy Ethereum RPC
|
||||||
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
ETHEREUM_RPC_URL=https://eth-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
||||||
|
|
||||||
# Optional BSC RPC if needed
|
# Optional BSC RPC (Alchemy BNB Mainnet)
|
||||||
BSC_RPC_URL=https://bsc-dataseed.binance.org
|
BSC_RPC_URL=https://bnb-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
||||||
|
|
||||||
# Solana RPC (optional)
|
# Solana RPC (Alchemy)
|
||||||
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
|
SOLANA_RPC_URL=https://solana-mainnet.g.alchemy.com/v2/P9kZiHB6Q7CLrBlMsUN3n
|
||||||
|
|
||||||
REDIS_URL=redis://127.0.0.1:6379
|
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:*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
在新工单中引用
屏蔽一个用户