diff --git a/README.md b/README.md
index dd843f5..912da86 100644
--- a/README.md
+++ b/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
diff --git a/app/.env b/app/.env
index 9da0e31..15fdbb3 100644
--- a/app/.env
+++ b/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
diff --git a/app/src/public/app.js b/app/src/public/app.js
new file mode 100644
index 0000000..3afcf9d
--- /dev/null
+++ b/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 = `
+
${merchant.id} |
+ ${merchant.name} |
+ ${merchant.email} |
+ ${merchant.webhookUrl || '-'} |
+ ${apiKey} |
+ ${webhookSecret} |
+ ${merchant.createdAt || '-'} |
+ `;
+ 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() || '';
+ 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: \" \\\n -H \"x-api-key: \" \\\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: \" \\\n -H \"x-api-key: \" \\\n -d '{\"orderId\":\"\",\"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();
+})();
diff --git a/app/src/public/docs/index.html b/app/src/public/docs/index.html
new file mode 100644
index 0000000..dc41deb
--- /dev/null
+++ b/app/src/public/docs/index.html
@@ -0,0 +1,62 @@
+
+
+
+
+
+ Capay API Docs
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/public/index.html b/app/src/public/index.html
new file mode 100644
index 0000000..3a957d5
--- /dev/null
+++ b/app/src/public/index.html
@@ -0,0 +1,136 @@
+
+
+
+
+
+ Capay 管理后台
+
+
+
+
+
+
+
Capay 管理后台
+
商户管理 · REST 配置 · 接入指导
+
+
+
+
+
+ 管理员登录
+ 使用 ADMIN_API_KEY 进入管理后台(默认:whoami139)。
+
+
+
+
+
+
+
+
+
+
已接入商户
+
支持多商户并行接入,复制 API Key / Secret 交付对应商户即可。
+
+
+
+
+ | ID |
+ 名称 |
+ 邮箱 |
+ Webhook |
+ API Key |
+ Secret |
+ 创建时间 |
+
+
+
+
+
+
暂无商户,请先新增。
+
+
+
+
+
+
REST 管理配置与教程
+
+
+ - 使用管理员密钥创建商户,获取 merchantId / apiKey / webhookSecret。
+ - 将 apiKey 与 merchantId 提供给商户,用于创建订单与跟踪交易。
+ - 商户创建订单后,把链上 txHash 回传到 /payments/track 进行确认。
+ - Webhook 由平台签名,商户侧自行验签并处理订单状态。
+
+
+
+
1. 创建商户(管理员)
+
+
+
2. 创建订单(商户)
+
+
+
3. 追踪链上交易
+
+
+
4. Webhook 验签提示
+
+
+
+
多商户接入
+
每个商户都会得到独立的 merchantId/apiKey/webhookSecret。重复创建即可支持不同商户并行接入。
+
+
+
+
+
+
+
+
diff --git a/app/src/public/openapi.json b/app/src/public/openapi.json
new file mode 100644
index 0000000..761e1b8
--- /dev/null
+++ b/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"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/public/styles.css b/app/src/public/styles.css
new file mode 100644
index 0000000..f390678
--- /dev/null
+++ b/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);
+}
diff --git a/app/src/server.js b/app/src/server.js
index 64e680e..0827629 100644
--- a/app/src/server.js
+++ b/app/src/server.js
@@ -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 });
});
diff --git a/app/src/utils/rpc.js b/app/src/utils/rpc.js
index 6757916..b1b7166 100644
--- a/app/src/utils/rpc.js
+++ b/app/src/utils/rpc.js
@@ -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
};
diff --git a/app/src/watchers/btcWatcher.js b/app/src/watchers/btcWatcher.js
new file mode 100644
index 0000000..6f42931
--- /dev/null
+++ b/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 };
diff --git a/infra/docker/btcpay-src b/infra/docker/btcpay-src
new file mode 160000
index 0000000..3bd29ae
--- /dev/null
+++ b/infra/docker/btcpay-src
@@ -0,0 +1 @@
+Subproject commit 3bd29ae5a43406043ec425eb7920655b3b6d6f2b
diff --git a/infra/docker/btcpay/docker-compose.yml b/infra/docker/btcpay/docker-compose.yml
new file mode 100644
index 0000000..4aee283
--- /dev/null
+++ b/infra/docker/btcpay/docker-compose.yml
@@ -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:
diff --git a/infra/docker/keagate-src b/infra/docker/keagate-src
new file mode 160000
index 0000000..0581525
--- /dev/null
+++ b/infra/docker/keagate-src
@@ -0,0 +1 @@
+Subproject commit 0581525abb6dec52836ac1fbc7ab16c16c022e19
diff --git a/infra/docker/keagate/local.json b/infra/docker/keagate/local.json
index 2925f21..66b1324 100644
--- a/infra/docker/keagate/local.json
+++ b/infra/docker/keagate/local.json
@@ -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
diff --git a/infra/docker/x402-rs b/infra/docker/x402-rs
new file mode 160000
index 0000000..4b0134a
--- /dev/null
+++ b/infra/docker/x402-rs
@@ -0,0 +1 @@
+Subproject commit 4b0134acb1d7b838a1c36d5c50a1648e653c310b
diff --git a/infra/nginx/sites-enabled/capay.conf b/infra/nginx/sites-enabled/capay.conf
index 9be4116..d40fc3c 100644
--- a/infra/nginx/sites-enabled/capay.conf
+++ b/infra/nginx/sites-enabled/capay.conf
@@ -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;
- }
-}
diff --git a/x402/facilitator/.env b/x402/facilitator/.env
index 0b3f7e9..611dcad 100644
--- a/x402/facilitator/.env
+++ b/x402/facilitator/.env
@@ -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
diff --git a/x402/facilitator/config.json b/x402/facilitator/config.json
new file mode 100644
index 0000000..27a47d7
--- /dev/null
+++ b/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:*"
+ }
+ ]
+}