@relaycore/sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISHING.md +37 -0
- package/README.md +434 -0
- package/agent-sdk.ts +392 -0
- package/consumer-sdk.ts +434 -0
- package/dist/index.js +14116 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +14057 -0
- package/dist/index.mjs.map +1 -0
- package/hooks.ts +250 -0
- package/index.ts +153 -0
- package/lib/erc8004.ts +51 -0
- package/lib/facilitator.ts +65 -0
- package/lib/ipfs.ts +71 -0
- package/lib/supabase.ts +5 -0
- package/lib/x402.ts +220 -0
- package/package.json +38 -0
- package/provider-sdk.ts +311 -0
- package/relay-agent.ts +1414 -0
- package/relay-rwa.ts +128 -0
- package/relay-service.ts +886 -0
- package/tsconfig.json +23 -0
- package/tsup.config.ts +19 -0
- package/types/chat.types.ts +146 -0
- package/types/x402.types.ts +114 -0
package/relay-service.ts
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay Core - Service SDK
|
|
3
|
+
*
|
|
4
|
+
* For service providers to expose services, handle payments, prove delivery,
|
|
5
|
+
* and track reputation on the Relay Core platform.
|
|
6
|
+
*
|
|
7
|
+
* Design Principles:
|
|
8
|
+
* - Explicit service definition (first-class metadata)
|
|
9
|
+
* - Payment-first thinking (not hidden magic)
|
|
10
|
+
* - Delivery proof is sacred
|
|
11
|
+
* - Built-in observability
|
|
12
|
+
* - Runtime-agnostic
|
|
13
|
+
*
|
|
14
|
+
* @example Quickstart (10 minutes)
|
|
15
|
+
* ```ts
|
|
16
|
+
* const service = defineService({
|
|
17
|
+
* name: "price-feed",
|
|
18
|
+
* category: "data.prices",
|
|
19
|
+
* price: "0.01",
|
|
20
|
+
* inputSchema: { type: "object", properties: { pair: { type: "string" } } },
|
|
21
|
+
* outputSchema: { type: "object", properties: { price: { type: "number" } } },
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* const provider = new RelayService({ wallet, network: "cronos-testnet" });
|
|
25
|
+
* await provider.register(service);
|
|
26
|
+
*
|
|
27
|
+
* // In your request handler:
|
|
28
|
+
* provider.onPaymentReceived(async (ctx) => {
|
|
29
|
+
* const result = await getPrice(ctx.input);
|
|
30
|
+
* ctx.deliver({ result, proof: hash(result) });
|
|
31
|
+
* });
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ethers } from 'ethers';
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// TYPES - Clear, descriptive names
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
/** Network configuration */
|
|
42
|
+
export type Network = 'cronos-mainnet' | 'cronos-testnet' | 'cronos-zkevm';
|
|
43
|
+
|
|
44
|
+
/** Service provider configuration */
|
|
45
|
+
export interface ServiceConfig {
|
|
46
|
+
/** Connected wallet for signing */
|
|
47
|
+
wallet: ethers.Signer;
|
|
48
|
+
/** Target network */
|
|
49
|
+
network?: Network;
|
|
50
|
+
/** API endpoint (defaults to production) */
|
|
51
|
+
apiUrl?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Service definition - first-class metadata */
|
|
55
|
+
export interface ServiceDefinition {
|
|
56
|
+
/** Unique service name */
|
|
57
|
+
name: string;
|
|
58
|
+
/** Human-readable description */
|
|
59
|
+
description?: string;
|
|
60
|
+
/** Service category (e.g., "data.prices", "trading.execution", "ai.inference") */
|
|
61
|
+
category: string;
|
|
62
|
+
/** Price per call in USDC (e.g., "0.01") */
|
|
63
|
+
price: string;
|
|
64
|
+
/** Service endpoint URL */
|
|
65
|
+
endpoint?: string;
|
|
66
|
+
/** Input JSON schema */
|
|
67
|
+
inputSchema?: JsonSchema;
|
|
68
|
+
/** Output JSON schema */
|
|
69
|
+
outputSchema?: JsonSchema;
|
|
70
|
+
/** Input type name for discovery (e.g., "PriceQuery") */
|
|
71
|
+
inputType?: string;
|
|
72
|
+
/** Output type name for discovery (e.g., "PriceData") */
|
|
73
|
+
outputType?: string;
|
|
74
|
+
/** Searchable tags */
|
|
75
|
+
tags?: string[];
|
|
76
|
+
/** Declared capabilities */
|
|
77
|
+
capabilities?: string[];
|
|
78
|
+
/** Version string */
|
|
79
|
+
version?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** JSON Schema type */
|
|
83
|
+
export interface JsonSchema {
|
|
84
|
+
type: string;
|
|
85
|
+
properties?: Record<string, JsonSchema>;
|
|
86
|
+
required?: string[];
|
|
87
|
+
items?: JsonSchema;
|
|
88
|
+
description?: string;
|
|
89
|
+
[key: string]: unknown;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Registered service (after registration) */
|
|
93
|
+
export interface RegisteredService extends ServiceDefinition {
|
|
94
|
+
id: string;
|
|
95
|
+
ownerAddress: string;
|
|
96
|
+
registeredAt: Date;
|
|
97
|
+
isActive: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Payment context passed to handlers */
|
|
101
|
+
export interface PaymentContext<TInput = unknown> {
|
|
102
|
+
/** Unique payment ID */
|
|
103
|
+
paymentId: string;
|
|
104
|
+
/** Transaction hash */
|
|
105
|
+
txHash: string;
|
|
106
|
+
/** Amount paid in USDC */
|
|
107
|
+
amount: string;
|
|
108
|
+
/** Payer's wallet address */
|
|
109
|
+
payerAddress: string;
|
|
110
|
+
/** Parsed input from request */
|
|
111
|
+
input: TInput;
|
|
112
|
+
/** Timestamp of payment */
|
|
113
|
+
timestamp: Date;
|
|
114
|
+
/** Deliver result with proof */
|
|
115
|
+
deliver: <TOutput>(output: DeliveryProof<TOutput>) => Promise<void>;
|
|
116
|
+
/** Report failure with reason */
|
|
117
|
+
fail: (reason: string, retryable?: boolean) => Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Delivery proof - the heart of the system */
|
|
121
|
+
export interface DeliveryProof<T = unknown> {
|
|
122
|
+
/** The actual result data */
|
|
123
|
+
result: T;
|
|
124
|
+
/** Hash of the result for verification */
|
|
125
|
+
proof?: string;
|
|
126
|
+
/** Additional evidence (receipts, signatures, etc.) */
|
|
127
|
+
evidence?: Record<string, unknown>;
|
|
128
|
+
/** Execution latency in ms */
|
|
129
|
+
latencyMs?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Payment status */
|
|
133
|
+
export type PaymentStatus = 'pending' | 'received' | 'settled' | 'failed' | 'timeout';
|
|
134
|
+
|
|
135
|
+
/** Payment event */
|
|
136
|
+
export interface PaymentEvent {
|
|
137
|
+
paymentId: string;
|
|
138
|
+
status: PaymentStatus;
|
|
139
|
+
txHash?: string;
|
|
140
|
+
amount?: string;
|
|
141
|
+
payerAddress?: string;
|
|
142
|
+
timestamp: Date;
|
|
143
|
+
error?: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Outcome types for reputation tracking */
|
|
147
|
+
export type OutcomeType = 'delivered' | 'failed' | 'partial' | 'timeout';
|
|
148
|
+
|
|
149
|
+
/** Outcome record */
|
|
150
|
+
export interface OutcomeRecord {
|
|
151
|
+
paymentId: string;
|
|
152
|
+
outcomeType: OutcomeType;
|
|
153
|
+
latencyMs: number;
|
|
154
|
+
proofHash?: string;
|
|
155
|
+
evidence?: Record<string, unknown>;
|
|
156
|
+
timestamp: Date;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Service metrics */
|
|
160
|
+
export interface ServiceMetrics {
|
|
161
|
+
timestamp: Date;
|
|
162
|
+
reputationScore: number;
|
|
163
|
+
successRate: number;
|
|
164
|
+
avgLatencyMs: number;
|
|
165
|
+
totalCalls: number;
|
|
166
|
+
totalPayments: number;
|
|
167
|
+
totalRevenue: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Provider reputation */
|
|
171
|
+
export interface ProviderReputation {
|
|
172
|
+
reputationScore: number;
|
|
173
|
+
successRate: number;
|
|
174
|
+
totalDeliveries: number;
|
|
175
|
+
avgLatencyMs: number;
|
|
176
|
+
trend: 'improving' | 'stable' | 'declining';
|
|
177
|
+
rank?: number;
|
|
178
|
+
percentile?: number;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** x402 payment requirements (for 402 responses) */
|
|
182
|
+
export interface PaymentRequirements {
|
|
183
|
+
x402Version: number;
|
|
184
|
+
paymentRequirements: {
|
|
185
|
+
scheme: 'exact';
|
|
186
|
+
network: string;
|
|
187
|
+
payTo: string;
|
|
188
|
+
asset: string;
|
|
189
|
+
maxAmountRequired: string;
|
|
190
|
+
maxTimeoutSeconds: number;
|
|
191
|
+
resource?: string;
|
|
192
|
+
description?: string;
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Observability/logging interface */
|
|
197
|
+
export interface ServiceLogger {
|
|
198
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
199
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
200
|
+
error(message: string, error?: Error, data?: Record<string, unknown>): void;
|
|
201
|
+
metric(name: string, value: number, tags?: Record<string, string>): void;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// HELPER FUNCTIONS
|
|
206
|
+
// ============================================================================
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Define a service with typed schema
|
|
210
|
+
*
|
|
211
|
+
* @example
|
|
212
|
+
* const myService = defineService({
|
|
213
|
+
* name: "price-feed",
|
|
214
|
+
* category: "data.prices",
|
|
215
|
+
* price: "0.01",
|
|
216
|
+
* inputSchema: { type: "object", properties: { pair: { type: "string" } } },
|
|
217
|
+
* outputSchema: { type: "object", properties: { price: { type: "number" } } },
|
|
218
|
+
* });
|
|
219
|
+
*/
|
|
220
|
+
export function defineService(definition: ServiceDefinition): ServiceDefinition {
|
|
221
|
+
// Validate required fields
|
|
222
|
+
if (!definition.name) throw new Error('Service name is required');
|
|
223
|
+
if (!definition.category) throw new Error('Service category is required');
|
|
224
|
+
if (!definition.price) throw new Error('Service price is required');
|
|
225
|
+
|
|
226
|
+
// Normalize price format
|
|
227
|
+
const normalizedPrice = definition.price.replace('$', '').replace(' USDC', '');
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
...definition,
|
|
231
|
+
price: normalizedPrice,
|
|
232
|
+
tags: definition.tags || [],
|
|
233
|
+
capabilities: definition.capabilities || [],
|
|
234
|
+
version: definition.version || '1.0.0',
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Create a hash of data for delivery proof
|
|
240
|
+
*
|
|
241
|
+
* @example
|
|
242
|
+
* const proof = hashProof(result);
|
|
243
|
+
* ctx.deliver({ result, proof });
|
|
244
|
+
*/
|
|
245
|
+
export function hashProof(data: unknown): string {
|
|
246
|
+
const json = JSON.stringify(data);
|
|
247
|
+
// Simple hash for demo - in production use crypto.subtle or ethers.keccak256
|
|
248
|
+
let hash = 0;
|
|
249
|
+
for (let i = 0; i < json.length; i++) {
|
|
250
|
+
const char = json.charCodeAt(i);
|
|
251
|
+
hash = ((hash << 5) - hash) + char;
|
|
252
|
+
hash = hash & hash;
|
|
253
|
+
}
|
|
254
|
+
return `0x${Math.abs(hash).toString(16).padStart(16, '0')}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ============================================================================
|
|
258
|
+
// IMPLEMENTATION
|
|
259
|
+
// ============================================================================
|
|
260
|
+
|
|
261
|
+
const NETWORK_CONFIG: Record<Network, { apiUrl: string; chainId: number; asset: string }> = {
|
|
262
|
+
'cronos-mainnet': {
|
|
263
|
+
apiUrl: 'https://api.relaycore.xyz',
|
|
264
|
+
chainId: 25,
|
|
265
|
+
asset: '0xf951eC28187D9E5Ca673Da8FE6757E6f0Be5F77C', // USDC.e
|
|
266
|
+
},
|
|
267
|
+
'cronos-testnet': {
|
|
268
|
+
apiUrl: 'https://testnet-api.relaycore.xyz',
|
|
269
|
+
chainId: 338,
|
|
270
|
+
asset: '0xf951eC28187D9E5Ca673Da8FE6757E6f0Be5F77C',
|
|
271
|
+
},
|
|
272
|
+
'cronos-zkevm': {
|
|
273
|
+
apiUrl: 'https://zkevm-api.relaycore.xyz',
|
|
274
|
+
chainId: 388,
|
|
275
|
+
asset: '0xf951eC28187D9E5Ca673Da8FE6757E6f0Be5F77C',
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Relay Service SDK
|
|
281
|
+
*
|
|
282
|
+
* The main entry point for service providers on Relay Core.
|
|
283
|
+
*/
|
|
284
|
+
export class RelayService {
|
|
285
|
+
private signer: ethers.Signer;
|
|
286
|
+
private address: string = '';
|
|
287
|
+
private network: Network;
|
|
288
|
+
private apiUrl: string;
|
|
289
|
+
private registeredServices: Map<string, RegisteredService> = new Map();
|
|
290
|
+
private outcomes: OutcomeRecord[] = [];
|
|
291
|
+
private logger: ServiceLogger;
|
|
292
|
+
|
|
293
|
+
// Event handlers
|
|
294
|
+
private paymentReceivedHandlers: Array<(ctx: PaymentContext) => Promise<void>> = [];
|
|
295
|
+
private paymentTimeoutHandlers: Array<(event: PaymentEvent) => Promise<void>> = [];
|
|
296
|
+
private paymentFailedHandlers: Array<(event: PaymentEvent) => Promise<void>> = [];
|
|
297
|
+
|
|
298
|
+
constructor(config: ServiceConfig) {
|
|
299
|
+
this.signer = config.wallet;
|
|
300
|
+
this.network = config.network || 'cronos-mainnet';
|
|
301
|
+
this.apiUrl = config.apiUrl || NETWORK_CONFIG[this.network].apiUrl;
|
|
302
|
+
|
|
303
|
+
// Get address
|
|
304
|
+
config.wallet.getAddress().then(addr => {
|
|
305
|
+
this.address = addr.toLowerCase();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Default console logger
|
|
309
|
+
this.logger = {
|
|
310
|
+
info: (msg, data) => console.log(`[RelayService] ${msg}`, data || ''),
|
|
311
|
+
warn: (msg, data) => console.warn(`[RelayService] ${msg}`, data || ''),
|
|
312
|
+
error: (msg, err, data) => console.error(`[RelayService] ${msg}`, err, data || ''),
|
|
313
|
+
metric: (name, value, tags) => console.log(`[Metric] ${name}=${value}`, tags || ''),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ==========================================================================
|
|
318
|
+
// CONFIGURATION
|
|
319
|
+
// ==========================================================================
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Set custom logger for observability
|
|
323
|
+
*/
|
|
324
|
+
setLogger(logger: ServiceLogger): void {
|
|
325
|
+
this.logger = logger;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get provider wallet address
|
|
330
|
+
*/
|
|
331
|
+
async getAddress(): Promise<string> {
|
|
332
|
+
if (!this.address) {
|
|
333
|
+
this.address = (await this.signer.getAddress()).toLowerCase();
|
|
334
|
+
}
|
|
335
|
+
return this.address;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ==========================================================================
|
|
339
|
+
// SERVICE REGISTRATION - Explicit, first-class
|
|
340
|
+
// ==========================================================================
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Register a service on Relay Core
|
|
344
|
+
*
|
|
345
|
+
* @example
|
|
346
|
+
* const registered = await provider.register(defineService({
|
|
347
|
+
* name: "price-feed",
|
|
348
|
+
* category: "data.prices",
|
|
349
|
+
* price: "0.01",
|
|
350
|
+
* }));
|
|
351
|
+
*
|
|
352
|
+
* console.log(`Service ID: ${registered.id}`);
|
|
353
|
+
*/
|
|
354
|
+
async register(service: ServiceDefinition): Promise<RegisteredService> {
|
|
355
|
+
const ownerAddress = await this.getAddress();
|
|
356
|
+
|
|
357
|
+
this.logger.info('Registering service', { name: service.name, category: service.category });
|
|
358
|
+
|
|
359
|
+
const response = await fetch(`${this.apiUrl}/api/services`, {
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: { 'Content-Type': 'application/json' },
|
|
362
|
+
body: JSON.stringify({
|
|
363
|
+
name: service.name,
|
|
364
|
+
description: service.description || `${service.name} service`,
|
|
365
|
+
category: service.category,
|
|
366
|
+
endpointUrl: service.endpoint,
|
|
367
|
+
pricePerCall: service.price,
|
|
368
|
+
ownerAddress,
|
|
369
|
+
inputSchema: service.inputSchema,
|
|
370
|
+
outputSchema: service.outputSchema,
|
|
371
|
+
inputType: service.inputType,
|
|
372
|
+
outputType: service.outputType,
|
|
373
|
+
tags: service.tags,
|
|
374
|
+
capabilities: service.capabilities,
|
|
375
|
+
}),
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
if (!response.ok) {
|
|
379
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
380
|
+
this.logger.error('Registration failed', new Error(error.error || 'Unknown'));
|
|
381
|
+
throw new Error(`Failed to register service: ${error.error || response.statusText}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const data = await response.json();
|
|
385
|
+
|
|
386
|
+
const registered: RegisteredService = {
|
|
387
|
+
...service,
|
|
388
|
+
id: data.id,
|
|
389
|
+
ownerAddress,
|
|
390
|
+
registeredAt: new Date(),
|
|
391
|
+
isActive: true,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
this.registeredServices.set(data.id, registered);
|
|
395
|
+
this.logger.info('Service registered', { id: data.id, name: service.name });
|
|
396
|
+
|
|
397
|
+
return registered;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Update an existing service
|
|
402
|
+
*/
|
|
403
|
+
async update(serviceId: string, updates: Partial<ServiceDefinition>): Promise<void> {
|
|
404
|
+
this.logger.info('Updating service', { id: serviceId, updates });
|
|
405
|
+
|
|
406
|
+
const response = await fetch(`${this.apiUrl}/api/services/${serviceId}`, {
|
|
407
|
+
method: 'PUT',
|
|
408
|
+
headers: { 'Content-Type': 'application/json' },
|
|
409
|
+
body: JSON.stringify({
|
|
410
|
+
name: updates.name,
|
|
411
|
+
description: updates.description,
|
|
412
|
+
category: updates.category,
|
|
413
|
+
endpointUrl: updates.endpoint,
|
|
414
|
+
pricePerCall: updates.price,
|
|
415
|
+
inputSchema: updates.inputSchema,
|
|
416
|
+
outputSchema: updates.outputSchema,
|
|
417
|
+
inputType: updates.inputType,
|
|
418
|
+
outputType: updates.outputType,
|
|
419
|
+
tags: updates.tags,
|
|
420
|
+
capabilities: updates.capabilities,
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (!response.ok) {
|
|
425
|
+
throw new Error(`Failed to update service: ${response.statusText}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Update local cache
|
|
429
|
+
const existing = this.registeredServices.get(serviceId);
|
|
430
|
+
if (existing) {
|
|
431
|
+
this.registeredServices.set(serviceId, { ...existing, ...updates });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
this.logger.info('Service updated', { id: serviceId });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Deactivate a service
|
|
439
|
+
*/
|
|
440
|
+
async deactivate(serviceId: string): Promise<void> {
|
|
441
|
+
await this.update(serviceId, { endpoint: undefined } as never);
|
|
442
|
+
|
|
443
|
+
const existing = this.registeredServices.get(serviceId);
|
|
444
|
+
if (existing) {
|
|
445
|
+
existing.isActive = false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
this.logger.info('Service deactivated', { id: serviceId });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Get all registered services for this provider
|
|
453
|
+
*/
|
|
454
|
+
async getMyServices(): Promise<RegisteredService[]> {
|
|
455
|
+
const ownerAddress = await this.getAddress();
|
|
456
|
+
|
|
457
|
+
const response = await fetch(
|
|
458
|
+
`${this.apiUrl}/api/services?ownerAddress=${ownerAddress}`
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
if (!response.ok) {
|
|
462
|
+
throw new Error('Failed to fetch services');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const data = await response.json();
|
|
466
|
+
return (data.services || []).map((s: Record<string, unknown>) => ({
|
|
467
|
+
id: s.id as string,
|
|
468
|
+
name: s.name as string,
|
|
469
|
+
description: s.description as string,
|
|
470
|
+
category: s.category as string,
|
|
471
|
+
price: s.pricePerCall as string,
|
|
472
|
+
endpoint: s.endpointUrl as string,
|
|
473
|
+
ownerAddress: s.ownerAddress as string,
|
|
474
|
+
registeredAt: new Date(s.createdAt as string),
|
|
475
|
+
isActive: s.isActive as boolean ?? true,
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ==========================================================================
|
|
480
|
+
// PAYMENT HANDLING - Explicit, not magic
|
|
481
|
+
// ==========================================================================
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Generate x402 payment requirements for a 402 response
|
|
485
|
+
*
|
|
486
|
+
* Use this when building your service's payment-required response.
|
|
487
|
+
*
|
|
488
|
+
* @example Express.js middleware
|
|
489
|
+
* ```ts
|
|
490
|
+
* app.use('/api/price', (req, res, next) => {
|
|
491
|
+
* const paymentId = req.headers['x-payment-id'];
|
|
492
|
+
*
|
|
493
|
+
* if (!paymentId) {
|
|
494
|
+
* const requirements = provider.createPaymentRequired({
|
|
495
|
+
* amount: "0.01",
|
|
496
|
+
* resource: "/api/price",
|
|
497
|
+
* description: "Price feed access",
|
|
498
|
+
* });
|
|
499
|
+
* return res.status(402).json(requirements);
|
|
500
|
+
* }
|
|
501
|
+
*
|
|
502
|
+
* next();
|
|
503
|
+
* });
|
|
504
|
+
* ```
|
|
505
|
+
*/
|
|
506
|
+
async createPaymentRequired(params: {
|
|
507
|
+
amount: string;
|
|
508
|
+
resource?: string;
|
|
509
|
+
description?: string;
|
|
510
|
+
timeoutSeconds?: number;
|
|
511
|
+
}): Promise<PaymentRequirements> {
|
|
512
|
+
const payTo = await this.getAddress();
|
|
513
|
+
const config = NETWORK_CONFIG[this.network];
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
x402Version: 1,
|
|
517
|
+
paymentRequirements: {
|
|
518
|
+
scheme: 'exact',
|
|
519
|
+
network: this.network === 'cronos-mainnet' ? 'cronos-mainnet' : 'cronos-testnet',
|
|
520
|
+
payTo,
|
|
521
|
+
asset: config.asset,
|
|
522
|
+
maxAmountRequired: params.amount,
|
|
523
|
+
maxTimeoutSeconds: params.timeoutSeconds || 60,
|
|
524
|
+
resource: params.resource,
|
|
525
|
+
description: params.description,
|
|
526
|
+
},
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Verify a payment was made
|
|
532
|
+
*
|
|
533
|
+
* @example
|
|
534
|
+
* const { verified, amount, payerAddress } = await provider.verifyPayment(paymentId);
|
|
535
|
+
* if (!verified) {
|
|
536
|
+
* return res.status(402).json({ error: 'Payment not verified' });
|
|
537
|
+
* }
|
|
538
|
+
*/
|
|
539
|
+
async verifyPayment(paymentId: string): Promise<{
|
|
540
|
+
verified: boolean;
|
|
541
|
+
status: PaymentStatus;
|
|
542
|
+
amount?: string;
|
|
543
|
+
payerAddress?: string;
|
|
544
|
+
txHash?: string;
|
|
545
|
+
}> {
|
|
546
|
+
const response = await fetch(`${this.apiUrl}/api/payments/${paymentId}`);
|
|
547
|
+
|
|
548
|
+
if (!response.ok) {
|
|
549
|
+
return { verified: false, status: 'failed' };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const data = await response.json();
|
|
553
|
+
const payment = data.payment;
|
|
554
|
+
|
|
555
|
+
return {
|
|
556
|
+
verified: payment?.status === 'settled',
|
|
557
|
+
status: payment?.status || 'pending',
|
|
558
|
+
amount: payment?.amount,
|
|
559
|
+
payerAddress: payment?.payerAddress,
|
|
560
|
+
txHash: payment?.txHash,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Register handler for payment received events
|
|
566
|
+
*
|
|
567
|
+
* @example
|
|
568
|
+
* provider.onPaymentReceived(async (ctx) => {
|
|
569
|
+
* const result = await processRequest(ctx.input);
|
|
570
|
+
* ctx.deliver({
|
|
571
|
+
* result,
|
|
572
|
+
* proof: hashProof(result),
|
|
573
|
+
* latencyMs: Date.now() - ctx.timestamp.getTime(),
|
|
574
|
+
* });
|
|
575
|
+
* });
|
|
576
|
+
*/
|
|
577
|
+
onPaymentReceived(handler: (ctx: PaymentContext) => Promise<void>): () => void {
|
|
578
|
+
this.paymentReceivedHandlers.push(handler);
|
|
579
|
+
return () => {
|
|
580
|
+
const idx = this.paymentReceivedHandlers.indexOf(handler);
|
|
581
|
+
if (idx > -1) this.paymentReceivedHandlers.splice(idx, 1);
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Register handler for payment timeout events
|
|
587
|
+
*/
|
|
588
|
+
onPaymentTimeout(handler: (event: PaymentEvent) => Promise<void>): () => void {
|
|
589
|
+
this.paymentTimeoutHandlers.push(handler);
|
|
590
|
+
return () => {
|
|
591
|
+
const idx = this.paymentTimeoutHandlers.indexOf(handler);
|
|
592
|
+
if (idx > -1) this.paymentTimeoutHandlers.splice(idx, 1);
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Register handler for payment failed events
|
|
598
|
+
*/
|
|
599
|
+
onPaymentFailed(handler: (event: PaymentEvent) => Promise<void>): () => void {
|
|
600
|
+
this.paymentFailedHandlers.push(handler);
|
|
601
|
+
return () => {
|
|
602
|
+
const idx = this.paymentFailedHandlers.indexOf(handler);
|
|
603
|
+
if (idx > -1) this.paymentFailedHandlers.splice(idx, 1);
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Process a verified payment and trigger handlers
|
|
609
|
+
*
|
|
610
|
+
* Call this from your request handler after verifying payment.
|
|
611
|
+
*/
|
|
612
|
+
async processPayment<TInput = unknown>(params: {
|
|
613
|
+
paymentId: string;
|
|
614
|
+
txHash: string;
|
|
615
|
+
amount: string;
|
|
616
|
+
payerAddress: string;
|
|
617
|
+
input: TInput;
|
|
618
|
+
}): Promise<void> {
|
|
619
|
+
const ctx: PaymentContext<TInput> = {
|
|
620
|
+
paymentId: params.paymentId,
|
|
621
|
+
txHash: params.txHash,
|
|
622
|
+
amount: params.amount,
|
|
623
|
+
payerAddress: params.payerAddress,
|
|
624
|
+
input: params.input,
|
|
625
|
+
timestamp: new Date(),
|
|
626
|
+
deliver: async (output) => this.recordDelivery(params.paymentId, output),
|
|
627
|
+
fail: async (reason, retryable) => this.recordFailure(params.paymentId, reason, retryable),
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
this.logger.info('Processing payment', { paymentId: params.paymentId, amount: params.amount });
|
|
631
|
+
|
|
632
|
+
for (const handler of this.paymentReceivedHandlers) {
|
|
633
|
+
try {
|
|
634
|
+
await handler(ctx as PaymentContext);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
this.logger.error('Payment handler error', error instanceof Error ? error : new Error(String(error)));
|
|
637
|
+
await ctx.fail(error instanceof Error ? error.message : 'Handler error', true);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// ==========================================================================
|
|
643
|
+
// DELIVERY PROOF - Sacred
|
|
644
|
+
// ==========================================================================
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* Record a successful delivery
|
|
648
|
+
*
|
|
649
|
+
* Called automatically by ctx.deliver() or can be called directly.
|
|
650
|
+
*/
|
|
651
|
+
async recordDelivery<T>(paymentId: string, output: DeliveryProof<T>): Promise<void> {
|
|
652
|
+
const outcome: OutcomeRecord = {
|
|
653
|
+
paymentId,
|
|
654
|
+
outcomeType: 'delivered',
|
|
655
|
+
latencyMs: output.latencyMs || 0,
|
|
656
|
+
proofHash: output.proof,
|
|
657
|
+
evidence: output.evidence,
|
|
658
|
+
timestamp: new Date(),
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
this.outcomes.push(outcome);
|
|
662
|
+
this.logger.info('Delivery recorded', { paymentId, proof: output.proof });
|
|
663
|
+
this.logger.metric('delivery.success', 1, { paymentId });
|
|
664
|
+
this.logger.metric('delivery.latency', output.latencyMs || 0, { paymentId });
|
|
665
|
+
|
|
666
|
+
// Report to API
|
|
667
|
+
await this.reportOutcome(outcome);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Record a failure
|
|
672
|
+
*
|
|
673
|
+
* Called automatically by ctx.fail() or can be called directly.
|
|
674
|
+
*/
|
|
675
|
+
async recordFailure(paymentId: string, reason: string, retryable?: boolean): Promise<void> {
|
|
676
|
+
const outcome: OutcomeRecord = {
|
|
677
|
+
paymentId,
|
|
678
|
+
outcomeType: retryable ? 'partial' : 'failed',
|
|
679
|
+
latencyMs: 0,
|
|
680
|
+
evidence: { reason, retryable },
|
|
681
|
+
timestamp: new Date(),
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
this.outcomes.push(outcome);
|
|
685
|
+
this.logger.warn('Failure recorded', { paymentId, reason, retryable });
|
|
686
|
+
this.logger.metric('delivery.failure', 1, { paymentId, reason });
|
|
687
|
+
|
|
688
|
+
await this.reportOutcome(outcome);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
private async reportOutcome(outcome: OutcomeRecord): Promise<void> {
|
|
692
|
+
try {
|
|
693
|
+
await fetch(`${this.apiUrl}/api/outcomes`, {
|
|
694
|
+
method: 'POST',
|
|
695
|
+
headers: { 'Content-Type': 'application/json' },
|
|
696
|
+
body: JSON.stringify({
|
|
697
|
+
paymentId: outcome.paymentId,
|
|
698
|
+
outcomeType: outcome.outcomeType,
|
|
699
|
+
latencyMs: outcome.latencyMs,
|
|
700
|
+
proofHash: outcome.proofHash,
|
|
701
|
+
evidence: outcome.evidence,
|
|
702
|
+
}),
|
|
703
|
+
});
|
|
704
|
+
} catch (error) {
|
|
705
|
+
this.logger.error('Failed to report outcome', error instanceof Error ? error : new Error(String(error)));
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ==========================================================================
|
|
710
|
+
// OBSERVABILITY - Built-in, not afterthought
|
|
711
|
+
// ==========================================================================
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Get current reputation
|
|
715
|
+
*/
|
|
716
|
+
async getReputation(): Promise<ProviderReputation> {
|
|
717
|
+
const ownerAddress = await this.getAddress();
|
|
718
|
+
|
|
719
|
+
const response = await fetch(
|
|
720
|
+
`${this.apiUrl}/api/services?ownerAddress=${ownerAddress}&limit=1`
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
if (!response.ok) {
|
|
724
|
+
throw new Error('Failed to fetch reputation');
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const data = await response.json();
|
|
728
|
+
const service = data.services?.[0];
|
|
729
|
+
|
|
730
|
+
if (!service) {
|
|
731
|
+
return {
|
|
732
|
+
reputationScore: 0,
|
|
733
|
+
successRate: 0,
|
|
734
|
+
totalDeliveries: 0,
|
|
735
|
+
avgLatencyMs: 0,
|
|
736
|
+
trend: 'stable',
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
reputationScore: service.reputationScore || 0,
|
|
742
|
+
successRate: service.successRate || 0,
|
|
743
|
+
totalDeliveries: service.totalPayments || 0,
|
|
744
|
+
avgLatencyMs: service.avgLatencyMs || 0,
|
|
745
|
+
trend: service.trend || 'stable',
|
|
746
|
+
rank: service.rank,
|
|
747
|
+
percentile: service.percentile,
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Get service metrics history
|
|
753
|
+
*/
|
|
754
|
+
async getMetrics(serviceId: string, options: {
|
|
755
|
+
from?: Date;
|
|
756
|
+
to?: Date;
|
|
757
|
+
interval?: '1h' | '1d' | '7d';
|
|
758
|
+
} = {}): Promise<ServiceMetrics[]> {
|
|
759
|
+
const params = new URLSearchParams();
|
|
760
|
+
if (options.from) params.set('from', options.from.toISOString());
|
|
761
|
+
if (options.to) params.set('to', options.to.toISOString());
|
|
762
|
+
if (options.interval) params.set('interval', options.interval);
|
|
763
|
+
|
|
764
|
+
const response = await fetch(
|
|
765
|
+
`${this.apiUrl}/api/services/${serviceId}/metrics?${params}`
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
if (!response.ok) {
|
|
769
|
+
throw new Error('Failed to fetch metrics');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const data = await response.json();
|
|
773
|
+
return (data.data || []).map((m: Record<string, unknown>) => ({
|
|
774
|
+
timestamp: new Date(m.timestamp as string),
|
|
775
|
+
reputationScore: m.reputationScore as number || 0,
|
|
776
|
+
successRate: m.successRate as number || 0,
|
|
777
|
+
avgLatencyMs: m.avgLatencyMs as number || 0,
|
|
778
|
+
totalCalls: m.totalCalls as number || 0,
|
|
779
|
+
totalPayments: m.totalPayments as number || 0,
|
|
780
|
+
totalRevenue: m.totalRevenue as string || '0',
|
|
781
|
+
}));
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Get local outcome stats (in-memory)
|
|
786
|
+
*/
|
|
787
|
+
getLocalStats(): {
|
|
788
|
+
totalOutcomes: number;
|
|
789
|
+
deliveries: number;
|
|
790
|
+
failures: number;
|
|
791
|
+
successRate: number;
|
|
792
|
+
avgLatencyMs: number;
|
|
793
|
+
} {
|
|
794
|
+
const deliveries = this.outcomes.filter(o => o.outcomeType === 'delivered').length;
|
|
795
|
+
const failures = this.outcomes.filter(o => o.outcomeType === 'failed').length;
|
|
796
|
+
const total = this.outcomes.length;
|
|
797
|
+
const avgLatency = total > 0
|
|
798
|
+
? this.outcomes.reduce((sum, o) => sum + o.latencyMs, 0) / total
|
|
799
|
+
: 0;
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
totalOutcomes: total,
|
|
803
|
+
deliveries,
|
|
804
|
+
failures,
|
|
805
|
+
successRate: total > 0 ? deliveries / total : 0,
|
|
806
|
+
avgLatencyMs: Math.round(avgLatency),
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Get recent outcomes
|
|
812
|
+
*/
|
|
813
|
+
getRecentOutcomes(limit: number = 10): OutcomeRecord[] {
|
|
814
|
+
return this.outcomes.slice(-limit);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// ============================================================================
|
|
819
|
+
// EXPRESS MIDDLEWARE
|
|
820
|
+
// ============================================================================
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Create Express middleware for x402 payment handling
|
|
824
|
+
*
|
|
825
|
+
* @example
|
|
826
|
+
* const paymentMiddleware = createPaymentMiddleware(provider, {
|
|
827
|
+
* amount: "0.01",
|
|
828
|
+
* description: "API access",
|
|
829
|
+
* });
|
|
830
|
+
*
|
|
831
|
+
* app.use('/api/protected', paymentMiddleware, (req, res) => {
|
|
832
|
+
* res.json({ data: 'protected data' });
|
|
833
|
+
* });
|
|
834
|
+
*/
|
|
835
|
+
export function createPaymentMiddleware(
|
|
836
|
+
provider: RelayService,
|
|
837
|
+
options: {
|
|
838
|
+
amount: string;
|
|
839
|
+
description?: string;
|
|
840
|
+
timeoutSeconds?: number;
|
|
841
|
+
}
|
|
842
|
+
) {
|
|
843
|
+
return async (req: { headers: Record<string, string | undefined> }, res: {
|
|
844
|
+
status: (code: number) => { json: (data: unknown) => void };
|
|
845
|
+
}, next: () => void) => {
|
|
846
|
+
const paymentId = req.headers['x-payment-id'];
|
|
847
|
+
const paymentTx = req.headers['x-payment'];
|
|
848
|
+
|
|
849
|
+
if (!paymentId || !paymentTx) {
|
|
850
|
+
const requirements = await provider.createPaymentRequired({
|
|
851
|
+
amount: options.amount,
|
|
852
|
+
description: options.description,
|
|
853
|
+
timeoutSeconds: options.timeoutSeconds,
|
|
854
|
+
});
|
|
855
|
+
return res.status(402).json(requirements);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Verify payment
|
|
859
|
+
const verification = await provider.verifyPayment(paymentId);
|
|
860
|
+
|
|
861
|
+
if (!verification.verified) {
|
|
862
|
+
return res.status(402).json({
|
|
863
|
+
error: 'Payment not verified',
|
|
864
|
+
status: verification.status,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
next();
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ============================================================================
|
|
873
|
+
// FACTORY & EXPORTS
|
|
874
|
+
// ============================================================================
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Create a Relay Service instance
|
|
878
|
+
*
|
|
879
|
+
* @example
|
|
880
|
+
* const provider = createService({ wallet, network: "cronos-testnet" });
|
|
881
|
+
*/
|
|
882
|
+
export function createService(config: ServiceConfig): RelayService {
|
|
883
|
+
return new RelayService(config);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
export default RelayService;
|