forgelayer-node 1.0.0 → 1.1.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.
Files changed (3) hide show
  1. package/README.md +117 -4
  2. package/package.json +1 -1
  3. package/src/server.js +228 -218
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Node.js / Express middleware for accepting crypto payments via [ForgeLayer](https://forgelayer.io).
4
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.
5
+ Drop `createCheckout()` into any Express app and get crypto payment endpoints in seconds — address generation, real-time rate conversion, payment polling, webhook verification, and pluggable storage all included.
6
6
 
7
7
  ---
8
8
 
@@ -72,12 +72,18 @@ createCheckout({
72
72
  currency: 'USD', // fiat currency for price display
73
73
  defaultChain: 'ethereum',
74
74
  defaultToken: 'USDT',
75
- paymentWindowMinutes: 30,
76
- reuseAddress: false,
75
+ paymentWindowMinutes: 30, // browser countdown timer
76
+ gracePeriodMinutes: 0, // extra server-side window after expiry (see below)
77
+ reuseAddress: false, // return same address for same orderId if still pending
77
78
 
78
79
  // Webhooks (optional — set via setupWebhook() instead)
79
80
  webhookSecret: process.env.FORGELAYER_WEBHOOK_SECRET,
80
81
 
82
+ // Storage hooks — recommended for production (see Storage section below)
83
+ async getOrder(sessionKey) { return await db.findOne({ sessionKey }); },
84
+ async saveOrder(sessionKey, order) { await db.insertOne({ sessionKey, ...order }); },
85
+ async updateOrder(sessionKey, patch) { await db.updateOne({ sessionKey }, { $set: patch }); },
86
+
81
87
  // Callbacks
82
88
  onConfirmed: async (orderId, orderData) => {}, // payment confirmed
83
89
  onWebhookEvent: async (event, data) => {}, // any verified webhook event
@@ -95,6 +101,97 @@ createCheckout({
95
101
 
96
102
  ---
97
103
 
104
+ ## Storage Hooks
105
+
106
+ By default, orders are kept in a process-local `Map` (fine for development). In production you should plug in your own database:
107
+
108
+ ```js
109
+ // MongoDB example
110
+ const checkout = createCheckout({
111
+ apiKey: process.env.FORGELAYER_API_KEY,
112
+
113
+ async getOrder(sessionKey) {
114
+ return await Order.findOne({ sessionKey }).lean();
115
+ },
116
+ async saveOrder(sessionKey, order) {
117
+ await Order.create({ sessionKey, ...order });
118
+ },
119
+ async updateOrder(sessionKey, patch) {
120
+ await Order.updateOne({ sessionKey }, { $set: patch });
121
+ },
122
+
123
+ onConfirmed: async (orderId, order) => {
124
+ await Order.updateOne({ orderId }, { $set: { paid: true } });
125
+ await sendConfirmationEmail(order.email);
126
+ },
127
+ });
128
+ ```
129
+
130
+ ```js
131
+ // PostgreSQL / Prisma example
132
+ const checkout = createCheckout({
133
+ apiKey: process.env.FORGELAYER_API_KEY,
134
+
135
+ async getOrder(sessionKey) {
136
+ return await prisma.order.findUnique({ where: { sessionKey } });
137
+ },
138
+ async saveOrder(sessionKey, order) {
139
+ await prisma.order.create({ data: { sessionKey, ...order } });
140
+ },
141
+ async updateOrder(sessionKey, patch) {
142
+ await prisma.order.update({ where: { sessionKey }, data: patch });
143
+ },
144
+ });
145
+ ```
146
+
147
+ All three hooks must be provided together. If any are omitted the plugin falls back to the built-in in-memory store.
148
+
149
+ ### In-memory store behaviour
150
+
151
+ The default store is suitable for development and simple single-process deployments:
152
+ - Orders are lost on process restart
153
+ - Does not work with multiple server instances (clusters)
154
+ - Auto-cleans orders older than 24 hours to prevent memory leaks
155
+
156
+ ---
157
+
158
+ ## Grace Period
159
+
160
+ `gracePeriodMinutes` extends the server-side payment acceptance window beyond what the browser countdown shows.
161
+
162
+ **Use case:** Slow networks or Bitcoin (where a transaction can take hours to confirm after broadcast). The browser sees the timer expire and shows an "expired" UI, but your server continues checking balances and accepting webhook confirmations for the extra time.
163
+
164
+ ```js
165
+ createCheckout({
166
+ apiKey: '...',
167
+ paymentWindowMinutes: 30, // browser shows 30-min countdown
168
+ gracePeriodMinutes: 60, // server accepts payment for 90 min total
169
+ onConfirmed: async (orderId, order) => {
170
+ // Fires even if payment arrived after the browser countdown ended
171
+ await sendLatePaymentConfirmationEmail(order);
172
+ },
173
+ });
174
+ ```
175
+
176
+ The `onConfirmed` callback receives the order with `status: 'confirmed'` regardless of whether the payment arrived during or after the payment window.
177
+
178
+ ---
179
+
180
+ ## Address Reuse
181
+
182
+ When `reuseAddress: true`, calling `/fl/create` with the same `orderId` within the payment window returns the existing deposit address instead of generating a new one:
183
+
184
+ ```js
185
+ createCheckout({
186
+ apiKey: '...',
187
+ reuseAddress: true, // or pass per-request: { reuseAddress: true } in the POST body
188
+ });
189
+ ```
190
+
191
+ This prevents a new address being generated every time a user navigates back to the payment page.
192
+
193
+ ---
194
+
98
195
  ## Routes
99
196
 
100
197
  Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
@@ -116,7 +213,8 @@ Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
116
213
  "chain": "ethereum",
117
214
  "token": "USDT",
118
215
  "orderId": "ORDER-123",
119
- "paymentWindow": 30
216
+ "paymentWindow": 30,
217
+ "reuseAddress": false
120
218
  }
121
219
  ```
122
220
 
@@ -139,6 +237,8 @@ Mounted at the path you choose (`app.use('/fl', checkout.middleware())`):
139
237
  }
140
238
  ```
141
239
 
240
+ When the same address is reused, the response also includes `"reused": true`.
241
+
142
242
  ### GET /fl/status
143
243
 
144
244
  ```
@@ -220,6 +320,19 @@ Returns an object with:
220
320
 
221
321
  ---
222
322
 
323
+ ## Changelog
324
+
325
+ ### 1.1.0
326
+ - **Storage hooks** — plug in any database via `getOrder` / `saveOrder` / `updateOrder` config options
327
+ - **Grace period** — `gracePeriodMinutes` keeps the server accepting payments after the browser timer expires
328
+ - **Address reuse fix** — `reuseAddress: true` now correctly returns the existing address across restarts when storage hooks are provided
329
+ - **In-memory TTL cleanup** — default store auto-removes orders older than 24 hours
330
+
331
+ ### 1.0.0
332
+ - Initial release
333
+
334
+ ---
335
+
223
336
  ## License
224
337
 
225
338
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forgelayer-node",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Node.js / Express middleware for crypto payments via ForgeLayer",
5
5
  "main": "index.js",
6
6
  "exports": {
package/src/server.js CHANGED
@@ -10,19 +10,20 @@
10
10
  * POST <base>/webhook → receive ForgeLayer deposit_confirmed events
11
11
  *
12
12
  * USAGE (Express):
13
- * const { createCheckout } = require('forgelayer-checkout');
13
+ * const { createCheckout } = require('forgelayer-node');
14
14
  * const checkout = createCheckout({ apiKey: 'flk_live_...' });
15
15
  * app.use('/fl', checkout.middleware());
16
16
  *
17
- * // One-time setup (run once from a setup script, not on every request):
18
- * await checkout.setupWebhook('https://mysite.com/fl/webhook');
17
+ * STORAGE HOOKS (recommended for production):
18
+ * createCheckout({
19
+ * apiKey: '...',
20
+ * async getOrder(sessionKey) { return await db.orders.findOne({ sessionKey }); },
21
+ * async saveOrder(sessionKey, order) { await db.orders.insertOne({ sessionKey, ...order }); },
22
+ * async updateOrder(sessionKey, patch) { await db.orders.updateOne({ sessionKey }, patch); },
23
+ * });
19
24
  *
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>
25
+ * Without these hooks the plugin uses an in-memory store (fine for dev/testing,
26
+ * orders are lost on restart).
26
27
  */
27
28
 
28
29
  const fs = require('fs');
@@ -37,47 +38,37 @@ try {
37
38
  fetchFn = global.fetch;
38
39
  }
39
40
 
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';
41
+ const FL_API_BASE = 'https://api.forgelayer.io/v1';
42
+ const CG_API_BASE = 'https://api.coingecko.com/api/v3';
43
+ const SDK_VERSION = '1.1.0';
43
44
 
44
45
  // Stablecoins pegged 1:1 to USD — skip CoinGecko for these
45
46
  const USD_STABLECOINS = new Set([
46
47
  'USDT', 'USDC', 'BUSD', 'DAI', 'TUSD', 'USDP', 'GUSD', 'FRAX', 'LUSD', 'USDD',
47
48
  ]);
48
49
 
49
- // Token symbol → CoinGecko coin ID (mirrors forgelayer-shopify/lib/coingecko.js)
50
+ // Token symbol → CoinGecko coin ID
50
51
  const CG_MAP = {
51
- // Native coins
52
52
  ETH: 'ethereum', BNB: 'binancecoin', BTC: 'bitcoin',
53
53
  TRX: 'tron',
54
- // Stablecoins
55
54
  USDT: 'tether', USDC: 'usd-coin', BUSD: 'binance-usd',
56
55
  DAI: 'dai', TUSD: 'true-usd', USDP: 'pax-dollar',
57
56
  FRAX: 'frax', LUSD: 'liquity-usd', GUSD: 'gemini-dollar',
58
57
  USDD: 'usdd',
59
- // Wrapped
60
58
  WBTC: 'wrapped-bitcoin', WETH: 'weth', WBNB: 'wbnb',
61
- // DeFi
62
59
  LINK: 'chainlink', UNI: 'uniswap', AAVE: 'aave',
63
60
  COMP: 'compound-governance-token', MKR: 'maker', SNX: 'havven',
64
61
  YFI: 'yearn-finance', SUSHI: 'sushi', CRV: 'curve-dao-token',
65
62
  BAL: 'balancer', LDO: 'lido-dao',
66
- // L2 / Infra
67
63
  MATIC: 'matic-network', ARB: 'arbitrum', OP: 'optimism',
68
64
  GRT: 'the-graph',
69
- // Meme
70
65
  SHIB: 'shiba-inu', PEPE: 'pepe', FLOKI: 'floki',
71
66
  DOGE: 'dogecoin',
72
- // Gaming
73
67
  SAND: 'the-sandbox', MANA: 'decentraland', AXS: 'axie-infinity',
74
68
  APE: 'apecoin', IMX: 'immutable-x', GALA: 'gala',
75
- // BSC
76
69
  CAKE: 'pancakeswap-token', XVS: 'venus',
77
- // Tron
78
70
  BTT: 'bittorrent', WIN: 'wink', JST: 'just',
79
71
  SUN: 'sun-token',
80
- // Other
81
72
  CRO: 'crypto-com-chain', BAT: 'basic-attention-token', ZRX: '0x',
82
73
  ENS: 'ethereum-name-service', CHZ: 'chiliz', FTM: 'fantom',
83
74
  GMT: 'stepn',
@@ -90,31 +81,37 @@ const CHAIN_NAMES = {
90
81
  bitcoin: 'Bitcoin',
91
82
  };
92
83
 
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;
84
+ // ── In-memory rate cache (process-wide, shared across all requests) ───────────
85
+ const rateCache = new Map();
86
+
87
+ // ── Default in-memory order store ─────────────────────────────────────────────
88
+ // Used when the developer does not supply getOrder/saveOrder/updateOrder hooks.
89
+ // Auto-cleans orders older than 24 hours to prevent memory leaks.
90
+ function createMemoryAdapter() {
91
+ const store = new Map();
92
+
93
+ const cleanupTimer = setInterval(() => {
94
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
95
+ for (const [key, order] of store) {
96
+ if ((order._savedAt || 0) < cutoff) store.delete(key);
97
+ }
98
+ }, 60 * 60 * 1000); // run every hour
99
+ if (cleanupTimer.unref) cleanupTimer.unref();
100
+
101
+ return {
102
+ async getOrder(key) { return store.get(key) || null; },
103
+ async saveOrder(key, order) { store.set(key, { ...order, _savedAt: Date.now() }); },
104
+ async updateOrder(key, patch) {
105
+ const existing = store.get(key);
106
+ if (existing) store.set(key, { ...existing, ...patch, _savedAt: existing._savedAt });
107
+ },
108
+ };
109
+ }
110
+
111
+ // ── ForgeLayer API ────────────────────────────────────────────────────────────
112
+
113
+ async function flRequest(method, urlPath, apiKey, body, query) {
114
+ let url = FL_API_BASE + urlPath;
118
115
  if (query && Object.keys(query).length) {
119
116
  url += '?' + new URLSearchParams(query).toString();
120
117
  }
@@ -128,7 +125,7 @@ async function flRequest(method, path, apiKey, body, query) {
128
125
  },
129
126
  };
130
127
  if (body && method !== 'GET') init.body = JSON.stringify(body);
131
- const res = await fetchFn(url, init);
128
+ const res = await fetchFn(url, init);
132
129
  const text = await res.text();
133
130
  let json;
134
131
  try { json = JSON.parse(text); } catch (_) {
@@ -151,21 +148,19 @@ async function getBalance(apiKey, address, chain) {
151
148
  return parseFloat(data.balance ?? 0);
152
149
  }
153
150
 
154
- // ── CoinGecko rate ───────────────────────────────────────────────────────────
151
+ // ── CoinGecko rates ───────────────────────────────────────────────────────────
155
152
 
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
153
  async function fetchAllRates(currency) {
160
154
  const cur = currency.toLowerCase();
161
155
  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' } });
156
+ const res = await fetchFn(
157
+ `${CG_API_BASE}/simple/price?ids=${allIds}&vs_currencies=${cur}`,
158
+ { headers: { 'Accept': 'application/json' } }
159
+ );
165
160
 
166
161
  if (res.status === 429) {
167
162
  console.warn('[ForgeLayer] CoinGecko rate limit (429) — keeping existing cache.');
168
- return; // keep whatever is already cached
163
+ return;
169
164
  }
170
165
  if (!res.ok) {
171
166
  console.warn('[ForgeLayer] CoinGecko error ' + res.status + ' — keeping existing cache.');
@@ -173,8 +168,7 @@ async function fetchAllRates(currency) {
173
168
  }
174
169
 
175
170
  const json = await res.json();
176
- // CoinGecko sometimes returns {"status": {"error_code": ...}} on errors
177
- if (json.status && json.status.error_code) {
171
+ if (json.status?.error_code) {
178
172
  console.warn('[ForgeLayer] CoinGecko API error:', json.status.error_message);
179
173
  return;
180
174
  }
@@ -183,41 +177,30 @@ async function fetchAllRates(currency) {
183
177
  for (const [id, prices] of Object.entries(json)) {
184
178
  if (prices[cur] != null) rates[id] = parseFloat(prices[cur]);
185
179
  }
186
-
187
180
  if (Object.keys(rates).length > 0) {
188
181
  rateCache.set('batch_' + cur, { rates, at: Date.now() });
189
182
  }
190
183
  }
191
184
 
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
185
  async function getCoinGeckoRate(token, currency) {
196
186
  const sym = token.toUpperCase();
197
187
  const cur = currency.toLowerCase();
198
188
 
199
- // Stablecoins are always ≈ 1 USD — never hit CoinGecko
200
189
  if (cur === 'usd' && USD_STABLECOINS.has(sym)) return 1.0;
201
190
 
202
191
  const coinId = CG_MAP[sym];
203
192
  if (!coinId) {
204
- throw new Error(
205
- 'No CoinGecko mapping for token: ' + token +
206
- '. Supported: ' + Object.keys(CG_MAP).join(', ')
207
- );
193
+ throw new Error('No CoinGecko mapping for token: ' + token + '. Supported: ' + Object.keys(CG_MAP).join(', '));
208
194
  }
209
195
 
210
196
  const cached = rateCache.get('batch_' + cur);
211
-
212
- // Cache is populated and fresh — use it (normal path after background timer runs)
213
197
  if (cached && Object.keys(cached.rates).length > 0) {
214
198
  const rate = parseFloat(cached.rates[coinId] ?? 0);
215
199
  if (rate > 0) return rate;
216
200
  }
217
201
 
218
- // Cache is empty (process just started, timer hasn't fired yet) — fetch once now
202
+ // First call before background timer has fired — fetch once now
219
203
  await fetchAllRates(cur);
220
-
221
204
  const refreshed = rateCache.get('batch_' + cur);
222
205
  const rate = parseFloat(refreshed?.rates[coinId] ?? 0);
223
206
  if (rate <= 0) {
@@ -229,71 +212,114 @@ async function getCoinGeckoRate(token, currency) {
229
212
  // ── Middleware factory ────────────────────────────────────────────────────────
230
213
 
231
214
  function createCheckout(config) {
232
- if (!config || !config.apiKey) {
233
- throw new Error('ForgeLayer: apiKey is required.');
234
- }
215
+ if (!config || !config.apiKey) throw new Error('ForgeLayer: apiKey is required.');
235
216
 
236
- const apiKey = String(config.apiKey).trim();
237
- // Webhook secret: from config, or env var, or the saved secret file
238
- let webhookSecret = String(
217
+ const apiKey = String(config.apiKey).trim();
218
+ let webhookSecret = String(
239
219
  config.webhookSecret || process.env.FORGELAYER_WEBHOOK_SECRET || loadSavedWebhookSecret() || ''
240
220
  ).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) => {}
221
+ const defaultCurrency = (config.currency || 'USD').toUpperCase();
222
+ const defaultChain = config.defaultChain || 'ethereum';
223
+ const defaultToken = (config.defaultToken || 'USDT').toUpperCase();
224
+ const defaultWindow = Math.max(1, +(config.paymentWindowMinutes || 30));
225
+ const defaultReuse = !!config.reuseAddress;
226
+ const gracePeriodSeconds = Math.max(0, +(config.gracePeriodMinutes || 0)) * 60;
227
+ const onConfirmed = config.onConfirmed || null;
228
+ const onWebhookEvent = config.onWebhookEvent || null;
229
+
230
+ // ── Storage adapter ─────────────────────────────────────────────────────────
231
+ // Use developer-supplied hooks if provided, otherwise fall back to in-memory.
232
+ const storage = (config.getOrder && config.saveOrder && config.updateOrder)
233
+ ? {
234
+ getOrder: config.getOrder.bind(config),
235
+ saveOrder: config.saveOrder.bind(config),
236
+ updateOrder: config.updateOrder.bind(config),
237
+ }
238
+ : createMemoryAdapter();
239
+
240
+ if (!config.getOrder) {
241
+ console.warn(
242
+ '[ForgeLayer] No storage hooks provided — using in-memory store. ' +
243
+ 'Orders will be lost on restart. Pass getOrder/saveOrder/updateOrder for production.'
244
+ );
245
+ }
248
246
 
249
247
  // ── 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
- );
248
+ const _cur = defaultCurrency.toLowerCase();
249
+ fetchAllRates(_cur).catch(e => console.warn('[ForgeLayer] Initial rate fetch failed:', e.message));
256
250
  const _rateTimer = setInterval(
257
- () => fetchAllRates(_refreshCurrency).catch(e =>
258
- console.warn('[ForgeLayer] Rate refresh failed:', e.message)
259
- ),
251
+ () => fetchAllRates(_cur).catch(e => console.warn('[ForgeLayer] Rate refresh failed:', e.message)),
260
252
  60_000
261
253
  );
262
- // Don't block process exit — the interval is fire-and-forget
263
254
  if (_rateTimer.unref) _rateTimer.unref();
264
255
 
265
256
  // Read browser.js once at startup
266
- const browserJsPath = path.join(__dirname, 'browser.js');
267
- const browserJsRaw = fs.readFileSync(browserJsPath, 'utf8');
257
+ const browserJsRaw = fs.readFileSync(path.join(__dirname, 'browser.js'), 'utf8');
268
258
 
269
- // ── Route handlers ──────────────────────────────────────────────────────────
259
+ // ── Helpers ─────────────────────────────────────────────────────────────────
270
260
 
271
- async function handleCreate(req, res, basePath) {
272
- if (req.method !== 'POST') {
273
- return res.status(405).json({ ok: false, error: 'POST required.' });
261
+ function toSessionKey(orderId) {
262
+ return 'fl_' + String(orderId).replace(/[^a-z0-9]/gi, '').toLowerCase();
263
+ }
264
+
265
+ async function markConfirmed(sessionKey, order) {
266
+ await storage.updateOrder(sessionKey, { status: 'confirmed' });
267
+ if (onConfirmed) {
268
+ onConfirmed(order.orderId, { ...order, status: 'confirmed' })
269
+ .catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
274
270
  }
271
+ }
272
+
273
+ // ── POST /create ─────────────────────────────────────────────────────────────
274
+
275
+ async function handleCreate(req, res) {
276
+ if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
275
277
 
276
278
  let body = req.body;
277
279
  if (!body || typeof body !== 'object') {
278
- try {
279
- const raw = await readBody(req);
280
- body = JSON.parse(raw || '{}');
281
- } catch (_) {
282
- body = {};
283
- }
280
+ try { body = JSON.parse(await readBody(req) || '{}'); } catch (_) { body = {}; }
284
281
  }
285
282
 
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 });
283
+ const amount = parseFloat(body.amount || 0);
284
+ const currency = (body.currency || defaultCurrency).toUpperCase();
285
+ const chain = (body.chain || defaultChain).toLowerCase();
286
+ const token = (body.token || defaultToken).toUpperCase();
287
+ const orderId = body.orderId || ('fl_' + Date.now() + '_' + Math.random().toString(36).slice(2));
288
+ const reuse = body.reuseAddress !== undefined ? !!body.reuseAddress : defaultReuse;
289
+ const window_ = Math.max(1, +(body.paymentWindow || defaultWindow));
290
+
291
+ if (amount <= 0) return res.status(400).json({ ok: false, error: 'Amount must be > 0.' });
292
+ if (!CHAIN_NAMES[chain]) return res.status(400).json({ ok: false, error: 'Unsupported chain: ' + chain });
293
+
294
+ const sessionKey = toSessionKey(orderId);
295
+
296
+ // ── Address reuse ─────────────────────────────────────────────────────────
297
+ // If reuseAddress is true and an active order already exists for this orderId,
298
+ // return the existing address instead of generating a new one.
299
+ if (reuse) {
300
+ const existing = await storage.getOrder(sessionKey);
301
+ if (existing && existing.status === 'pending') {
302
+ const now = Math.floor(Date.now() / 1000);
303
+ if (now < existing.expiresAt) {
304
+ return res.json({
305
+ ok: true, reused: true,
306
+ address: existing.address,
307
+ chain: existing.chain,
308
+ chainName: CHAIN_NAMES[existing.chain] || existing.chain,
309
+ token: existing.token,
310
+ amount: existing.amount,
311
+ currency: existing.currency,
312
+ cryptoAmount: existing.cryptoAmount,
313
+ expiresAt: existing.expiresAt,
314
+ orderId: existing.orderId,
315
+ qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(existing.address),
316
+ sessionKey,
317
+ });
318
+ }
319
+ }
320
+ }
296
321
 
322
+ // ── Generate new address ──────────────────────────────────────────────────
297
323
  let address;
298
324
  try { address = await generateAddress(apiKey, chain, orderId); }
299
325
  catch (e) { return res.status(500).json({ ok: false, error: 'Address generation failed: ' + e.message }); }
@@ -302,119 +328,84 @@ function createCheckout(config) {
302
328
  try {
303
329
  const rate = await getCoinGeckoRate(token, currency);
304
330
  if (rate > 0) cryptoAmount = (amount / rate).toFixed(8).replace(/\.?0+$/, '');
305
- } catch (_) { /* show fiat only */ }
331
+ } catch (_) { /* show fiat amount only */ }
306
332
 
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);
333
+ const expiresAt = Math.floor(Date.now() / 1000) + window_ * 60;
334
+ const order = { orderId, address, chain, token, amount, currency, cryptoAmount, expiresAt, status: 'pending' };
310
335
 
311
- // Save order
312
- orderStore.set(sessionKey, {
313
- orderId, address, chain, token, amount, currency,
314
- cryptoAmount, expiresAt, status: 'pending',
315
- });
336
+ await storage.saveOrder(sessionKey, order);
316
337
 
317
338
  return res.json({
318
339
  ok: true,
319
340
  address, chain,
320
341
  chainName: CHAIN_NAMES[chain] || chain,
321
342
  token, amount, currency, cryptoAmount,
322
- expiresAt, orderId, qrUrl, sessionKey,
343
+ expiresAt, orderId, sessionKey,
344
+ qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address),
323
345
  });
324
346
  }
325
347
 
348
+ // ── GET /status ───────────────────────────────────────────────────────────────
349
+
326
350
  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') || '';
351
+ const qs = new URLSearchParams((req.url || '').split('?')[1] || '');
352
+ const sessionKey = req.query?.session || qs.get('session') || '';
353
+ const orderIdQ = req.query?.orderId || qs.get('orderId') || '';
354
+ const key = sessionKey || toSessionKey(orderIdQ);
329
355
 
330
- const key = sessionKey || ('fl_' + orderId.replace(/[^a-z0-9]/gi, ''));
331
- const order = orderStore.get(key);
356
+ const order = await storage.getOrder(key);
332
357
  if (!order) return res.status(404).json({ ok: false, error: 'Order not found.' });
333
358
 
359
+ // Already confirmed
334
360
  if (order.status === 'confirmed') return res.json({ ok: true, status: 'confirmed' });
335
361
 
336
- // Server-authoritative expiry
337
- if (Math.floor(Date.now() / 1000) >= order.expiresAt) {
338
- order.status = 'expired';
362
+ const now = Math.floor(Date.now() / 1000);
363
+ const graceEndsAt = order.expiresAt + gracePeriodSeconds;
364
+
365
+ // Past grace period entirely — hard expired, no more checks
366
+ if (now >= graceEndsAt) {
367
+ await storage.updateOrder(key, { status: 'expired' });
339
368
  return res.json({ ok: true, status: 'expired' });
340
369
  }
341
370
 
342
- // Check balance
371
+ // Check balance — works both within and outside the payment window
343
372
  try {
344
373
  const balance = await getBalance(apiKey, order.address, order.chain);
345
374
  const expected = parseFloat(order.cryptoAmount || 0);
346
375
  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
- }
376
+ await markConfirmed(key, order);
351
377
  return res.json({ ok: true, status: 'confirmed' });
352
378
  }
353
379
  } catch (_) { /* best-effort */ }
354
380
 
381
+ // Payment window closed — tell the browser "expired" so it stops showing the UI,
382
+ // but we keep accepting via webhook until the grace period ends.
383
+ if (now >= order.expiresAt) {
384
+ return res.json({ ok: true, status: 'expired' });
385
+ }
386
+
355
387
  return res.json({ ok: true, status: 'pending' });
356
388
  }
357
389
 
390
+ // ── GET /checkout.js ──────────────────────────────────────────────────────────
391
+
358
392
  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 + '"};';
393
+ const config_js = 'var FL_CONFIG={"createUrl":"' + basePath + '/create","statusUrl":"' + basePath + '/status"};';
362
394
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
363
395
  res.setHeader('Cache-Control', 'public, max-age=300');
364
396
  res.end(config_js + '\n' + browserJsRaw);
365
397
  }
366
398
 
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 ─────────────────────────────────────────────────────────
399
+ // ── POST /webhook ─────────────────────────────────────────────────────────────
407
400
 
408
401
  async function handleWebhook(req, res) {
409
- if (req.method !== 'POST') {
410
- return res.status(405).json({ ok: false, error: 'POST required.' });
411
- }
402
+ if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
412
403
 
413
404
  const rawBody = await readBody(req);
414
405
  const sig = req.headers['x-fl-signature'] || '';
415
406
 
416
407
  if (!webhookSecret) {
417
- console.error('[ForgeLayer] Webhook received but no webhookSecret is configured. Call setupWebhook() first.');
408
+ console.error('[ForgeLayer] Webhook received but no webhookSecret configured. Call setupWebhook() first.');
418
409
  return res.status(500).json({ ok: false, error: 'Webhook secret not configured.' });
419
410
  }
420
411
 
@@ -424,49 +415,72 @@ function createCheckout(config) {
424
415
  }
425
416
 
426
417
  let event;
427
- try { event = JSON.parse(rawBody); } catch (_) {
428
- return res.status(400).json({ ok: false, error: 'Invalid JSON body.' });
429
- }
418
+ try { event = JSON.parse(rawBody); }
419
+ catch (_) { return res.status(400).json({ ok: false, error: 'Invalid JSON body.' }); }
430
420
 
431
421
  if (event.event === 'deposit_confirmed') {
432
422
  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));
423
+ const sessionKey = toSessionKey(orderId);
424
+ const order = await storage.getOrder(sessionKey);
425
+
426
+ if (order && order.status !== 'confirmed') {
427
+ const now = Math.floor(Date.now() / 1000);
428
+ const graceEndsAt = order.expiresAt + gracePeriodSeconds;
429
+
430
+ // Accept payment if within the payment window OR within the grace period
431
+ if (now < graceEndsAt) {
432
+ await markConfirmed(sessionKey, order);
433
+ } else {
434
+ console.warn('[ForgeLayer] Late payment received for order ' + orderId + ' but grace period has ended.');
439
435
  }
440
436
  }
441
437
  }
442
438
 
443
439
  if (onWebhookEvent) {
444
- onWebhookEvent(event.event, event.data).catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
440
+ onWebhookEvent(event.event, event.data)
441
+ .catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
445
442
  }
446
443
 
447
444
  return res.status(200).json({ ok: true });
448
445
  }
449
446
 
450
- // ── Express middleware ──────────────────────────────────────────────────────
447
+ // ── Webhook registration ──────────────────────────────────────────────────────
448
+
449
+ async function setupWebhook(webhookUrl, confirmations) {
450
+ if (!webhookUrl || typeof webhookUrl !== 'string') throw new Error('setupWebhook: webhookUrl is required.');
451
+ confirmations = Math.max(1, +(confirmations || 1));
452
+
453
+ const newSecret = crypto.randomBytes(32).toString('hex');
454
+
455
+ const oldId = loadSavedWebhookId();
456
+ if (oldId) {
457
+ try { await flRequest('DELETE', '/webhooks/' + encodeURIComponent(oldId), apiKey); }
458
+ catch (_) {}
459
+ }
460
+
461
+ const data = await flRequest('POST', '/webhooks', apiKey, {
462
+ url: webhookUrl, secret: newSecret, events: ['deposit_confirmed'], confirmations,
463
+ });
464
+
465
+ const webhookId = data.id || data.webhookId || '';
466
+ saveWebhookSecret(newSecret);
467
+ if (webhookId) saveWebhookId(webhookId);
468
+ webhookSecret = newSecret;
469
+
470
+ return { webhookId, webhookUrl, confirmations };
471
+ }
472
+
473
+ // ── Express middleware ────────────────────────────────────────────────────────
451
474
 
452
475
  function middleware() {
453
476
  return function forgeLayerMiddleware(req, res, next) {
454
- // Determine the path relative to where this middleware is mounted
455
477
  const mountPath = req.baseUrl || '';
456
478
  const urlPath = (req.path || '/').replace(/\/$/, '') || '/';
457
479
 
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
- }
480
+ if (urlPath === '/checkout.js' && req.method === 'GET') return serveBrowserScript(req, res, mountPath);
481
+ if (urlPath === '/create' && req.method === 'POST') return handleCreate(req, res).catch(next);
482
+ if (urlPath === '/status' && req.method === 'GET') return handleStatus(req, res).catch(next);
483
+ if (urlPath === '/webhook' && req.method === 'POST') return handleWebhook(req, res).catch(next);
470
484
  return next();
471
485
  };
472
486
  }
@@ -474,11 +488,10 @@ function createCheckout(config) {
474
488
  return { middleware, setupWebhook, handleCreate, handleStatus, handleWebhook, serveBrowserScript };
475
489
  }
476
490
 
477
- // ── Helpers ──────────────────────────────────────────────────────────────────
491
+ // ── Helpers ───────────────────────────────────────────────────────────────────
478
492
 
479
493
  function readBody(req) {
480
494
  return new Promise(function (resolve, reject) {
481
- // If Express (or body-parser) already consumed the body, return it as a raw Buffer
482
495
  if (req.body !== undefined) {
483
496
  const raw = typeof req.body === 'string'
484
497
  ? req.body
@@ -486,13 +499,13 @@ function readBody(req) {
486
499
  return resolve(raw);
487
500
  }
488
501
  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')); });
502
+ req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
503
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
491
504
  req.on('error', reject);
492
505
  });
493
506
  }
494
507
 
495
- // ── Webhook secret/ID file persistence ───────────────────────────────────────
508
+ // ── Webhook secret/ID persistence ─────────────────────────────────────────────
496
509
 
497
510
  const SECRET_FILE = path.join(__dirname, '..', '.fl_webhook_secret');
498
511
  const ID_FILE = path.join(__dirname, '..', '.fl_webhook_id');
@@ -500,15 +513,12 @@ const ID_FILE = path.join(__dirname, '..', '.fl_webhook_id');
500
513
  function loadSavedWebhookSecret() {
501
514
  try { return fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { return ''; }
502
515
  }
503
-
504
516
  function saveWebhookSecret(secret) {
505
517
  fs.writeFileSync(SECRET_FILE, secret, { encoding: 'utf8', mode: 0o600 });
506
518
  }
507
-
508
519
  function loadSavedWebhookId() {
509
520
  try { return fs.readFileSync(ID_FILE, 'utf8').trim(); } catch (_) { return ''; }
510
521
  }
511
-
512
522
  function saveWebhookId(id) {
513
523
  fs.writeFileSync(ID_FILE, id, 'utf8');
514
524
  }