forgelayer-node 1.0.0 → 1.1.1

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 +237 -221
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.1",
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.1';
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,115 @@ 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
+ // SHA-256 so ORDER-1 and ORDER_1 don't collapse to the same key
263
+ return 'fl_' + crypto.createHash('sha256').update(String(orderId)).digest('hex').slice(0, 32);
264
+ }
265
+
266
+ async function markConfirmed(sessionKey, order) {
267
+ await storage.updateOrder(sessionKey, { status: 'confirmed' });
268
+ if (onConfirmed) {
269
+ onConfirmed(order.orderId, { ...order, status: 'confirmed' })
270
+ .catch(e => console.error('[ForgeLayer] onConfirmed error:', e));
274
271
  }
272
+ }
273
+
274
+ // ── POST /create ─────────────────────────────────────────────────────────────
275
+
276
+ async function handleCreate(req, res) {
277
+ if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
275
278
 
276
279
  let body = req.body;
277
280
  if (!body || typeof body !== 'object') {
278
- try {
279
- const raw = await readBody(req);
280
- body = JSON.parse(raw || '{}');
281
- } catch (_) {
282
- body = {};
283
- }
281
+ try { body = JSON.parse(await readBody(req) || '{}'); } catch (_) { body = {}; }
284
282
  }
285
283
 
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 });
284
+ const amount = parseFloat(body.amount || 0);
285
+ const currency = (body.currency || defaultCurrency).toUpperCase();
286
+ const chain = (body.chain || defaultChain).toLowerCase();
287
+ const token = (body.token || defaultToken).toUpperCase();
288
+ const orderId = body.orderId || ('fl_' + Date.now() + '_' + Math.random().toString(36).slice(2));
289
+ const reuse = body.reuseAddress !== undefined ? !!body.reuseAddress : defaultReuse;
290
+ const window_ = Math.max(1, +(body.paymentWindow || defaultWindow));
291
+
292
+ if (amount <= 0) return res.status(400).json({ ok: false, error: 'Amount must be > 0.' });
293
+ if (!CHAIN_NAMES[chain]) return res.status(400).json({ ok: false, error: 'Unsupported chain: ' + chain });
294
+
295
+ const sessionKey = toSessionKey(orderId);
296
+
297
+ // ── Address reuse ─────────────────────────────────────────────────────────
298
+ // If reuseAddress is true and an active order already exists for this orderId,
299
+ // return the existing address instead of generating a new one.
300
+ if (reuse) {
301
+ const existing = await storage.getOrder(sessionKey);
302
+ if (existing && existing.status === 'pending') {
303
+ const now = Math.floor(Date.now() / 1000);
304
+ if (now < existing.expiresAt) {
305
+ return res.json({
306
+ ok: true, reused: true,
307
+ address: existing.address,
308
+ chain: existing.chain,
309
+ chainName: CHAIN_NAMES[existing.chain] || existing.chain,
310
+ token: existing.token,
311
+ amount: existing.amount,
312
+ currency: existing.currency,
313
+ cryptoAmount: existing.cryptoAmount,
314
+ expiresAt: existing.expiresAt,
315
+ orderId: existing.orderId,
316
+ qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(existing.address),
317
+ sessionKey,
318
+ });
319
+ }
320
+ }
321
+ }
296
322
 
323
+ // ── Generate new address ──────────────────────────────────────────────────
297
324
  let address;
298
325
  try { address = await generateAddress(apiKey, chain, orderId); }
299
326
  catch (e) { return res.status(500).json({ ok: false, error: 'Address generation failed: ' + e.message }); }
@@ -302,171 +329,162 @@ function createCheckout(config) {
302
329
  try {
303
330
  const rate = await getCoinGeckoRate(token, currency);
304
331
  if (rate > 0) cryptoAmount = (amount / rate).toFixed(8).replace(/\.?0+$/, '');
305
- } catch (_) { /* show fiat only */ }
332
+ } catch (_) { /* show fiat amount only */ }
306
333
 
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);
334
+ const expiresAt = Math.floor(Date.now() / 1000) + window_ * 60;
335
+ const order = { orderId, address, chain, token, amount, currency, cryptoAmount, expiresAt, status: 'pending' };
310
336
 
311
- // Save order
312
- orderStore.set(sessionKey, {
313
- orderId, address, chain, token, amount, currency,
314
- cryptoAmount, expiresAt, status: 'pending',
315
- });
337
+ await storage.saveOrder(sessionKey, order);
316
338
 
317
339
  return res.json({
318
340
  ok: true,
319
341
  address, chain,
320
342
  chainName: CHAIN_NAMES[chain] || chain,
321
343
  token, amount, currency, cryptoAmount,
322
- expiresAt, orderId, qrUrl, sessionKey,
344
+ expiresAt, orderId, sessionKey,
345
+ qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=160x160&margin=2&data=' + encodeURIComponent(address),
323
346
  });
324
347
  }
325
348
 
349
+ // ── GET /status ───────────────────────────────────────────────────────────────
350
+
326
351
  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') || '';
352
+ const qs = new URLSearchParams((req.url || '').split('?')[1] || '');
353
+ const sessionKey = req.query?.session || qs.get('session') || '';
354
+ const orderIdQ = req.query?.orderId || qs.get('orderId') || '';
355
+ const key = sessionKey || toSessionKey(orderIdQ);
329
356
 
330
- const key = sessionKey || ('fl_' + orderId.replace(/[^a-z0-9]/gi, ''));
331
- const order = orderStore.get(key);
357
+ const order = await storage.getOrder(key);
332
358
  if (!order) return res.status(404).json({ ok: false, error: 'Order not found.' });
333
359
 
360
+ // Already confirmed
334
361
  if (order.status === 'confirmed') return res.json({ ok: true, status: 'confirmed' });
335
362
 
336
- // Server-authoritative expiry
337
- if (Math.floor(Date.now() / 1000) >= order.expiresAt) {
338
- order.status = 'expired';
363
+ const now = Math.floor(Date.now() / 1000);
364
+ const graceEndsAt = order.expiresAt + gracePeriodSeconds;
365
+
366
+ // Past grace period entirely — hard expired, no more checks
367
+ if (now >= graceEndsAt) {
368
+ await storage.updateOrder(key, { status: 'expired' });
339
369
  return res.json({ ok: true, status: 'expired' });
340
370
  }
341
371
 
342
- // Check balance
372
+ // Check balance — works both within and outside the payment window
343
373
  try {
344
374
  const balance = await getBalance(apiKey, order.address, order.chain);
345
375
  const expected = parseFloat(order.cryptoAmount || 0);
346
376
  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
- }
377
+ await markConfirmed(key, order);
351
378
  return res.json({ ok: true, status: 'confirmed' });
352
379
  }
353
380
  } catch (_) { /* best-effort */ }
354
381
 
382
+ // Payment window closed — tell the browser "expired" so it stops showing the UI,
383
+ // but we keep accepting via webhook until the grace period ends.
384
+ if (now >= order.expiresAt) {
385
+ return res.json({ ok: true, status: 'expired' });
386
+ }
387
+
355
388
  return res.json({ ok: true, status: 'pending' });
356
389
  }
357
390
 
391
+ // ── GET /checkout.js ──────────────────────────────────────────────────────────
392
+
358
393
  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 + '"};';
394
+ const config_js = 'var FL_CONFIG={"createUrl":"' + basePath + '/create","statusUrl":"' + basePath + '/status"};';
362
395
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
363
396
  res.setHeader('Cache-Control', 'public, max-age=300');
364
397
  res.end(config_js + '\n' + browserJsRaw);
365
398
  }
366
399
 
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 ─────────────────────────────────────────────────────────
400
+ // ── POST /webhook ─────────────────────────────────────────────────────────────
407
401
 
408
402
  async function handleWebhook(req, res) {
409
- if (req.method !== 'POST') {
410
- return res.status(405).json({ ok: false, error: 'POST required.' });
411
- }
403
+ if (req.method !== 'POST') return res.status(405).json({ ok: false, error: 'POST required.' });
412
404
 
413
405
  const rawBody = await readBody(req);
414
406
  const sig = req.headers['x-fl-signature'] || '';
415
407
 
416
408
  if (!webhookSecret) {
417
- console.error('[ForgeLayer] Webhook received but no webhookSecret is configured. Call setupWebhook() first.');
409
+ console.error('[ForgeLayer] Webhook received but no webhookSecret configured. Call setupWebhook() first.');
418
410
  return res.status(500).json({ ok: false, error: 'Webhook secret not configured.' });
419
411
  }
420
412
 
421
413
  const expected = crypto.createHmac('sha256', webhookSecret).update(rawBody).digest('hex');
422
- if (!crypto.timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'))) {
414
+ // timingSafeEqual throws if buffer lengths differ, so check length first
415
+ const sigBuf = Buffer.from(sig);
416
+ const expBuf = Buffer.from(expected);
417
+ if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
423
418
  return res.status(401).json({ ok: false, error: 'Invalid signature.' });
424
419
  }
425
420
 
426
421
  let event;
427
- try { event = JSON.parse(rawBody); } catch (_) {
428
- return res.status(400).json({ ok: false, error: 'Invalid JSON body.' });
429
- }
422
+ try { event = JSON.parse(rawBody); }
423
+ catch (_) { return res.status(400).json({ ok: false, error: 'Invalid JSON body.' }); }
430
424
 
431
425
  if (event.event === 'deposit_confirmed') {
432
426
  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));
427
+ const sessionKey = toSessionKey(orderId);
428
+ const order = await storage.getOrder(sessionKey);
429
+
430
+ if (order && order.status !== 'confirmed') {
431
+ const now = Math.floor(Date.now() / 1000);
432
+ const graceEndsAt = order.expiresAt + gracePeriodSeconds;
433
+
434
+ // Accept payment if within the payment window OR within the grace period
435
+ if (now < graceEndsAt) {
436
+ await markConfirmed(sessionKey, order);
437
+ } else {
438
+ console.warn('[ForgeLayer] Late payment received for order ' + orderId + ' but grace period has ended.');
439
439
  }
440
440
  }
441
441
  }
442
442
 
443
443
  if (onWebhookEvent) {
444
- onWebhookEvent(event.event, event.data).catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
444
+ onWebhookEvent(event.event, event.data)
445
+ .catch(e => console.error('[ForgeLayer] onWebhookEvent error:', e));
445
446
  }
446
447
 
447
448
  return res.status(200).json({ ok: true });
448
449
  }
449
450
 
450
- // ── Express middleware ──────────────────────────────────────────────────────
451
+ // ── Webhook registration ──────────────────────────────────────────────────────
452
+
453
+ async function setupWebhook(webhookUrl, confirmations) {
454
+ if (!webhookUrl || typeof webhookUrl !== 'string') throw new Error('setupWebhook: webhookUrl is required.');
455
+ confirmations = Math.max(1, +(confirmations || 1));
456
+
457
+ const newSecret = crypto.randomBytes(32).toString('hex');
458
+
459
+ const oldId = loadSavedWebhookId();
460
+ if (oldId) {
461
+ try { await flRequest('DELETE', '/webhooks/' + encodeURIComponent(oldId), apiKey); }
462
+ catch (_) {}
463
+ }
464
+
465
+ const data = await flRequest('POST', '/webhooks', apiKey, {
466
+ url: webhookUrl, secret: newSecret, events: ['deposit_confirmed'], confirmations,
467
+ });
468
+
469
+ const webhookId = data.id || data.webhookId || '';
470
+ saveWebhookSecret(newSecret);
471
+ if (webhookId) saveWebhookId(webhookId);
472
+ webhookSecret = newSecret;
473
+
474
+ return { webhookId, webhookUrl, confirmations };
475
+ }
476
+
477
+ // ── Express middleware ────────────────────────────────────────────────────────
451
478
 
452
479
  function middleware() {
453
480
  return function forgeLayerMiddleware(req, res, next) {
454
- // Determine the path relative to where this middleware is mounted
455
481
  const mountPath = req.baseUrl || '';
456
482
  const urlPath = (req.path || '/').replace(/\/$/, '') || '/';
457
483
 
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
- }
484
+ if (urlPath === '/checkout.js' && req.method === 'GET') return serveBrowserScript(req, res, mountPath);
485
+ if (urlPath === '/create' && req.method === 'POST') return handleCreate(req, res).catch(next);
486
+ if (urlPath === '/status' && req.method === 'GET') return handleStatus(req, res).catch(next);
487
+ if (urlPath === '/webhook' && req.method === 'POST') return handleWebhook(req, res).catch(next);
470
488
  return next();
471
489
  };
472
490
  }
@@ -474,11 +492,10 @@ function createCheckout(config) {
474
492
  return { middleware, setupWebhook, handleCreate, handleStatus, handleWebhook, serveBrowserScript };
475
493
  }
476
494
 
477
- // ── Helpers ──────────────────────────────────────────────────────────────────
495
+ // ── Helpers ───────────────────────────────────────────────────────────────────
478
496
 
479
497
  function readBody(req) {
480
498
  return new Promise(function (resolve, reject) {
481
- // If Express (or body-parser) already consumed the body, return it as a raw Buffer
482
499
  if (req.body !== undefined) {
483
500
  const raw = typeof req.body === 'string'
484
501
  ? req.body
@@ -486,29 +503,28 @@ function readBody(req) {
486
503
  return resolve(raw);
487
504
  }
488
505
  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')); });
506
+ req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
507
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
491
508
  req.on('error', reject);
492
509
  });
493
510
  }
494
511
 
495
- // ── Webhook secret/ID file persistence ───────────────────────────────────────
512
+ // ── Webhook secret/ID persistence ─────────────────────────────────────────────
496
513
 
497
- const SECRET_FILE = path.join(__dirname, '..', '.fl_webhook_secret');
498
- const ID_FILE = path.join(__dirname, '..', '.fl_webhook_id');
514
+ // Use process.cwd() so the file lands in the developer's project root,
515
+ // not inside node_modules/forgelayer-node/ where it gets wiped on reinstall.
516
+ const SECRET_FILE = path.join(process.cwd(), '.fl_webhook_secret');
517
+ const ID_FILE = path.join(process.cwd(), '.fl_webhook_id');
499
518
 
500
519
  function loadSavedWebhookSecret() {
501
520
  try { return fs.readFileSync(SECRET_FILE, 'utf8').trim(); } catch (_) { return ''; }
502
521
  }
503
-
504
522
  function saveWebhookSecret(secret) {
505
523
  fs.writeFileSync(SECRET_FILE, secret, { encoding: 'utf8', mode: 0o600 });
506
524
  }
507
-
508
525
  function loadSavedWebhookId() {
509
526
  try { return fs.readFileSync(ID_FILE, 'utf8').trim(); } catch (_) { return ''; }
510
527
  }
511
-
512
528
  function saveWebhookId(id) {
513
529
  fs.writeFileSync(ID_FILE, id, 'utf8');
514
530
  }