@aibtc/mcp-server 1.1.0 → 1.2.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/dist/services/scaffold.service.d.ts +9 -3
- package/dist/services/scaffold.service.d.ts.map +1 -1
- package/dist/services/scaffold.service.js +553 -311
- package/dist/services/scaffold.service.js.map +1 -1
- package/dist/tools/scaffold.tools.d.ts.map +1 -1
- package/dist/tools/scaffold.tools.js +173 -62
- package/dist/tools/scaffold.tools.js.map +1 -1
- package/package.json +1 -1
|
@@ -6,6 +6,16 @@ const TOKEN_DECIMALS = {
|
|
|
6
6
|
sBTC: 8,
|
|
7
7
|
USDCx: 6,
|
|
8
8
|
};
|
|
9
|
+
// Pricing tier amounts (matches x402-api and stx402 patterns)
|
|
10
|
+
const TIER_AMOUNTS = {
|
|
11
|
+
free: { STX: "0", sBTC: "0", USDCx: "0" },
|
|
12
|
+
simple: { STX: "0.001", sBTC: "0.000001", USDCx: "0.001" },
|
|
13
|
+
standard: { STX: "0.001", sBTC: "0.000001", USDCx: "0.001" },
|
|
14
|
+
ai: { STX: "0.003", sBTC: "0.000003", USDCx: "0.003" },
|
|
15
|
+
heavy_ai: { STX: "0.01", sBTC: "0.00001", USDCx: "0.01" },
|
|
16
|
+
storage_read: { STX: "0.0005", sBTC: "0.0000005", USDCx: "0.0005" },
|
|
17
|
+
storage_write: { STX: "0.001", sBTC: "0.000001", USDCx: "0.001" },
|
|
18
|
+
};
|
|
9
19
|
/**
|
|
10
20
|
* Convert human-readable amount to smallest unit (microSTX, sats, etc.)
|
|
11
21
|
*/
|
|
@@ -15,27 +25,35 @@ function toSmallestUnit(amount, tokenType) {
|
|
|
15
25
|
const paddedFraction = fraction.padEnd(decimals, "0").slice(0, decimals);
|
|
16
26
|
return BigInt(whole + paddedFraction).toString();
|
|
17
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Get amount for endpoint based on tier or explicit amount
|
|
30
|
+
*/
|
|
31
|
+
function getEndpointAmount(ep) {
|
|
32
|
+
if (ep.tier) {
|
|
33
|
+
return TIER_AMOUNTS[ep.tier][ep.tokenType];
|
|
34
|
+
}
|
|
35
|
+
return ep.amount || TIER_AMOUNTS.standard[ep.tokenType];
|
|
36
|
+
}
|
|
18
37
|
/**
|
|
19
38
|
* Generate Hono route code for each endpoint
|
|
20
39
|
*/
|
|
21
40
|
function generateEndpointCode(endpoints) {
|
|
22
41
|
return endpoints
|
|
23
42
|
.map((ep) => {
|
|
24
|
-
const
|
|
43
|
+
const amount = getEndpointAmount(ep);
|
|
44
|
+
const amountSmallest = toSmallestUnit(amount, ep.tokenType);
|
|
45
|
+
const tierComment = ep.tier ? ` (tier: ${ep.tier})` : "";
|
|
25
46
|
// Generate real example logic based on endpoint characteristics
|
|
26
47
|
const exampleLogic = generateExampleLogic(ep);
|
|
27
48
|
return `
|
|
28
|
-
// ${ep.description}
|
|
49
|
+
// ${ep.description}${tierComment}
|
|
29
50
|
app.${ep.method.toLowerCase()}('${ep.path}',
|
|
30
51
|
x402Middleware({
|
|
31
52
|
amount: '${amountSmallest}',
|
|
32
|
-
address: env.RECIPIENT_ADDRESS,
|
|
33
|
-
network: env.NETWORK as 'mainnet' | 'testnet',
|
|
34
53
|
tokenType: '${ep.tokenType}',
|
|
35
|
-
facilitatorUrl: env.FACILITATOR_URL,
|
|
36
54
|
}),
|
|
37
55
|
async (c) => {
|
|
38
|
-
const payment = c.get('
|
|
56
|
+
const payment = c.get('x402');
|
|
39
57
|
${exampleLogic}
|
|
40
58
|
}
|
|
41
59
|
);`;
|
|
@@ -62,9 +80,8 @@ function generateExampleLogic(ep) {
|
|
|
62
80
|
success: true,
|
|
63
81
|
data: result,
|
|
64
82
|
payment: {
|
|
65
|
-
txId: payment?.txId,
|
|
66
|
-
sender: payment?.
|
|
67
|
-
amount: payment?.amount?.toString(),
|
|
83
|
+
txId: payment?.settleResult?.txId,
|
|
84
|
+
sender: payment?.payerAddress,
|
|
68
85
|
},
|
|
69
86
|
});`;
|
|
70
87
|
}
|
|
@@ -81,9 +98,8 @@ function generateExampleLogic(ep) {
|
|
|
81
98
|
success: true,
|
|
82
99
|
data,
|
|
83
100
|
payment: {
|
|
84
|
-
txId: payment?.txId,
|
|
85
|
-
sender: payment?.
|
|
86
|
-
amount: payment?.amount?.toString(),
|
|
101
|
+
txId: payment?.settleResult?.txId,
|
|
102
|
+
sender: payment?.payerAddress,
|
|
87
103
|
},
|
|
88
104
|
});`;
|
|
89
105
|
}
|
|
@@ -93,9 +109,11 @@ function generateExampleLogic(ep) {
|
|
|
93
109
|
function generateEndpointDocs(endpoints) {
|
|
94
110
|
return endpoints
|
|
95
111
|
.map((ep) => {
|
|
112
|
+
const amount = getEndpointAmount(ep);
|
|
113
|
+
const tierInfo = ep.tier ? ` (tier: ${ep.tier})` : "";
|
|
96
114
|
return `### ${ep.method} ${ep.path}
|
|
97
115
|
- **Description:** ${ep.description}
|
|
98
|
-
- **Cost:** ${
|
|
116
|
+
- **Cost:** ${amount} ${ep.tokenType}${tierInfo}
|
|
99
117
|
- **Payment Required:** Yes`;
|
|
100
118
|
})
|
|
101
119
|
.join("\n\n");
|
|
@@ -112,247 +130,382 @@ function generateTokenList(endpoints) {
|
|
|
112
130
|
// =============================================================================
|
|
113
131
|
function getIndexTemplate(endpoints) {
|
|
114
132
|
const endpointCode = generateEndpointCode(endpoints);
|
|
115
|
-
return
|
|
133
|
+
return `// BigInt.toJSON polyfill for JSON.stringify compatibility
|
|
134
|
+
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
|
|
135
|
+
return this.toString();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
import { Hono } from 'hono';
|
|
116
139
|
import { cors } from 'hono/cors';
|
|
117
140
|
import { x402Middleware } from './x402-middleware';
|
|
141
|
+
import type { X402Context } from './x402-middleware';
|
|
118
142
|
|
|
119
|
-
type
|
|
143
|
+
type Env = {
|
|
120
144
|
RECIPIENT_ADDRESS: string;
|
|
121
145
|
NETWORK: string;
|
|
122
146
|
FACILITATOR_URL: string;
|
|
123
147
|
};
|
|
124
148
|
|
|
125
149
|
type Variables = {
|
|
126
|
-
|
|
127
|
-
txId: string;
|
|
128
|
-
status: string;
|
|
129
|
-
sender: string;
|
|
130
|
-
recipient: string;
|
|
131
|
-
amount: bigint;
|
|
132
|
-
};
|
|
150
|
+
x402?: X402Context;
|
|
133
151
|
};
|
|
134
152
|
|
|
135
|
-
const app = new Hono<{ Bindings:
|
|
153
|
+
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
136
154
|
|
|
137
|
-
|
|
155
|
+
// CORS middleware with x402 headers
|
|
156
|
+
app.use('*', cors({
|
|
157
|
+
origin: '*',
|
|
158
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
159
|
+
allowHeaders: ['X-PAYMENT', 'X-PAYMENT-TOKEN-TYPE', 'Authorization', 'Content-Type'],
|
|
160
|
+
exposeHeaders: ['X-PAYMENT-RESPONSE', 'X-PAYER-ADDRESS'],
|
|
161
|
+
}));
|
|
138
162
|
|
|
139
163
|
// Startup validation - fail fast if required secrets are missing
|
|
140
164
|
app.use('*', async (c, next) => {
|
|
165
|
+
// Skip validation for health check
|
|
166
|
+
if (c.req.path === '/health') {
|
|
167
|
+
return next();
|
|
168
|
+
}
|
|
169
|
+
|
|
141
170
|
const missingSecrets: string[] = [];
|
|
142
171
|
|
|
143
172
|
if (!c.env.RECIPIENT_ADDRESS) {
|
|
144
173
|
missingSecrets.push('RECIPIENT_ADDRESS');
|
|
145
174
|
}
|
|
146
|
-
if (!c.env.FACILITATOR_URL) {
|
|
147
|
-
missingSecrets.push('FACILITATOR_URL');
|
|
148
|
-
}
|
|
149
175
|
|
|
150
176
|
if (missingSecrets.length > 0) {
|
|
151
177
|
return c.json({
|
|
152
178
|
error: 'Server configuration error',
|
|
153
179
|
message: \`Missing required secrets: \${missingSecrets.join(', ')}\`,
|
|
154
|
-
hint: missingSecrets.map(s => \`Run:
|
|
180
|
+
hint: missingSecrets.map(s => \`Run: wrangler secret put \${s}\`).join(' && '),
|
|
155
181
|
}, 503);
|
|
156
182
|
}
|
|
157
183
|
|
|
158
184
|
await next();
|
|
159
185
|
});
|
|
160
186
|
|
|
187
|
+
// Service info at root (free)
|
|
188
|
+
app.get('/', (c) => {
|
|
189
|
+
return c.json({
|
|
190
|
+
service: '${endpoints.length > 0 ? "x402-api" : "my-x402-api"}',
|
|
191
|
+
version: '1.0.0',
|
|
192
|
+
health: '/health',
|
|
193
|
+
payment: {
|
|
194
|
+
tokens: ['STX', 'sBTC', 'USDCx'],
|
|
195
|
+
header: 'X-PAYMENT',
|
|
196
|
+
tokenTypeHeader: 'X-PAYMENT-TOKEN-TYPE',
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
161
201
|
// Health check (free)
|
|
162
202
|
app.get('/health', (c) => {
|
|
163
203
|
return c.json({
|
|
164
204
|
status: 'ok',
|
|
165
205
|
timestamp: new Date().toISOString(),
|
|
206
|
+
network: c.env.NETWORK || 'testnet',
|
|
166
207
|
});
|
|
167
208
|
});
|
|
168
|
-
|
|
169
|
-
// x402-protected endpoints
|
|
170
|
-
app.use('*', async (c, next) => {
|
|
171
|
-
// Make env available to middleware
|
|
172
|
-
const env = c.env;
|
|
173
|
-
(globalThis as Record<string, unknown>).__env = env;
|
|
174
|
-
await next();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
const env = {
|
|
178
|
-
get RECIPIENT_ADDRESS() {
|
|
179
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.RECIPIENT_ADDRESS || '';
|
|
180
|
-
},
|
|
181
|
-
get NETWORK() {
|
|
182
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.NETWORK || 'testnet';
|
|
183
|
-
},
|
|
184
|
-
get FACILITATOR_URL() {
|
|
185
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.FACILITATOR_URL || '';
|
|
186
|
-
},
|
|
187
|
-
};
|
|
188
209
|
${endpointCode}
|
|
189
210
|
|
|
190
211
|
export default app;
|
|
191
212
|
`;
|
|
192
213
|
}
|
|
193
214
|
function getMiddlewareTemplate() {
|
|
194
|
-
return
|
|
215
|
+
return `/**
|
|
216
|
+
* x402 Payment Middleware for Hono
|
|
217
|
+
*
|
|
218
|
+
* Based on production implementations from:
|
|
219
|
+
* - https://github.com/aibtcdev/x402-api
|
|
220
|
+
* - https://github.com/whoabuddy/stx402
|
|
221
|
+
*
|
|
222
|
+
* Uses the x402-stacks library for payment verification.
|
|
223
|
+
*/
|
|
224
|
+
|
|
225
|
+
import type { Context, Next } from 'hono';
|
|
226
|
+
import { X402PaymentVerifier } from 'x402-stacks';
|
|
227
|
+
|
|
228
|
+
// =============================================================================
|
|
229
|
+
// Types
|
|
230
|
+
// =============================================================================
|
|
231
|
+
|
|
232
|
+
export type TokenType = 'STX' | 'sBTC' | 'USDCx';
|
|
233
|
+
|
|
234
|
+
export interface TokenContract {
|
|
235
|
+
address: string;
|
|
236
|
+
name: string;
|
|
237
|
+
}
|
|
195
238
|
|
|
196
239
|
export interface X402Config {
|
|
197
240
|
amount: string;
|
|
198
|
-
|
|
199
|
-
network: 'mainnet' | 'testnet';
|
|
200
|
-
tokenType: 'STX' | 'sBTC' | 'USDCx';
|
|
201
|
-
facilitatorUrl?: string;
|
|
202
|
-
resource?: string;
|
|
241
|
+
tokenType: TokenType;
|
|
203
242
|
}
|
|
204
243
|
|
|
205
|
-
interface
|
|
206
|
-
|
|
207
|
-
|
|
244
|
+
export interface SettleResult {
|
|
245
|
+
isValid: boolean;
|
|
246
|
+
txId?: string;
|
|
247
|
+
status?: string;
|
|
248
|
+
sender?: string;
|
|
249
|
+
senderAddress?: string;
|
|
250
|
+
sender_address?: string;
|
|
251
|
+
recipient?: string;
|
|
252
|
+
error?: string;
|
|
253
|
+
reason?: string;
|
|
254
|
+
validationError?: string;
|
|
208
255
|
}
|
|
209
256
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
USDCx: { address: 'SP2XD7417HGPRTREMKF748VNEQPDRR0RMANB7X1NK', name: 'token-usdcx' },
|
|
216
|
-
},
|
|
217
|
-
testnet: {
|
|
218
|
-
STX: null,
|
|
219
|
-
sBTC: { address: 'ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM', name: 'sbtc-token' },
|
|
220
|
-
USDCx: null,
|
|
221
|
-
},
|
|
222
|
-
};
|
|
257
|
+
export interface X402Context {
|
|
258
|
+
payerAddress: string;
|
|
259
|
+
settleResult: SettleResult;
|
|
260
|
+
signedTx: string;
|
|
261
|
+
}
|
|
223
262
|
|
|
224
263
|
interface PaymentRequirement {
|
|
225
264
|
maxAmountRequired: string;
|
|
226
265
|
resource: string;
|
|
227
266
|
payTo: string;
|
|
228
|
-
network:
|
|
267
|
+
network: 'mainnet' | 'testnet';
|
|
229
268
|
nonce: string;
|
|
230
269
|
expiresAt: string;
|
|
231
|
-
tokenType:
|
|
270
|
+
tokenType: TokenType;
|
|
232
271
|
tokenContract?: TokenContract;
|
|
233
272
|
}
|
|
234
273
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
274
|
+
type PaymentErrorCode =
|
|
275
|
+
| 'FACILITATOR_UNAVAILABLE'
|
|
276
|
+
| 'FACILITATOR_ERROR'
|
|
277
|
+
| 'PAYMENT_INVALID'
|
|
278
|
+
| 'INSUFFICIENT_FUNDS'
|
|
279
|
+
| 'PAYMENT_EXPIRED'
|
|
280
|
+
| 'AMOUNT_TOO_LOW'
|
|
281
|
+
| 'NETWORK_ERROR'
|
|
282
|
+
| 'UNKNOWN_ERROR';
|
|
283
|
+
|
|
284
|
+
interface PaymentErrorResponse {
|
|
285
|
+
error: string;
|
|
286
|
+
code: PaymentErrorCode;
|
|
287
|
+
retryAfter?: number;
|
|
288
|
+
tokenType: TokenType;
|
|
241
289
|
resource: string;
|
|
242
|
-
|
|
290
|
+
details?: Record<string, string | undefined>;
|
|
243
291
|
}
|
|
244
292
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
293
|
+
// =============================================================================
|
|
294
|
+
// Token Contracts (correct mainnet/testnet addresses)
|
|
295
|
+
// =============================================================================
|
|
296
|
+
|
|
297
|
+
const TOKEN_CONTRACTS: Record<'mainnet' | 'testnet', Record<'sBTC' | 'USDCx', TokenContract>> = {
|
|
298
|
+
mainnet: {
|
|
299
|
+
sBTC: { address: 'SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4', name: 'sbtc-token' },
|
|
300
|
+
USDCx: { address: 'SP120SBRBQJ00MCWS7TM5R8WJNTTKD5K0HFRC2CNE', name: 'usdcx' },
|
|
301
|
+
},
|
|
302
|
+
testnet: {
|
|
303
|
+
sBTC: { address: 'ST1F7QA2MDF17S807EPA36TSS8AMEFY4KA9TVGWXT', name: 'sbtc-token' },
|
|
304
|
+
USDCx: { address: 'ST1NXBK3K5YYMD6FD41MVNP3JS1GABZ8TRVX023PT', name: 'usdcx' },
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// =============================================================================
|
|
309
|
+
// Error Classification
|
|
310
|
+
// =============================================================================
|
|
311
|
+
|
|
312
|
+
function classifyPaymentError(error: unknown, settleResult?: SettleResult): {
|
|
313
|
+
code: PaymentErrorCode;
|
|
314
|
+
message: string;
|
|
315
|
+
httpStatus: number;
|
|
316
|
+
retryAfter?: number;
|
|
317
|
+
} {
|
|
318
|
+
const errorStr = String(error).toLowerCase();
|
|
319
|
+
const resultError = settleResult?.error?.toLowerCase() || '';
|
|
320
|
+
const resultReason = settleResult?.reason?.toLowerCase() || '';
|
|
321
|
+
const validationError = settleResult?.validationError?.toLowerCase() || '';
|
|
322
|
+
const combined = \`\${errorStr} \${resultError} \${resultReason} \${validationError}\`;
|
|
323
|
+
|
|
324
|
+
if (combined.includes('fetch') || combined.includes('network') || combined.includes('timeout')) {
|
|
325
|
+
return { code: 'NETWORK_ERROR', message: 'Network error with payment facilitator', httpStatus: 502, retryAfter: 5 };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (combined.includes('503') || combined.includes('unavailable')) {
|
|
329
|
+
return { code: 'FACILITATOR_UNAVAILABLE', message: 'Payment facilitator temporarily unavailable', httpStatus: 503, retryAfter: 30 };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (combined.includes('insufficient') || combined.includes('balance')) {
|
|
333
|
+
return { code: 'INSUFFICIENT_FUNDS', message: 'Insufficient funds in wallet', httpStatus: 402 };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (combined.includes('expired') || combined.includes('nonce')) {
|
|
337
|
+
return { code: 'PAYMENT_EXPIRED', message: 'Payment expired, please sign a new payment', httpStatus: 402 };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (combined.includes('amount') && (combined.includes('low') || combined.includes('minimum'))) {
|
|
341
|
+
return { code: 'AMOUNT_TOO_LOW', message: 'Payment amount below minimum required', httpStatus: 402 };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (combined.includes('invalid') || combined.includes('signature')) {
|
|
345
|
+
return { code: 'PAYMENT_INVALID', message: 'Invalid payment signature', httpStatus: 400 };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return { code: 'UNKNOWN_ERROR', message: 'Payment processing error', httpStatus: 500, retryAfter: 5 };
|
|
253
349
|
}
|
|
254
350
|
|
|
351
|
+
// =============================================================================
|
|
352
|
+
// Middleware
|
|
353
|
+
// =============================================================================
|
|
354
|
+
|
|
355
|
+
type Env = {
|
|
356
|
+
RECIPIENT_ADDRESS: string;
|
|
357
|
+
NETWORK: string;
|
|
358
|
+
FACILITATOR_URL: string;
|
|
359
|
+
};
|
|
360
|
+
|
|
255
361
|
/**
|
|
256
|
-
* x402 Payment Middleware
|
|
362
|
+
* x402 Payment Middleware
|
|
257
363
|
*
|
|
258
364
|
* Handles the x402 payment flow:
|
|
259
365
|
* 1. If no X-PAYMENT header, return 402 with payment requirements
|
|
260
366
|
* 2. If X-PAYMENT header present, verify payment via facilitator
|
|
261
|
-
* 3. On success, attach payment
|
|
367
|
+
* 3. On success, attach payment context and continue to handler
|
|
262
368
|
*/
|
|
263
369
|
export function x402Middleware(config: X402Config) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
370
|
+
return async (c: Context<{ Bindings: Env; Variables: { x402?: X402Context } }>, next: Next) => {
|
|
371
|
+
const env = c.env;
|
|
372
|
+
const network = (env.NETWORK || 'testnet') as 'mainnet' | 'testnet';
|
|
373
|
+
const facilitatorUrl = env.FACILITATOR_URL || 'https://facilitator.x402stacks.xyz';
|
|
374
|
+
const recipientAddress = env.RECIPIENT_ADDRESS;
|
|
375
|
+
|
|
376
|
+
// Get token type from header or use config default
|
|
377
|
+
const headerTokenType = c.req.header('X-PAYMENT-TOKEN-TYPE');
|
|
378
|
+
const tokenType = (headerTokenType?.toUpperCase() === 'SBTC' ? 'sBTC' :
|
|
379
|
+
headerTokenType?.toUpperCase() === 'USDCX' ? 'USDCx' :
|
|
380
|
+
headerTokenType?.toUpperCase() === 'STX' ? 'STX' :
|
|
381
|
+
config.tokenType) as TokenType;
|
|
382
|
+
|
|
383
|
+
const minAmount = BigInt(config.amount);
|
|
384
|
+
const signedTx = c.req.header('X-PAYMENT');
|
|
385
|
+
|
|
386
|
+
if (!signedTx) {
|
|
387
|
+
// Return 402 Payment Required
|
|
388
|
+
let tokenContract: TokenContract | undefined;
|
|
389
|
+
if (tokenType === 'sBTC' || tokenType === 'USDCx') {
|
|
390
|
+
tokenContract = TOKEN_CONTRACTS[network][tokenType];
|
|
391
|
+
}
|
|
269
392
|
|
|
270
|
-
if (!signedPayment) {
|
|
271
|
-
// Return 402 Payment Required with payment details
|
|
272
393
|
const paymentReq: PaymentRequirement = {
|
|
273
394
|
maxAmountRequired: config.amount,
|
|
274
|
-
resource:
|
|
275
|
-
payTo:
|
|
276
|
-
network
|
|
395
|
+
resource: c.req.path,
|
|
396
|
+
payTo: recipientAddress,
|
|
397
|
+
network,
|
|
277
398
|
nonce: crypto.randomUUID(),
|
|
278
|
-
expiresAt: new Date(Date.now() +
|
|
279
|
-
tokenType
|
|
399
|
+
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|
400
|
+
tokenType,
|
|
401
|
+
...(tokenContract && { tokenContract }),
|
|
280
402
|
};
|
|
281
403
|
|
|
282
|
-
if (tokenContract) {
|
|
283
|
-
paymentReq.tokenContract = tokenContract;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
404
|
return c.json(paymentReq, 402);
|
|
287
405
|
}
|
|
288
406
|
|
|
289
|
-
// Verify
|
|
407
|
+
// Verify payment via x402-stacks
|
|
408
|
+
const verifier = new X402PaymentVerifier(facilitatorUrl, network);
|
|
409
|
+
|
|
410
|
+
let settleResult: SettleResult;
|
|
290
411
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
412
|
+
settleResult = await verifier.settlePayment(signedTx, {
|
|
413
|
+
expectedRecipient: recipientAddress,
|
|
414
|
+
minAmount,
|
|
415
|
+
tokenType,
|
|
416
|
+
}) as SettleResult;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
const classified = classifyPaymentError(error);
|
|
419
|
+
|
|
420
|
+
const errorResponse: PaymentErrorResponse = {
|
|
421
|
+
error: classified.message,
|
|
422
|
+
code: classified.code,
|
|
423
|
+
retryAfter: classified.retryAfter,
|
|
424
|
+
tokenType,
|
|
425
|
+
resource: c.req.path,
|
|
426
|
+
details: { exceptionMessage: String(error) },
|
|
299
427
|
};
|
|
300
428
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
headers: { 'Content-Type': 'application/json' },
|
|
304
|
-
body: JSON.stringify(settleRequest),
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
const result = (await response.json()) as SettleResponse;
|
|
308
|
-
|
|
309
|
-
if (!result.success) {
|
|
310
|
-
return c.json(
|
|
311
|
-
{
|
|
312
|
-
error: 'Payment verification failed',
|
|
313
|
-
reason: result.error || 'Unknown error',
|
|
314
|
-
},
|
|
315
|
-
402
|
|
316
|
-
);
|
|
429
|
+
if (classified.retryAfter) {
|
|
430
|
+
c.header('Retry-After', String(classified.retryAfter));
|
|
317
431
|
}
|
|
318
432
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
txId: result.tx_id,
|
|
322
|
-
status: result.status,
|
|
323
|
-
sender: result.sender_address,
|
|
324
|
-
recipient: result.recipient_address,
|
|
325
|
-
amount: BigInt(result.amount || 0),
|
|
326
|
-
});
|
|
433
|
+
return c.json(errorResponse, classified.httpStatus as 400 | 402 | 500 | 502 | 503);
|
|
434
|
+
}
|
|
327
435
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
error: 'Payment processing error',
|
|
333
|
-
message: error instanceof Error ? error.message : 'Unknown error',
|
|
334
|
-
},
|
|
335
|
-
500
|
|
436
|
+
if (!settleResult.isValid) {
|
|
437
|
+
const classified = classifyPaymentError(
|
|
438
|
+
settleResult.validationError || settleResult.error || 'invalid',
|
|
439
|
+
settleResult
|
|
336
440
|
);
|
|
441
|
+
|
|
442
|
+
const errorResponse: PaymentErrorResponse = {
|
|
443
|
+
error: classified.message,
|
|
444
|
+
code: classified.code,
|
|
445
|
+
retryAfter: classified.retryAfter,
|
|
446
|
+
tokenType,
|
|
447
|
+
resource: c.req.path,
|
|
448
|
+
details: {
|
|
449
|
+
settleError: settleResult.error,
|
|
450
|
+
settleReason: settleResult.reason,
|
|
451
|
+
validationError: settleResult.validationError,
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
if (classified.retryAfter) {
|
|
456
|
+
c.header('Retry-After', String(classified.retryAfter));
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return c.json(errorResponse, classified.httpStatus as 400 | 402 | 500 | 502 | 503);
|
|
337
460
|
}
|
|
461
|
+
|
|
462
|
+
// Extract payer address
|
|
463
|
+
const payerAddress = settleResult.senderAddress || settleResult.sender_address || settleResult.sender || 'unknown';
|
|
464
|
+
|
|
465
|
+
// Store context for downstream handlers
|
|
466
|
+
c.set('x402', {
|
|
467
|
+
payerAddress,
|
|
468
|
+
settleResult,
|
|
469
|
+
signedTx,
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// Add response headers
|
|
473
|
+
c.header('X-PAYMENT-RESPONSE', JSON.stringify(settleResult));
|
|
474
|
+
c.header('X-PAYER-ADDRESS', payerAddress);
|
|
475
|
+
|
|
476
|
+
await next();
|
|
338
477
|
};
|
|
339
478
|
}
|
|
340
479
|
`;
|
|
341
480
|
}
|
|
342
481
|
function getWranglerTemplate(projectName, network, facilitatorUrl) {
|
|
482
|
+
const mainnetFacilitator = "https://facilitator.x402stacks.xyz";
|
|
343
483
|
return `{
|
|
484
|
+
"$schema": "node_modules/wrangler/config-schema.json",
|
|
344
485
|
"name": "${projectName}",
|
|
345
486
|
"main": "src/index.ts",
|
|
346
487
|
"compatibility_date": "2026-01-14",
|
|
347
|
-
"compatibility_flags": ["
|
|
488
|
+
"compatibility_flags": ["nodejs_compat_v2"],
|
|
489
|
+
"workers_dev": true,
|
|
348
490
|
"vars": {
|
|
349
491
|
"NETWORK": "${network}",
|
|
350
492
|
"FACILITATOR_URL": "${facilitatorUrl}"
|
|
351
493
|
},
|
|
352
494
|
"env": {
|
|
495
|
+
"staging": {
|
|
496
|
+
"name": "${projectName}-staging",
|
|
497
|
+
"workers_dev": true,
|
|
498
|
+
"vars": {
|
|
499
|
+
"NETWORK": "testnet",
|
|
500
|
+
"FACILITATOR_URL": "${facilitatorUrl}"
|
|
501
|
+
}
|
|
502
|
+
},
|
|
353
503
|
"production": {
|
|
504
|
+
"name": "${projectName}",
|
|
505
|
+
"workers_dev": false,
|
|
354
506
|
"vars": {
|
|
355
|
-
"NETWORK": "mainnet"
|
|
507
|
+
"NETWORK": "mainnet",
|
|
508
|
+
"FACILITATOR_URL": "${mainnetFacilitator}"
|
|
356
509
|
}
|
|
357
510
|
}
|
|
358
511
|
}
|
|
@@ -366,20 +519,21 @@ function getPackageJsonTemplate(projectName) {
|
|
|
366
519
|
"private": true,
|
|
367
520
|
"type": "module",
|
|
368
521
|
"scripts": {
|
|
369
|
-
"
|
|
370
|
-
"
|
|
371
|
-
"deploy": "
|
|
372
|
-
"deploy:
|
|
373
|
-
"
|
|
374
|
-
"
|
|
522
|
+
"dev": "wrangler dev",
|
|
523
|
+
"deploy": "wrangler deploy",
|
|
524
|
+
"deploy:staging": "wrangler deploy --env staging",
|
|
525
|
+
"deploy:production": "wrangler deploy --env production",
|
|
526
|
+
"tail": "wrangler tail",
|
|
527
|
+
"cf-typegen": "wrangler types"
|
|
375
528
|
},
|
|
376
529
|
"dependencies": {
|
|
377
|
-
"hono": "^4.7.0"
|
|
530
|
+
"hono": "^4.7.0",
|
|
531
|
+
"x402-stacks": "^1.1.1"
|
|
378
532
|
},
|
|
379
533
|
"devDependencies": {
|
|
380
534
|
"@cloudflare/workers-types": "^4.20250109.0",
|
|
381
535
|
"typescript": "^5.7.0",
|
|
382
|
-
"wrangler": "^4.
|
|
536
|
+
"wrangler": "^4.56.0"
|
|
383
537
|
}
|
|
384
538
|
}
|
|
385
539
|
`;
|
|
@@ -405,13 +559,26 @@ function getTsconfigTemplate() {
|
|
|
405
559
|
`;
|
|
406
560
|
}
|
|
407
561
|
function getEnvExampleTemplate(recipientAddress) {
|
|
408
|
-
|
|
562
|
+
const addressNote = recipientAddress
|
|
563
|
+
? `# Value: ${recipientAddress}`
|
|
564
|
+
: "# Value: Your Stacks address (SP... for mainnet, ST... for testnet)";
|
|
565
|
+
return `# Cloudflare credentials (only needed for CI/CD deployment)
|
|
566
|
+
# For local dev, wrangler uses browser-based auth
|
|
409
567
|
CLOUDFLARE_API_TOKEN=your-api-token-here
|
|
410
568
|
CLOUDFLARE_ACCOUNT_ID=your-account-id-here
|
|
411
569
|
|
|
412
570
|
# x402 recipient address (set via wrangler secret)
|
|
413
571
|
# wrangler secret put RECIPIENT_ADDRESS
|
|
414
|
-
|
|
572
|
+
${addressNote}
|
|
573
|
+
`;
|
|
574
|
+
}
|
|
575
|
+
function getDevVarsTemplate(recipientAddress) {
|
|
576
|
+
const address = recipientAddress || "YOUR_STACKS_ADDRESS_HERE";
|
|
577
|
+
return `# Local development variables
|
|
578
|
+
# These are used by wrangler dev and are NOT deployed to production
|
|
579
|
+
# For production secrets, use: wrangler secret put RECIPIENT_ADDRESS
|
|
580
|
+
|
|
581
|
+
RECIPIENT_ADDRESS=${address}
|
|
415
582
|
`;
|
|
416
583
|
}
|
|
417
584
|
function getGitignoreTemplate() {
|
|
@@ -425,66 +592,64 @@ dist/
|
|
|
425
592
|
function getReadmeTemplate(projectName, endpoints, recipientAddress) {
|
|
426
593
|
const tokenList = generateTokenList(endpoints);
|
|
427
594
|
const endpointDocs = generateEndpointDocs(endpoints);
|
|
595
|
+
const addressDisplay = recipientAddress || "YOUR_STACKS_ADDRESS";
|
|
428
596
|
return `# ${projectName}
|
|
429
597
|
|
|
430
598
|
x402-enabled API endpoints on Cloudflare Workers.
|
|
431
599
|
|
|
600
|
+
Built using patterns from:
|
|
601
|
+
- [x402-api](https://github.com/aibtcdev/x402-api)
|
|
602
|
+
- [stx402](https://github.com/whoabuddy/stx402)
|
|
603
|
+
|
|
604
|
+
## Quick Start
|
|
605
|
+
|
|
606
|
+
\`\`\`bash
|
|
607
|
+
# Install dependencies
|
|
608
|
+
npm install
|
|
609
|
+
|
|
610
|
+
# Set your recipient address for local dev
|
|
611
|
+
# Edit .dev.vars and replace YOUR_STACKS_ADDRESS_HERE with your address
|
|
612
|
+
|
|
613
|
+
# Start local dev server
|
|
614
|
+
npm run dev
|
|
615
|
+
\`\`\`
|
|
616
|
+
|
|
617
|
+
The server will start at http://localhost:8787
|
|
618
|
+
|
|
432
619
|
## Payment Tokens
|
|
433
620
|
|
|
434
621
|
This API accepts payments in:
|
|
435
622
|
${tokenList}
|
|
436
623
|
|
|
437
|
-
## Recipient Address
|
|
438
|
-
|
|
439
|
-
Payments are sent to: \`${recipientAddress}\`
|
|
440
|
-
|
|
441
624
|
## Endpoints
|
|
442
625
|
|
|
626
|
+
### GET /
|
|
627
|
+
- **Description:** Service info
|
|
628
|
+
- **Cost:** Free
|
|
629
|
+
|
|
443
630
|
### GET /health
|
|
444
631
|
- **Description:** Health check endpoint
|
|
445
632
|
- **Cost:** Free
|
|
446
|
-
- **Payment Required:** No
|
|
447
633
|
|
|
448
634
|
${endpointDocs}
|
|
449
635
|
|
|
450
|
-
##
|
|
451
|
-
|
|
452
|
-
1. Install dependencies:
|
|
453
|
-
\`\`\`bash
|
|
454
|
-
npm install
|
|
455
|
-
\`\`\`
|
|
456
|
-
|
|
457
|
-
2. Create \`.env\` file from \`.env.example\`:
|
|
458
|
-
\`\`\`bash
|
|
459
|
-
cp .env.example .env
|
|
460
|
-
\`\`\`
|
|
636
|
+
## Deployment
|
|
461
637
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
4. Set the recipient address as a secret:
|
|
465
|
-
\`\`\`bash
|
|
466
|
-
npm run wrangler -- secret put RECIPIENT_ADDRESS
|
|
467
|
-
# Enter: ${recipientAddress}
|
|
468
|
-
\`\`\`
|
|
469
|
-
|
|
470
|
-
## Local Development
|
|
638
|
+
### Set Production Secrets
|
|
471
639
|
|
|
472
640
|
\`\`\`bash
|
|
473
|
-
|
|
641
|
+
# Set your recipient address (where payments will be sent)
|
|
642
|
+
wrangler secret put RECIPIENT_ADDRESS
|
|
643
|
+
# Enter: ${addressDisplay}
|
|
474
644
|
\`\`\`
|
|
475
645
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
## Deploy
|
|
646
|
+
### Deploy
|
|
479
647
|
|
|
480
648
|
\`\`\`bash
|
|
481
|
-
#
|
|
482
|
-
npm run deploy:
|
|
483
|
-
|
|
484
|
-
# Deploy to staging
|
|
485
|
-
npm run deploy
|
|
649
|
+
# Deploy to staging (testnet)
|
|
650
|
+
npm run deploy:staging
|
|
486
651
|
|
|
487
|
-
# Deploy to production
|
|
652
|
+
# Deploy to production (mainnet)
|
|
488
653
|
npm run deploy:production
|
|
489
654
|
\`\`\`
|
|
490
655
|
|
|
@@ -496,9 +661,11 @@ npm run deploy:production
|
|
|
496
661
|
{
|
|
497
662
|
"maxAmountRequired": "1000",
|
|
498
663
|
"resource": "/api/endpoint",
|
|
499
|
-
"payTo": "${
|
|
664
|
+
"payTo": "${addressDisplay}",
|
|
500
665
|
"network": "testnet",
|
|
501
|
-
"tokenType": "STX"
|
|
666
|
+
"tokenType": "STX",
|
|
667
|
+
"nonce": "uuid",
|
|
668
|
+
"expiresAt": "2024-01-01T00:05:00Z"
|
|
502
669
|
}
|
|
503
670
|
\`\`\`
|
|
504
671
|
3. Client signs payment transaction (does NOT broadcast)
|
|
@@ -509,6 +676,9 @@ npm run deploy:production
|
|
|
509
676
|
## Testing with curl
|
|
510
677
|
|
|
511
678
|
\`\`\`bash
|
|
679
|
+
# Service info (free)
|
|
680
|
+
curl http://localhost:8787/
|
|
681
|
+
|
|
512
682
|
# Health check (free)
|
|
513
683
|
curl http://localhost:8787/health
|
|
514
684
|
|
|
@@ -516,9 +686,33 @@ curl http://localhost:8787/health
|
|
|
516
686
|
curl http://localhost:8787${endpoints[0]?.path || "/api/endpoint"}
|
|
517
687
|
\`\`\`
|
|
518
688
|
|
|
689
|
+
## Token Type Selection
|
|
690
|
+
|
|
691
|
+
Clients can specify which token to pay with using the \`X-PAYMENT-TOKEN-TYPE\` header:
|
|
692
|
+
|
|
693
|
+
\`\`\`bash
|
|
694
|
+
# Pay with sBTC instead of STX
|
|
695
|
+
curl -H "X-PAYMENT-TOKEN-TYPE: sBTC" http://localhost:8787${endpoints[0]?.path || "/api/endpoint"}
|
|
696
|
+
\`\`\`
|
|
697
|
+
|
|
698
|
+
Supported values: \`STX\`, \`sBTC\`, \`USDCx\`
|
|
699
|
+
|
|
700
|
+
## Error Codes
|
|
701
|
+
|
|
702
|
+
The API returns structured error responses for payment failures:
|
|
703
|
+
|
|
704
|
+
| Code | Description | HTTP Status |
|
|
705
|
+
|------|-------------|-------------|
|
|
706
|
+
| \`INSUFFICIENT_FUNDS\` | Wallet needs funding | 402 |
|
|
707
|
+
| \`PAYMENT_EXPIRED\` | Sign a new payment | 402 |
|
|
708
|
+
| \`AMOUNT_TOO_LOW\` | Payment below minimum | 402 |
|
|
709
|
+
| \`PAYMENT_INVALID\` | Bad signature/params | 400 |
|
|
710
|
+
| \`NETWORK_ERROR\` | Transient error | 502 |
|
|
711
|
+
| \`FACILITATOR_UNAVAILABLE\` | Try again later | 503 |
|
|
712
|
+
|
|
519
713
|
---
|
|
520
714
|
|
|
521
|
-
Generated with @aibtc/mcp-server scaffold tool.
|
|
715
|
+
Generated with [@aibtc/mcp-server](https://www.npmjs.com/package/@aibtc/mcp-server) scaffold tool.
|
|
522
716
|
`;
|
|
523
717
|
}
|
|
524
718
|
// =============================================================================
|
|
@@ -553,6 +747,7 @@ export async function scaffoldProject(config) {
|
|
|
553
747
|
{ name: "package.json", content: getPackageJsonTemplate(projectName) },
|
|
554
748
|
{ name: "tsconfig.json", content: getTsconfigTemplate() },
|
|
555
749
|
{ name: ".env.example", content: getEnvExampleTemplate(recipientAddress) },
|
|
750
|
+
{ name: ".dev.vars", content: getDevVarsTemplate(recipientAddress) },
|
|
556
751
|
{ name: ".gitignore", content: getGitignoreTemplate() },
|
|
557
752
|
{ name: "README.md", content: getReadmeTemplate(projectName, endpoints, recipientAddress) },
|
|
558
753
|
];
|
|
@@ -561,16 +756,22 @@ export async function scaffoldProject(config) {
|
|
|
561
756
|
await fs.writeFile(filePath, file.content, "utf-8");
|
|
562
757
|
filesCreated.push(file.name);
|
|
563
758
|
}
|
|
759
|
+
const addressInstruction = recipientAddress
|
|
760
|
+
? `wrangler secret put RECIPIENT_ADDRESS (enter: ${recipientAddress})`
|
|
761
|
+
: "wrangler secret put RECIPIENT_ADDRESS (enter your Stacks address)";
|
|
564
762
|
return {
|
|
565
763
|
projectPath,
|
|
566
764
|
filesCreated,
|
|
567
765
|
nextSteps: [
|
|
568
766
|
`cd ${projectPath}`,
|
|
569
767
|
"npm install",
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
768
|
+
recipientAddress
|
|
769
|
+
? "# .dev.vars is pre-configured with your address"
|
|
770
|
+
: "# Edit .dev.vars and set your RECIPIENT_ADDRESS",
|
|
573
771
|
"npm run dev",
|
|
772
|
+
"# For production deployment:",
|
|
773
|
+
addressInstruction,
|
|
774
|
+
"npm run deploy:production",
|
|
574
775
|
],
|
|
575
776
|
};
|
|
576
777
|
}
|
|
@@ -611,13 +812,10 @@ function generateAIEndpointCode(endpoints, defaultModel) {
|
|
|
611
812
|
app.post('${ep.path}',
|
|
612
813
|
x402Middleware({
|
|
613
814
|
amount: '${amountSmallest}',
|
|
614
|
-
address: env.RECIPIENT_ADDRESS,
|
|
615
|
-
network: env.NETWORK as 'mainnet' | 'testnet',
|
|
616
815
|
tokenType: '${ep.tokenType}',
|
|
617
|
-
facilitatorUrl: env.FACILITATOR_URL,
|
|
618
816
|
}),
|
|
619
817
|
async (c) => {
|
|
620
|
-
const payment = c.get('
|
|
818
|
+
const payment = c.get('x402');
|
|
621
819
|
const body = await c.req.json<{ prompt?: string; message?: string; text?: string; targetLanguage?: string }>();
|
|
622
820
|
const userInput = body.prompt || body.message || body.text || '';
|
|
623
821
|
|
|
@@ -626,7 +824,7 @@ app.post('${ep.path}',
|
|
|
626
824
|
}
|
|
627
825
|
|
|
628
826
|
const result = await callOpenRouter({
|
|
629
|
-
apiKey: env.OPENROUTER_API_KEY,
|
|
827
|
+
apiKey: c.env.OPENROUTER_API_KEY,
|
|
630
828
|
model: '${model}',
|
|
631
829
|
systemPrompt: \`${systemPrompt.replace(/`/g, "\\`")}\`,
|
|
632
830
|
userMessage: ${ep.aiType === "translate" ? "`Translate to ${body.targetLanguage || 'English'}: ${userInput}`" : "userInput"},
|
|
@@ -636,7 +834,10 @@ app.post('${ep.path}',
|
|
|
636
834
|
result: result.content,
|
|
637
835
|
model: result.model,
|
|
638
836
|
usage: result.usage,
|
|
639
|
-
|
|
837
|
+
payment: {
|
|
838
|
+
txId: payment?.settleResult?.txId,
|
|
839
|
+
sender: payment?.payerAddress,
|
|
840
|
+
},
|
|
640
841
|
});
|
|
641
842
|
}
|
|
642
843
|
);`;
|
|
@@ -658,12 +859,18 @@ function generateAIEndpointDocs(endpoints) {
|
|
|
658
859
|
}
|
|
659
860
|
function getAIIndexTemplate(endpoints, defaultModel) {
|
|
660
861
|
const endpointCode = generateAIEndpointCode(endpoints, defaultModel);
|
|
661
|
-
return
|
|
862
|
+
return `// BigInt.toJSON polyfill for JSON.stringify compatibility
|
|
863
|
+
(BigInt.prototype as unknown as { toJSON: () => string }).toJSON = function () {
|
|
864
|
+
return this.toString();
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
import { Hono } from 'hono';
|
|
662
868
|
import { cors } from 'hono/cors';
|
|
663
869
|
import { x402Middleware } from './x402-middleware';
|
|
870
|
+
import type { X402Context } from './x402-middleware';
|
|
664
871
|
import { callOpenRouter } from './openrouter';
|
|
665
872
|
|
|
666
|
-
type
|
|
873
|
+
type Env = {
|
|
667
874
|
RECIPIENT_ADDRESS: string;
|
|
668
875
|
NETWORK: string;
|
|
669
876
|
FACILITATOR_URL: string;
|
|
@@ -671,29 +878,31 @@ type Bindings = {
|
|
|
671
878
|
};
|
|
672
879
|
|
|
673
880
|
type Variables = {
|
|
674
|
-
|
|
675
|
-
txId: string;
|
|
676
|
-
status: string;
|
|
677
|
-
sender: string;
|
|
678
|
-
recipient: string;
|
|
679
|
-
amount: bigint;
|
|
680
|
-
};
|
|
881
|
+
x402?: X402Context;
|
|
681
882
|
};
|
|
682
883
|
|
|
683
|
-
const app = new Hono<{ Bindings:
|
|
884
|
+
const app = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
684
885
|
|
|
685
|
-
|
|
886
|
+
// CORS middleware with x402 headers
|
|
887
|
+
app.use('*', cors({
|
|
888
|
+
origin: '*',
|
|
889
|
+
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
890
|
+
allowHeaders: ['X-PAYMENT', 'X-PAYMENT-TOKEN-TYPE', 'Authorization', 'Content-Type'],
|
|
891
|
+
exposeHeaders: ['X-PAYMENT-RESPONSE', 'X-PAYER-ADDRESS'],
|
|
892
|
+
}));
|
|
686
893
|
|
|
687
894
|
// Startup validation - fail fast if required secrets are missing
|
|
688
895
|
app.use('*', async (c, next) => {
|
|
896
|
+
// Skip validation for health check and root
|
|
897
|
+
if (c.req.path === '/health' || c.req.path === '/') {
|
|
898
|
+
return next();
|
|
899
|
+
}
|
|
900
|
+
|
|
689
901
|
const missingSecrets: string[] = [];
|
|
690
902
|
|
|
691
903
|
if (!c.env.RECIPIENT_ADDRESS) {
|
|
692
904
|
missingSecrets.push('RECIPIENT_ADDRESS');
|
|
693
905
|
}
|
|
694
|
-
if (!c.env.FACILITATOR_URL) {
|
|
695
|
-
missingSecrets.push('FACILITATOR_URL');
|
|
696
|
-
}
|
|
697
906
|
if (!c.env.OPENROUTER_API_KEY) {
|
|
698
907
|
missingSecrets.push('OPENROUTER_API_KEY');
|
|
699
908
|
}
|
|
@@ -702,43 +911,36 @@ app.use('*', async (c, next) => {
|
|
|
702
911
|
return c.json({
|
|
703
912
|
error: 'Server configuration error',
|
|
704
913
|
message: \`Missing required secrets: \${missingSecrets.join(', ')}\`,
|
|
705
|
-
hint: missingSecrets.map(s => \`Run:
|
|
914
|
+
hint: missingSecrets.map(s => \`Run: wrangler secret put \${s}\`).join(' && '),
|
|
706
915
|
}, 503);
|
|
707
916
|
}
|
|
708
917
|
|
|
709
918
|
await next();
|
|
710
919
|
});
|
|
711
920
|
|
|
921
|
+
// Service info at root (free)
|
|
922
|
+
app.get('/', (c) => {
|
|
923
|
+
return c.json({
|
|
924
|
+
service: 'x402-ai-api',
|
|
925
|
+
version: '1.0.0',
|
|
926
|
+
defaultModel: '${defaultModel}',
|
|
927
|
+
health: '/health',
|
|
928
|
+
payment: {
|
|
929
|
+
tokens: ['STX', 'sBTC', 'USDCx'],
|
|
930
|
+
header: 'X-PAYMENT',
|
|
931
|
+
tokenTypeHeader: 'X-PAYMENT-TOKEN-TYPE',
|
|
932
|
+
},
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
712
936
|
// Health check (free)
|
|
713
937
|
app.get('/health', (c) => {
|
|
714
938
|
return c.json({
|
|
715
939
|
status: 'ok',
|
|
716
940
|
timestamp: new Date().toISOString(),
|
|
941
|
+
network: c.env.NETWORK || 'testnet',
|
|
717
942
|
});
|
|
718
943
|
});
|
|
719
|
-
|
|
720
|
-
// x402-protected AI endpoints
|
|
721
|
-
app.use('*', async (c, next) => {
|
|
722
|
-
// Make env available to middleware
|
|
723
|
-
const env = c.env;
|
|
724
|
-
(globalThis as Record<string, unknown>).__env = env;
|
|
725
|
-
await next();
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
const env = {
|
|
729
|
-
get RECIPIENT_ADDRESS() {
|
|
730
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.RECIPIENT_ADDRESS || '';
|
|
731
|
-
},
|
|
732
|
-
get NETWORK() {
|
|
733
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.NETWORK || 'testnet';
|
|
734
|
-
},
|
|
735
|
-
get FACILITATOR_URL() {
|
|
736
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.FACILITATOR_URL || '';
|
|
737
|
-
},
|
|
738
|
-
get OPENROUTER_API_KEY() {
|
|
739
|
-
return ((globalThis as Record<string, unknown>).__env as Bindings)?.OPENROUTER_API_KEY || '';
|
|
740
|
-
},
|
|
741
|
-
};
|
|
742
944
|
${endpointCode}
|
|
743
945
|
|
|
744
946
|
export default app;
|
|
@@ -841,123 +1043,134 @@ function getAIPackageJsonTemplate(projectName) {
|
|
|
841
1043
|
"private": true,
|
|
842
1044
|
"type": "module",
|
|
843
1045
|
"scripts": {
|
|
844
|
-
"
|
|
845
|
-
"
|
|
846
|
-
"deploy": "
|
|
847
|
-
"deploy:
|
|
848
|
-
"
|
|
849
|
-
"
|
|
1046
|
+
"dev": "wrangler dev",
|
|
1047
|
+
"deploy": "wrangler deploy",
|
|
1048
|
+
"deploy:staging": "wrangler deploy --env staging",
|
|
1049
|
+
"deploy:production": "wrangler deploy --env production",
|
|
1050
|
+
"tail": "wrangler tail",
|
|
1051
|
+
"cf-typegen": "wrangler types"
|
|
850
1052
|
},
|
|
851
1053
|
"dependencies": {
|
|
852
|
-
"hono": "^4.7.0"
|
|
1054
|
+
"hono": "^4.7.0",
|
|
1055
|
+
"x402-stacks": "^1.1.1"
|
|
853
1056
|
},
|
|
854
1057
|
"devDependencies": {
|
|
855
1058
|
"@cloudflare/workers-types": "^4.20250109.0",
|
|
856
1059
|
"typescript": "^5.7.0",
|
|
857
|
-
"wrangler": "^4.
|
|
1060
|
+
"wrangler": "^4.56.0"
|
|
858
1061
|
}
|
|
859
1062
|
}
|
|
860
1063
|
`;
|
|
861
1064
|
}
|
|
862
1065
|
function getAIEnvExampleTemplate(recipientAddress) {
|
|
863
|
-
|
|
1066
|
+
const addressNote = recipientAddress
|
|
1067
|
+
? `# Value: ${recipientAddress}`
|
|
1068
|
+
: "# Value: Your Stacks address (SP... for mainnet, ST... for testnet)";
|
|
1069
|
+
return `# Cloudflare credentials (only needed for CI/CD deployment)
|
|
1070
|
+
# For local dev, wrangler uses browser-based auth
|
|
864
1071
|
CLOUDFLARE_API_TOKEN=your-api-token-here
|
|
865
1072
|
CLOUDFLARE_ACCOUNT_ID=your-account-id-here
|
|
866
1073
|
|
|
867
1074
|
# x402 recipient address (set via wrangler secret)
|
|
868
1075
|
# wrangler secret put RECIPIENT_ADDRESS
|
|
869
|
-
|
|
1076
|
+
${addressNote}
|
|
870
1077
|
|
|
871
1078
|
# OpenRouter API key (set via wrangler secret)
|
|
872
1079
|
# Get your key at https://openrouter.ai/keys
|
|
873
1080
|
# wrangler secret put OPENROUTER_API_KEY
|
|
874
1081
|
`;
|
|
875
1082
|
}
|
|
1083
|
+
function getAIDevVarsTemplate(recipientAddress) {
|
|
1084
|
+
const address = recipientAddress || "YOUR_STACKS_ADDRESS_HERE";
|
|
1085
|
+
return `# Local development variables
|
|
1086
|
+
# These are used by wrangler dev and are NOT deployed to production
|
|
1087
|
+
# For production secrets, use: wrangler secret put <SECRET_NAME>
|
|
1088
|
+
|
|
1089
|
+
RECIPIENT_ADDRESS=${address}
|
|
1090
|
+
OPENROUTER_API_KEY=your-openrouter-key-here
|
|
1091
|
+
`;
|
|
1092
|
+
}
|
|
876
1093
|
function getAIReadmeTemplate(projectName, endpoints, recipientAddress, defaultModel) {
|
|
877
1094
|
const tokenList = [...new Set(endpoints.map((ep) => `- ${ep.tokenType}`))].join("\n");
|
|
878
1095
|
const endpointDocs = generateAIEndpointDocs(endpoints);
|
|
1096
|
+
const addressDisplay = recipientAddress || "YOUR_STACKS_ADDRESS";
|
|
1097
|
+
const modelDisplay = defaultModel || "anthropic/claude-3-haiku";
|
|
879
1098
|
return `# ${projectName}
|
|
880
1099
|
|
|
881
1100
|
x402-enabled AI API endpoints on Cloudflare Workers, powered by OpenRouter.
|
|
882
1101
|
|
|
1102
|
+
Built using patterns from:
|
|
1103
|
+
- [x402-api](https://github.com/aibtcdev/x402-api)
|
|
1104
|
+
- [stx402](https://github.com/whoabuddy/stx402)
|
|
1105
|
+
|
|
1106
|
+
## Quick Start
|
|
1107
|
+
|
|
1108
|
+
\`\`\`bash
|
|
1109
|
+
# Install dependencies
|
|
1110
|
+
npm install
|
|
1111
|
+
|
|
1112
|
+
# Edit .dev.vars with your settings:
|
|
1113
|
+
# - RECIPIENT_ADDRESS: Your Stacks address
|
|
1114
|
+
# - OPENROUTER_API_KEY: Get from https://openrouter.ai/keys
|
|
1115
|
+
|
|
1116
|
+
# Start local dev server
|
|
1117
|
+
npm run dev
|
|
1118
|
+
\`\`\`
|
|
1119
|
+
|
|
1120
|
+
The server will start at http://localhost:8787
|
|
1121
|
+
|
|
883
1122
|
## AI Provider
|
|
884
1123
|
|
|
885
1124
|
This API uses [OpenRouter](https://openrouter.ai) to access AI models.
|
|
886
|
-
Default model: \`${
|
|
1125
|
+
Default model: \`${modelDisplay}\`
|
|
887
1126
|
|
|
888
1127
|
## Payment Tokens
|
|
889
1128
|
|
|
890
1129
|
This API accepts payments in:
|
|
891
1130
|
${tokenList}
|
|
892
1131
|
|
|
893
|
-
## Recipient Address
|
|
894
|
-
|
|
895
|
-
Payments are sent to: \`${recipientAddress}\`
|
|
896
|
-
|
|
897
1132
|
## Endpoints
|
|
898
1133
|
|
|
1134
|
+
### GET /
|
|
1135
|
+
- **Description:** Service info
|
|
1136
|
+
- **Cost:** Free
|
|
1137
|
+
|
|
899
1138
|
### GET /health
|
|
900
1139
|
- **Description:** Health check endpoint
|
|
901
1140
|
- **Cost:** Free
|
|
902
|
-
- **Payment Required:** No
|
|
903
1141
|
|
|
904
1142
|
${endpointDocs}
|
|
905
1143
|
|
|
906
|
-
##
|
|
907
|
-
|
|
908
|
-
1. Install dependencies:
|
|
909
|
-
\`\`\`bash
|
|
910
|
-
npm install
|
|
911
|
-
\`\`\`
|
|
912
|
-
|
|
913
|
-
2. Create \`.env\` file from \`.env.example\`:
|
|
914
|
-
\`\`\`bash
|
|
915
|
-
cp .env.example .env
|
|
916
|
-
\`\`\`
|
|
917
|
-
|
|
918
|
-
3. Add your Cloudflare credentials to \`.env\`
|
|
919
|
-
|
|
920
|
-
4. Set secrets:
|
|
921
|
-
\`\`\`bash
|
|
922
|
-
# Recipient address for payments
|
|
923
|
-
npm run wrangler -- secret put RECIPIENT_ADDRESS
|
|
924
|
-
# Enter: ${recipientAddress}
|
|
1144
|
+
## Deployment
|
|
925
1145
|
|
|
926
|
-
|
|
927
|
-
npm run wrangler -- secret put OPENROUTER_API_KEY
|
|
928
|
-
\`\`\`
|
|
929
|
-
|
|
930
|
-
## Local Development
|
|
1146
|
+
### Set Production Secrets
|
|
931
1147
|
|
|
932
|
-
For local development, create a \`.dev.vars\` file:
|
|
933
|
-
\`\`\`
|
|
934
|
-
RECIPIENT_ADDRESS=${recipientAddress}
|
|
935
|
-
OPENROUTER_API_KEY=your-openrouter-key
|
|
936
|
-
\`\`\`
|
|
937
|
-
|
|
938
|
-
Then run:
|
|
939
1148
|
\`\`\`bash
|
|
940
|
-
|
|
941
|
-
|
|
1149
|
+
# Set your recipient address (where payments will be sent)
|
|
1150
|
+
wrangler secret put RECIPIENT_ADDRESS
|
|
1151
|
+
# Enter: ${addressDisplay}
|
|
942
1152
|
|
|
943
|
-
|
|
1153
|
+
# Set your OpenRouter API key
|
|
1154
|
+
wrangler secret put OPENROUTER_API_KEY
|
|
1155
|
+
# Enter: your-api-key-from-openrouter.ai/keys
|
|
1156
|
+
\`\`\`
|
|
944
1157
|
|
|
945
|
-
|
|
1158
|
+
### Deploy
|
|
946
1159
|
|
|
947
1160
|
\`\`\`bash
|
|
948
|
-
#
|
|
949
|
-
npm run deploy:
|
|
1161
|
+
# Deploy to staging (testnet)
|
|
1162
|
+
npm run deploy:staging
|
|
950
1163
|
|
|
951
|
-
# Deploy to
|
|
952
|
-
npm run deploy
|
|
953
|
-
|
|
954
|
-
# Deploy to production
|
|
1164
|
+
# Deploy to production (mainnet)
|
|
955
1165
|
npm run deploy:production
|
|
956
1166
|
\`\`\`
|
|
957
1167
|
|
|
958
1168
|
## Example Usage
|
|
959
1169
|
|
|
960
1170
|
\`\`\`bash
|
|
1171
|
+
# Service info (free)
|
|
1172
|
+
curl http://localhost:8787/
|
|
1173
|
+
|
|
961
1174
|
# Health check (free)
|
|
962
1175
|
curl http://localhost:8787/health
|
|
963
1176
|
|
|
@@ -976,21 +1189,46 @@ curl -X POST http://localhost:8787${endpoints[0]?.path || "/api/chat"} \\
|
|
|
976
1189
|
5. Server verifies and settles payment via facilitator
|
|
977
1190
|
6. Server calls OpenRouter API and returns AI response
|
|
978
1191
|
|
|
1192
|
+
## Token Type Selection
|
|
1193
|
+
|
|
1194
|
+
Clients can specify which token to pay with using the \`X-PAYMENT-TOKEN-TYPE\` header:
|
|
1195
|
+
|
|
1196
|
+
\`\`\`bash
|
|
1197
|
+
# Pay with sBTC instead of STX
|
|
1198
|
+
curl -H "X-PAYMENT-TOKEN-TYPE: sBTC" -X POST http://localhost:8787${endpoints[0]?.path || "/api/chat"} \\
|
|
1199
|
+
-H "Content-Type: application/json" \\
|
|
1200
|
+
-d '{"prompt": "Hello"}'
|
|
1201
|
+
\`\`\`
|
|
1202
|
+
|
|
1203
|
+
Supported values: \`STX\`, \`sBTC\`, \`USDCx\`
|
|
1204
|
+
|
|
979
1205
|
## OpenRouter Models
|
|
980
1206
|
|
|
981
|
-
|
|
982
|
-
- \`anthropic/claude-
|
|
983
|
-
- \`anthropic/claude-3-haiku\` - Fast and affordable
|
|
1207
|
+
Popular options:
|
|
1208
|
+
- \`anthropic/claude-sonnet-4.5\` - Best overall, 1M context
|
|
1209
|
+
- \`anthropic/claude-3.5-haiku\` - Fast and affordable
|
|
984
1210
|
- \`openai/gpt-4o\` - OpenAI's latest
|
|
985
1211
|
- \`openai/gpt-4o-mini\` - Fast and cheap
|
|
986
|
-
- \`
|
|
987
|
-
- \`
|
|
1212
|
+
- \`google/gemini-2.5-flash\` - 1M context, fast
|
|
1213
|
+
- \`deepseek/deepseek-r1\` - Excellent reasoning
|
|
1214
|
+
- \`meta-llama/llama-3.3-70b-instruct\` - Best open source
|
|
988
1215
|
|
|
989
1216
|
See all models: https://openrouter.ai/models
|
|
990
1217
|
|
|
1218
|
+
## Error Codes
|
|
1219
|
+
|
|
1220
|
+
| Code | Description | HTTP Status |
|
|
1221
|
+
|------|-------------|-------------|
|
|
1222
|
+
| \`INSUFFICIENT_FUNDS\` | Wallet needs funding | 402 |
|
|
1223
|
+
| \`PAYMENT_EXPIRED\` | Sign a new payment | 402 |
|
|
1224
|
+
| \`AMOUNT_TOO_LOW\` | Payment below minimum | 402 |
|
|
1225
|
+
| \`PAYMENT_INVALID\` | Bad signature/params | 400 |
|
|
1226
|
+
| \`NETWORK_ERROR\` | Transient error | 502 |
|
|
1227
|
+
| \`FACILITATOR_UNAVAILABLE\` | Try again later | 503 |
|
|
1228
|
+
|
|
991
1229
|
---
|
|
992
1230
|
|
|
993
|
-
Generated with @aibtc/mcp-server scaffold tool.
|
|
1231
|
+
Generated with [@aibtc/mcp-server](https://www.npmjs.com/package/@aibtc/mcp-server) scaffold tool.
|
|
994
1232
|
`;
|
|
995
1233
|
}
|
|
996
1234
|
// =============================================================================
|
|
@@ -1026,6 +1264,7 @@ export async function scaffoldAIProject(config) {
|
|
|
1026
1264
|
{ name: "package.json", content: getAIPackageJsonTemplate(projectName) },
|
|
1027
1265
|
{ name: "tsconfig.json", content: getTsconfigTemplate() },
|
|
1028
1266
|
{ name: ".env.example", content: getAIEnvExampleTemplate(recipientAddress) },
|
|
1267
|
+
{ name: ".dev.vars", content: getAIDevVarsTemplate(recipientAddress) },
|
|
1029
1268
|
{ name: ".gitignore", content: getGitignoreTemplate() },
|
|
1030
1269
|
{
|
|
1031
1270
|
name: "README.md",
|
|
@@ -1037,18 +1276,21 @@ export async function scaffoldAIProject(config) {
|
|
|
1037
1276
|
await fs.writeFile(filePath, file.content, "utf-8");
|
|
1038
1277
|
filesCreated.push(file.name);
|
|
1039
1278
|
}
|
|
1279
|
+
const addressInstruction = recipientAddress
|
|
1280
|
+
? `wrangler secret put RECIPIENT_ADDRESS (enter: ${recipientAddress})`
|
|
1281
|
+
: "wrangler secret put RECIPIENT_ADDRESS (enter your Stacks address)";
|
|
1040
1282
|
return {
|
|
1041
1283
|
projectPath,
|
|
1042
1284
|
filesCreated,
|
|
1043
1285
|
nextSteps: [
|
|
1044
1286
|
`cd ${projectPath}`,
|
|
1045
1287
|
"npm install",
|
|
1046
|
-
"
|
|
1047
|
-
"# Add your Cloudflare credentials to .env",
|
|
1048
|
-
`npm run wrangler -- secret put RECIPIENT_ADDRESS (enter: ${recipientAddress})`,
|
|
1049
|
-
"npm run wrangler -- secret put OPENROUTER_API_KEY (get from https://openrouter.ai/keys)",
|
|
1050
|
-
"# For local dev, create .dev.vars with RECIPIENT_ADDRESS and OPENROUTER_API_KEY",
|
|
1288
|
+
"# Edit .dev.vars with your RECIPIENT_ADDRESS and OPENROUTER_API_KEY",
|
|
1051
1289
|
"npm run dev",
|
|
1290
|
+
"# For production deployment:",
|
|
1291
|
+
addressInstruction,
|
|
1292
|
+
"wrangler secret put OPENROUTER_API_KEY (get from https://openrouter.ai/keys)",
|
|
1293
|
+
"npm run deploy:production",
|
|
1052
1294
|
],
|
|
1053
1295
|
};
|
|
1054
1296
|
}
|