forgelayer-node 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # forgelayer-node
2
+
3
+ > Node.js / Express middleware for accepting crypto payments via [ForgeLayer](https://forgelayer.io).
4
+
5
+ Drop `createCheckout()` into any Express app and get crypto payment endpoints in seconds — address generation, real-time rate conversion, payment polling, and webhook verification all included.
6
+
7
+ ---
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install forgelayer-node
13
+ ```
14
+
15
+ ---
16
+
17
+ ## Quick Start
18
+
19
+ ```js
20
+ const express = require('express');
21
+ const { createCheckout } = require('forgelayer-node');
22
+
23
+ const app = express();
24
+
25
+ const checkout = createCheckout({
26
+ apiKey: process.env.FORGELAYER_API_KEY,
27
+
28
+ onConfirmed: async (orderId, order) => {
29
+ // Payment confirmed — update your database here
30
+ await db.markOrderPaid(orderId);
31
+ },
32
+ });
33
+
34
+ // Mounts /fl/create, /fl/status, /fl/webhook
35
+ app.use('/fl', checkout.middleware());
36
+
37
+ app.listen(3000);
38
+ ```
39
+
40
+ Set your API key in `.env`:
41
+
42
+ ```
43
+ FORGELAYER_API_KEY=flk_live_...
44
+ ```
45
+
46
+ Get your API key at [forgelayer.io/dashboard](https://forgelayer.io/dashboard).
47
+
48
+ ---
49
+
50
+ ## How It Works
51
+
52
+ ```
53
+ React / browser
54
+
55
+ ├── POST /fl/create → generates ForgeLayer deposit address
56
+ ├── GET /fl/status → polls for payment confirmation
57
+ └── POST /fl/webhook → receives ForgeLayer webhook events (HMAC-verified)
58
+ ```
59
+
60
+ Rates are fetched from CoinGecko once at startup and refreshed every 60 seconds in the background — checkout button clicks never wait on a network call.
61
+
62
+ ---
63
+
64
+ ## Configuration
65
+
66
+ ```js
67
+ createCheckout({
68
+ // Required
69
+ apiKey: 'flk_live_...',
70
+
71
+ // Defaults (all optional — React frontend can override per button)
72
+ currency: 'USD', // fiat currency for price display
73
+ defaultChain: 'ethereum',
74
+ defaultToken: 'USDT',
75
+ paymentWindowMinutes: 30,
76
+ reuseAddress: false,
77
+
78
+ // Webhooks (optional — set via setupWebhook() instead)
79
+ webhookSecret: process.env.FORGELAYER_WEBHOOK_SECRET,
80
+
81
+ // Callbacks
82
+ onConfirmed: async (orderId, orderData) => {}, // payment confirmed
83
+ onWebhookEvent: async (event, data) => {}, // any verified webhook event
84
+ });
85
+ ```
86
+
87
+ ### Supported chains
88
+
89
+ | Chain | Value |
90
+ |---|---|
91
+ | Ethereum | `ethereum` |
92
+ | BNB Smart Chain | `bsc` |
93
+ | Tron | `tron` |
94
+ | Bitcoin | `bitcoin` |
95
+
96
+ ---
97
+
98
+ ## Routes
99
+
100
+ Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
101
+
102
+ | Method | Path | Description |
103
+ |---|---|---|
104
+ | `POST` | `/fl/create` | Generate a deposit address. Returns address, QR URL, crypto amount, expiry. |
105
+ | `GET` | `/fl/status` | Poll payment status. Returns `pending`, `confirmed`, or `expired`. |
106
+ | `POST` | `/fl/webhook` | Receive ForgeLayer `deposit_confirmed` events (HMAC-SHA256 verified). |
107
+
108
+ ### POST /fl/create
109
+
110
+ **Request body:**
111
+
112
+ ```json
113
+ {
114
+ "amount": 49.99,
115
+ "currency": "USD",
116
+ "chain": "ethereum",
117
+ "token": "USDT",
118
+ "orderId": "ORDER-123",
119
+ "paymentWindow": 30
120
+ }
121
+ ```
122
+
123
+ **Response:**
124
+
125
+ ```json
126
+ {
127
+ "ok": true,
128
+ "address": "0xabc...",
129
+ "chain": "ethereum",
130
+ "chainName": "Ethereum",
131
+ "token": "USDT",
132
+ "amount": 49.99,
133
+ "currency": "USD",
134
+ "cryptoAmount": "49.99",
135
+ "expiresAt": 1718300000,
136
+ "orderId": "ORDER-123",
137
+ "qrUrl": "https://api.qrserver.com/...",
138
+ "sessionKey": "fl_abc123"
139
+ }
140
+ ```
141
+
142
+ ### GET /fl/status
143
+
144
+ ```
145
+ GET /fl/status?session=fl_abc123
146
+ ```
147
+
148
+ ```json
149
+ { "ok": true, "status": "pending" }
150
+ { "ok": true, "status": "confirmed" }
151
+ { "ok": true, "status": "expired" }
152
+ ```
153
+
154
+ ---
155
+
156
+ ## Webhook Setup
157
+
158
+ Run once when you deploy (or whenever your public URL changes):
159
+
160
+ ```js
161
+ const result = await checkout.setupWebhook('https://yoursite.com/fl/webhook');
162
+ console.log(result.webhookId); // saved automatically for future restarts
163
+ ```
164
+
165
+ The secret is saved to `.fl_webhook_secret` alongside your project and auto-loaded on every restart — you never need to manage it manually.
166
+
167
+ ---
168
+
169
+ ## Usage with forgelayer-react
170
+
171
+ If you're using React on the frontend, pair this with [`forgelayer-react`](https://github.com/forgelayer-tech/forgelayer-react):
172
+
173
+ ```js
174
+ // Backend — forgelayer-node
175
+ app.use('/fl', checkout.middleware());
176
+ ```
177
+
178
+ ```jsx
179
+ // Frontend — forgelayer-react
180
+ import { ForgeLayerButton } from 'forgelayer-react';
181
+
182
+ <ForgeLayerButton amount={49.99} chain="ethereum" token="USDT" baseUrl="/fl" />
183
+ ```
184
+
185
+ Configure your Vite dev server to proxy `/fl` to your backend:
186
+
187
+ ```js
188
+ // vite.config.js
189
+ export default {
190
+ server: {
191
+ proxy: { '/fl': 'http://localhost:3000' },
192
+ },
193
+ };
194
+ ```
195
+
196
+ ---
197
+
198
+ ## API Reference
199
+
200
+ ### `createCheckout(config)`
201
+
202
+ Returns an object with:
203
+
204
+ | Method | Description |
205
+ |---|---|
206
+ | `middleware()` | Returns an Express middleware function. Mount with `app.use('/fl', checkout.middleware())`. |
207
+ | `setupWebhook(url, confirmations?)` | One-time webhook registration with ForgeLayer. Saves secret to `.fl_webhook_secret`. |
208
+ | `handleCreate(req, res)` | Raw route handler for `POST /create`. |
209
+ | `handleStatus(req, res)` | Raw route handler for `GET /status`. |
210
+ | `handleWebhook(req, res)` | Raw route handler for `POST /webhook`. |
211
+
212
+ ---
213
+
214
+ ## Environment Variables
215
+
216
+ | Variable | Description |
217
+ |---|---|
218
+ | `FORGELAYER_API_KEY` | Your ForgeLayer API key. |
219
+ | `FORGELAYER_WEBHOOK_SECRET` | Optional — auto-managed by `setupWebhook()`. |
220
+
221
+ ---
222
+
223
+ ## License
224
+
225
+ MIT
package/index.js ADDED
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+
3
+ module.exports = require('./src/server');
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "forgelayer-node",
3
+ "version": "1.0.0",
4
+ "description": "Node.js / Express middleware for crypto payments via ForgeLayer",
5
+ "main": "index.js",
6
+ "exports": {
7
+ ".": "./index.js"
8
+ },
9
+ "files": [
10
+ "index.js",
11
+ "src/server.js",
12
+ "src/browser.js",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "forgelayer", "crypto", "payments", "bitcoin", "ethereum",
17
+ "usdt", "express", "nodejs", "middleware", "checkout"
18
+ ],
19
+ "author": "ForgeLayer <support@forgelayer.io>",
20
+ "license": "MIT",
21
+ "homepage": "https://github.com/forgelayer-tech/forgelayer-node#readme",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/forgelayer-tech/forgelayer-node.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/forgelayer-tech/forgelayer-node/issues"
28
+ },
29
+ "engines": {
30
+ "node": ">=16.0.0"
31
+ },
32
+ "dependencies": {
33
+ "node-fetch": "^2.7.0"
34
+ },
35
+ "devDependencies": {
36
+ "dotenv": "^17.4.2",
37
+ "express": "^5.2.1"
38
+ }
39
+ }
package/src/browser.js ADDED
@@ -0,0 +1,402 @@
1
+ /**
2
+ * ForgeLayer Checkout — browser script
3
+ *
4
+ * Served automatically by the Node.js middleware at GET /fl/checkout.js
5
+ * (or include manually from your own CDN / static host).
6
+ *
7
+ * Usage (data-attribute, zero JS needed):
8
+ * <button class="fl-checkout-btn"
9
+ * data-fl-amount="49.99"
10
+ * data-fl-currency="USD"
11
+ * data-fl-chain="ethereum"
12
+ * data-fl-token="USDT"
13
+ * data-fl-order-id="order_123">
14
+ * Pay with Crypto
15
+ * </button>
16
+ *
17
+ * Usage (programmatic):
18
+ * ForgeLayerCheckout.mount('#my-btn', {
19
+ * amount: 49.99, currency: 'USD', chain: 'ethereum', token: 'USDT',
20
+ * orderId: 'order_123',
21
+ * onSuccess: function(data) { window.location = '/thank-you'; },
22
+ * });
23
+ *
24
+ * The server injects FL_CONFIG (createUrl, statusUrl) before this script runs.
25
+ */
26
+
27
+ /* global FL_CONFIG */
28
+ (function (global) {
29
+ 'use strict';
30
+
31
+ // Endpoints injected by the server middleware (e.g. /fl/create, /fl/status)
32
+ var cfg = (typeof FL_CONFIG !== 'undefined' && FL_CONFIG) || {};
33
+ var DEFAULT_CREATE_URL = cfg.createUrl || '/fl/create';
34
+ var DEFAULT_STATUS_URL = cfg.statusUrl || '/fl/status';
35
+
36
+ // ── Modal singleton ───────────────────────────────────────────────────────
37
+ var backdrop = null;
38
+ var pollTmr = null;
39
+ var cdTmr = null;
40
+ var activeOrder = null;
41
+ var userCallbacks = {};
42
+
43
+ var MODAL_HTML = [
44
+ '<div class="fl-modal" id="fl-modal" role="dialog" aria-modal="true" aria-labelledby="fl-modal-title">',
45
+ '<div class="fl-mhd">',
46
+ ' <div class="fl-mtitle" id="fl-modal-title"><div class="fl-logo">FL</div>Pay with Crypto</div>',
47
+ ' <button class="fl-xbtn" id="fl-xbtn" aria-label="Close">&times;</button>',
48
+ '</div>',
49
+ '<div class="fl-mbody">',
50
+ /* loading */
51
+ ' <div id="fl-loading"><div class="fl-spinner"></div><p class="fl-spin-lbl">Generating payment address…</p></div>',
52
+ /* payment */
53
+ ' <div id="fl-pay" style="display:none">',
54
+ ' <div class="fl-sbar">',
55
+ ' <span class="fl-sdot pending" id="fl-sdot"></span>',
56
+ ' <span id="fl-stxt">Awaiting payment…</span>',
57
+ ' <span class="fl-timer" id="fl-timer">--:--</span>',
58
+ ' </div>',
59
+ ' <div class="fl-warn" id="fl-warn"></div>',
60
+ ' <div class="fl-grid">',
61
+ ' <div class="fl-qrside">',
62
+ ' <img class="fl-qrimg" id="fl-qr" src="" alt="QR code" loading="lazy" />',
63
+ ' <p class="fl-qrlbl">Scan with wallet</p>',
64
+ ' </div>',
65
+ ' <div class="fl-infoside">',
66
+ ' <div class="fl-iblk"><label>Amount to Send</label><div class="fl-amt" id="fl-amt"></div><div class="fl-amtsub" id="fl-amtsub"></div></div>',
67
+ ' <div class="fl-iblk"><label>Deposit Address</label>',
68
+ ' <div class="fl-arow"><span class="fl-aval" id="fl-addr"></span><button type="button" class="fl-cpbtn" id="fl-cpbtn">Copy</button></div>',
69
+ ' </div>',
70
+ ' <div class="fl-iblk"><label>Network</label><span class="fl-nbadge"><span class="fl-ndot"></span><span id="fl-net"></span></span></div>',
71
+ ' </div>',
72
+ ' </div>',
73
+ ' </div>',
74
+ /* success */
75
+ ' <div class="fl-rstate" id="fl-ok">',
76
+ ' <div class="fl-rico">✅</div>',
77
+ ' <div class="fl-rtitle">Payment Confirmed!</div>',
78
+ ' <div class="fl-rsub" id="fl-ok-sub">Your crypto payment has been received.</div>',
79
+ ' </div>',
80
+ /* expired */
81
+ ' <div class="fl-rstate" id="fl-exp">',
82
+ ' <div class="fl-rico">⏳</div>',
83
+ ' <div class="fl-rtitle">Payment Expired</div>',
84
+ ' <div class="fl-rsub">The payment window has closed. Please start a new payment.</div>',
85
+ ' </div>',
86
+ '</div>',
87
+ '<div class="fl-foot">Secured by <a href="https://forgelayer.io" target="_blank" rel="noopener">ForgeLayer</a></div>',
88
+ '</div>',
89
+ ].join('');
90
+
91
+ var MODAL_CSS = [
92
+ '.fl-checkout-btn{display:inline-flex;align-items:center;gap:8px;padding:12px 24px;background:#f7931a;color:#fff;border:none;border-radius:8px;font-size:15px;font-weight:600;cursor:pointer;transition:background .15s,transform .1s;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.2}',
93
+ '.fl-checkout-btn:hover{background:#e07b10}.fl-checkout-btn:active{transform:scale(.97)}.fl-checkout-btn:disabled{opacity:.6;cursor:not-allowed}',
94
+ '.fl-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:99999;padding:16px}',
95
+ '.fl-backdrop.fl-open{display:flex;align-items:center;justify-content:center;animation:fl-fade .18s ease}',
96
+ '.fl-modal{background:#fff;border-radius:16px;width:100%;max-width:520px;box-shadow:0 24px 64px rgba(0,0,0,.28);overflow:hidden;animation:fl-up .22s ease;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;color:#111}',
97
+ '.fl-mhd{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid #e5e7eb;background:#fafafa}',
98
+ '.fl-mtitle{display:flex;align-items:center;gap:9px;font-size:15px;font-weight:700;color:#111}',
99
+ '.fl-logo{width:26px;height:26px;background:#f7931a;border-radius:6px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:10px;font-weight:800;flex-shrink:0;letter-spacing:-.5px}',
100
+ '.fl-xbtn{background:none;border:none;font-size:22px;line-height:1;cursor:pointer;color:#6b7280;padding:4px 6px;border-radius:5px;transition:background .12s}',
101
+ '.fl-xbtn:hover{background:#f3f4f6;color:#111}',
102
+ '.fl-mbody{padding:20px 18px}',
103
+ '#fl-loading{text-align:center;padding:28px 0}',
104
+ '.fl-spinner{width:38px;height:38px;border:3px solid #e5e7eb;border-top-color:#f7931a;border-radius:50%;animation:fl-spin .7s linear infinite;margin:0 auto 14px}',
105
+ '.fl-spin-lbl{color:#6b7280;font-size:13px}',
106
+ '.fl-sbar{display:flex;align-items:center;gap:9px;padding:9px 13px;border-radius:8px;background:#f9fafb;border:1px solid #e5e7eb;margin-bottom:14px;font-size:13px}',
107
+ '.fl-sdot{width:8px;height:8px;border-radius:50%;flex-shrink:0}',
108
+ '.fl-sdot.pending{background:#f59e0b;animation:fl-pulse 1.6s ease-in-out infinite}',
109
+ '.fl-sdot.confirmed{background:#10b981}.fl-sdot.expired{background:#ef4444}',
110
+ '.fl-timer{margin-left:auto;font-weight:600;font-size:13px;color:#374151;font-variant-numeric:tabular-nums}.fl-timer.urgent{color:#ef4444}',
111
+ '.fl-warn{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:9px 13px;font-size:12px;color:#92400e;margin-bottom:14px;line-height:1.5}',
112
+ '.fl-grid{display:flex;gap:18px;margin-bottom:14px}',
113
+ '.fl-qrside{flex-shrink:0;display:flex;flex-direction:column;align-items:center;gap:6px}',
114
+ '.fl-qrimg{width:148px;height:148px;border:1px solid #e5e7eb;border-radius:10px;background:#f9fafb;display:block}',
115
+ '.fl-qrlbl{font-size:11px;color:#9ca3af}',
116
+ '.fl-infoside{flex:1;min-width:0;display:flex;flex-direction:column;gap:13px}',
117
+ '.fl-iblk label{display:block;font-size:10px;font-weight:700;color:#6b7280;text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px}',
118
+ '.fl-amt{font-size:22px;font-weight:700;color:#111;line-height:1.2}.fl-amtsub{font-size:12px;color:#6b7280;margin-top:2px}',
119
+ '.fl-arow{display:flex;align-items:flex-start;gap:7px}',
120
+ '.fl-aval{font-family:"SFMono-Regular",Consolas,monospace;font-size:12px;color:#374151;word-break:break-all;flex:1;line-height:1.5}',
121
+ '.fl-cpbtn{flex-shrink:0;background:#fff;border:1px solid #d1d5db;border-radius:6px;padding:5px 11px;font-size:12px;cursor:pointer;color:#374151;transition:background .12s;white-space:nowrap}',
122
+ '.fl-cpbtn:hover{background:#f3f4f6}.fl-cpbtn.copied{border-color:#10b981;color:#059669}',
123
+ '.fl-nbadge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;background:#f3f4f6;border-radius:100px;font-size:12px;font-weight:500;color:#374151}',
124
+ '.fl-ndot{width:7px;height:7px;border-radius:50%;background:#10b981}',
125
+ '.fl-rstate{display:none;text-align:center;padding:30px 16px}',
126
+ '.fl-rstate.fl-show{display:block}',
127
+ '.fl-rico{font-size:52px;margin-bottom:14px}.fl-rtitle{font-size:19px;font-weight:700;color:#111;margin-bottom:7px}.fl-rsub{font-size:13px;color:#6b7280}',
128
+ '.fl-foot{padding:10px 18px 14px;text-align:center;font-size:11px;color:#9ca3af;border-top:1px solid #f3f4f6}',
129
+ '.fl-foot a{color:#f7931a;text-decoration:none}',
130
+ '@keyframes fl-fade{from{opacity:0}to{opacity:1}}',
131
+ '@keyframes fl-up{from{transform:translateY(18px);opacity:0}to{transform:translateY(0);opacity:1}}',
132
+ '@keyframes fl-spin{to{transform:rotate(360deg)}}',
133
+ '@keyframes fl-pulse{0%,100%{opacity:1}50%{opacity:.35}}',
134
+ '@media(max-width:460px){.fl-grid{flex-direction:column;align-items:center}.fl-qrside{flex-direction:row;gap:12px}}',
135
+ ].join('');
136
+
137
+ // ── DOM helpers ─────────────────────────────────────────────────────────────
138
+ function qs(id) { return document.getElementById(id); }
139
+
140
+ function injectStyles() {
141
+ if (document.getElementById('fl-styles')) return;
142
+ var s = document.createElement('style');
143
+ s.id = 'fl-styles';
144
+ s.textContent = MODAL_CSS;
145
+ (document.head || document.documentElement).appendChild(s);
146
+ }
147
+
148
+ function buildBackdrop() {
149
+ if (qs('fl-bd')) { backdrop = qs('fl-bd'); return; }
150
+ injectStyles();
151
+ backdrop = document.createElement('div');
152
+ backdrop.id = 'fl-bd';
153
+ backdrop.className = 'fl-backdrop';
154
+ backdrop.innerHTML = MODAL_HTML;
155
+ document.body.appendChild(backdrop);
156
+
157
+ backdrop.addEventListener('click', function (e) { if (e.target === backdrop) closeModal(); });
158
+ qs('fl-xbtn').addEventListener('click', closeModal);
159
+ qs('fl-cpbtn').addEventListener('click', copyAddr);
160
+ document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
161
+ }
162
+
163
+ function copyAddr() {
164
+ var addr = qs('fl-addr').textContent;
165
+ var btn = qs('fl-cpbtn');
166
+ var done = function () {
167
+ btn.textContent = 'Copied!'; btn.classList.add('copied');
168
+ setTimeout(function () { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
169
+ };
170
+ if (navigator.clipboard) { navigator.clipboard.writeText(addr).then(done).catch(done); }
171
+ else {
172
+ var ta = document.createElement('textarea');
173
+ ta.value = addr; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
174
+ document.body.appendChild(ta); ta.select();
175
+ try { document.execCommand('copy'); } catch (e) { /* ignore */ }
176
+ document.body.removeChild(ta); done();
177
+ }
178
+ }
179
+
180
+ // ── State machine ────────────────────────────────────────────────────────────
181
+ function showPane(id) {
182
+ ['fl-loading', 'fl-pay', 'fl-ok', 'fl-exp'].forEach(function (k) {
183
+ var el = qs(k); if (el) el.style.display = (k === id) ? 'block' : 'none';
184
+ });
185
+ ['fl-ok', 'fl-exp'].forEach(function (k) {
186
+ var el = qs(k); if (el) el.classList.toggle('fl-show', k === id);
187
+ });
188
+ }
189
+
190
+ function openModal() {
191
+ buildBackdrop();
192
+ backdrop.classList.add('fl-open');
193
+ document.body.style.overflow = 'hidden';
194
+ showPane('fl-loading');
195
+ }
196
+
197
+ function closeModal() {
198
+ if (backdrop) backdrop.classList.remove('fl-open');
199
+ document.body.style.overflow = '';
200
+ stopPoll(); stopCd();
201
+ if (userCallbacks.onCancel) userCallbacks.onCancel();
202
+ activeOrder = null;
203
+ userCallbacks = {};
204
+ }
205
+
206
+ function fillPayment(o) {
207
+ activeOrder = o;
208
+ qs('fl-qr').src = o.qrUrl;
209
+ qs('fl-qr').alt = 'Send to ' + o.address;
210
+ qs('fl-addr').textContent = o.address;
211
+ qs('fl-net').textContent = o.chainName + ' · ' + o.token;
212
+
213
+ if (o.cryptoAmount) {
214
+ qs('fl-amt').textContent = (+o.cryptoAmount).toFixed(8).replace(/\.?0+$/, '') + ' ' + o.token;
215
+ qs('fl-amtsub').textContent = '≈ ' + o.currency + ' ' + (+o.amount).toFixed(2);
216
+ } else {
217
+ qs('fl-amt').textContent = o.currency + ' ' + (+o.amount).toFixed(2);
218
+ qs('fl-amtsub').textContent = '';
219
+ }
220
+
221
+ qs('fl-warn').innerHTML =
222
+ '<strong>⚠ Important:</strong> Send only <strong>' + o.token +
223
+ '</strong> on the <strong>' + o.chainName + '</strong> network. Wrong network = permanent loss.';
224
+
225
+ showPane('fl-pay');
226
+ startCd(o.expiresAt);
227
+ startPoll(o);
228
+ }
229
+
230
+ function triggerSuccess(o) {
231
+ showPane('fl-ok');
232
+ qs('fl-sdot').className = 'fl-sdot confirmed';
233
+ stopPoll(); stopCd();
234
+ if (userCallbacks.onSuccess) userCallbacks.onSuccess(o);
235
+ if (o.successUrl) setTimeout(function () { window.location.href = o.successUrl; }, 2200);
236
+ }
237
+
238
+ function triggerExpired() {
239
+ showPane('fl-exp');
240
+ stopPoll(); stopCd();
241
+ if (userCallbacks.onExpired) userCallbacks.onExpired();
242
+ }
243
+
244
+ // ── Countdown ────────────────────────────────────────────────────────────────
245
+ function startCd(expiresAt) {
246
+ stopCd();
247
+ function tick() {
248
+ var rem = expiresAt - Math.floor(Date.now() / 1000);
249
+ var el = qs('fl-timer');
250
+ if (!el) return;
251
+ if (rem <= 0) { el.textContent = '00:00'; el.classList.add('urgent'); stopCd(); return; }
252
+ var m = Math.floor(rem / 60), s = rem % 60;
253
+ el.textContent = (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
254
+ if (rem <= 120) el.classList.add('urgent');
255
+ }
256
+ tick();
257
+ cdTmr = setInterval(tick, 1000);
258
+ }
259
+ function stopCd() { if (cdTmr) { clearInterval(cdTmr); cdTmr = null; } }
260
+
261
+ // ── Status polling ────────────────────────────────────────────────────────────
262
+ function startPoll(o) {
263
+ stopPoll();
264
+ pollTmr = setInterval(function () {
265
+ var url = (o.statusUrl || DEFAULT_STATUS_URL) + '?orderId=' + encodeURIComponent(o.orderId) + '&session=' + encodeURIComponent(o.sessionKey || '');
266
+ fetch(url, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
267
+ .then(function (r) { return r.json(); })
268
+ .then(function (d) {
269
+ if (!d.ok) return;
270
+ if (d.status === 'confirmed') triggerSuccess(o);
271
+ else if (d.status === 'expired') triggerExpired();
272
+ })
273
+ .catch(function () { /* keep polling */ });
274
+ }, 15000);
275
+ }
276
+ function stopPoll() { if (pollTmr) { clearInterval(pollTmr); pollTmr = null; } }
277
+
278
+ // ── Trigger a payment from a button or config object ─────────────────────────
279
+ function triggerPayment(params, callbacks) {
280
+ userCallbacks = callbacks || {};
281
+ openModal();
282
+
283
+ var createUrl = params.createUrl || params.serverUrl || DEFAULT_CREATE_URL;
284
+ var statusUrl = params.statusUrl || DEFAULT_STATUS_URL;
285
+
286
+ fetch(createUrl, {
287
+ method: 'POST',
288
+ headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
289
+ body: JSON.stringify({
290
+ amount: +params.amount || 0,
291
+ currency: params.currency || 'USD',
292
+ chain: params.chain || 'ethereum',
293
+ token: params.token || 'USDT',
294
+ orderId: params.orderId || '',
295
+ reuseAddress: !!params.reuseAddress,
296
+ paymentWindow: +params.paymentWindow || 30,
297
+ }),
298
+ })
299
+ .then(function (r) { return r.json(); })
300
+ .then(function (d) {
301
+ if (!d.ok) {
302
+ closeModal();
303
+ if (userCallbacks.onError) userCallbacks.onError(new Error(d.error || 'Address generation failed.'));
304
+ else alert('ForgeLayer: ' + (d.error || 'Failed to generate payment address.'));
305
+ return;
306
+ }
307
+ fillPayment(Object.assign({}, d, {
308
+ statusUrl: statusUrl,
309
+ successUrl: params.successUrl || '',
310
+ }));
311
+ })
312
+ .catch(function (e) {
313
+ closeModal();
314
+ if (userCallbacks.onError) userCallbacks.onError(e);
315
+ else alert('Network error: ' + e.message);
316
+ });
317
+ }
318
+
319
+ // ── Wire data-attribute buttons ───────────────────────────────────────────────
320
+ function handleDataBtn(btn) {
321
+ var ds = btn.dataset;
322
+ triggerPayment(
323
+ {
324
+ createUrl: ds.flCreateUrl || ds.flEndpoint || DEFAULT_CREATE_URL,
325
+ statusUrl: ds.flStatusUrl || DEFAULT_STATUS_URL,
326
+ amount: ds.flAmount,
327
+ currency: ds.flCurrency,
328
+ chain: ds.flChain,
329
+ token: ds.flToken,
330
+ orderId: ds.flOrderId,
331
+ reuseAddress: ds.flReuse === '1',
332
+ paymentWindow: ds.flWindow,
333
+ successUrl: ds.flSuccess,
334
+ },
335
+ {}
336
+ );
337
+ }
338
+
339
+ function wireBtns() {
340
+ document.querySelectorAll('.fl-checkout-btn').forEach(function (btn) {
341
+ if (btn.dataset.flWired) return;
342
+ btn.dataset.flWired = '1';
343
+ btn.addEventListener('click', function () { handleDataBtn(btn); });
344
+ });
345
+ }
346
+
347
+ // ── Public API ────────────────────────────────────────────────────────────────
348
+ var ForgeLayerCheckout = {
349
+ /**
350
+ * Programmatically mount a button and handle its click.
351
+ *
352
+ * @param {string|Element} selector - CSS selector or DOM element.
353
+ * @param {object} params - Payment params (amount, currency, chain, token, orderId, …).
354
+ * @param {object} [callbacks] - { onSuccess, onExpired, onCancel, onError }
355
+ */
356
+ mount: function (selector, params, callbacks) {
357
+ var el = typeof selector === 'string' ? document.querySelector(selector) : selector;
358
+ if (!el) return console.warn('ForgeLayerCheckout.mount: element not found:', selector);
359
+
360
+ // If the element is already a button, wire it; otherwise inject one
361
+ if (el.tagName === 'BUTTON') {
362
+ el.classList.add('fl-checkout-btn');
363
+ el.removeEventListener('click', el._flHandler);
364
+ el._flHandler = function () { triggerPayment(params, callbacks || {}); };
365
+ el.addEventListener('click', el._flHandler);
366
+ el.dataset.flWired = '1';
367
+ } else {
368
+ var btn = document.createElement('button');
369
+ btn.type = 'button';
370
+ btn.className = 'fl-checkout-btn';
371
+ btn.textContent = params.label || 'Pay with Crypto';
372
+ btn.addEventListener('click', function () { triggerPayment(params, callbacks || {}); });
373
+ btn.dataset.flWired = '1';
374
+ el.innerHTML = '';
375
+ el.appendChild(btn);
376
+ }
377
+ },
378
+
379
+ /** Open the modal immediately (e.g. from your own button). */
380
+ open: function (params, callbacks) {
381
+ triggerPayment(params, callbacks || {});
382
+ },
383
+
384
+ /** Close the modal. */
385
+ close: closeModal,
386
+ };
387
+
388
+ // ── Boot ───────────────────────────────────────────────────────────────────────
389
+ if (document.readyState === 'loading') {
390
+ document.addEventListener('DOMContentLoaded', wireBtns);
391
+ } else {
392
+ wireBtns();
393
+ }
394
+ new MutationObserver(wireBtns).observe(
395
+ document.body || document.documentElement,
396
+ { childList: true, subtree: true }
397
+ );
398
+
399
+ // Expose globally
400
+ global.ForgeLayerCheckout = ForgeLayerCheckout;
401
+
402
+ })(typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : this);
package/src/server.js ADDED
@@ -0,0 +1,516 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * ForgeLayer Checkout — Node.js middleware
5
+ *
6
+ * Mounts on an Express sub-path and provides:
7
+ * GET <base>/checkout.js → browser script (auto-configured with endpoint URLs)
8
+ * POST <base>/create → generate ForgeLayer deposit address
9
+ * GET <base>/status → poll payment status
10
+ * POST <base>/webhook → receive ForgeLayer deposit_confirmed events
11
+ *
12
+ * USAGE (Express):
13
+ * const { createCheckout } = require('forgelayer-checkout');
14
+ * const checkout = createCheckout({ apiKey: 'flk_live_...' });
15
+ * app.use('/fl', checkout.middleware());
16
+ *
17
+ * // One-time setup (run once from a setup script, not on every request):
18
+ * await checkout.setupWebhook('https://mysite.com/fl/webhook');
19
+ *
20
+ * Then in HTML:
21
+ * <script src="/fl/checkout.js"></script>
22
+ * <button class="fl-checkout-btn"
23
+ * data-fl-amount="49.99" data-fl-currency="USD"
24
+ * data-fl-chain="ethereum" data-fl-token="USDT"
25
+ * data-fl-order-id="order_123">Pay with Crypto</button>
26
+ */
27
+
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const crypto = require('crypto');
31
+
32
+ // Use built-in fetch (Node 18+) or fall back to node-fetch v2
33
+ let fetchFn;
34
+ try {
35
+ fetchFn = global.fetch || require('node-fetch');
36
+ } catch (_) {
37
+ fetchFn = global.fetch;
38
+ }
39
+
40
+ const FL_API_BASE = 'https://api.forgelayer.io/v1';
41
+ const CG_API_BASE = 'https://api.coingecko.com/api/v3';
42
+ const SDK_VERSION = '1.0.0';
43
+
44
+ // Stablecoins pegged 1:1 to USD — skip CoinGecko for these
45
+ const USD_STABLECOINS = new Set([
46
+ 'USDT', 'USDC', 'BUSD', 'DAI', 'TUSD', 'USDP', 'GUSD', 'FRAX', 'LUSD', 'USDD',
47
+ ]);
48
+
49
+ // Token symbol → CoinGecko coin ID (mirrors forgelayer-shopify/lib/coingecko.js)
50
+ const CG_MAP = {
51
+ // Native coins
52
+ ETH: 'ethereum', BNB: 'binancecoin', BTC: 'bitcoin',
53
+ TRX: 'tron',
54
+ // Stablecoins
55
+ USDT: 'tether', USDC: 'usd-coin', BUSD: 'binance-usd',
56
+ DAI: 'dai', TUSD: 'true-usd', USDP: 'pax-dollar',
57
+ FRAX: 'frax', LUSD: 'liquity-usd', GUSD: 'gemini-dollar',
58
+ USDD: 'usdd',
59
+ // Wrapped
60
+ WBTC: 'wrapped-bitcoin', WETH: 'weth', WBNB: 'wbnb',
61
+ // DeFi
62
+ LINK: 'chainlink', UNI: 'uniswap', AAVE: 'aave',
63
+ COMP: 'compound-governance-token', MKR: 'maker', SNX: 'havven',
64
+ YFI: 'yearn-finance', SUSHI: 'sushi', CRV: 'curve-dao-token',
65
+ BAL: 'balancer', LDO: 'lido-dao',
66
+ // L2 / Infra
67
+ MATIC: 'matic-network', ARB: 'arbitrum', OP: 'optimism',
68
+ GRT: 'the-graph',
69
+ // Meme
70
+ SHIB: 'shiba-inu', PEPE: 'pepe', FLOKI: 'floki',
71
+ DOGE: 'dogecoin',
72
+ // Gaming
73
+ SAND: 'the-sandbox', MANA: 'decentraland', AXS: 'axie-infinity',
74
+ APE: 'apecoin', IMX: 'immutable-x', GALA: 'gala',
75
+ // BSC
76
+ CAKE: 'pancakeswap-token', XVS: 'venus',
77
+ // Tron
78
+ BTT: 'bittorrent', WIN: 'wink', JST: 'just',
79
+ SUN: 'sun-token',
80
+ // Other
81
+ CRO: 'crypto-com-chain', BAT: 'basic-attention-token', ZRX: '0x',
82
+ ENS: 'ethereum-name-service', CHZ: 'chiliz', FTM: 'fantom',
83
+ GMT: 'stepn',
84
+ };
85
+
86
+ const CHAIN_NAMES = {
87
+ ethereum: 'Ethereum',
88
+ bsc: 'BNB Smart Chain',
89
+ tron: 'Tron',
90
+ bitcoin: 'Bitcoin',
91
+ };
92
+
93
+ // In-memory order store — replace with a real DB for production
94
+ const orderStore = new Map();
95
+
96
+ // In-memory rate cache: "batch_{currency}" → { rates: { coinId: price }, at: ms }
97
+ //
98
+ // Because Node.js is single-process, this Map is shared across ALL concurrent
99
+ // requests — so the first request in any 60-second window fetches from CoinGecko
100
+ // and every subsequent request reads from memory. No file or Redis needed.
101
+ //
102
+ // Layout example:
103
+ // "batch_usd" → {
104
+ // at: 1718300000000, // Date.now() when fetched
105
+ // rates: {
106
+ // "ethereum": 1678.39,
107
+ // "bitcoin": 64000,
108
+ // "tether": 0.9995,
109
+ // ...all ~60 coins...
110
+ // }
111
+ // }
112
+ const rateCache = new Map();
113
+
114
+ // ── ForgeLayer API calls ────────────────────────────────────────────────────
115
+
116
+ async function flRequest(method, path, apiKey, body, query) {
117
+ let url = FL_API_BASE + path;
118
+ if (query && Object.keys(query).length) {
119
+ url += '?' + new URLSearchParams(query).toString();
120
+ }
121
+ const init = {
122
+ method,
123
+ headers: {
124
+ 'Authorization': 'Bearer ' + apiKey,
125
+ 'Content-Type': 'application/json',
126
+ 'Accept': 'application/json',
127
+ 'User-Agent': 'ForgeLayer-JS-Plugin/' + SDK_VERSION + ' Node/' + process.version,
128
+ },
129
+ };
130
+ if (body && method !== 'GET') init.body = JSON.stringify(body);
131
+ const res = await fetchFn(url, init);
132
+ const text = await res.text();
133
+ let json;
134
+ try { json = JSON.parse(text); } catch (_) {
135
+ throw new Error('Invalid JSON from ForgeLayer (HTTP ' + res.status + ')');
136
+ }
137
+ if (json && json.success === false) {
138
+ throw new Error(json.error?.message || json.message || 'ForgeLayer API error');
139
+ }
140
+ return json.data ?? json;
141
+ }
142
+
143
+ async function generateAddress(apiKey, chain, label) {
144
+ const data = await flRequest('POST', '/addresses', apiKey, { chain, label });
145
+ if (!data.address) throw new Error('No address in ForgeLayer response.');
146
+ return data.address;
147
+ }
148
+
149
+ async function getBalance(apiKey, address, chain) {
150
+ const data = await flRequest('GET', '/addresses/' + encodeURIComponent(address) + '/balance', apiKey, null, { chain });
151
+ return parseFloat(data.balance ?? 0);
152
+ }
153
+
154
+ // ── CoinGecko rate ───────────────────────────────────────────────────────────
155
+
156
+ // Fetch all ~60 coin prices for a given currency in one CoinGecko request and
157
+ // write the result into rateCache. Called by the background timer and, as a
158
+ // one-shot fallback, by getCoinGeckoRate if the cache is completely empty.
159
+ async function fetchAllRates(currency) {
160
+ const cur = currency.toLowerCase();
161
+ const allIds = [...new Set(Object.values(CG_MAP))].join(',');
162
+ const url = `${CG_API_BASE}/simple/price?ids=${allIds}&vs_currencies=${cur}`;
163
+
164
+ const res = await fetchFn(url, { headers: { 'Accept': 'application/json' } });
165
+
166
+ if (res.status === 429) {
167
+ console.warn('[ForgeLayer] CoinGecko rate limit (429) — keeping existing cache.');
168
+ return; // keep whatever is already cached
169
+ }
170
+ if (!res.ok) {
171
+ console.warn('[ForgeLayer] CoinGecko error ' + res.status + ' — keeping existing cache.');
172
+ return;
173
+ }
174
+
175
+ const json = await res.json();
176
+ // CoinGecko sometimes returns {"status": {"error_code": ...}} on errors
177
+ if (json.status && json.status.error_code) {
178
+ console.warn('[ForgeLayer] CoinGecko API error:', json.status.error_message);
179
+ return;
180
+ }
181
+
182
+ const rates = {};
183
+ for (const [id, prices] of Object.entries(json)) {
184
+ if (prices[cur] != null) rates[id] = parseFloat(prices[cur]);
185
+ }
186
+
187
+ if (Object.keys(rates).length > 0) {
188
+ rateCache.set('batch_' + cur, { rates, at: Date.now() });
189
+ }
190
+ }
191
+
192
+ // Read the cached rate for a token/currency pair.
193
+ // Falls back to a one-shot fetch ONLY on the very first call before the
194
+ // background timer has had a chance to populate the cache.
195
+ async function getCoinGeckoRate(token, currency) {
196
+ const sym = token.toUpperCase();
197
+ const cur = currency.toLowerCase();
198
+
199
+ // Stablecoins are always ≈ 1 USD — never hit CoinGecko
200
+ if (cur === 'usd' && USD_STABLECOINS.has(sym)) return 1.0;
201
+
202
+ const coinId = CG_MAP[sym];
203
+ if (!coinId) {
204
+ throw new Error(
205
+ 'No CoinGecko mapping for token: ' + token +
206
+ '. Supported: ' + Object.keys(CG_MAP).join(', ')
207
+ );
208
+ }
209
+
210
+ const cached = rateCache.get('batch_' + cur);
211
+
212
+ // Cache is populated and fresh — use it (normal path after background timer runs)
213
+ if (cached && Object.keys(cached.rates).length > 0) {
214
+ const rate = parseFloat(cached.rates[coinId] ?? 0);
215
+ if (rate > 0) return rate;
216
+ }
217
+
218
+ // Cache is empty (process just started, timer hasn't fired yet) — fetch once now
219
+ await fetchAllRates(cur);
220
+
221
+ const refreshed = rateCache.get('batch_' + cur);
222
+ const rate = parseFloat(refreshed?.rates[coinId] ?? 0);
223
+ if (rate <= 0) {
224
+ throw new Error('No rate available for ' + sym + '/' + currency.toUpperCase() + ' — CoinGecko may be unavailable.');
225
+ }
226
+ return rate;
227
+ }
228
+
229
+ // ── Middleware factory ────────────────────────────────────────────────────────
230
+
231
+ function createCheckout(config) {
232
+ if (!config || !config.apiKey) {
233
+ throw new Error('ForgeLayer: apiKey is required.');
234
+ }
235
+
236
+ const apiKey = String(config.apiKey).trim();
237
+ // Webhook secret: from config, or env var, or the saved secret file
238
+ let webhookSecret = String(
239
+ config.webhookSecret || process.env.FORGELAYER_WEBHOOK_SECRET || loadSavedWebhookSecret() || ''
240
+ ).trim();
241
+ const defaultCurrency = (config.currency || 'USD').toUpperCase();
242
+ const defaultChain = config.defaultChain || 'ethereum';
243
+ const defaultToken = (config.defaultToken || 'USDT').toUpperCase();
244
+ const defaultPaymentWindow = Math.max(1, +(config.paymentWindowMinutes || 30));
245
+ const defaultReuseAddress = !!config.reuseAddress;
246
+ const onConfirmed = config.onConfirmed || null; // async (orderId, data) => {}
247
+ const onWebhookEvent = config.onWebhookEvent || null; // async (event, data) => {}
248
+
249
+ // ── Background rate refresh ─────────────────────────────────────────────────
250
+ // Fetch CoinGecko prices immediately on startup, then every 60 seconds.
251
+ // This keeps the cache warm so checkout button clicks never wait on a network call.
252
+ const _refreshCurrency = defaultCurrency.toLowerCase();
253
+ fetchAllRates(_refreshCurrency).catch(e =>
254
+ console.warn('[ForgeLayer] Initial rate fetch failed:', e.message)
255
+ );
256
+ const _rateTimer = setInterval(
257
+ () => fetchAllRates(_refreshCurrency).catch(e =>
258
+ console.warn('[ForgeLayer] Rate refresh failed:', e.message)
259
+ ),
260
+ 60_000
261
+ );
262
+ // Don't block process exit — the interval is fire-and-forget
263
+ if (_rateTimer.unref) _rateTimer.unref();
264
+
265
+ // Read browser.js once at startup
266
+ const browserJsPath = path.join(__dirname, 'browser.js');
267
+ const browserJsRaw = fs.readFileSync(browserJsPath, 'utf8');
268
+
269
+ // ── Route handlers ──────────────────────────────────────────────────────────
270
+
271
+ async function handleCreate(req, res, basePath) {
272
+ if (req.method !== 'POST') {
273
+ return res.status(405).json({ ok: false, error: 'POST required.' });
274
+ }
275
+
276
+ let body = req.body;
277
+ if (!body || typeof body !== 'object') {
278
+ try {
279
+ const raw = await readBody(req);
280
+ body = JSON.parse(raw || '{}');
281
+ } catch (_) {
282
+ body = {};
283
+ }
284
+ }
285
+
286
+ const amount = parseFloat(body.amount || 0);
287
+ const currency = ((body.currency || defaultCurrency)).toUpperCase();
288
+ const chain = (body.chain || defaultChain).toLowerCase();
289
+ const token = ((body.token || defaultToken)).toUpperCase();
290
+ const orderId = body.orderId || ('fl_' + Date.now() + '_' + Math.random().toString(36).slice(2));
291
+ const reuse = body.reuseAddress !== undefined ? !!body.reuseAddress : defaultReuseAddress;
292
+ const window_ = Math.max(1, +(body.paymentWindow || defaultPaymentWindow));
293
+
294
+ if (amount <= 0) return res.status(400).json({ ok: false, error: 'Amount must be > 0.' });
295
+ if (!CHAIN_NAMES[chain]) return res.status(400).json({ ok: false, error: 'Unsupported chain: ' + chain });
296
+
297
+ let address;
298
+ try { address = await generateAddress(apiKey, chain, orderId); }
299
+ catch (e) { return res.status(500).json({ ok: false, error: 'Address generation failed: ' + e.message }); }
300
+
301
+ let cryptoAmount = null;
302
+ try {
303
+ const rate = await getCoinGeckoRate(token, currency);
304
+ if (rate > 0) cryptoAmount = (amount / rate).toFixed(8).replace(/\.?0+$/, '');
305
+ } catch (_) { /* show fiat only */ }
306
+
307
+ const expiresAt = Math.floor(Date.now() / 1000) + window_ * 60;
308
+ const sessionKey = 'fl_' + orderId.replace(/[^a-z0-9]/gi, '');
309
+ const qrUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address);
310
+
311
+ // Save order
312
+ orderStore.set(sessionKey, {
313
+ orderId, address, chain, token, amount, currency,
314
+ cryptoAmount, expiresAt, status: 'pending',
315
+ });
316
+
317
+ return res.json({
318
+ ok: true,
319
+ address, chain,
320
+ chainName: CHAIN_NAMES[chain] || chain,
321
+ token, amount, currency, cryptoAmount,
322
+ expiresAt, orderId, qrUrl, sessionKey,
323
+ });
324
+ }
325
+
326
+ async function handleStatus(req, res) {
327
+ const orderId = req.query?.orderId || (new URLSearchParams(req.url.split('?')[1] || '')).get('orderId') || '';
328
+ const sessionKey = req.query?.session || (new URLSearchParams(req.url.split('?')[1] || '')).get('session') || '';
329
+
330
+ const key = sessionKey || ('fl_' + orderId.replace(/[^a-z0-9]/gi, ''));
331
+ const order = orderStore.get(key);
332
+ if (!order) return res.status(404).json({ ok: false, error: 'Order not found.' });
333
+
334
+ if (order.status === 'confirmed') return res.json({ ok: true, status: 'confirmed' });
335
+
336
+ // Server-authoritative expiry
337
+ if (Math.floor(Date.now() / 1000) >= order.expiresAt) {
338
+ order.status = 'expired';
339
+ return res.json({ ok: true, status: 'expired' });
340
+ }
341
+
342
+ // Check balance
343
+ try {
344
+ const balance = await getBalance(apiKey, order.address, order.chain);
345
+ const expected = parseFloat(order.cryptoAmount || 0);
346
+ if (expected > 0 && balance >= expected * 0.99) {
347
+ order.status = 'confirmed';
348
+ if (onConfirmed) {
349
+ onConfirmed(order.orderId, order).catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
350
+ }
351
+ return res.json({ ok: true, status: 'confirmed' });
352
+ }
353
+ } catch (_) { /* best-effort */ }
354
+
355
+ return res.json({ ok: true, status: 'pending' });
356
+ }
357
+
358
+ function serveBrowserScript(req, res, basePath) {
359
+ const createUrl = basePath + '/create';
360
+ const statusUrl = basePath + '/status';
361
+ const config_js = 'var FL_CONFIG={"createUrl":"' + createUrl + '","statusUrl":"' + statusUrl + '"};';
362
+ res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
363
+ res.setHeader('Cache-Control', 'public, max-age=300');
364
+ res.end(config_js + '\n' + browserJsRaw);
365
+ }
366
+
367
+ // ── Webhook setup ───────────────────────────────────────────────────────────
368
+
369
+ async function setupWebhook(webhookUrl, confirmations) {
370
+ if (!webhookUrl || typeof webhookUrl !== 'string') {
371
+ throw new Error('setupWebhook: webhookUrl is required.');
372
+ }
373
+ confirmations = Math.max(1, +(confirmations || 1));
374
+
375
+ // Generate a fresh secret
376
+ const newSecret = crypto.randomBytes(32).toString('hex');
377
+
378
+ // Delete the old webhook if we have its ID on file
379
+ const oldId = loadSavedWebhookId();
380
+ if (oldId) {
381
+ try {
382
+ await flRequest('DELETE', '/webhooks/' + encodeURIComponent(oldId), apiKey);
383
+ } catch (_) { /* best-effort — old webhook may already be gone */ }
384
+ }
385
+
386
+ // Register with ForgeLayer
387
+ const data = await flRequest('POST', '/webhooks', apiKey, {
388
+ url: webhookUrl,
389
+ secret: newSecret,
390
+ events: ['deposit_confirmed'],
391
+ confirmations,
392
+ });
393
+
394
+ const webhookId = data.id || data.webhookId || '';
395
+
396
+ // Persist for next restart
397
+ saveWebhookSecret(newSecret);
398
+ if (webhookId) saveWebhookId(webhookId);
399
+
400
+ // Update the live secret so the running process verifies correctly immediately
401
+ webhookSecret = newSecret;
402
+
403
+ return { webhookId, webhookUrl, confirmations };
404
+ }
405
+
406
+ // ── Webhook handler ─────────────────────────────────────────────────────────
407
+
408
+ async function handleWebhook(req, res) {
409
+ if (req.method !== 'POST') {
410
+ return res.status(405).json({ ok: false, error: 'POST required.' });
411
+ }
412
+
413
+ const rawBody = await readBody(req);
414
+ const sig = req.headers['x-fl-signature'] || '';
415
+
416
+ if (!webhookSecret) {
417
+ console.error('[ForgeLayer] Webhook received but no webhookSecret is configured. Call setupWebhook() first.');
418
+ return res.status(500).json({ ok: false, error: 'Webhook secret not configured.' });
419
+ }
420
+
421
+ const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
422
+ if (!crypto.timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'))) {
423
+ return res.status(401).json({ ok: false, error: 'Invalid signature.' });
424
+ }
425
+
426
+ let event;
427
+ try { event = JSON.parse(rawBody); } catch (_) {
428
+ return res.status(400).json({ ok: false, error: 'Invalid JSON body.' });
429
+ }
430
+
431
+ if (event.event === 'deposit_confirmed') {
432
+ const orderId = event.data?.orderId || event.data?.label || '';
433
+ const sessionKey = 'fl_' + orderId.replace(/[^a-z0-9]/gi, '');
434
+ const order = orderStore.get(sessionKey);
435
+ if (order) {
436
+ order.status = 'confirmed';
437
+ if (onConfirmed) {
438
+ onConfirmed(orderId, order).catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
439
+ }
440
+ }
441
+ }
442
+
443
+ if (onWebhookEvent) {
444
+ onWebhookEvent(event.event, event.data).catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
445
+ }
446
+
447
+ return res.status(200).json({ ok: true });
448
+ }
449
+
450
+ // ── Express middleware ──────────────────────────────────────────────────────
451
+
452
+ function middleware() {
453
+ return function forgeLayerMiddleware(req, res, next) {
454
+ // Determine the path relative to where this middleware is mounted
455
+ const mountPath = req.baseUrl || '';
456
+ const urlPath = (req.path || '/').replace(/\/$/, '') || '/';
457
+
458
+ if (urlPath === '/checkout.js' && req.method === 'GET') {
459
+ return serveBrowserScript(req, res, mountPath);
460
+ }
461
+ if (urlPath === '/create' && req.method === 'POST') {
462
+ return handleCreate(req, res, mountPath).catch(next);
463
+ }
464
+ if (urlPath === '/status' && req.method === 'GET') {
465
+ return handleStatus(req, res).catch(next);
466
+ }
467
+ if (urlPath === '/webhook' && req.method === 'POST') {
468
+ return handleWebhook(req, res).catch(next);
469
+ }
470
+ return next();
471
+ };
472
+ }
473
+
474
+ return { middleware, setupWebhook, handleCreate, handleStatus, handleWebhook, serveBrowserScript };
475
+ }
476
+
477
+ // ── Helpers ──────────────────────────────────────────────────────────────────
478
+
479
+ function readBody(req) {
480
+ return new Promise(function (resolve, reject) {
481
+ // If Express (or body-parser) already consumed the body, return it as a raw Buffer
482
+ if (req.body !== undefined) {
483
+ const raw = typeof req.body === 'string'
484
+ ? req.body
485
+ : (Buffer.isBuffer(req.body) ? req.body.toString('utf8') : JSON.stringify(req.body));
486
+ return resolve(raw);
487
+ }
488
+ const chunks = [];
489
+ req.on('data', function (chunk) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); });
490
+ req.on('end', function () { resolve(Buffer.concat(chunks).toString('utf8')); });
491
+ req.on('error', reject);
492
+ });
493
+ }
494
+
495
+ // ── Webhook secret/ID file persistence ───────────────────────────────────────
496
+
497
+ const SECRET_FILE = path.join(__dirname, '..', '.fl_webhook_secret');
498
+ const ID_FILE = path.join(__dirname, '..', '.fl_webhook_id');
499
+
500
+ function loadSavedWebhookSecret() {
501
+ try { return fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { return ''; }
502
+ }
503
+
504
+ function saveWebhookSecret(secret) {
505
+ fs.writeFileSync(SECRET_FILE, secret, { encoding: 'utf8', mode: 0o600 });
506
+ }
507
+
508
+ function loadSavedWebhookId() {
509
+ try { return fs.readFileSync(ID_FILE, 'utf8').trim(); } catch (_) { return ''; }
510
+ }
511
+
512
+ function saveWebhookId(id) {
513
+ fs.writeFileSync(ID_FILE, id, 'utf8');
514
+ }
515
+
516
+ module.exports = { createCheckout };