create-mantle-facilitator 0.1.0 → 0.2.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/package.json CHANGED
@@ -1,7 +1,6 @@
1
-
2
1
  {
3
2
  "name": "create-mantle-facilitator",
4
- "version": "0.1.0",
3
+ "version": "0.2.1",
5
4
  "private": false,
6
5
  "type": "module",
7
6
  "bin": {
@@ -15,3 +15,11 @@ FACILITATOR_PRIVATE_KEY=0xYOUR_PRIVATE_KEY
15
15
 
16
16
  # Optional: enable verbose logs
17
17
  LOG_LEVEL=debug
18
+
19
+ # =============================================================================
20
+ # Optional: Analytics & Telemetry
21
+ # =============================================================================
22
+ # Uncomment to send usage metrics to analytics backend
23
+ # This helps improve the x402 ecosystem and provides you with payment analytics
24
+
25
+ # TELEMETRY_PROJECT_KEY=your_project_key_here
@@ -23,3 +23,28 @@ npm install
23
23
  cp .env.example .env
24
24
  # Fill RPC_URL and FACILITATOR_PRIVATE_KEY
25
25
  npm start
26
+ ```
27
+
28
+ ## Optional: Analytics & Telemetry
29
+
30
+ To enable opt-in telemetry for payment tracking and analytics:
31
+
32
+ 1. Get your project key from https://nosubs.ai/dashboard (or your analytics platform)
33
+ 2. Add to `.env`:
34
+ ```bash
35
+ TELEMETRY_PROJECT_KEY=proj_abc123xyz
36
+ ```
37
+
38
+ **Note:** Telemetry is sent automatically to the official analytics backend. If you don't want to send telemetry, simply don't set `TELEMETRY_PROJECT_KEY`.
39
+
40
+ **What data is sent:**
41
+ - Payment metadata (buyer address, amount, asset, network)
42
+ - Transaction hash (after settlement)
43
+ - Timestamp
44
+
45
+ **What is NOT sent:**
46
+ - Private keys
47
+ - User personal information
48
+ - Request/response payloads from your protected endpoints
49
+
50
+ Telemetry errors never break payment processing - if the analytics backend is down, payments continue to work normally.
@@ -0,0 +1,324 @@
1
+ // src/index.ts
2
+ import express from "express";
3
+
4
+ // src/config.ts
5
+ import "dotenv/config";
6
+
7
+ // src/constants.ts
8
+ var DEFAULT_TELEMETRY_ENDPOINT = void 0;
9
+
10
+ // src/config.ts
11
+ function required(name) {
12
+ const v = process.env[name];
13
+ if (!v) throw new Error(`Missing env var: ${name}`);
14
+ return v;
15
+ }
16
+ var CONFIG = {
17
+ port: Number(process.env.PORT ?? 8080),
18
+ networkId: process.env.NETWORK_ID ?? "mantle-mainnet",
19
+ chainId: Number(process.env.CHAIN_ID ?? 5e3),
20
+ rpcUrl: required("RPC_URL"),
21
+ usdcAddress: required("USDC_ADDRESS"),
22
+ usdcDecimals: Number(process.env.USDC_DECIMALS ?? 6),
23
+ facilitatorPrivateKey: required("FACILITATOR_PRIVATE_KEY"),
24
+ logLevel: process.env.LOG_LEVEL ?? "info",
25
+ // Telemetry config (opt-in via projectKey)
26
+ telemetry: process.env.TELEMETRY_PROJECT_KEY ? {
27
+ projectKey: process.env.TELEMETRY_PROJECT_KEY,
28
+ endpoint: DEFAULT_TELEMETRY_ENDPOINT
29
+ } : void 0
30
+ };
31
+
32
+ // src/routes/health.ts
33
+ import { ethers as ethers2 } from "ethers";
34
+
35
+ // src/blockchain.ts
36
+ import { ethers } from "ethers";
37
+ var USDC_EIP3009_ABI = [
38
+ "function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s) external",
39
+ "function balanceOf(address account) view returns (uint256)"
40
+ ];
41
+ function getProvider() {
42
+ return new ethers.JsonRpcProvider(CONFIG.rpcUrl, CONFIG.chainId);
43
+ }
44
+ function getFacilitatorSigner() {
45
+ const provider = getProvider();
46
+ return new ethers.Wallet(CONFIG.facilitatorPrivateKey, provider);
47
+ }
48
+ function getUsdcContract(signerOrProvider) {
49
+ return new ethers.Contract(CONFIG.usdcAddress, USDC_EIP3009_ABI, signerOrProvider);
50
+ }
51
+ async function getMntBalance(address) {
52
+ const provider = getProvider();
53
+ return provider.getBalance(address);
54
+ }
55
+
56
+ // src/routes/health.ts
57
+ async function healthRoute(_req, res) {
58
+ try {
59
+ const signer = getFacilitatorSigner();
60
+ const provider = signer.provider;
61
+ if (!provider) {
62
+ res.status(500).json({ ok: false, error: "No provider available" });
63
+ return;
64
+ }
65
+ const currentBlock = await provider.getBlockNumber();
66
+ const mntBalanceWei = await getMntBalance(signer.address);
67
+ res.status(200).json({
68
+ ok: true,
69
+ network: CONFIG.networkId,
70
+ chainId: CONFIG.chainId,
71
+ facilitatorAddress: signer.address,
72
+ currentBlock,
73
+ mntBalanceWei: mntBalanceWei.toString(),
74
+ mntBalance: ethers2.formatEther(mntBalanceWei)
75
+ });
76
+ } catch (err) {
77
+ const msg = err instanceof Error ? err.message : "Unknown error";
78
+ res.status(500).json({ ok: false, error: msg });
79
+ }
80
+ }
81
+
82
+ // src/routes/supported.ts
83
+ function supportedRoute(_req, res) {
84
+ res.status(200).json({
85
+ networkId: CONFIG.networkId,
86
+ chainId: CONFIG.chainId,
87
+ schemes: ["exact"],
88
+ assets: [
89
+ {
90
+ symbol: "USDC",
91
+ address: CONFIG.usdcAddress,
92
+ decimals: CONFIG.usdcDecimals
93
+ }
94
+ ]
95
+ });
96
+ }
97
+
98
+ // src/x402.ts
99
+ import { ethers as ethers3 } from "ethers";
100
+ function decodePaymentHeader(paymentHeader) {
101
+ try {
102
+ const json = Buffer.from(paymentHeader, "base64").toString("utf8");
103
+ return JSON.parse(json);
104
+ } catch (err) {
105
+ const msg = err instanceof Error ? err.message : "Unknown error";
106
+ throw new Error(`Failed to decode paymentHeader: ${msg}`);
107
+ }
108
+ }
109
+ function validateHeaderShape(headerObj) {
110
+ if (!headerObj || typeof headerObj !== "object") {
111
+ return { ok: false, reason: "Header is not an object" };
112
+ }
113
+ if (headerObj.x402Version !== 1) {
114
+ return { ok: false, reason: "Unsupported x402Version" };
115
+ }
116
+ if (headerObj.scheme !== "exact") {
117
+ return { ok: false, reason: "Unsupported scheme" };
118
+ }
119
+ if (!headerObj.network) {
120
+ return { ok: false, reason: "Missing network" };
121
+ }
122
+ if (!headerObj.payload?.authorization || !headerObj.payload?.signature) {
123
+ return { ok: false, reason: "Missing payload.authorization or payload.signature" };
124
+ }
125
+ return { ok: true };
126
+ }
127
+ function getUsdcTypedData(authorization) {
128
+ const domain = {
129
+ name: "USD Coin",
130
+ version: "2",
131
+ chainId: CONFIG.chainId,
132
+ verifyingContract: CONFIG.usdcAddress
133
+ };
134
+ const types = {
135
+ TransferWithAuthorization: [
136
+ { name: "from", type: "address" },
137
+ { name: "to", type: "address" },
138
+ { name: "value", type: "uint256" },
139
+ { name: "validAfter", type: "uint256" },
140
+ { name: "validBefore", type: "uint256" },
141
+ { name: "nonce", type: "bytes32" }
142
+ ]
143
+ };
144
+ return { domain, types, primaryType: "TransferWithAuthorization", message: authorization };
145
+ }
146
+ function verifyAuthorizationSignature(authorization, signature) {
147
+ const { domain, types, message } = getUsdcTypedData(authorization);
148
+ return ethers3.verifyTypedData(domain, types, message, signature);
149
+ }
150
+ function verifyPayment(headerObj, paymentRequirements) {
151
+ const shape = validateHeaderShape(headerObj);
152
+ if (!shape.ok) return { isValid: false, invalidReason: shape.reason };
153
+ const { authorization, signature } = headerObj.payload;
154
+ if (headerObj.network !== paymentRequirements.network) {
155
+ return { isValid: false, invalidReason: "Network mismatch" };
156
+ }
157
+ if (paymentRequirements.scheme !== "exact") {
158
+ return { isValid: false, invalidReason: "Only exact scheme supported" };
159
+ }
160
+ if (authorization.to.toLowerCase() !== paymentRequirements.payTo.toLowerCase()) {
161
+ return { isValid: false, invalidReason: "Authorization.to does not match payTo" };
162
+ }
163
+ const authValue = BigInt(authorization.value);
164
+ const maxValue = BigInt(paymentRequirements.maxAmountRequired);
165
+ if (authValue !== maxValue) {
166
+ return { isValid: false, invalidReason: "Authorization.value does not match maxAmountRequired" };
167
+ }
168
+ try {
169
+ const recovered = verifyAuthorizationSignature(authorization, signature);
170
+ if (recovered.toLowerCase() !== authorization.from.toLowerCase()) {
171
+ return { isValid: false, invalidReason: "Signature does not match authorization.from" };
172
+ }
173
+ } catch (err) {
174
+ const msg = err instanceof Error ? err.message : "Unknown error";
175
+ return { isValid: false, invalidReason: `Signature verification failed: ${msg}` };
176
+ }
177
+ return { isValid: true, invalidReason: null };
178
+ }
179
+
180
+ // src/routes/verify.ts
181
+ async function verifyRoute(req, res) {
182
+ try {
183
+ const { x402Version, paymentHeader, paymentRequirements } = req.body ?? {};
184
+ const projectKey = req.header("X-Project-Key");
185
+ if (projectKey) {
186
+ console.log(`[billing] Verify request from project: ${projectKey}`);
187
+ }
188
+ if (x402Version !== 1) {
189
+ res.status(400).json({ isValid: false, invalidReason: "Unsupported x402Version" });
190
+ return;
191
+ }
192
+ if (!paymentHeader || !paymentRequirements) {
193
+ res.status(400).json({ isValid: false, invalidReason: "Missing paymentHeader or paymentRequirements" });
194
+ return;
195
+ }
196
+ const headerObj = decodePaymentHeader(paymentHeader);
197
+ const result = verifyPayment(headerObj, paymentRequirements);
198
+ res.status(200).json(result);
199
+ } catch (err) {
200
+ const msg = err instanceof Error ? err.message : "Unknown error";
201
+ res.status(500).json({ isValid: false, invalidReason: msg });
202
+ }
203
+ }
204
+
205
+ // src/routes/settle.ts
206
+ import { ethers as ethers4 } from "ethers";
207
+ function signatureToVRS(signature) {
208
+ const sig = ethers4.Signature.from(signature);
209
+ return { v: sig.v, r: sig.r, s: sig.s };
210
+ }
211
+ async function settleRoute(req, res) {
212
+ const raw = req.body ?? {};
213
+ try {
214
+ const { x402Version, paymentHeader, paymentRequirements } = raw;
215
+ const projectKey = req.header("X-Project-Key");
216
+ if (projectKey) {
217
+ console.log(`[billing] Settle request from project: ${projectKey}`);
218
+ }
219
+ if (x402Version !== 1) {
220
+ res.status(400).json({
221
+ success: false,
222
+ error: "Unsupported x402Version",
223
+ txHash: null,
224
+ networkId: paymentRequirements?.network
225
+ });
226
+ return;
227
+ }
228
+ if (!paymentHeader || !paymentRequirements) {
229
+ res.status(400).json({
230
+ success: false,
231
+ error: "Missing paymentHeader or paymentRequirements",
232
+ txHash: null,
233
+ networkId: paymentRequirements?.network
234
+ });
235
+ return;
236
+ }
237
+ const headerObj = decodePaymentHeader(paymentHeader);
238
+ const verify = verifyPayment(headerObj, paymentRequirements);
239
+ if (!verify.isValid) {
240
+ res.status(400).json({
241
+ success: false,
242
+ error: verify.invalidReason ?? "Invalid payment",
243
+ txHash: null,
244
+ networkId: paymentRequirements.network
245
+ });
246
+ return;
247
+ }
248
+ const { authorization, signature } = headerObj.payload;
249
+ const { v, r, s } = signatureToVRS(signature);
250
+ const signer = getFacilitatorSigner();
251
+ const usdc = getUsdcContract(signer);
252
+ const tx = await usdc.transferWithAuthorization(
253
+ authorization.from,
254
+ authorization.to,
255
+ authorization.value,
256
+ authorization.validAfter,
257
+ authorization.validBefore,
258
+ authorization.nonce,
259
+ v,
260
+ r,
261
+ s
262
+ );
263
+ const receipt = await tx.wait();
264
+ if (CONFIG.telemetry && CONFIG.telemetry.endpoint) {
265
+ const telemetryEvent = {
266
+ event: "payment_settled",
267
+ ts: Date.now(),
268
+ projectKey: CONFIG.telemetry.projectKey,
269
+ network: paymentRequirements.network,
270
+ buyer: authorization.from,
271
+ payTo: authorization.to,
272
+ amountAtomic: authorization.value,
273
+ asset: CONFIG.usdcAddress,
274
+ decimals: CONFIG.usdcDecimals,
275
+ nonce: authorization.nonce,
276
+ route: "facilitator_settle",
277
+ // Facilitator doesn't know original route
278
+ // Facilitator metadata
279
+ facilitatorType: "self-hosted",
280
+ facilitatorUrl: void 0,
281
+ // Facilitator doesn't have URL to itself
282
+ facilitatorAddress: signer.address,
283
+ // Get from signer (line 66)
284
+ // Optional metadata
285
+ txHash: receipt?.hash ?? tx.hash,
286
+ priceUsd: paymentRequirements.price
287
+ };
288
+ fetch(CONFIG.telemetry.endpoint, {
289
+ method: "POST",
290
+ headers: {
291
+ "Content-Type": "application/json",
292
+ "Authorization": `Bearer ${CONFIG.telemetry.projectKey}`
293
+ },
294
+ body: JSON.stringify(telemetryEvent)
295
+ }).catch((err) => console.error("[telemetry] Send failed:", err));
296
+ }
297
+ res.status(200).json({
298
+ success: true,
299
+ error: null,
300
+ txHash: receipt?.hash ?? tx.hash,
301
+ networkId: paymentRequirements.network
302
+ });
303
+ } catch (err) {
304
+ const msg = err instanceof Error ? err.message : "Unknown error";
305
+ res.status(500).json({
306
+ success: false,
307
+ error: msg,
308
+ txHash: null,
309
+ networkId: raw?.paymentRequirements?.network
310
+ });
311
+ }
312
+ }
313
+
314
+ // src/index.ts
315
+ var app = express();
316
+ app.use(express.json({ limit: "1mb" }));
317
+ app.get("/health", healthRoute);
318
+ app.get("/supported", supportedRoute);
319
+ app.post("/verify", verifyRoute);
320
+ app.post("/settle", settleRoute);
321
+ app.listen(CONFIG.port, () => {
322
+ console.log(`Facilitator server listening on http://localhost:${CONFIG.port}`);
323
+ console.log(`Network: ${CONFIG.networkId} (chainId=${CONFIG.chainId})`);
324
+ });
@@ -1,5 +1,6 @@
1
1
  // src/config.ts
2
2
  import "dotenv/config";
3
+ import { DEFAULT_TELEMETRY_ENDPOINT } from "./constants";
3
4
 
4
5
  function required(name: string): string {
5
6
  const v = process.env[name];
@@ -20,4 +21,12 @@ export const CONFIG = {
20
21
  facilitatorPrivateKey: required("FACILITATOR_PRIVATE_KEY"),
21
22
 
22
23
  logLevel: process.env.LOG_LEVEL ?? "info",
24
+
25
+ // Telemetry config (opt-in via projectKey)
26
+ telemetry: process.env.TELEMETRY_PROJECT_KEY
27
+ ? {
28
+ projectKey: process.env.TELEMETRY_PROJECT_KEY,
29
+ endpoint: DEFAULT_TELEMETRY_ENDPOINT,
30
+ }
31
+ : undefined,
23
32
  } as const;
@@ -0,0 +1,9 @@
1
+ // src/constants.ts
2
+
3
+ /**
4
+ * Default telemetry endpoint URL.
5
+ *
6
+ * Set to a valid URL when the official analytics backend is ready.
7
+ * If undefined, telemetry will only be sent if TELEMETRY_ENDPOINT env var is set.
8
+ */
9
+ export const DEFAULT_TELEMETRY_ENDPOINT: string | undefined = undefined;
@@ -3,6 +3,7 @@ import type { Request, Response } from "express";
3
3
  import { ethers } from "ethers";
4
4
  import { decodePaymentHeader, verifyPayment, type PaymentRequirements } from "../x402";
5
5
  import { getFacilitatorSigner, getUsdcContract } from "../blockchain";
6
+ import { CONFIG } from "../config";
6
7
 
7
8
  function signatureToVRS(signature: string) {
8
9
  const sig = ethers.Signature.from(signature);
@@ -19,6 +20,12 @@ export async function settleRoute(req: Request, res: Response) {
19
20
  paymentHeader?: string;
20
21
  paymentRequirements?: PaymentRequirements;
21
22
  };
23
+ const projectKey = req.header("X-Project-Key"); // Optional: for hosted facilitator billing
24
+
25
+ // Optional: Log for billing/attribution (useful for hosted facilitators)
26
+ if (projectKey) {
27
+ console.log(`[billing] Settle request from project: ${projectKey}`);
28
+ }
22
29
 
23
30
  if (x402Version !== 1) {
24
31
  res.status(400).json({
@@ -71,6 +78,44 @@ export async function settleRoute(req: Request, res: Response) {
71
78
 
72
79
  const receipt = await tx.wait();
73
80
 
81
+ // Send telemetry if configured and endpoint is available
82
+ if (CONFIG.telemetry && CONFIG.telemetry.endpoint) {
83
+ // NOTE: This event structure matches TelemetryEvent from x402-mantle-sdk
84
+ // See: packages/x402-mantle-sdk/src/server/types.ts
85
+ const telemetryEvent = {
86
+ event: "payment_settled" as const,
87
+ ts: Date.now(),
88
+ projectKey: CONFIG.telemetry.projectKey,
89
+ network: paymentRequirements.network,
90
+ buyer: authorization.from,
91
+ payTo: authorization.to,
92
+ amountAtomic: authorization.value,
93
+ asset: CONFIG.usdcAddress,
94
+ decimals: CONFIG.usdcDecimals,
95
+ nonce: authorization.nonce,
96
+ route: "facilitator_settle", // Facilitator doesn't know original route
97
+
98
+ // Facilitator metadata
99
+ facilitatorType: "self-hosted" as const,
100
+ facilitatorUrl: undefined, // Facilitator doesn't have URL to itself
101
+ facilitatorAddress: signer.address, // Get from signer (line 66)
102
+
103
+ // Optional metadata
104
+ txHash: receipt?.hash ?? tx.hash,
105
+ priceUsd: paymentRequirements.price,
106
+ };
107
+
108
+ // Fire-and-forget (don't await - don't delay response)
109
+ fetch(CONFIG.telemetry.endpoint, {
110
+ method: "POST",
111
+ headers: {
112
+ "Content-Type": "application/json",
113
+ "Authorization": `Bearer ${CONFIG.telemetry.projectKey}`,
114
+ },
115
+ body: JSON.stringify(telemetryEvent),
116
+ }).catch(err => console.error("[telemetry] Send failed:", err));
117
+ }
118
+
74
119
  res.status(200).json({
75
120
  success: true,
76
121
  error: null,
@@ -5,6 +5,12 @@ import { decodePaymentHeader, verifyPayment, type PaymentRequirements } from "..
5
5
  export async function verifyRoute(req: Request, res: Response) {
6
6
  try {
7
7
  const { x402Version, paymentHeader, paymentRequirements } = req.body ?? {};
8
+ const projectKey = req.header("X-Project-Key"); // Optional: for hosted facilitator billing
9
+
10
+ // Optional: Log for billing/attribution (useful for hosted facilitators)
11
+ if (projectKey) {
12
+ console.log(`[billing] Verify request from project: ${projectKey}`);
13
+ }
8
14
 
9
15
  if (x402Version !== 1) {
10
16
  res.status(400).json({ isValid: false, invalidReason: "Unsupported x402Version" });