From 810d0420d622e1c10612fc7812d79a32b5dfcbe5 Mon Sep 17 00:00:00 2001 From: cryptocommuniums-afk Date: Mon, 2 Feb 2026 10:05:00 +0800 Subject: [PATCH] chore: update app, infra configs, assets --- README.md | 10 +- app/.env | 8 + app/src/public/app.js | 221 +++++++++++++++ app/src/public/docs/index.html | 62 +++++ app/src/public/index.html | 136 +++++++++ app/src/public/openapi.json | 365 +++++++++++++++++++++++++ app/src/public/styles.css | 292 ++++++++++++++++++++ app/src/server.js | 51 +++- app/src/utils/rpc.js | 25 ++ app/src/watchers/btcWatcher.js | 103 +++++++ infra/docker/btcpay-src | 1 + infra/docker/btcpay/docker-compose.yml | 43 +++ infra/docker/keagate-src | 1 + infra/docker/keagate/local.json | 7 +- infra/docker/x402-rs | 1 + infra/nginx/sites-enabled/capay.conf | 19 +- x402/facilitator/.env | 10 +- x402/facilitator/config.json | 49 ++++ 18 files changed, 1374 insertions(+), 30 deletions(-) create mode 100644 app/src/public/app.js create mode 100644 app/src/public/docs/index.html create mode 100644 app/src/public/index.html create mode 100644 app/src/public/openapi.json create mode 100644 app/src/public/styles.css create mode 100644 app/src/watchers/btcWatcher.js create mode 160000 infra/docker/btcpay-src create mode 100644 infra/docker/btcpay/docker-compose.yml create mode 160000 infra/docker/keagate-src create mode 160000 infra/docker/x402-rs create mode 100644 x402/facilitator/config.json 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 + + + + + +
+
+

Capay API Docs

+

Swagger-style documentation with live requests.

+
+ Back to Admin +
+ +
+ + + + + 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 配置 · 接入指导

+
+
+ API Admin + 未连接 + API Docs +
+
+ +
+

管理员登录

+

使用 ADMIN_API_KEY 进入管理后台(默认:whoami139)。

+
+ +
+ + +
+ +
+
+ + +
+ + + + 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:*" + } + ] +}