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 +225 -0
- package/index.js +3 -0
- package/package.json +39 -0
- package/src/browser.js +402 -0
- package/src/server.js +516 -0
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
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">×</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 };
|