create-mantle-facilitator 0.3.5 → 0.4.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.
- package/README.md +35 -5
- package/dist/index.mjs +11 -0
- package/package.json +1 -1
- package/template/README.md +31 -1
- package/template/src/config.ts +128 -7
- package/template/src/index.ts +32 -3
- package/template/src/routes/settle.ts +15 -1
- package/template/src/security.ts +126 -0
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ After creation, edit the `.env` file:
|
|
|
37
37
|
# Required
|
|
38
38
|
FACILITATOR_PRIVATE_KEY=0x... # Wallet that pays gas fees
|
|
39
39
|
RPC_URL=https://rpc.mantle.xyz # Mantle RPC endpoint
|
|
40
|
+
FACILITATOR_SECRET=fac_xxx... # Auto-generated security secret
|
|
40
41
|
|
|
41
42
|
# Optional
|
|
42
43
|
USDC_ADDRESS=0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9
|
|
@@ -51,8 +52,28 @@ TELEMETRY_PROJECT_KEY=pk_xxx # Get from dashboard
|
|
|
51
52
|
### Important Notes
|
|
52
53
|
|
|
53
54
|
- **FACILITATOR_PRIVATE_KEY**: This wallet will pay gas fees for all settlements. Make sure it has MNT for gas.
|
|
55
|
+
- **FACILITATOR_SECRET**: Auto-generated during setup. **Copy this to your backend's environment variables** to enable secure authentication.
|
|
54
56
|
- **Security**: Never commit `.env` to version control. The generated `.gitignore` already excludes it.
|
|
55
57
|
|
|
58
|
+
### Connecting to Your Backend
|
|
59
|
+
|
|
60
|
+
Add `FACILITATOR_SECRET` to your backend and configure the SDK:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { mantlePaywall } from '@puga-labs/x402-mantle-sdk/server/express';
|
|
64
|
+
|
|
65
|
+
// Self-hosted facilitator (requires facilitatorUrl + facilitatorSecret)
|
|
66
|
+
const pay = mantlePaywall({
|
|
67
|
+
priceUsd: 0.01,
|
|
68
|
+
payTo: '0xYourWallet',
|
|
69
|
+
facilitatorUrl: 'https://your-facilitator.com', // Required for self-hosted
|
|
70
|
+
facilitatorSecret: process.env.FACILITATOR_SECRET!, // Required for self-hosted
|
|
71
|
+
// projectKey: process.env.PROJECT_KEY, // Optional: for analytics
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Note:** When using `facilitatorSecret` (self-hosted mode), `facilitatorUrl` is required. The URL is automatically passed to clients via 402 responses, so frontend code doesn't need to configure it.
|
|
76
|
+
|
|
56
77
|
## Running Locally
|
|
57
78
|
|
|
58
79
|
```bash
|
|
@@ -221,7 +242,7 @@ curl -X POST http://localhost:8080/verify \
|
|
|
221
242
|
|
|
222
243
|
### POST /settle
|
|
223
244
|
|
|
224
|
-
Executes the USDC transfer on-chain.
|
|
245
|
+
Executes the USDC transfer on-chain. Requires a valid `settleToken` for authentication.
|
|
225
246
|
|
|
226
247
|
```bash
|
|
227
248
|
curl -X POST http://localhost:8080/settle \
|
|
@@ -229,7 +250,8 @@ curl -X POST http://localhost:8080/settle \
|
|
|
229
250
|
-d '{
|
|
230
251
|
"x402Version": 1,
|
|
231
252
|
"paymentHeader": "base64...",
|
|
232
|
-
"paymentRequirements": {...}
|
|
253
|
+
"paymentRequirements": {...},
|
|
254
|
+
"settleToken": "base64..."
|
|
233
255
|
}'
|
|
234
256
|
```
|
|
235
257
|
|
|
@@ -240,6 +262,8 @@ curl -X POST http://localhost:8080/settle \
|
|
|
240
262
|
}
|
|
241
263
|
```
|
|
242
264
|
|
|
265
|
+
**Note:** The `settleToken` is automatically generated by the SDK when `facilitatorSecret` is configured, and passed through the client to authenticate with your facilitator.
|
|
266
|
+
|
|
243
267
|
---
|
|
244
268
|
|
|
245
269
|
## How It Works
|
|
@@ -298,17 +322,23 @@ Telemetry errors never affect payment processing — if analytics backend is dow
|
|
|
298
322
|
|
|
299
323
|
## Security Considerations
|
|
300
324
|
|
|
301
|
-
1. **
|
|
325
|
+
1. **Facilitator Secret (REQUIRED)**
|
|
326
|
+
- `FACILITATOR_SECRET` prevents unauthorized usage of your facilitator
|
|
327
|
+
- Without it, anyone could use your facilitator URL to settle their payments and drain your gas wallet
|
|
328
|
+
- The secret creates a secure token flow: Backend → Client → Facilitator
|
|
329
|
+
- Always set `facilitatorSecret` in your backend SDK configuration
|
|
330
|
+
|
|
331
|
+
2. **Private Key Security**
|
|
302
332
|
- Never commit `.env` to git
|
|
303
333
|
- Use secret management in production (Railway secrets, Fly secrets, etc.)
|
|
304
334
|
- Consider using hardware wallets for high-value facilitators
|
|
305
335
|
|
|
306
|
-
|
|
336
|
+
3. **Gas Management**
|
|
307
337
|
- Monitor MNT balance regularly
|
|
308
338
|
- Set up alerts for low balance
|
|
309
339
|
- Facilitator needs ~0.001 MNT per settlement
|
|
310
340
|
|
|
311
|
-
|
|
341
|
+
4. **Network Security**
|
|
312
342
|
- Use HTTPS in production
|
|
313
343
|
- Consider rate limiting
|
|
314
344
|
- Monitor for unusual patterns
|
package/dist/index.mjs
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import path from "path";
|
|
5
5
|
import fs from "fs";
|
|
6
|
+
import crypto from "crypto";
|
|
6
7
|
import { fileURLToPath } from "url";
|
|
7
8
|
import fse from "fs-extra";
|
|
8
9
|
import prompts from "prompts";
|
|
10
|
+
function generateFacilitatorSecret() {
|
|
11
|
+
return "fac_" + crypto.randomBytes(32).toString("hex");
|
|
12
|
+
}
|
|
9
13
|
function getAsciiArt() {
|
|
10
14
|
const asciiArtPath = path.join(
|
|
11
15
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -94,6 +98,7 @@ async function promptSetup() {
|
|
|
94
98
|
});
|
|
95
99
|
}
|
|
96
100
|
function generateEnvFile(answers) {
|
|
101
|
+
const facilitatorSecret = generateFacilitatorSecret();
|
|
97
102
|
const env = [
|
|
98
103
|
`# Server Configuration`,
|
|
99
104
|
`PORT=8080`,
|
|
@@ -110,6 +115,10 @@ function generateEnvFile(answers) {
|
|
|
110
115
|
`# Facilitator Wallet (keeps private key safe)`,
|
|
111
116
|
answers.privateKey ? `FACILITATOR_PRIVATE_KEY=${answers.privateKey}` : `# FACILITATOR_PRIVATE_KEY=0xYOUR_PRIVATE_KEY_HERE`,
|
|
112
117
|
``,
|
|
118
|
+
`# Security: Shared secret between your backend and this facilitator`,
|
|
119
|
+
`# IMPORTANT: Copy this value to your backend's facilitatorSecret config`,
|
|
120
|
+
`FACILITATOR_SECRET=${facilitatorSecret}`,
|
|
121
|
+
``,
|
|
113
122
|
`# Logging`,
|
|
114
123
|
`LOG_LEVEL=info`,
|
|
115
124
|
``
|
|
@@ -167,6 +176,7 @@ Creating facilitator in ${targetDir}...`);
|
|
|
167
176
|
if (!answers.privateKey) {
|
|
168
177
|
reminders.push("IMPORTANT: Set FACILITATOR_PRIVATE_KEY in .env before starting");
|
|
169
178
|
}
|
|
179
|
+
reminders.push("IMPORTANT: Copy FACILITATOR_SECRET from .env to your backend configuration");
|
|
170
180
|
if (answers.enableTelemetry && !answers.telemetryKey) {
|
|
171
181
|
reminders.push("Telemetry enabled: Get PROJECT_KEY from https://x402mantlesdk.xyz/dashboard");
|
|
172
182
|
}
|
|
@@ -210,6 +220,7 @@ async function createProjectNonInteractive(projectName) {
|
|
|
210
220
|
await fse.writeFile(path.join(targetDir, ".env"), envContent);
|
|
211
221
|
console.log("\n\u2713 Done!\n");
|
|
212
222
|
console.log("IMPORTANT: Edit .env file and set FACILITATOR_PRIVATE_KEY");
|
|
223
|
+
console.log("IMPORTANT: Copy FACILITATOR_SECRET from .env to your backend configuration");
|
|
213
224
|
console.log(`Then run: cd ${projectName} && npm install && npm run dev`);
|
|
214
225
|
}
|
|
215
226
|
async function main() {
|
package/package.json
CHANGED
package/template/README.md
CHANGED
|
@@ -24,6 +24,7 @@ Edit `.env` file:
|
|
|
24
24
|
# Required
|
|
25
25
|
FACILITATOR_PRIVATE_KEY=0x... # Wallet that pays gas
|
|
26
26
|
RPC_URL=https://rpc.mantle.xyz
|
|
27
|
+
FACILITATOR_SECRET=fac_xxx... # Auto-generated, share with your backend
|
|
27
28
|
|
|
28
29
|
# Optional
|
|
29
30
|
PORT=8080
|
|
@@ -33,7 +34,36 @@ USDC_ADDRESS=0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9
|
|
|
33
34
|
TELEMETRY_PROJECT_KEY=pk_xxx # Get from https://x402mantlesdk.xyz/dashboard
|
|
34
35
|
```
|
|
35
36
|
|
|
36
|
-
**Important:**
|
|
37
|
+
**Important:**
|
|
38
|
+
- The facilitator wallet needs MNT for gas fees. Check balance at `/health`.
|
|
39
|
+
- Copy `FACILITATOR_SECRET` to your backend's environment variables.
|
|
40
|
+
|
|
41
|
+
## Security
|
|
42
|
+
|
|
43
|
+
The `FACILITATOR_SECRET` protects your facilitator from unauthorized usage:
|
|
44
|
+
|
|
45
|
+
1. Your backend generates a `settleToken` using this secret when returning 402 responses
|
|
46
|
+
2. The client passes this token to your facilitator's `/settle` endpoint
|
|
47
|
+
3. The facilitator verifies the token matches before executing the transaction
|
|
48
|
+
|
|
49
|
+
This prevents third parties from using your facilitator to settle their payments and drain your gas wallet.
|
|
50
|
+
|
|
51
|
+
**Backend configuration:**
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { mantlePaywall } from '@puga-labs/x402-mantle-sdk/server/express';
|
|
55
|
+
|
|
56
|
+
// Self-hosted facilitator (requires facilitatorUrl + facilitatorSecret)
|
|
57
|
+
const pay = mantlePaywall({
|
|
58
|
+
priceUsd: 0.01,
|
|
59
|
+
payTo: '0xYourWallet',
|
|
60
|
+
facilitatorUrl: 'https://your-facilitator.com', // Required for self-hosted
|
|
61
|
+
facilitatorSecret: process.env.FACILITATOR_SECRET!, // Required for self-hosted
|
|
62
|
+
// projectKey: process.env.PROJECT_KEY, // Optional: for analytics
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**Note:** When using `facilitatorSecret`, `facilitatorUrl` is required. The URL is automatically passed to clients via 402 responses.
|
|
37
67
|
|
|
38
68
|
## Endpoints
|
|
39
69
|
|
package/template/src/config.ts
CHANGED
|
@@ -1,24 +1,136 @@
|
|
|
1
1
|
// src/config.ts
|
|
2
2
|
import "dotenv/config";
|
|
3
|
+
import { ethers } from "ethers";
|
|
3
4
|
import { DEFAULT_TELEMETRY_ENDPOINT } from "./constants";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
// ============================================
|
|
7
|
+
// Validation types and helpers
|
|
8
|
+
// ============================================
|
|
9
|
+
|
|
10
|
+
interface ConfigError {
|
|
11
|
+
field: string;
|
|
12
|
+
message: string;
|
|
13
|
+
howToFix: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function validateConfig(): ConfigError[] {
|
|
17
|
+
const errors: ConfigError[] = [];
|
|
18
|
+
|
|
19
|
+
// Required: FACILITATOR_PRIVATE_KEY
|
|
20
|
+
const privateKey = process.env.FACILITATOR_PRIVATE_KEY;
|
|
21
|
+
if (!privateKey) {
|
|
22
|
+
errors.push({
|
|
23
|
+
field: "FACILITATOR_PRIVATE_KEY",
|
|
24
|
+
message: "This is the wallet that will pay gas fees for settlements.",
|
|
25
|
+
howToFix: [
|
|
26
|
+
"Open .env file",
|
|
27
|
+
"Set FACILITATOR_PRIVATE_KEY=0xYourPrivateKey",
|
|
28
|
+
"Need a wallet? Create one at https://metamask.io",
|
|
29
|
+
"Make sure it has MNT for gas fees.",
|
|
30
|
+
],
|
|
31
|
+
});
|
|
32
|
+
} else if (!privateKey.startsWith("0x") || privateKey.length !== 66) {
|
|
33
|
+
errors.push({
|
|
34
|
+
field: "FACILITATOR_PRIVATE_KEY",
|
|
35
|
+
message: "Invalid private key format.",
|
|
36
|
+
howToFix: [
|
|
37
|
+
"Private key must start with 0x",
|
|
38
|
+
"Private key must be 66 characters (0x + 64 hex digits)",
|
|
39
|
+
`Current length: ${privateKey.length} characters`,
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Required: FACILITATOR_SECRET
|
|
45
|
+
const secret = process.env.FACILITATOR_SECRET;
|
|
46
|
+
if (!secret) {
|
|
47
|
+
errors.push({
|
|
48
|
+
field: "FACILITATOR_SECRET",
|
|
49
|
+
message: "This secret authenticates your backend with the facilitator.",
|
|
50
|
+
howToFix: [
|
|
51
|
+
"Open .env file",
|
|
52
|
+
"Set FACILITATOR_SECRET=fac_YOUR_SECRET_HERE",
|
|
53
|
+
"Then copy this value to your backend's facilitatorSecret config",
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Required: RPC_URL
|
|
59
|
+
if (!process.env.RPC_URL) {
|
|
60
|
+
errors.push({
|
|
61
|
+
field: "RPC_URL",
|
|
62
|
+
message: "RPC URL for connecting to Mantle network.",
|
|
63
|
+
howToFix: [
|
|
64
|
+
"Open .env file",
|
|
65
|
+
"Set RPC_URL=https://rpc.mantle.xyz",
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Required: USDC_ADDRESS
|
|
71
|
+
if (!process.env.USDC_ADDRESS) {
|
|
72
|
+
errors.push({
|
|
73
|
+
field: "USDC_ADDRESS",
|
|
74
|
+
message: "USDC contract address on Mantle.",
|
|
75
|
+
howToFix: [
|
|
76
|
+
"Open .env file",
|
|
77
|
+
"Set USDC_ADDRESS=0x09Bc4E0D864854c6aFB6eB9A9cdF58aC190D0dF9",
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return errors;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function printConfigErrors(errors: ConfigError[]): void {
|
|
86
|
+
console.error("");
|
|
87
|
+
console.error("CONFIGURATION ERROR");
|
|
88
|
+
console.error("=".repeat(50));
|
|
89
|
+
|
|
90
|
+
for (const error of errors) {
|
|
91
|
+
console.error("");
|
|
92
|
+
console.error(`Missing: ${error.field}`);
|
|
93
|
+
console.error(`${error.message}`);
|
|
94
|
+
console.error("");
|
|
95
|
+
console.error("To fix:");
|
|
96
|
+
error.howToFix.forEach((step, i) => {
|
|
97
|
+
console.error(` ${i + 1}. ${step}`);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.error("");
|
|
102
|
+
console.error("=".repeat(50));
|
|
103
|
+
console.error("");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================
|
|
107
|
+
// Run validation at startup
|
|
108
|
+
// ============================================
|
|
109
|
+
|
|
110
|
+
const configErrors = validateConfig();
|
|
111
|
+
if (configErrors.length > 0) {
|
|
112
|
+
printConfigErrors(configErrors);
|
|
113
|
+
process.exit(1);
|
|
9
114
|
}
|
|
10
115
|
|
|
116
|
+
// ============================================
|
|
117
|
+
// Build config (all required fields are validated)
|
|
118
|
+
// ============================================
|
|
119
|
+
|
|
11
120
|
export const CONFIG = {
|
|
12
121
|
port: Number(process.env.PORT ?? 8080),
|
|
13
122
|
|
|
14
123
|
networkId: process.env.NETWORK_ID ?? "mantle-mainnet",
|
|
15
124
|
chainId: Number(process.env.CHAIN_ID ?? 5000),
|
|
16
|
-
rpcUrl:
|
|
125
|
+
rpcUrl: process.env.RPC_URL!,
|
|
17
126
|
|
|
18
|
-
usdcAddress:
|
|
127
|
+
usdcAddress: process.env.USDC_ADDRESS!,
|
|
19
128
|
usdcDecimals: Number(process.env.USDC_DECIMALS ?? 6),
|
|
20
129
|
|
|
21
|
-
facilitatorPrivateKey:
|
|
130
|
+
facilitatorPrivateKey: process.env.FACILITATOR_PRIVATE_KEY!,
|
|
131
|
+
|
|
132
|
+
// Secret for settle token verification (required for security)
|
|
133
|
+
facilitatorSecret: process.env.FACILITATOR_SECRET!,
|
|
22
134
|
|
|
23
135
|
logLevel: process.env.LOG_LEVEL ?? "info",
|
|
24
136
|
|
|
@@ -30,3 +142,12 @@ export const CONFIG = {
|
|
|
30
142
|
}
|
|
31
143
|
: undefined,
|
|
32
144
|
} as const;
|
|
145
|
+
|
|
146
|
+
// ============================================
|
|
147
|
+
// Derive facilitator address from private key
|
|
148
|
+
// ============================================
|
|
149
|
+
|
|
150
|
+
export function getFacilitatorAddress(): string {
|
|
151
|
+
const wallet = new ethers.Wallet(CONFIG.facilitatorPrivateKey);
|
|
152
|
+
return wallet.address;
|
|
153
|
+
}
|
package/template/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import express from "express";
|
|
3
3
|
import cors from "cors";
|
|
4
|
-
import { CONFIG } from "./config";
|
|
4
|
+
import { CONFIG, getFacilitatorAddress } from "./config";
|
|
5
5
|
|
|
6
6
|
import { healthRoute } from "./routes/health";
|
|
7
7
|
import { supportedRoute } from "./routes/supported";
|
|
@@ -25,7 +25,36 @@ app.get("/supported", supportedRoute);
|
|
|
25
25
|
app.post("/verify", verifyRoute);
|
|
26
26
|
app.post("/settle", settleRoute);
|
|
27
27
|
|
|
28
|
+
// ============================================
|
|
29
|
+
// Startup banner
|
|
30
|
+
// ============================================
|
|
31
|
+
|
|
32
|
+
function printStartupBanner(): void {
|
|
33
|
+
const address = getFacilitatorAddress();
|
|
34
|
+
const shortAddress = `${address.slice(0, 6)}...${address.slice(-4)}`;
|
|
35
|
+
const telemetryStatus = CONFIG.telemetry ? "enabled" : "disabled";
|
|
36
|
+
|
|
37
|
+
console.log("");
|
|
38
|
+
console.log("+".padEnd(50, "-") + "+");
|
|
39
|
+
console.log("| Mantle x402 Facilitator".padEnd(50) + "|");
|
|
40
|
+
console.log("+".padEnd(50, "-") + "+");
|
|
41
|
+
console.log(`| Network: ${CONFIG.networkId} (${CONFIG.chainId})`.padEnd(50) + "|");
|
|
42
|
+
console.log(`| Address: ${shortAddress}`.padEnd(50) + "|");
|
|
43
|
+
console.log(`| Port: ${CONFIG.port}`.padEnd(50) + "|");
|
|
44
|
+
console.log(`| Telemetry: ${telemetryStatus}`.padEnd(50) + "|");
|
|
45
|
+
console.log("+".padEnd(50, "-") + "+");
|
|
46
|
+
console.log("");
|
|
47
|
+
|
|
48
|
+
// Telemetry warning
|
|
49
|
+
if (!CONFIG.telemetry) {
|
|
50
|
+
console.log("Note: Telemetry is disabled.");
|
|
51
|
+
console.log(" To enable analytics, set TELEMETRY_PROJECT_KEY in .env");
|
|
52
|
+
console.log(" Get your key at https://x402mantlesdk.xyz/dashboard");
|
|
53
|
+
console.log("");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
28
57
|
app.listen(CONFIG.port, () => {
|
|
29
|
-
|
|
30
|
-
console.log(`
|
|
58
|
+
printStartupBanner();
|
|
59
|
+
console.log(`Server listening on http://localhost:${CONFIG.port}`);
|
|
31
60
|
});
|
|
@@ -4,6 +4,7 @@ import { ethers } from "ethers";
|
|
|
4
4
|
import { decodePaymentHeader, verifyPayment, type PaymentRequirements } from "../x402";
|
|
5
5
|
import { getFacilitatorSigner, getUsdcContract } from "../blockchain";
|
|
6
6
|
import { CONFIG } from "../config";
|
|
7
|
+
import { verifySettleToken } from "../security";
|
|
7
8
|
|
|
8
9
|
function signatureToVRS(signature: string) {
|
|
9
10
|
const sig = ethers.Signature.from(signature);
|
|
@@ -15,10 +16,11 @@ export async function settleRoute(req: Request, res: Response) {
|
|
|
15
16
|
const raw = req.body ?? {};
|
|
16
17
|
|
|
17
18
|
try {
|
|
18
|
-
const { x402Version, paymentHeader, paymentRequirements } = raw as {
|
|
19
|
+
const { x402Version, paymentHeader, paymentRequirements, settleToken } = raw as {
|
|
19
20
|
x402Version?: number;
|
|
20
21
|
paymentHeader?: string;
|
|
21
22
|
paymentRequirements?: PaymentRequirements;
|
|
23
|
+
settleToken?: string;
|
|
22
24
|
};
|
|
23
25
|
const projectKey = req.header("X-Project-Key"); // Optional: for hosted facilitator billing
|
|
24
26
|
|
|
@@ -47,6 +49,18 @@ export async function settleRoute(req: Request, res: Response) {
|
|
|
47
49
|
return;
|
|
48
50
|
}
|
|
49
51
|
|
|
52
|
+
// Verify settle token (required for self-hosted facilitator security)
|
|
53
|
+
const tokenVerification = verifySettleToken(settleToken, paymentRequirements, CONFIG.facilitatorSecret);
|
|
54
|
+
if (!tokenVerification.valid) {
|
|
55
|
+
res.status(401).json({
|
|
56
|
+
success: false,
|
|
57
|
+
error: tokenVerification.error || "Invalid settle token",
|
|
58
|
+
txHash: null,
|
|
59
|
+
networkId: paymentRequirements.network,
|
|
60
|
+
});
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
50
64
|
const headerObj = decodePaymentHeader(paymentHeader);
|
|
51
65
|
const verify = verifyPayment(headerObj, paymentRequirements);
|
|
52
66
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// src/security.ts
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
|
|
4
|
+
export interface PaymentRequirementsForToken {
|
|
5
|
+
payTo: string;
|
|
6
|
+
maxAmountRequired: string;
|
|
7
|
+
asset: string;
|
|
8
|
+
network: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SettleTokenPayload {
|
|
12
|
+
payTo: string;
|
|
13
|
+
amount: string;
|
|
14
|
+
asset: string;
|
|
15
|
+
network: string;
|
|
16
|
+
timestamp: number;
|
|
17
|
+
expires: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DecodedSettleToken {
|
|
21
|
+
payload: SettleTokenPayload;
|
|
22
|
+
signature: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a settle token that authorizes a specific payment.
|
|
27
|
+
* The token is signed with HMAC-SHA256 and includes expiration.
|
|
28
|
+
*
|
|
29
|
+
* @param paymentRequirements - The payment requirements to authorize
|
|
30
|
+
* @param secret - The FACILITATOR_SECRET
|
|
31
|
+
* @param expiresInSeconds - Token validity period (default: 600 seconds = 10 minutes)
|
|
32
|
+
* @returns Base64-encoded settle token
|
|
33
|
+
*/
|
|
34
|
+
export function createSettleToken(
|
|
35
|
+
paymentRequirements: PaymentRequirementsForToken,
|
|
36
|
+
secret: string,
|
|
37
|
+
expiresInSeconds: number = 600
|
|
38
|
+
): string {
|
|
39
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
40
|
+
|
|
41
|
+
const payload: SettleTokenPayload = {
|
|
42
|
+
payTo: paymentRequirements.payTo.toLowerCase(),
|
|
43
|
+
amount: paymentRequirements.maxAmountRequired,
|
|
44
|
+
asset: paymentRequirements.asset.toLowerCase(),
|
|
45
|
+
network: paymentRequirements.network,
|
|
46
|
+
timestamp,
|
|
47
|
+
expires: timestamp + expiresInSeconds,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const data = JSON.stringify(payload);
|
|
51
|
+
const signature = crypto.createHmac("sha256", secret).update(data).digest("hex");
|
|
52
|
+
|
|
53
|
+
return Buffer.from(JSON.stringify({ payload, signature })).toString("base64");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Verifies a settle token against payment requirements.
|
|
58
|
+
* Checks signature, expiration, and that the token matches the requirements.
|
|
59
|
+
*
|
|
60
|
+
* @param token - Base64-encoded settle token
|
|
61
|
+
* @param paymentRequirements - The payment requirements to verify against
|
|
62
|
+
* @param secret - The FACILITATOR_SECRET
|
|
63
|
+
* @returns true if token is valid, false otherwise
|
|
64
|
+
*/
|
|
65
|
+
export function verifySettleToken(
|
|
66
|
+
token: string | undefined,
|
|
67
|
+
paymentRequirements: PaymentRequirementsForToken,
|
|
68
|
+
secret: string
|
|
69
|
+
): { valid: boolean; error?: string } {
|
|
70
|
+
if (!token) {
|
|
71
|
+
return { valid: false, error: "Missing settle token" };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const decoded: DecodedSettleToken = JSON.parse(
|
|
76
|
+
Buffer.from(token, "base64").toString("utf-8")
|
|
77
|
+
);
|
|
78
|
+
const { payload, signature } = decoded;
|
|
79
|
+
|
|
80
|
+
// Check expiration
|
|
81
|
+
const now = Math.floor(Date.now() / 1000);
|
|
82
|
+
if (payload.expires < now) {
|
|
83
|
+
return { valid: false, error: "Settle token expired" };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check that token matches payment requirements (case-insensitive for addresses)
|
|
87
|
+
if (payload.payTo.toLowerCase() !== paymentRequirements.payTo.toLowerCase()) {
|
|
88
|
+
return { valid: false, error: "PayTo address mismatch" };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (payload.amount !== paymentRequirements.maxAmountRequired) {
|
|
92
|
+
return { valid: false, error: "Amount mismatch" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (payload.asset.toLowerCase() !== paymentRequirements.asset.toLowerCase()) {
|
|
96
|
+
return { valid: false, error: "Asset mismatch" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (payload.network !== paymentRequirements.network) {
|
|
100
|
+
return { valid: false, error: "Network mismatch" };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Verify signature
|
|
104
|
+
const data = JSON.stringify(payload);
|
|
105
|
+
const expectedSignature = crypto
|
|
106
|
+
.createHmac("sha256", secret)
|
|
107
|
+
.update(data)
|
|
108
|
+
.digest("hex");
|
|
109
|
+
|
|
110
|
+
if (signature !== expectedSignature) {
|
|
111
|
+
return { valid: false, error: "Invalid signature" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { valid: true };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { valid: false, error: "Invalid token format" };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Generates a random secret for FACILITATOR_SECRET.
|
|
122
|
+
* Format: fac_<64 hex characters>
|
|
123
|
+
*/
|
|
124
|
+
export function generateFacilitatorSecret(): string {
|
|
125
|
+
return "fac_" + crypto.randomBytes(32).toString("hex");
|
|
126
|
+
}
|