@routedock/routedock 0.1.1 → 0.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.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +281 -19
  3. package/dist/MppSessionHandler-B4TyD7Xg.d.cts +121 -0
  4. package/dist/MppSessionHandler-CLB5SzgN.d.ts +121 -0
  5. package/dist/ProviderRegistry-BJ9oJLVz.d.cts +142 -0
  6. package/dist/ProviderRegistry-C50nyhaF.d.ts +142 -0
  7. package/dist/RouteDockClient-B1roCa8h.d.cts +455 -0
  8. package/dist/RouteDockClient-BbkqjhMG.d.ts +455 -0
  9. package/dist/SessionReconciler-Dscw43F3.d.ts +125 -0
  10. package/dist/SessionReconciler-bi0z2qtN.d.cts +125 -0
  11. package/dist/SessionStore-BpK8vv0L.d.cts +111 -0
  12. package/dist/SessionStore-DvkZv8-E.d.ts +111 -0
  13. package/dist/client.cjs +2419 -202
  14. package/dist/client.d.cts +50 -81
  15. package/dist/client.d.ts +50 -81
  16. package/dist/client.js +2373 -203
  17. package/dist/index.cjs +3080 -298
  18. package/dist/index.d.cts +12 -3
  19. package/dist/index.d.ts +12 -3
  20. package/dist/index.js +3026 -298
  21. package/dist/provider/fastify.cjs +882 -0
  22. package/dist/provider/fastify.d.cts +35 -0
  23. package/dist/provider/fastify.d.ts +35 -0
  24. package/dist/provider/fastify.js +880 -0
  25. package/dist/provider/hono.cjs +948 -0
  26. package/dist/provider/hono.d.cts +99 -0
  27. package/dist/provider/hono.d.ts +99 -0
  28. package/dist/provider/hono.js +938 -0
  29. package/dist/provider.cjs +827 -97
  30. package/dist/provider.d.cts +32 -25
  31. package/dist/provider.d.ts +32 -25
  32. package/dist/provider.js +810 -99
  33. package/dist/react.cjs +2641 -0
  34. package/dist/react.d.cts +107 -0
  35. package/dist/react.d.ts +107 -0
  36. package/dist/react.js +2634 -0
  37. package/dist/routedockMiddleware-BlDsHuPl.d.ts +59 -0
  38. package/dist/routedockMiddleware-D97A5ptQ.d.cts +59 -0
  39. package/dist/schema.cjs +364 -0
  40. package/dist/schema.d.cts +3 -0
  41. package/dist/schema.d.ts +3 -0
  42. package/dist/schema.js +359 -0
  43. package/dist/sign-CUf3rhTx.d.cts +53 -0
  44. package/dist/sign-Dpj2Mp7k.d.ts +53 -0
  45. package/dist/testing.cjs +57 -0
  46. package/dist/testing.d.cts +95 -0
  47. package/dist/testing.d.ts +95 -0
  48. package/dist/testing.js +54 -0
  49. package/dist/types-DmhmfLzP.d.cts +410 -0
  50. package/dist/types-DmhmfLzP.d.ts +410 -0
  51. package/package.json +143 -23
  52. package/PUBLISH_CHECKLIST.md +0 -70
  53. package/dist/SessionStore-CCVO0PHM.d.cts +0 -152
  54. package/dist/SessionStore-CCVO0PHM.d.ts +0 -152
  55. package/dist/routedockMiddleware-B7WTB3LT.d.cts +0 -53
  56. package/dist/routedockMiddleware-DRgdrycG.d.ts +0 -53
  57. package/src/client/ModeRouter.ts +0 -104
  58. package/src/client/MppChargeClient.ts +0 -44
  59. package/src/client/MppSessionClient.ts +0 -144
  60. package/src/client/RouteDockClient.ts +0 -124
  61. package/src/client/index.ts +0 -8
  62. package/src/client/x402Client.ts +0 -84
  63. package/src/index.ts +0 -9
  64. package/src/provider/MppChargeHandler.ts +0 -90
  65. package/src/provider/MppSessionHandler.ts +0 -157
  66. package/src/provider/index.ts +0 -7
  67. package/src/provider/routedockMiddleware.ts +0 -154
  68. package/src/provider/x402Handler.ts +0 -164
  69. package/src/schemas/routedock.schema.json +0 -159
  70. package/src/store/SessionStore.ts +0 -90
  71. package/src/tests/smoke.test.ts +0 -228
  72. package/src/types.ts +0 -165
  73. package/tsconfig.json +0 -14
  74. package/tsup.config.ts +0 -15
package/dist/index.cjs CHANGED
@@ -1,14 +1,20 @@
1
1
  'use strict';
2
2
 
3
+ var nulthSdk = require('@routedock/nulth-sdk');
3
4
  var stellarSdk = require('@stellar/stellar-sdk');
4
- var Ajv = require('ajv');
5
+ var jsonSchema = require('@cfworker/json-schema');
6
+ var crypto$1 = require('crypto');
5
7
  var stellar = require('@x402/stellar');
6
8
  var client = require('@x402/stellar/exact/client');
7
9
  var client$1 = require('@x402/core/client');
8
10
  var http = require('@x402/core/http');
9
11
  var client$3 = require('@stellar/mpp/charge/client');
10
12
  var client$2 = require('mppx/client');
13
+ var mpp = require('@stellar/mpp');
11
14
  var client$4 = require('@stellar/mpp/channel/client');
15
+ var mppx = require('mppx');
16
+ var fs = require('fs/promises');
17
+ var path = require('path');
12
18
  var facilitator = require('@x402/stellar/exact/facilitator');
13
19
  var server$1 = require('@x402/stellar/exact/server');
14
20
  var server = require('@x402/core/server');
@@ -18,46 +24,210 @@ var server$4 = require('@stellar/mpp/channel/server');
18
24
 
19
25
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
20
26
 
21
- var Ajv__default = /*#__PURE__*/_interopDefault(Ajv);
27
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
28
+ var path__default = /*#__PURE__*/_interopDefault(path);
22
29
 
23
- // src/types.ts
30
+ // src/errors.ts
24
31
  var RouteDockError = class extends Error {
25
- constructor(message) {
26
- super(message);
32
+ code;
33
+ retryable;
34
+ retryAfterMs;
35
+ constructor(message, code, retryable, options = {}) {
36
+ super(message, { cause: options.cause });
27
37
  this.name = "RouteDockError";
38
+ this.code = code;
39
+ this.retryable = retryable;
40
+ if (options.retryAfterMs !== void 0) {
41
+ this.retryAfterMs = options.retryAfterMs;
42
+ }
28
43
  }
29
44
  };
30
45
  var RouteDockManifestError = class extends RouteDockError {
31
- constructor(message) {
32
- super(message);
46
+ constructor(message, options = {}) {
47
+ super(message, "MANIFEST", false, options);
33
48
  this.name = "RouteDockManifestError";
34
49
  }
35
50
  };
51
+ var RouteDockManifestTimeoutError = class extends RouteDockError {
52
+ constructor(message, options = {}) {
53
+ super(message, "MANIFEST_TIMEOUT", true, options);
54
+ this.name = "RouteDockManifestTimeoutError";
55
+ }
56
+ };
57
+ var RouteDockManifestSunsetError = class extends RouteDockError {
58
+ constructor(message, options = {}) {
59
+ super(message, "MANIFEST_SUNSET", false, options);
60
+ this.name = "RouteDockManifestSunsetError";
61
+ }
62
+ };
36
63
  var RouteDockNoSupportedModeError = class extends RouteDockError {
37
- constructor(message) {
38
- super(message);
64
+ constructor(message, options = {}) {
65
+ super(message, "NO_SUPPORTED_MODE", false, options);
39
66
  this.name = "RouteDockNoSupportedModeError";
40
67
  }
41
68
  };
42
- var RouteDockSessionError = class extends RouteDockError {
43
- constructor(message) {
44
- super(message);
45
- this.name = "RouteDockSessionError";
69
+ var RouteDockClientVersionError = class extends RouteDockError {
70
+ constructor(message, options = {}) {
71
+ super(message, "CLIENT_VERSION_TOO_OLD", false, options);
72
+ this.name = "RouteDockClientVersionError";
73
+ }
74
+ };
75
+ var RouteDockTrustlineError = class extends RouteDockError {
76
+ asset;
77
+ issuer;
78
+ remediation;
79
+ constructor(asset, issuer, remediation, options = {}) {
80
+ super(
81
+ `Missing trustline for ${asset} (issuer: ${issuer}). ${remediation}`,
82
+ "TRUSTLINE",
83
+ false,
84
+ options
85
+ );
86
+ this.name = "RouteDockTrustlineError";
87
+ this.asset = asset;
88
+ this.issuer = issuer;
89
+ this.remediation = remediation;
90
+ }
91
+ };
92
+ var RouteDockFacilitatorError = class extends RouteDockError {
93
+ status;
94
+ constructor(message, status, options = {}) {
95
+ super(message, "FACILITATOR", true, options);
96
+ this.name = "RouteDockFacilitatorError";
97
+ this.status = status;
98
+ }
99
+ };
100
+ var RouteDockNetworkError = class extends RouteDockError {
101
+ constructor(message, options = {}) {
102
+ super(message, "NETWORK", true, options);
103
+ this.name = "RouteDockNetworkError";
104
+ }
105
+ };
106
+ var RouteDockSignatureError = class extends RouteDockError {
107
+ constructor(message, options = {}) {
108
+ super(message, "SIGNATURE", false, options);
109
+ this.name = "RouteDockSignatureError";
110
+ }
111
+ };
112
+ var RouteDockVoucherMonotonicityError = class extends RouteDockError {
113
+ constructor(message, options = {}) {
114
+ super(message, "VOUCHER_MONOTONICITY", false, options);
115
+ this.name = "RouteDockVoucherMonotonicityError";
46
116
  }
47
117
  };
48
- var RouteDockPolicyRejectedError = class extends RouteDockError {
118
+ var RouteDockPolicyRejectError = class extends RouteDockError {
49
119
  reason;
50
- constructor(reason) {
51
- super(`Payment rejected by policy: ${reason}`);
52
- this.name = "RouteDockPolicyRejectedError";
120
+ constructor(reason, options = {}) {
121
+ super(`Payment rejected by policy: ${reason}`, "POLICY_REJECT", false, options);
122
+ this.name = "RouteDockPolicyRejectError";
53
123
  this.reason = reason;
54
124
  }
55
125
  };
126
+ var RouteDockChannelStateError = class extends RouteDockError {
127
+ constructor(message, options = {}) {
128
+ super(message, "CHANNEL_STATE", false, options);
129
+ this.name = "RouteDockChannelStateError";
130
+ }
131
+ };
132
+ var RouteDockDisputeError = class extends RouteDockError {
133
+ constructor(message, options = {}) {
134
+ super(message, "DISPUTE", false, options);
135
+ this.name = "RouteDockDisputeError";
136
+ }
137
+ };
138
+ var RouteDockRefundWindowError = class extends RouteDockError {
139
+ constructor(message, options = {}) {
140
+ super(message, "REFUND_WINDOW", false, options);
141
+ this.name = "RouteDockRefundWindowError";
142
+ }
143
+ };
144
+ var RouteDockPolicyRejectedError = RouteDockPolicyRejectError;
145
+ var RouteDockSessionError = RouteDockVoucherMonotonicityError;
146
+ function parseRetryAfterMs(response) {
147
+ const header = response.headers.get("Retry-After");
148
+ if (!header) return void 0;
149
+ const seconds = Number(header);
150
+ if (!Number.isNaN(seconds) && seconds >= 0) {
151
+ return seconds * 1e3;
152
+ }
153
+ const date = Date.parse(header);
154
+ if (!Number.isNaN(date)) {
155
+ return Math.max(0, date - Date.now());
156
+ }
157
+ return void 0;
158
+ }
159
+ function httpStatusToError(message, status, response) {
160
+ const retryAfterMs = response ? parseRetryAfterMs(response) : void 0;
161
+ const opts = retryAfterMs !== void 0 ? { retryAfterMs } : {};
162
+ if (status === 429 || status === 503 || status >= 500 && status < 600) {
163
+ return new RouteDockFacilitatorError(message, status, opts);
164
+ }
165
+ if (status >= 400) {
166
+ return new RouteDockManifestError(message, opts);
167
+ }
168
+ return new RouteDockNetworkError(message, opts);
169
+ }
170
+ function wrapFetchError(err, context) {
171
+ if (err instanceof RouteDockError) return err;
172
+ return new RouteDockNetworkError(`${context}: ${String(err)}`, { cause: err });
173
+ }
174
+
175
+ // src/types.ts
176
+ var DEFAULT_MAX_SESSION_DURATION_MS = 36e5;
177
+
178
+ // src/internal/retry.ts
179
+ var DEFAULT_RETRY_POLICY = {
180
+ maxAttempts: 4,
181
+ baseDelayMs: 250,
182
+ maxDelayMs: 3e4
183
+ };
184
+ function backoffDelayMs(attempt, baseDelayMs, maxDelayMs = DEFAULT_RETRY_POLICY.maxDelayMs) {
185
+ const cappedDelay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
186
+ return Math.floor(Math.random() * (cappedDelay + 1));
187
+ }
188
+ function sleep(ms) {
189
+ return new Promise((resolve) => setTimeout(resolve, ms));
190
+ }
191
+ async function withRetry(fn, policy = {}) {
192
+ const { maxAttempts, baseDelayMs, maxDelayMs, onRetry } = {
193
+ ...DEFAULT_RETRY_POLICY,
194
+ ...policy
195
+ };
196
+ let lastError;
197
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
198
+ try {
199
+ return await fn();
200
+ } catch (err) {
201
+ if (!(err instanceof RouteDockError)) {
202
+ throw err;
203
+ }
204
+ lastError = err;
205
+ if (!err.retryable || attempt >= maxAttempts - 1) {
206
+ throw err;
207
+ }
208
+ const delay = err.retryAfterMs !== void 0 ? err.retryAfterMs : backoffDelayMs(attempt, baseDelayMs, maxDelayMs);
209
+ if (onRetry) {
210
+ try {
211
+ await onRetry(attempt, err, delay);
212
+ } catch (cbErr) {
213
+ console.error("[withRetry] onRetry callback threw:", cbErr);
214
+ }
215
+ }
216
+ await sleep(delay);
217
+ }
218
+ }
219
+ throw lastError ?? new RouteDockError("Retry exhausted", "NETWORK", false);
220
+ }
221
+ var USDC_ISSUERS = {
222
+ testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
223
+ mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
224
+ };
225
+ var usdcToUnits = nulthSdk.usdcToStroops;
56
226
 
57
227
  // src/schemas/routedock.schema.json
58
228
  var routedock_schema_default = {
59
229
  $schema: "http://json-schema.org/draft-07/schema#",
60
- $id: "https://routedock.dev/schemas/routedock.schema.json",
230
+ $id: "https://routedock.xyz/schemas/routedock.schema.json",
61
231
  title: "RouteDock Manifest",
62
232
  description: "Discovery manifest served at /.well-known/routedock.json on every RouteDock provider endpoint",
63
233
  type: "object",
@@ -78,8 +248,26 @@ var routedock_schema_default = {
78
248
  properties: {
79
249
  routedock: {
80
250
  type: "string",
81
- const: "1.0",
82
- description: "Manifest schema version. Always '1.0' for this version."
251
+ pattern: "^[0-9]+\\.[0-9]+$",
252
+ description: "Manifest schema version, e.g. '1.0', '1.1', '2.0'."
253
+ },
254
+ min_client_version: {
255
+ type: "string",
256
+ pattern: "^[0-9]+\\.[0-9]+$",
257
+ description: "Minimum SDK client version (major.minor) required to use this manifest. Clients below this version must refuse to pay."
258
+ },
259
+ deprecated_modes: {
260
+ type: "array",
261
+ items: {
262
+ $ref: "#/definitions/PaymentMode"
263
+ },
264
+ uniqueItems: true,
265
+ description: "Payment modes still listed in 'modes' for backwards compatibility but deprecated for new integrations."
266
+ },
267
+ sunset_at: {
268
+ type: "string",
269
+ format: "date-time",
270
+ description: "ISO 8601 timestamp after which this manifest version is no longer supported by the provider."
83
271
  },
84
272
  name: {
85
273
  type: "string",
@@ -132,18 +320,25 @@ var routedock_schema_default = {
132
320
  },
133
321
  "mpp-session": {
134
322
  $ref: "#/definitions/SessionPricingConfig"
323
+ },
324
+ "mpp-session-ws": {
325
+ $ref: "#/definitions/SessionPricingConfig",
326
+ description: "WebSocket transport variant of mpp-session: channel opened and first voucher negotiated over HTTP, then the connection upgrades to WebSocket for push-based streaming."
135
327
  }
136
328
  },
137
329
  minProperties: 1
138
330
  },
139
331
  endpoints: {
140
332
  type: "object",
141
- description: "Map of endpoint name to HTTP method + path (e.g. { 'price': 'GET /price' })",
333
+ description: "Map of endpoint name to endpoint descriptors",
142
334
  additionalProperties: {
143
- type: "string"
335
+ $ref: "#/definitions/EndpointDescriptor"
144
336
  },
145
337
  minProperties: 1
146
338
  },
339
+ sla: {
340
+ $ref: "#/definitions/SLAConfig"
341
+ },
147
342
  tags: {
148
343
  type: "array",
149
344
  items: {
@@ -153,12 +348,91 @@ var routedock_schema_default = {
153
348
  },
154
349
  uniqueItems: true,
155
350
  description: "Capability tags indexed with trigram search in the provider registry"
351
+ },
352
+ vault: {
353
+ $ref: "#/definitions/VaultMode"
354
+ },
355
+ nulth_account: {
356
+ type: "string",
357
+ description: "Optional Nulth smart account address associated with this provider"
358
+ },
359
+ capabilities: {
360
+ type: "object",
361
+ description: "Optional protocol features this provider supports",
362
+ properties: {
363
+ streaming: {
364
+ type: "array",
365
+ items: {
366
+ type: "string",
367
+ enum: ["sse", "websocket"]
368
+ },
369
+ description: "Supported streaming transports"
370
+ },
371
+ webhooks: {
372
+ type: "boolean",
373
+ description: "Whether webhook callbacks are supported"
374
+ },
375
+ idempotency_keys: {
376
+ type: "boolean",
377
+ description: "Whether idempotency keys are supported"
378
+ },
379
+ content_types: {
380
+ type: "array",
381
+ items: {
382
+ type: "string"
383
+ },
384
+ description: "Supported content types for request/response"
385
+ }
386
+ },
387
+ additionalProperties: false
388
+ },
389
+ regions: {
390
+ type: "array",
391
+ items: {
392
+ type: "string",
393
+ minLength: 2,
394
+ maxLength: 10
395
+ },
396
+ uniqueItems: true,
397
+ description: "IATA airport codes or UN/LOCODE identifiers for regions where this provider has infrastructure (e.g. ['IAD', 'AMS']). Used by agents to select the nearest provider."
398
+ },
399
+ latency_hints: {
400
+ type: "object",
401
+ additionalProperties: {
402
+ type: "number",
403
+ minimum: 0
404
+ },
405
+ description: "Provider-declared p50 latency in milliseconds per region code. Keys must be a subset of 'regions'. Example: { 'IAD': 14, 'AMS': 22 }."
406
+ },
407
+ signature: {
408
+ type: "string",
409
+ description: "Base64 Ed25519 signature (payee keypair) over the SHA-256 hash of this manifest with the signature field omitted. Clients must verify before trusting any routing field."
410
+ },
411
+ signature_version: {
412
+ type: "string",
413
+ const: "2",
414
+ description: "Manifest signature format. Version 2 recursively includes every nested manifest field in the signed digest."
415
+ },
416
+ categories: {
417
+ type: "array",
418
+ items: {
419
+ type: "string",
420
+ minLength: 1,
421
+ maxLength: 128
422
+ },
423
+ uniqueItems: true,
424
+ description: "Optional hierarchical categories from a published taxonomy (e.g. data/price/crypto)"
156
425
  }
157
426
  },
158
427
  definitions: {
428
+ VaultMode: {
429
+ type: "string",
430
+ enum: ["local-key", "agent-vault", "nulth"],
431
+ description: "Agent vault / custody mode for payments from this provider"
432
+ },
159
433
  PaymentMode: {
160
434
  type: "string",
161
- enum: ["x402", "mpp-charge", "mpp-session"],
435
+ enum: ["x402", "mpp-charge", "mpp-session", "mpp-session-ws"],
162
436
  description: "A supported RouteDock payment mode"
163
437
  },
164
438
  PricingConfig: {
@@ -179,13 +453,17 @@ var routedock_schema_default = {
179
453
  facilitator: {
180
454
  type: "string",
181
455
  description: "x402 facilitator URL (required for x402 mode). OpenZeppelin Channels endpoint."
456
+ },
457
+ payee: {
458
+ type: "string",
459
+ description: "Optional per-mode payee override (Stellar G... address). Directs settlements for this mode to a separate account for treasury separation. Falls back to the top-level 'payee' when omitted."
182
460
  }
183
461
  }
184
462
  },
185
463
  SessionPricingConfig: {
186
464
  type: "object",
187
465
  description: "Per-voucher pricing for mpp-session (one-way-channel) mode",
188
- required: ["rate", "per", "channel_contract", "min_deposit", "refund_waiting_period_ledgers"],
466
+ required: ["rate", "per", "channel_factory", "min_deposit", "refund_waiting_period_ledgers"],
189
467
  additionalProperties: false,
190
468
  properties: {
191
469
  rate: {
@@ -197,9 +475,9 @@ var routedock_schema_default = {
197
475
  const: "voucher",
198
476
  description: "Billing unit \u2014 always 'voucher' for session mode"
199
477
  },
200
- channel_contract: {
478
+ channel_factory: {
201
479
  type: "string",
202
- description: "Soroban contract address (C...) for the one-way-channel contract"
480
+ description: "Stable Soroban factory address (C...) used to deploy a per-session channel"
203
481
  },
204
482
  min_deposit: {
205
483
  type: "string",
@@ -211,88 +489,502 @@ var routedock_schema_default = {
211
489
  description: "Ledgers the server has to settle before the funder can reclaim. Default: 17280 (~24h). Do not reduce in production."
212
490
  }
213
491
  }
492
+ },
493
+ SLAConfig: {
494
+ type: "object",
495
+ description: "Service Level Agreement for the provider",
496
+ required: ["uptime_30d_percent", "p95_latency_ms"],
497
+ additionalProperties: false,
498
+ properties: {
499
+ uptime_30d_percent: {
500
+ type: "number",
501
+ description: "Uptime percentage over the last 30 days"
502
+ },
503
+ p95_latency_ms: {
504
+ type: "number",
505
+ description: "P95 latency in milliseconds"
506
+ },
507
+ maintenance_windows: {
508
+ type: "array",
509
+ items: {
510
+ type: "object",
511
+ required: ["cron", "duration_minutes"],
512
+ additionalProperties: false,
513
+ properties: {
514
+ cron: {
515
+ type: "string",
516
+ description: "Cron expression for the maintenance window"
517
+ },
518
+ duration_minutes: {
519
+ type: "number",
520
+ description: "Duration of the maintenance window in minutes"
521
+ }
522
+ }
523
+ }
524
+ }
525
+ }
526
+ },
527
+ EndpointDescriptor: {
528
+ type: "object",
529
+ description: "Descriptor for a provider endpoint",
530
+ required: ["method", "path"],
531
+ additionalProperties: false,
532
+ properties: {
533
+ method: {
534
+ type: "string",
535
+ description: "HTTP method for the endpoint"
536
+ },
537
+ path: {
538
+ type: "string",
539
+ description: "Path for the endpoint"
540
+ },
541
+ deprecated: {
542
+ type: "boolean",
543
+ description: "Whether this endpoint is retained only for backwards compatibility"
544
+ },
545
+ sunset_at: {
546
+ type: "string",
547
+ format: "date-time",
548
+ description: "ISO 8601 timestamp after which callers should stop using this endpoint"
549
+ },
550
+ headers: {
551
+ type: "object",
552
+ additionalProperties: {
553
+ type: "string"
554
+ },
555
+ description: "Expected request headers"
556
+ },
557
+ request_schema: {
558
+ description: "JSON schema for the request body"
559
+ },
560
+ response_schema: {
561
+ description: "JSON schema for the response body"
562
+ },
563
+ rate_limit: {
564
+ type: "object",
565
+ required: ["requests", "window_seconds"],
566
+ additionalProperties: false,
567
+ properties: {
568
+ requests: {
569
+ type: "number"
570
+ },
571
+ window_seconds: {
572
+ type: "number"
573
+ }
574
+ }
575
+ }
576
+ }
214
577
  }
215
578
  }
216
579
  };
217
580
 
581
+ // package.json
582
+ var package_default = {
583
+ version: "0.2.0"};
584
+ var MANIFEST_SIGNATURE_VERSION = "2";
585
+ function canonicalize(value) {
586
+ if (Array.isArray(value)) {
587
+ return value.map(canonicalize);
588
+ }
589
+ if (value !== null && typeof value === "object") {
590
+ return Object.fromEntries(
591
+ Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
592
+ );
593
+ }
594
+ return value;
595
+ }
596
+ function manifestDigest(manifest) {
597
+ const { signature: _omit, ...rest } = manifest;
598
+ const canonical = JSON.stringify(canonicalize(rest));
599
+ return crypto$1.createHash("sha256").update(canonical, "utf8").digest();
600
+ }
601
+ function signManifest(manifest, payeeSecretKey) {
602
+ const keypair = stellarSdk.Keypair.fromSecret(payeeSecretKey);
603
+ const versionedManifest = {
604
+ ...manifest,
605
+ signature_version: MANIFEST_SIGNATURE_VERSION
606
+ };
607
+ const digest = manifestDigest(versionedManifest);
608
+ const sig = keypair.sign(digest);
609
+ return { ...versionedManifest, signature: Buffer.from(sig).toString("base64") };
610
+ }
611
+ function verifyManifestSignature(manifest, expectedPayee) {
612
+ if (!manifest.signature) {
613
+ throw new RouteDockSignatureError(
614
+ "Manifest is missing a signature field \u2014 cannot verify payee authenticity"
615
+ );
616
+ }
617
+ if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
618
+ throw new RouteDockSignatureError(
619
+ `Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
620
+ );
621
+ }
622
+ if (expectedPayee !== void 0 && manifest.payee !== expectedPayee) {
623
+ throw new RouteDockSignatureError(
624
+ `Manifest payee ${manifest.payee} does not match expected payee ${expectedPayee}`
625
+ );
626
+ }
627
+ const keypair = stellarSdk.Keypair.fromPublicKey(manifest.payee);
628
+ const digest = manifestDigest(manifest);
629
+ let sigBytes;
630
+ try {
631
+ sigBytes = Buffer.from(manifest.signature, "base64");
632
+ } catch {
633
+ throw new RouteDockSignatureError("Manifest signature is not valid base64");
634
+ }
635
+ let valid = false;
636
+ try {
637
+ valid = keypair.verify(digest, sigBytes);
638
+ } catch {
639
+ throw new RouteDockSignatureError(
640
+ `Manifest signature verification failed for payee ${manifest.payee}`
641
+ );
642
+ }
643
+ if (!valid) {
644
+ throw new RouteDockSignatureError(
645
+ `Manifest signature verification failed for payee ${manifest.payee}`
646
+ );
647
+ }
648
+ }
649
+
218
650
  // src/client/ModeRouter.ts
219
- var ajv = new Ajv__default.default();
220
- var validateManifest = ajv.compile(routedock_schema_default);
651
+ var validator = new jsonSchema.Validator(routedock_schema_default, "7");
652
+ var SDK_VERSION = package_default.version;
653
+ function parseMajorMinor(version) {
654
+ const parts = version.split(".");
655
+ return [Number.parseInt(parts[0] ?? "0", 10), Number.parseInt(parts[1] ?? "0", 10)];
656
+ }
657
+ function isVersionBelow(a, b) {
658
+ const [aMajor, aMinor] = parseMajorMinor(a);
659
+ const [bMajor, bMinor] = parseMajorMinor(b);
660
+ return aMajor < bMajor || aMajor === bMajor && aMinor < bMinor;
661
+ }
662
+ function assertClientVersionSupported(manifest, baseUrl) {
663
+ const minVersion = manifest.min_client_version;
664
+ if (minVersion && isVersionBelow(SDK_VERSION, minVersion)) {
665
+ throw new RouteDockClientVersionError(
666
+ `SDK version ${SDK_VERSION} is below the minimum required version ${minVersion} for provider at ${baseUrl}. Please upgrade the SDK.`
667
+ );
668
+ }
669
+ }
670
+ function assertManifestActive(manifest, baseUrl) {
671
+ const sunsetAt = manifest.sunset_at;
672
+ if (!sunsetAt) return;
673
+ const sunsetTime = Date.parse(sunsetAt);
674
+ if (!Number.isFinite(sunsetTime)) {
675
+ throw new RouteDockManifestError(
676
+ `Manifest at ${baseUrl} contains an invalid sunset_at timestamp: ${sunsetAt}`
677
+ );
678
+ }
679
+ if (sunsetTime <= Date.now()) {
680
+ throw new RouteDockManifestSunsetError(
681
+ `Manifest for provider at ${baseUrl} sunset at ${sunsetAt} and can no longer be used`
682
+ );
683
+ }
684
+ }
685
+ function assertEndpointActive(manifest, url, logger) {
686
+ const pathname = new URL(url).pathname;
687
+ const matches = Object.entries(manifest.endpoints).filter(
688
+ ([, descriptor]) => descriptor.path === pathname
689
+ );
690
+ if (matches.length === 0) return;
691
+ const now = Date.now();
692
+ const allSunset = matches.every(([, descriptor]) => {
693
+ const sunsetAt = descriptor.sunset_at;
694
+ return typeof sunsetAt === "string" && Date.parse(sunsetAt) <= now;
695
+ });
696
+ if (allSunset) {
697
+ const [key, descriptor] = matches[0];
698
+ throw new RouteDockManifestSunsetError(
699
+ `Endpoint '${key}' (${pathname}) sunset at ${descriptor.sunset_at} and can no longer be used`
700
+ );
701
+ }
702
+ const deprecated = matches.find(([, descriptor]) => descriptor.deprecated === true);
703
+ if (deprecated && logger) {
704
+ const [key, descriptor] = deprecated;
705
+ const sunsetSuffix = descriptor.sunset_at ? `; sunset_at ${descriptor.sunset_at}` : "";
706
+ logger(
707
+ `[RouteDock] WARNING: ${manifest.name} \u2192 ${pathname}; endpoint '${key}' is deprecated${sunsetSuffix}`
708
+ );
709
+ }
710
+ }
711
+ function assertManifestValid(manifest, baseUrl) {
712
+ const context = baseUrl ? ` at ${baseUrl}` : "";
713
+ if (manifest.latency_hints !== void 0) {
714
+ if (!Array.isArray(manifest.regions) || manifest.regions.length === 0) {
715
+ throw new RouteDockManifestError(
716
+ `Invalid manifest${context}: latency_hints defined without declaring regions`
717
+ );
718
+ }
719
+ const regionSet = new Set(manifest.regions);
720
+ for (const region of Object.keys(manifest.latency_hints)) {
721
+ if (!regionSet.has(region)) {
722
+ throw new RouteDockManifestError(
723
+ `Invalid manifest${context}: latency_hints key '${region}' is not declared in regions (${manifest.regions.join(", ")})`
724
+ );
725
+ }
726
+ }
727
+ }
728
+ }
221
729
  var CACHE_TTL_MS = 6e4;
222
- var manifestCache = /* @__PURE__ */ new Map();
223
- async function fetchManifest(baseUrl) {
730
+ var DEFAULT_MANIFEST_CACHE_MAX_SIZE = 512;
731
+ var DEFAULT_MANIFEST_TIMEOUT_MS = 5e3;
732
+ var LruCache = class {
733
+ constructor(maxSize) {
734
+ this.maxSize = maxSize;
735
+ }
736
+ maxSize;
737
+ map = /* @__PURE__ */ new Map();
738
+ get(key) {
739
+ if (!this.map.has(key)) return void 0;
740
+ const value = this.map.get(key);
741
+ this.map.delete(key);
742
+ this.map.set(key, value);
743
+ return value;
744
+ }
745
+ set(key, value) {
746
+ if (this.map.has(key)) {
747
+ this.map.delete(key);
748
+ } else if (this.map.size >= this.maxSize) {
749
+ const oldestKey = this.map.keys().next().value;
750
+ if (oldestKey !== void 0) {
751
+ this.map.delete(oldestKey);
752
+ }
753
+ }
754
+ this.map.set(key, value);
755
+ }
756
+ setMaxSize(maxSize) {
757
+ this.maxSize = maxSize;
758
+ while (this.map.size > this.maxSize) {
759
+ const oldestKey = this.map.keys().next().value;
760
+ if (oldestKey === void 0) break;
761
+ this.map.delete(oldestKey);
762
+ }
763
+ }
764
+ delete(key) {
765
+ this.map.delete(key);
766
+ }
767
+ };
768
+ var manifestCache = new LruCache(DEFAULT_MANIFEST_CACHE_MAX_SIZE);
769
+ function configureManifestCache(maxSize) {
770
+ if (!Number.isInteger(maxSize) || maxSize <= 0) {
771
+ throw new RouteDockManifestError("Invalid manifest cache size: " + maxSize);
772
+ }
773
+ manifestCache.setMaxSize(maxSize);
774
+ }
775
+ function invalidateManifest(baseUrl) {
776
+ manifestCache.delete(baseUrl);
777
+ }
778
+ function ttlFromHeaders(headers, now) {
779
+ const cacheControl = headers.get("cache-control");
780
+ if (cacheControl) {
781
+ const directives = cacheControl.split(",").map((directive) => directive.trim().toLowerCase());
782
+ if (directives.includes("no-store") || directives.includes("no-cache")) {
783
+ return 0;
784
+ }
785
+ const maxAge = /max-age=(\d+)/i.exec(cacheControl);
786
+ if (maxAge) {
787
+ return Number.parseInt(maxAge[1], 10) * 1e3;
788
+ }
789
+ }
790
+ const expires = headers.get("expires");
791
+ if (expires) {
792
+ const expiresTime = Date.parse(expires);
793
+ if (Number.isFinite(expiresTime)) {
794
+ return expiresTime - now;
795
+ }
796
+ }
797
+ return CACHE_TTL_MS;
798
+ }
799
+ async function fetchManifest(baseUrl, retryPolicy, manifestTimeoutMs = DEFAULT_MANIFEST_TIMEOUT_MS, expectedPayee) {
224
800
  const cached = manifestCache.get(baseUrl);
225
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
801
+ if (cached && Date.now() < cached.expiresAt) {
802
+ verifyManifestSignature(cached.manifest, expectedPayee);
803
+ assertClientVersionSupported(cached.manifest, baseUrl);
804
+ assertManifestValid(cached.manifest, baseUrl);
805
+ assertManifestActive(cached.manifest, baseUrl);
226
806
  return cached.manifest;
227
807
  }
228
808
  const url = baseUrl.replace(/\/$/, "") + "/.well-known/routedock.json";
229
- let raw;
230
- try {
231
- const resp = await fetch(url);
232
- if (!resp.ok) {
233
- throw new RouteDockManifestError(`Manifest fetch failed: HTTP ${resp.status} from ${url}`);
809
+ return withRetry(async () => {
810
+ let raw;
811
+ let resp;
812
+ let expiresAt = 0;
813
+ try {
814
+ resp = await fetch(url, { signal: AbortSignal.timeout(manifestTimeoutMs) });
815
+ if (!resp.ok) {
816
+ if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
817
+ throw httpStatusToError(
818
+ `Manifest fetch failed: HTTP ${resp.status} from ${url}`,
819
+ resp.status,
820
+ resp
821
+ );
822
+ }
823
+ throw new RouteDockManifestError(
824
+ `Manifest fetch failed: HTTP ${resp.status} from ${url}`
825
+ );
826
+ }
827
+ } catch (err) {
828
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
829
+ throw new RouteDockManifestTimeoutError(
830
+ `Manifest fetch timed out after ${manifestTimeoutMs}ms from ${url}`,
831
+ { cause: err }
832
+ );
833
+ }
834
+ if (err instanceof RouteDockError) throw err;
835
+ throw wrapFetchError(err, `Manifest fetch error from ${url}`);
836
+ }
837
+ const now = Date.now();
838
+ expiresAt = now + ttlFromHeaders(resp.headers, now);
839
+ try {
840
+ raw = await resp.json();
841
+ } catch (err) {
842
+ throw new RouteDockManifestError(`Manifest at ${url} is not valid JSON`, { cause: err });
843
+ }
844
+ const result = validator.validate(raw);
845
+ if (!result.valid) {
846
+ const msgs = result.errors.map((err) => err.error).join("; ");
847
+ throw new RouteDockManifestError(`Invalid manifest at ${url}: ${msgs}`);
848
+ }
849
+ const manifest = raw;
850
+ assertManifestValid(manifest, baseUrl);
851
+ verifyManifestSignature(manifest, expectedPayee);
852
+ assertClientVersionSupported(manifest, baseUrl);
853
+ assertManifestActive(manifest, baseUrl);
854
+ manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now(), expiresAt });
855
+ return manifest;
856
+ }, retryPolicy);
857
+ }
858
+ function selectFromModes(modes, manifest, options) {
859
+ if (options.sustained || options.session) {
860
+ if (options.transport === "websocket") {
861
+ if (modes.includes("mpp-session-ws")) return { mode: "mpp-session-ws" };
862
+ if (modes.includes("mpp-session")) return { mode: "mpp-session" };
863
+ } else {
864
+ if (modes.includes("mpp-session")) return { mode: "mpp-session" };
865
+ if (modes.includes("mpp-session-ws")) return { mode: "mpp-session-ws" };
866
+ }
867
+ }
868
+ if (options.optimize === "cost") {
869
+ let budgetStroops = null;
870
+ if (options.budget_per_request !== void 0) {
871
+ try {
872
+ budgetStroops = nulthSdk.usdcToStroops(options.budget_per_request);
873
+ } catch {
874
+ throw new RouteDockPolicyRejectError("invalid_budget_per_request");
875
+ }
876
+ }
877
+ const candidates = ["x402", "mpp-charge"].filter((mode) => modes.includes(mode)).flatMap((mode) => {
878
+ const amount = manifest.pricing[mode]?.amount;
879
+ if (typeof amount !== "string") return [];
880
+ try {
881
+ return [{ mode, amountStroops: nulthSdk.usdcToStroops(amount) }];
882
+ } catch {
883
+ return [];
884
+ }
885
+ });
886
+ if (candidates.length > 0) {
887
+ const affordableCandidates = budgetStroops === null ? candidates : candidates.filter((candidate) => candidate.amountStroops <= budgetStroops);
888
+ const cheapestCandidate = [...affordableCandidates].sort(
889
+ (a, b) => a.amountStroops < b.amountStroops ? -1 : a.amountStroops > b.amountStroops ? 1 : 0
890
+ )[0];
891
+ if (cheapestCandidate) {
892
+ return { mode: cheapestCandidate.mode, reason: "cost-optimized" };
893
+ }
894
+ if (budgetStroops !== null) {
895
+ throw new RouteDockPolicyRejectError("budget_per_request_exceeded");
896
+ }
234
897
  }
235
- raw = await resp.json();
236
- } catch (err) {
237
- if (err instanceof RouteDockManifestError) throw err;
238
- throw new RouteDockManifestError(`Manifest fetch error: ${String(err)}`);
239
898
  }
240
- if (!validateManifest(raw)) {
241
- const msgs = ajv.errorsText(validateManifest.errors);
242
- throw new RouteDockManifestError(`Invalid manifest at ${url}: ${msgs}`);
899
+ if (modes.includes("mpp-charge")) return { mode: "mpp-charge" };
900
+ if (modes.includes("x402")) return { mode: "x402" };
901
+ return void 0;
902
+ }
903
+ function logSelection(manifest, selection, log, deprecated) {
904
+ const reason = selection.reason ? ` (${selection.reason})` : "";
905
+ if (deprecated) {
906
+ log(
907
+ `[RouteDock] WARNING: ${manifest.name} \u2192 ${selection.mode}${reason}; selected deprecated mode because no active supported mode is available`
908
+ );
909
+ return;
243
910
  }
244
- const manifest = raw;
245
- manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now() });
246
- return manifest;
911
+ log(`[RouteDock] ${manifest.name} \u2192 ${selection.mode}${reason}`);
247
912
  }
248
913
  function selectMode(manifest, options = {}) {
249
914
  const modes = manifest.modes;
915
+ const log = options.logger ?? (() => {
916
+ });
917
+ const deprecatedSet = new Set(manifest.deprecated_modes ?? []);
250
918
  if (options.forceMode) {
251
919
  if (!modes.includes(options.forceMode)) {
252
920
  throw new RouteDockNoSupportedModeError(
253
921
  `Provider does not support forced mode: ${options.forceMode} (available: ${modes.join(", ")})`
254
922
  );
255
923
  }
256
- console.log(`[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
924
+ if (deprecatedSet.has(options.forceMode)) {
925
+ log(
926
+ `[RouteDock] WARNING: ${manifest.name} \u2192 ${options.forceMode} (forced); selected mode is deprecated`
927
+ );
928
+ } else {
929
+ log(`[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
930
+ }
257
931
  return options.forceMode;
258
932
  }
259
- if ((options.sustained || options.session) && modes.includes("mpp-session")) {
260
- const mode = "mpp-session";
261
- console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
262
- return mode;
263
- }
264
- if (modes.includes("mpp-charge")) {
265
- const mode = "mpp-charge";
266
- console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
267
- return mode;
933
+ const activeModes = modes.filter((mode) => !deprecatedSet.has(mode));
934
+ const deprecatedModes = modes.filter((mode) => deprecatedSet.has(mode));
935
+ const activeSelection = selectFromModes(activeModes, manifest, options);
936
+ if (activeSelection) {
937
+ logSelection(manifest, activeSelection, log, false);
938
+ return activeSelection.mode;
268
939
  }
269
- if (modes.includes("x402")) {
270
- const mode = "x402";
271
- console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
272
- return mode;
940
+ const deprecatedSelection = selectFromModes(deprecatedModes, manifest, options);
941
+ if (deprecatedSelection) {
942
+ logSelection(manifest, deprecatedSelection, log, true);
943
+ return deprecatedSelection.mode;
273
944
  }
274
945
  throw new RouteDockNoSupportedModeError(
275
946
  `No supported payment mode found in manifest (modes: ${modes.join(", ")})`
276
947
  );
277
948
  }
949
+ function rankProvidersByLatency(manifests, targetRegion) {
950
+ return [...manifests].sort((a, b) => {
951
+ const latA = a.latency_hints?.[targetRegion];
952
+ const latB = b.latency_hints?.[targetRegion];
953
+ const inRegionsA = a.regions?.includes(targetRegion) ?? false;
954
+ const inRegionsB = b.regions?.includes(targetRegion) ?? false;
955
+ if (latA !== void 0 && latB !== void 0) {
956
+ return latA - latB;
957
+ }
958
+ if (latA !== void 0) return -1;
959
+ if (latB !== void 0) return 1;
960
+ if (inRegionsA && !inRegionsB) return -1;
961
+ if (!inRegionsA && inRegionsB) return 1;
962
+ return 0;
963
+ });
964
+ }
278
965
  var CAIP2 = {
279
966
  testnet: "stellar:testnet",
280
967
  mainnet: "stellar:pubnet"
281
968
  };
282
- var X402Client = class {
283
- constructor(secretKey, network) {
284
- this.secretKey = secretKey;
969
+ var X402Client = class _X402Client {
970
+ constructor(secretKeyOrSigner, network, retryPolicy) {
285
971
  this.network = network;
972
+ this.retryPolicy = retryPolicy;
286
973
  const caip2 = CAIP2[network];
287
- const signer = stellar.createEd25519Signer(secretKey, caip2);
288
- const scheme = new client.ExactStellarScheme(signer);
974
+ this.signer = typeof secretKeyOrSigner === "string" ? stellar.createEd25519Signer(secretKeyOrSigner, caip2) : secretKeyOrSigner;
975
+ const scheme = new client.ExactStellarScheme(this.signer);
289
976
  const core = new client$1.x402Client();
290
977
  core.register(caip2, scheme);
291
978
  this.httpClient = new client$1.x402HTTPClient(core);
292
979
  }
293
- secretKey;
294
980
  network;
981
+ retryPolicy;
295
982
  httpClient;
983
+ signer;
984
+ /** Replace signer (e.g. swap to Nulth ZK account payer before pay) */
985
+ withSigner(signer) {
986
+ return new _X402Client(signer, this.network, this.retryPolicy);
987
+ }
296
988
  async pay(url, manifest) {
297
989
  const pricing = manifest.pricing.x402;
298
990
  if (!pricing) {
@@ -301,82 +993,194 @@ var X402Client = class {
301
993
  if (!pricing.facilitator) {
302
994
  throw new RouteDockManifestError("manifest.pricing.x402.facilitator missing");
303
995
  }
304
- const init = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
305
- if (init.status !== 402) {
306
- const data2 = await init.json();
307
- return { data: data2, txHash: null, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
308
- }
309
- const reqHeader = init.headers.get("X-Payment-Requirements");
310
- if (!reqHeader) {
311
- throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
312
- }
313
- const paymentRequired = http.decodePaymentRequiredHeader(reqHeader);
314
- const paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
315
- const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
316
- const settled = await fetch(url, { headers: paymentHeaders });
317
- if (!settled.ok) {
318
- throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
319
- }
320
- let txHash = null;
321
- const responseHeader = settled.headers.get("X-Payment-Response");
322
- if (responseHeader) {
996
+ return withRetry(async () => {
997
+ let init;
323
998
  try {
324
- const settleResponse = http.decodePaymentResponseHeader(responseHeader);
325
- txHash = settleResponse.transaction ?? null;
326
- } catch {
999
+ init = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
1000
+ } catch (err) {
1001
+ throw wrapFetchError(err, "x402 initial request");
327
1002
  }
1003
+ if (init.status !== 402) {
1004
+ if (!init.ok) {
1005
+ throw httpStatusToError(
1006
+ `x402 initial request failed: HTTP ${init.status}`,
1007
+ init.status,
1008
+ init
1009
+ );
1010
+ }
1011
+ let data2;
1012
+ try {
1013
+ data2 = await init.json();
1014
+ } catch {
1015
+ throw new RouteDockManifestError(`Failed to parse JSON from non-402 response (HTTP ${init.status})`);
1016
+ }
1017
+ return { data: data2, txHash: null, mode: "x402", amount: "0", timestamp: Date.now() };
1018
+ }
1019
+ const reqHeader = init.headers.get("X-Payment-Requirements");
1020
+ if (!reqHeader) {
1021
+ throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
1022
+ }
1023
+ let paymentRequired;
1024
+ try {
1025
+ paymentRequired = http.decodePaymentRequiredHeader(reqHeader);
1026
+ } catch (err) {
1027
+ throw new RouteDockManifestError("402 X-Payment-Requirements header is malformed", {
1028
+ cause: err
1029
+ });
1030
+ }
1031
+ let paymentPayload;
1032
+ try {
1033
+ paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
1034
+ } catch (err) {
1035
+ throw new RouteDockSignatureError(`x402 payment signing failed: ${String(err)}`, {
1036
+ cause: err
1037
+ });
1038
+ }
1039
+ const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
1040
+ let settled;
1041
+ try {
1042
+ settled = await fetch(url, { headers: paymentHeaders });
1043
+ } catch (err) {
1044
+ throw wrapFetchError(err, "x402 settlement request");
1045
+ }
1046
+ if (!settled.ok) {
1047
+ if (settled.status >= 500 || settled.status === 429 || settled.status === 503) {
1048
+ throw httpStatusToError(
1049
+ `x402 payment failed: HTTP ${settled.status}`,
1050
+ settled.status,
1051
+ settled
1052
+ );
1053
+ }
1054
+ throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
1055
+ }
1056
+ let txHash = null;
1057
+ const responseHeader = settled.headers.get("X-Payment-Response");
1058
+ if (responseHeader) {
1059
+ try {
1060
+ const settleResponse = http.decodePaymentResponseHeader(responseHeader);
1061
+ txHash = settleResponse.transaction ?? null;
1062
+ } catch {
1063
+ }
1064
+ }
1065
+ let data;
1066
+ try {
1067
+ data = await settled.json();
1068
+ } catch {
1069
+ throw new RouteDockManifestError("Failed to parse JSON from settled response");
1070
+ }
1071
+ return { data, txHash, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
1072
+ }, this.retryPolicy);
1073
+ }
1074
+ };
1075
+ var MppChargeClient = class {
1076
+ constructor(keypair, network, retryPolicy) {
1077
+ this.keypair = keypair;
1078
+ this.network = network;
1079
+ this.retryPolicy = retryPolicy;
1080
+ }
1081
+ keypair;
1082
+ network;
1083
+ retryPolicy;
1084
+ async pay(url, manifest) {
1085
+ const pricing = manifest.pricing["mpp-charge"];
1086
+ if (!pricing) {
1087
+ throw new RouteDockManifestError("manifest.pricing.mpp-charge missing");
328
1088
  }
329
- const data = await settled.json();
330
- return { data, txHash, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
331
- }
332
- };
333
- var MppChargeClient = class {
334
- constructor(keypair, network) {
335
- this.keypair = keypair;
336
- this.network = network;
337
- }
338
- keypair;
339
- network;
340
- async pay(url, manifest) {
341
- const pricing = manifest.pricing["mpp-charge"];
342
- if (!pricing) {
343
- throw new RouteDockManifestError("manifest.pricing.mpp-charge missing");
344
- }
345
- let txHash = null;
346
- const mppx = client$2.Mppx.create({
347
- polyfill: false,
348
- methods: [
349
- client$3.stellar.charge({
350
- keypair: this.keypair,
351
- mode: "pull",
352
- onProgress(event) {
353
- if (event.type === "paid") {
354
- txHash = event.hash;
355
- }
356
- }
357
- })
358
- ]
359
- });
360
- const response = await mppx.fetch(url);
361
- if (!response.ok) {
362
- throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`);
363
- }
364
- const data = await response.json();
365
- return { data, txHash, mode: "mpp-charge", amount: pricing.amount, timestamp: Date.now() };
1089
+ return withRetry(async () => {
1090
+ let txHash = null;
1091
+ const mppx = client$2.Mppx.create({
1092
+ polyfill: false,
1093
+ methods: [
1094
+ client$3.stellar.charge({
1095
+ keypair: this.keypair,
1096
+ mode: "pull",
1097
+ onProgress(event) {
1098
+ if (event.type === "paid") {
1099
+ txHash = event.hash;
1100
+ }
1101
+ }
1102
+ })
1103
+ ]
1104
+ });
1105
+ let response;
1106
+ try {
1107
+ response = await mppx.fetch(url);
1108
+ } catch (err) {
1109
+ throw wrapFetchError(err, "MPP charge request");
1110
+ }
1111
+ if (!response.ok) {
1112
+ if (response.status >= 500 || response.status === 429 || response.status === 503) {
1113
+ throw httpStatusToError(
1114
+ `MPP charge failed: HTTP ${response.status}`,
1115
+ response.status,
1116
+ response
1117
+ );
1118
+ }
1119
+ throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`);
1120
+ }
1121
+ let data;
1122
+ try {
1123
+ data = await response.json();
1124
+ } catch (cause) {
1125
+ throw new RouteDockManifestError(
1126
+ `Failed to parse JSON from response (HTTP ${response.status})`,
1127
+ { cause }
1128
+ );
1129
+ }
1130
+ return { data, txHash, mode: "mpp-charge", amount: pricing.amount, timestamp: Date.now() };
1131
+ }, this.retryPolicy);
366
1132
  }
367
1133
  };
368
1134
  var MIN_REFUND_WAITING_PERIOD = 17280;
1135
+ var STREAM_CLEANUP_TIMEOUT_MS = 3e3;
1136
+ var WS_CONNECTING = 0;
1137
+ var WS_OPEN = 1;
1138
+ function defaultWebSocketFactory(url, init, handlers) {
1139
+ const socket = new WebSocket(
1140
+ url,
1141
+ { headers: init.headers }
1142
+ );
1143
+ socket.onopen = () => handlers.onOpen();
1144
+ socket.onmessage = (event) => {
1145
+ const data = event.data;
1146
+ if (typeof data === "string") {
1147
+ handlers.onMessage(data);
1148
+ } else if (data instanceof ArrayBuffer) {
1149
+ handlers.onMessage(Buffer.from(data).toString("utf8"));
1150
+ } else {
1151
+ void data.arrayBuffer().then((buffer) => handlers.onMessage(Buffer.from(buffer).toString("utf8"))).catch(() => {
1152
+ });
1153
+ }
1154
+ };
1155
+ socket.onclose = (event) => handlers.onClose(event.code, event.reason);
1156
+ socket.onerror = () => handlers.onError();
1157
+ return socket;
1158
+ }
1159
+ function parseIntegerString(value) {
1160
+ return /^\d+$/.test(value) ? BigInt(value) : null;
1161
+ }
1162
+ function clientCumulativeStoreKey(networkId, channel) {
1163
+ return `stellar:channel:client:${networkId}:${channel}:cumulative`;
1164
+ }
1165
+ function resolveStoreNetworkId(network) {
1166
+ return network === mpp.STELLAR_PUBNET ? mpp.STELLAR_PUBNET : mpp.STELLAR_TESTNET;
1167
+ }
369
1168
  var MppSessionClient = class {
370
- constructor(keypair, network) {
1169
+ constructor(keypair, network, retryPolicy, webSocketFactory = defaultWebSocketFactory) {
371
1170
  this.keypair = keypair;
372
1171
  this.network = network;
1172
+ this.retryPolicy = retryPolicy;
1173
+ this.webSocketFactory = webSocketFactory;
373
1174
  }
374
1175
  keypair;
375
1176
  network;
376
- async openSession(url, manifest, commitmentSecret) {
377
- const pricing = manifest.pricing["mpp-session"];
1177
+ retryPolicy;
1178
+ webSocketFactory;
1179
+ async openSession(url, manifest, commitmentSecret, options, onSpend) {
1180
+ const mode = options?.mode ?? "mpp-session";
1181
+ const pricing = manifest.pricing[mode];
378
1182
  if (!pricing) {
379
- throw new RouteDockManifestError("manifest.pricing.mpp-session missing");
1183
+ throw new RouteDockManifestError(`manifest.pricing.${mode} missing`);
380
1184
  }
381
1185
  const refundPeriod = pricing.refund_waiting_period_ledgers;
382
1186
  if (refundPeriod < MIN_REFUND_WAITING_PERIOD) {
@@ -385,166 +1189,1383 @@ var MppSessionClient = class {
385
1189
  );
386
1190
  }
387
1191
  const commitmentKey = stellarSdk.Keypair.fromSecret(commitmentSecret);
388
- const channelContract = pricing.channel_contract;
1192
+ const channelFactory = pricing.channel_factory;
389
1193
  const agentPublicKey = this.keypair.publicKey();
1194
+ const agentKeypair = this.keypair;
390
1195
  let currentCumulative = 0n;
391
1196
  let vouchersIssued = 0;
1197
+ let rateStroops;
1198
+ try {
1199
+ rateStroops = nulthSdk.usdcToStroops(pricing.rate);
1200
+ } catch (err) {
1201
+ throw new RouteDockManifestError(
1202
+ `manifest.pricing.${mode}.rate is not a valid USDC amount: "${pricing.rate}"`,
1203
+ { cause: err }
1204
+ );
1205
+ }
1206
+ const store = options?.store;
1207
+ let reservedCumulative = null;
1208
+ let storeSeed;
1209
+ const seedFromStore = (networkId, channel) => {
1210
+ storeSeed ??= Promise.resolve(
1211
+ store.get(clientCumulativeStoreKey(networkId, channel))
1212
+ ).then((stored) => {
1213
+ if (stored && typeof stored === "object" && "amount" in stored) {
1214
+ const raw = stored.amount;
1215
+ const amount = typeof raw === "string" ? parseIntegerString(raw) : null;
1216
+ if (amount === null) {
1217
+ throw new RouteDockChannelStateError(
1218
+ `Stored cumulative for channel ${channel} is not an integer string: ${JSON.stringify(raw)}`
1219
+ );
1220
+ }
1221
+ return amount;
1222
+ }
1223
+ return 0n;
1224
+ });
1225
+ return storeSeed;
1226
+ };
1227
+ const reserveNextCumulative = async (challenge) => {
1228
+ const request = challenge.request;
1229
+ const channelValue = request["channel"];
1230
+ if (typeof channelValue !== "string" || channelValue !== channelFactory) {
1231
+ throw new RouteDockChannelStateError(
1232
+ `Challenge names channel ${String(channelValue)}, but the manifest channel_factory is ${channelFactory}`
1233
+ );
1234
+ }
1235
+ const amountValue = request["amount"];
1236
+ const amount = typeof amountValue === "string" ? parseIntegerString(amountValue) : null;
1237
+ if (amount === null) {
1238
+ throw new RouteDockChannelStateError(
1239
+ `Challenge request.amount is not a base-10 integer string: ${JSON.stringify(amountValue)}`
1240
+ );
1241
+ }
1242
+ if (amount !== rateStroops) {
1243
+ throw new RouteDockChannelStateError(
1244
+ `Challenge request.amount ${amount} does not match the manifest rate ${rateStroops} stroops per voucher`
1245
+ );
1246
+ }
1247
+ const methodDetailsValue = request["methodDetails"];
1248
+ const methodDetails = methodDetailsValue !== null && typeof methodDetailsValue === "object" ? methodDetailsValue : void 0;
1249
+ let serverReported = 0n;
1250
+ if (methodDetails && "cumulativeAmount" in methodDetails) {
1251
+ const rawCumulative = methodDetails["cumulativeAmount"];
1252
+ const parsed = typeof rawCumulative === "string" ? parseIntegerString(rawCumulative) : null;
1253
+ if (parsed === null) {
1254
+ throw new RouteDockChannelStateError(
1255
+ `Challenge methodDetails.cumulativeAmount is not a base-10 integer string: ${JSON.stringify(rawCumulative)}`
1256
+ );
1257
+ }
1258
+ serverReported = parsed;
1259
+ }
1260
+ let baseline = reservedCumulative;
1261
+ if (baseline === null) {
1262
+ if (store) {
1263
+ const challengedNetwork = methodDetails?.["network"];
1264
+ const seeded = await seedFromStore(
1265
+ resolveStoreNetworkId(
1266
+ typeof challengedNetwork === "string" ? challengedNetwork : void 0
1267
+ ),
1268
+ channelValue
1269
+ );
1270
+ baseline = reservedCumulative ?? seeded;
1271
+ } else if (serverReported === 0n) {
1272
+ baseline = 0n;
1273
+ } else {
1274
+ throw new RouteDockChannelStateError(
1275
+ `Challenge reports cumulative ${serverReported} for a channel this client has no baseline for. Pass a \`store\` in SessionOptions so the signed cumulative survives restarts, or close the channel before reusing it.`
1276
+ );
1277
+ }
1278
+ }
1279
+ if (serverReported > baseline) {
1280
+ throw new RouteDockChannelStateError(
1281
+ `Challenge reports cumulative ${serverReported}, above the locally reserved baseline ${baseline}`
1282
+ );
1283
+ }
1284
+ const next = baseline + rateStroops;
1285
+ reservedCumulative = next;
1286
+ return next;
1287
+ };
1288
+ const releaseReservation = (reserved) => {
1289
+ if (reservedCumulative === reserved) {
1290
+ reservedCumulative = reserved - rateStroops;
1291
+ }
1292
+ };
1293
+ const createGuardedCredential = async (challenge, create) => {
1294
+ const next = await reserveNextCumulative(challenge);
1295
+ try {
1296
+ return await create(next.toString());
1297
+ } catch (err) {
1298
+ releaseReservation(next);
1299
+ throw err;
1300
+ }
1301
+ };
1302
+ const streamWs = (u, m, createCredential, onSigned) => this.streamWebSocket(u, m, createCredential, onSigned);
392
1303
  const mppx = client$2.Mppx.create({
393
1304
  polyfill: false,
394
1305
  methods: [
395
1306
  client$4.stellar.channel({
396
1307
  commitmentKey,
397
1308
  sourceAccount: this.keypair.publicKey(),
1309
+ ...store ? { store } : {},
398
1310
  onProgress(event) {
399
1311
  if (event.type === "signed") {
400
1312
  currentCumulative = BigInt(event.cumulativeAmount);
401
1313
  }
402
1314
  }
403
1315
  })
404
- ]
1316
+ ],
1317
+ // mppx runs onChallenge on the HTTP path only (createCredential skips it),
1318
+ // so the WebSocket path runs the same guard inside streamWebSocket.
1319
+ onChallenge: (challenge, helpers) => createGuardedCredential(
1320
+ challenge,
1321
+ (cumulativeAmount) => helpers.createCredential({ cumulativeAmount })
1322
+ )
405
1323
  });
406
- const openTxHash = channelContract;
1324
+ const retryPolicy = this.retryPolicy;
1325
+ const maxDurationMs = options?.maxDurationMs ?? DEFAULT_MAX_SESSION_DURATION_MS;
1326
+ const listeners = /* @__PURE__ */ new Map();
1327
+ let timeoutId;
1328
+ let closed = false;
1329
+ const emit = (event, payload) => {
1330
+ const set = listeners.get(event);
1331
+ if (!set) return;
1332
+ for (const listener of set) {
1333
+ try {
1334
+ const returned = listener(payload);
1335
+ if (returned && typeof returned.then === "function") {
1336
+ void Promise.resolve(returned).catch(() => {
1337
+ });
1338
+ }
1339
+ } catch {
1340
+ }
1341
+ }
1342
+ };
1343
+ const clearSessionTimer = () => {
1344
+ if (timeoutId !== void 0) {
1345
+ clearTimeout(timeoutId);
1346
+ timeoutId = void 0;
1347
+ }
1348
+ };
1349
+ const network = this.network;
407
1350
  const handle = {
408
- channelId: channelContract,
409
- openTxHash,
410
- async *stream() {
411
- while (true) {
412
- let resp = null;
413
- for (let attempt = 0; attempt < 3; attempt++) {
414
- try {
415
- resp = await mppx.fetch(url);
416
- if (resp.ok) break;
417
- resp = null;
418
- } catch {
419
- if (attempt === 2) throw new RouteDockSessionError("Voucher request failed after 3 retries");
420
- await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
1351
+ channelId: channelFactory,
1352
+ // The channel is pre-deployed and funded before the agent runs, so the
1353
+ // client never issues the channel-open transaction and has no hash for
1354
+ // it. Report null rather than the contract address (a non-transaction
1355
+ // identifier that produces broken explorer links downstream).
1356
+ openTxHash: null,
1357
+ /**
1358
+ * Live session snapshot. vouchersIssued increments once per signed voucher
1359
+ * and currentCumulative is updated by the channel client's onProgress when
1360
+ * a voucher is signed, so a stats() call immediately after a yield always
1361
+ * reflects everything consumed so far.
1362
+ */
1363
+ stats() {
1364
+ return {
1365
+ vouchersIssued,
1366
+ // Same conversion as SessionCloseResult.totalPaid so a per-session
1367
+ // sub-cap can be compared against stats().currentCumulative directly.
1368
+ currentCumulative: (Number(currentCumulative) / 1e7).toFixed(7),
1369
+ channelId: channelFactory,
1370
+ openTxHash: null
1371
+ };
1372
+ },
1373
+ async *stream(options2) {
1374
+ const checkSpend = () => {
1375
+ if (!onSpend) return Promise.resolve();
1376
+ return onSpend(pricing.rate);
1377
+ };
1378
+ if (mode === "mpp-session-ws") {
1379
+ await checkSpend();
1380
+ const onSigned = () => {
1381
+ vouchersIssued++;
1382
+ };
1383
+ for await (const item of streamWs(url, mppx, createGuardedCredential, onSigned)) {
1384
+ yield item;
1385
+ }
1386
+ return;
1387
+ }
1388
+ const concurrency = Math.max(1, options2?.concurrency ?? 1);
1389
+ const assertOpen = () => {
1390
+ if (closed) {
1391
+ throw new RouteDockChannelStateError("session closed");
1392
+ }
1393
+ };
1394
+ const abortController = new AbortController();
1395
+ const doFetch = () => withRetry(async () => {
1396
+ let resp;
1397
+ try {
1398
+ resp = await mppx.fetch(url, { signal: abortController.signal });
1399
+ } catch (err) {
1400
+ if (abortController.signal.aborted) {
1401
+ throw err;
421
1402
  }
1403
+ throw wrapFetchError(err, "Voucher request");
422
1404
  }
423
- if (!resp || !resp.ok) {
424
- throw new RouteDockSessionError(`Voucher request failed: HTTP ${resp?.status ?? "unknown"}`);
1405
+ if (!resp.ok) {
1406
+ if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
1407
+ throw httpStatusToError(
1408
+ `Voucher request failed: HTTP ${resp.status}`,
1409
+ resp.status,
1410
+ resp
1411
+ );
1412
+ }
1413
+ throw new RouteDockChannelStateError(
1414
+ `Voucher request failed: HTTP ${resp.status}`
1415
+ );
1416
+ }
1417
+ try {
1418
+ return await resp.json();
1419
+ } catch (cause) {
1420
+ throw new RouteDockChannelStateError(
1421
+ `Voucher response was not valid JSON (HTTP ${resp.status})`,
1422
+ { cause }
1423
+ );
1424
+ }
1425
+ }, retryPolicy);
1426
+ if (concurrency === 1) {
1427
+ try {
1428
+ while (true) {
1429
+ assertOpen();
1430
+ await checkSpend();
1431
+ const data = await doFetch();
1432
+ vouchersIssued++;
1433
+ yield data;
1434
+ }
1435
+ } finally {
1436
+ abortController.abort();
1437
+ }
1438
+ } else {
1439
+ const queue = [];
1440
+ try {
1441
+ for (let i = 0; i < concurrency; i++) {
1442
+ assertOpen();
1443
+ await checkSpend();
1444
+ const p = doFetch();
1445
+ p.catch(() => {
1446
+ });
1447
+ queue.push(p);
1448
+ }
1449
+ while (true) {
1450
+ assertOpen();
1451
+ const data = await queue.shift();
1452
+ await checkSpend();
1453
+ const p = doFetch();
1454
+ p.catch(() => {
1455
+ });
1456
+ queue.push(p);
1457
+ vouchersIssued++;
1458
+ yield data;
1459
+ }
1460
+ } finally {
1461
+ abortController.abort();
1462
+ let timer;
1463
+ try {
1464
+ await Promise.race([
1465
+ Promise.allSettled(queue),
1466
+ new Promise((resolve) => {
1467
+ timer = setTimeout(resolve, STREAM_CLEANUP_TIMEOUT_MS);
1468
+ timer.unref?.();
1469
+ })
1470
+ ]);
1471
+ } finally {
1472
+ if (timer) {
1473
+ clearTimeout(timer);
1474
+ }
1475
+ }
1476
+ queue.length = 0;
425
1477
  }
426
- vouchersIssued++;
427
- yield await resp.json();
428
1478
  }
429
1479
  },
430
1480
  async close() {
431
- const { rpc: rpcMod, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } = await import('@stellar/stellar-sdk');
432
- const rpcUrl = "https://soroban-testnet.stellar.org";
1481
+ clearSessionTimer();
1482
+ closed = true;
1483
+ const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1484
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
433
1485
  const server = new rpcMod.Server(rpcUrl);
434
- const contract = new Contract(channelContract);
435
- const passphrase = "Test SDF Network ; September 2015";
436
- const account = await server.getAccount(agentPublicKey);
437
- const simTx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: passphrase }).addOperation(contract.call("prepare_commitment", nativeToScVal(currentCumulative, { type: "i128" }))).setTimeout(30).build();
438
- const simResult = await server.simulateTransaction(simTx);
1486
+ const contract = new Contract2(channelFactory);
1487
+ const passphrase = network === "mainnet" ? stellarSdk.Networks.PUBLIC : stellarSdk.Networks.TESTNET;
1488
+ const account = await withRetry(async () => {
1489
+ try {
1490
+ return await server.getAccount(agentPublicKey);
1491
+ } catch (err) {
1492
+ throw wrapFetchError(err, "Horizon getAccount");
1493
+ }
1494
+ }, retryPolicy);
1495
+ const simResult = await withRetry(async () => {
1496
+ try {
1497
+ const simTx = new TransactionBuilder2(account, {
1498
+ fee: BASE_FEE2,
1499
+ networkPassphrase: passphrase
1500
+ }).addOperation(
1501
+ contract.call(
1502
+ "prepare_commitment",
1503
+ nativeToScVal2(currentCumulative, { type: "i128" })
1504
+ )
1505
+ ).setTimeout(30).build();
1506
+ return await server.simulateTransaction(simTx);
1507
+ } catch (err) {
1508
+ throw wrapFetchError(err, "prepare_commitment RPC");
1509
+ }
1510
+ }, retryPolicy);
439
1511
  if (rpcMod.Api.isSimulationError(simResult)) {
440
- throw new RouteDockSessionError(`prepare_commitment simulation failed: ${simResult.error}`);
1512
+ throw new RouteDockChannelStateError(
1513
+ `prepare_commitment simulation failed: ${simResult.error}`
1514
+ );
441
1515
  }
442
1516
  const commitmentBytes = simResult.result?.retval?.bytes();
443
- if (!commitmentBytes) throw new RouteDockSessionError("prepare_commitment returned no bytes");
444
- const signature = commitmentKey.sign(Buffer.from(commitmentBytes));
445
- const closeResp = await fetch(url, {
446
- method: "DELETE",
447
- headers: { "Content-Type": "application/json" },
448
- body: JSON.stringify({
449
- amount: currentCumulative.toString(),
450
- signature: Buffer.from(signature).toString("hex")
451
- })
452
- });
453
- if (!closeResp.ok) {
454
- throw new RouteDockSessionError(`Channel close failed: HTTP ${closeResp.status}`);
1517
+ if (!commitmentBytes) {
1518
+ throw new RouteDockChannelStateError("prepare_commitment returned no bytes");
455
1519
  }
456
- const closeData = await closeResp.json();
457
- const closeTxHash = closeData.closeTxHash ?? null;
458
- if (!closeTxHash) {
459
- throw new RouteDockSessionError("Channel close response missing closeTxHash");
1520
+ let signature;
1521
+ try {
1522
+ signature = commitmentKey.sign(Buffer.from(commitmentBytes));
1523
+ } catch (err) {
1524
+ throw new RouteDockSignatureError("Channel close commitment signing failed", {
1525
+ cause: err
1526
+ });
460
1527
  }
1528
+ const closeData = await withRetry(async () => {
1529
+ let closeResp;
1530
+ try {
1531
+ closeResp = await fetch(url, {
1532
+ method: "DELETE",
1533
+ headers: { "Content-Type": "application/json" },
1534
+ body: JSON.stringify({
1535
+ amount: currentCumulative.toString(),
1536
+ signature: Buffer.from(signature).toString("hex")
1537
+ })
1538
+ });
1539
+ } catch (err) {
1540
+ throw wrapFetchError(err, "Channel close request");
1541
+ }
1542
+ if (!closeResp.ok) {
1543
+ if (closeResp.status >= 500 || closeResp.status === 429 || closeResp.status === 503) {
1544
+ throw httpStatusToError(
1545
+ `Channel close failed: HTTP ${closeResp.status}`,
1546
+ closeResp.status,
1547
+ closeResp
1548
+ );
1549
+ }
1550
+ throw new RouteDockChannelStateError(
1551
+ `Channel close failed: HTTP ${closeResp.status}`
1552
+ );
1553
+ }
1554
+ let body;
1555
+ try {
1556
+ body = await closeResp.json();
1557
+ } catch (cause) {
1558
+ throw new RouteDockChannelStateError(
1559
+ `Channel close response was not valid JSON (HTTP ${closeResp.status})`,
1560
+ { cause }
1561
+ );
1562
+ }
1563
+ const closeTxHash = body.closeTxHash ?? null;
1564
+ if (!closeTxHash) {
1565
+ throw new RouteDockChannelStateError(
1566
+ "Channel close response missing closeTxHash"
1567
+ );
1568
+ }
1569
+ return { closeTxHash, body };
1570
+ }, retryPolicy);
461
1571
  const totalPaid = (Number(currentCumulative) / 1e7).toFixed(7);
462
- return { closeTxHash, totalPaid, vouchersIssued };
1572
+ return {
1573
+ closeTxHash: closeData.closeTxHash,
1574
+ totalPaid,
1575
+ vouchersIssued
1576
+ };
1577
+ },
1578
+ async requestRefund() {
1579
+ const { rpc: rpcMod, Contract: Contract2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1580
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1581
+ const server = new rpcMod.Server(rpcUrl);
1582
+ const contract = new Contract2(channelFactory);
1583
+ const passphrase = network === "mainnet" ? stellarSdk.Networks.PUBLIC : stellarSdk.Networks.TESTNET;
1584
+ try {
1585
+ const account = await server.getAccount(agentPublicKey);
1586
+ const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("request_refund")).setTimeout(30).build();
1587
+ const simResult = await server.simulateTransaction(tx);
1588
+ if (rpcMod.Api.isSimulationError(simResult)) {
1589
+ throw new RouteDockDisputeError(`request_refund simulation failed: ${simResult.error}`);
1590
+ }
1591
+ const preparedTx = await server.prepareTransaction(tx);
1592
+ preparedTx.sign(agentKeypair);
1593
+ const result = await server.sendTransaction(preparedTx);
1594
+ if (result.status === "ERROR") {
1595
+ const errDetail = result.errorResult ? JSON.stringify(result.errorResult) : "status ERROR";
1596
+ throw new RouteDockDisputeError(`Refund request transaction failed: ${errDetail}`);
1597
+ }
1598
+ if (!result.hash) {
1599
+ throw new RouteDockDisputeError("Refund request transaction not sent");
1600
+ }
1601
+ return result.hash;
1602
+ } catch (err) {
1603
+ if (err instanceof RouteDockDisputeError) throw err;
1604
+ throw new RouteDockDisputeError(`Failed to request refund: ${err instanceof Error ? err.message : String(err)}`);
1605
+ }
1606
+ },
1607
+ async settleWithLatestVoucher() {
1608
+ const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1609
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1610
+ const server = new rpcMod.Server(rpcUrl);
1611
+ const contract = new Contract2(channelFactory);
1612
+ const passphrase = network === "mainnet" ? stellarSdk.Networks.PUBLIC : stellarSdk.Networks.TESTNET;
1613
+ try {
1614
+ const account = await server.getAccount(agentPublicKey);
1615
+ const simTx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("prepare_commitment", nativeToScVal2(currentCumulative, { type: "i128" }))).setTimeout(30).build();
1616
+ const simResult = await server.simulateTransaction(simTx);
1617
+ if (rpcMod.Api.isSimulationError(simResult)) {
1618
+ throw new RouteDockDisputeError(`prepare_commitment simulation failed: ${simResult.error}`);
1619
+ }
1620
+ const commitmentBytes = simResult.result?.retval?.bytes();
1621
+ if (!commitmentBytes) throw new RouteDockDisputeError("prepare_commitment returned no bytes");
1622
+ const signature = commitmentKey.sign(Buffer.from(commitmentBytes));
1623
+ const settleAccount = await server.getAccount(agentPublicKey);
1624
+ const settleTx = new TransactionBuilder2(settleAccount, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(
1625
+ contract.call(
1626
+ "settle_with_signature",
1627
+ nativeToScVal2(currentCumulative, { type: "i128" }),
1628
+ nativeToScVal2(Buffer.from(signature))
1629
+ )
1630
+ ).setTimeout(30).build();
1631
+ const preparedSettleTx = await server.prepareTransaction(settleTx);
1632
+ preparedSettleTx.sign(agentKeypair);
1633
+ const settleResult = await server.sendTransaction(preparedSettleTx);
1634
+ if (settleResult.status === "ERROR") {
1635
+ const errDetail = settleResult.errorResult ? JSON.stringify(settleResult.errorResult) : "status ERROR";
1636
+ throw new RouteDockDisputeError(`Settlement transaction failed: ${errDetail}`);
1637
+ }
1638
+ if (!settleResult.hash) {
1639
+ throw new RouteDockDisputeError("Settlement transaction not sent");
1640
+ }
1641
+ return settleResult.hash;
1642
+ } catch (err) {
1643
+ if (err instanceof RouteDockDisputeError) throw err;
1644
+ throw new RouteDockDisputeError(`Failed to settle with latest voucher: ${err instanceof Error ? err.message : String(err)}`);
1645
+ }
1646
+ },
1647
+ async getDisputeStatus() {
1648
+ const { rpc: rpcMod, Contract: Contract2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1649
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1650
+ const server = new rpcMod.Server(rpcUrl);
1651
+ const contract = new Contract2(channelFactory);
1652
+ const passphrase = network === "mainnet" ? stellarSdk.Networks.PUBLIC : stellarSdk.Networks.TESTNET;
1653
+ try {
1654
+ const account = await server.getAccount(agentPublicKey);
1655
+ const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("get_channel_state")).setTimeout(30).build();
1656
+ const simResult = await server.simulateTransaction(tx);
1657
+ if (rpcMod.Api.isSimulationError(simResult)) {
1658
+ throw new RouteDockChannelStateError(`Failed to query channel state: ${simResult.error}`);
1659
+ }
1660
+ const retval = simResult.result?.retval;
1661
+ if (!retval) {
1662
+ throw new RouteDockChannelStateError("No channel state returned");
1663
+ }
1664
+ if (typeof retval === "object" && retval !== null) {
1665
+ const status = retval.status;
1666
+ if (status === "open") return "open";
1667
+ if (status === "in_refund_window") return "in-refund-window";
1668
+ if (status === "refundable") return "refundable";
1669
+ if (status === "settled") return "settled";
1670
+ }
1671
+ return "open";
1672
+ } catch (err) {
1673
+ if (err instanceof RouteDockChannelStateError) throw err;
1674
+ throw new RouteDockChannelStateError(`Failed to get dispute status: ${err instanceof Error ? err.message : String(err)}`);
1675
+ }
1676
+ },
1677
+ on(event, listener) {
1678
+ let set = listeners.get(event);
1679
+ if (!set) {
1680
+ set = /* @__PURE__ */ new Set();
1681
+ listeners.set(event, set);
1682
+ }
1683
+ const stored = listener;
1684
+ set.add(stored);
1685
+ return () => {
1686
+ set?.delete(stored);
1687
+ };
463
1688
  }
464
1689
  };
1690
+ if (Number.isFinite(maxDurationMs) && maxDurationMs > 0) {
1691
+ timeoutId = setTimeout(() => {
1692
+ if (closed) return;
1693
+ emit("session:timeout", { maxDurationMs });
1694
+ void handle.close().catch((error) => {
1695
+ emit("session:close-failed", { maxDurationMs, error });
1696
+ console.warn(
1697
+ `RouteDock: maxDuration auto-close failed after ${maxDurationMs}ms \u2014 the channel may still hold collateral; retry close() or call requestRefund().`,
1698
+ error
1699
+ );
1700
+ });
1701
+ }, maxDurationMs);
1702
+ timeoutId.unref?.();
1703
+ }
465
1704
  return handle;
466
1705
  }
1706
+ /**
1707
+ * mpp-session-ws streaming transport.
1708
+ *
1709
+ * Mirrors the issue's three-step sequence:
1710
+ * 1. open the channel — probe the provider URL over HTTP;
1711
+ * 2. negotiate the voucher — sign the mppx 402 challenge into a credential;
1712
+ * 3. upgrade the HTTP connection to WebSocket — hand the signed voucher to
1713
+ * the provider as the Authorization header of the WebSocket handshake.
1714
+ *
1715
+ * Each server frame is yielded (JSON frames parsed; raw strings yielded
1716
+ * as-is). The stream ends when the server closes the socket with a normal
1717
+ * close code (1000/1001); abnormal closes and connection failures surface as
1718
+ * RouteDockChannelStateError.
1719
+ */
1720
+ async *streamWebSocket(url, mppx$1, createCredential, onSigned) {
1721
+ const request = { method: "GET" };
1722
+ let probe;
1723
+ try {
1724
+ probe = await mppx$1.rawFetch(url, request);
1725
+ } catch (err) {
1726
+ throw wrapFetchError(err, "Voucher request");
1727
+ }
1728
+ if (probe.status !== 402) {
1729
+ if (probe.status >= 500 || probe.status === 429 || probe.status === 503) {
1730
+ throw httpStatusToError(
1731
+ `Voucher request failed: HTTP ${probe.status}`,
1732
+ probe.status,
1733
+ probe
1734
+ );
1735
+ }
1736
+ throw new RouteDockChannelStateError(
1737
+ `Voucher request failed: expected HTTP 402 challenge, got HTTP ${probe.status}`
1738
+ );
1739
+ }
1740
+ let credential;
1741
+ try {
1742
+ credential = await createCredential(
1743
+ mppx.Challenge.fromResponse(probe),
1744
+ (cumulativeAmount) => mppx$1.createCredential(probe, { cumulativeAmount })
1745
+ );
1746
+ onSigned?.();
1747
+ } catch (err) {
1748
+ throw wrapFetchError(err, "Voucher credential");
1749
+ }
1750
+ const wsUrl = url.replace(/^http/, "ws");
1751
+ const queue = [];
1752
+ const waiters = [];
1753
+ const drain = () => {
1754
+ while (queue.length > 0 && waiters.length > 0) {
1755
+ const waiter = waiters.shift();
1756
+ waiter(queue.shift());
1757
+ }
1758
+ };
1759
+ const nextEvent = () => queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((resolve) => {
1760
+ waiters.push(resolve);
1761
+ });
1762
+ const socket = this.webSocketFactory(
1763
+ wsUrl,
1764
+ { headers: { authorization: credential } },
1765
+ {
1766
+ onOpen: () => {
1767
+ queue.push({ type: "open" });
1768
+ drain();
1769
+ },
1770
+ onMessage: (data) => {
1771
+ queue.push({ type: "message", data });
1772
+ drain();
1773
+ },
1774
+ onClose: (code, reason) => {
1775
+ queue.push({ type: "close", code, reason });
1776
+ drain();
1777
+ },
1778
+ onError: () => {
1779
+ queue.push({ type: "error" });
1780
+ drain();
1781
+ }
1782
+ }
1783
+ );
1784
+ try {
1785
+ for (; ; ) {
1786
+ const event = await nextEvent();
1787
+ if (event.type === "open") break;
1788
+ if (event.type === "error") {
1789
+ throw new RouteDockChannelStateError(
1790
+ "WebSocket upgrade failed: connection error"
1791
+ );
1792
+ }
1793
+ if (event.type === "close") {
1794
+ throw new RouteDockChannelStateError(
1795
+ `WebSocket upgrade failed: server closed the connection before upgrading (code ${event.code})`
1796
+ );
1797
+ }
1798
+ }
1799
+ for (; ; ) {
1800
+ const event = await nextEvent();
1801
+ if (event.type === "message") {
1802
+ let parsed = event.data;
1803
+ try {
1804
+ parsed = JSON.parse(event.data);
1805
+ } catch {
1806
+ }
1807
+ yield parsed;
1808
+ continue;
1809
+ }
1810
+ if (event.type === "error") {
1811
+ throw new RouteDockChannelStateError("WebSocket connection error");
1812
+ }
1813
+ if (event.type === "open") continue;
1814
+ if (event.code === 1e3 || event.code === 1001) {
1815
+ return;
1816
+ }
1817
+ throw new RouteDockChannelStateError(
1818
+ `WebSocket stream ended abnormally (code ${event.code}${event.reason ? `: ${event.reason}` : ""})`
1819
+ );
1820
+ }
1821
+ } finally {
1822
+ if (socket.readyState === WS_CONNECTING || socket.readyState === WS_OPEN) {
1823
+ socket.close(1e3, "stream ended");
1824
+ }
1825
+ }
1826
+ }
1827
+ };
1828
+ var NulthPolicyError = class extends Error {
1829
+ code;
1830
+ constructor(code, message) {
1831
+ super(message);
1832
+ this.name = "NulthPolicyError";
1833
+ this.code = code;
1834
+ }
1835
+ };
1836
+ function sha256Hex(data) {
1837
+ const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
1838
+ return crypto$1.createHash("sha256").update(buf).digest("hex");
1839
+ }
1840
+ function commitDailyCap(dailyCapStroops, witnessSecret) {
1841
+ return sha256Hex(`${witnessSecret}:cap:${dailyCapStroops.toString()}`);
1842
+ }
1843
+ function commitAllowlist(payees, witnessSecret) {
1844
+ const sorted = [...payees].sort().join(",");
1845
+ return sha256Hex(`${witnessSecret}:allowlist:${sorted}`);
1846
+ }
1847
+ function hashPayee(payee) {
1848
+ return sha256Hex(`payee:${payee}`);
1849
+ }
1850
+ function dayBucketFromLedger(ledgerSequence) {
1851
+ return Math.floor(ledgerSequence / 17280);
1852
+ }
1853
+ function authDigestFromEntry(authEntryBase64) {
1854
+ const entryHash = stellarSdk.hash(Buffer.from(authEntryBase64, "base64"));
1855
+ return sha256Hex(entryHash);
1856
+ }
1857
+ function assertAuthEntryMatchesContext(authEntry, from, ctx) {
1858
+ let preimage;
1859
+ try {
1860
+ preimage = stellarSdk.xdr.HashIdPreimage.fromXDR(authEntry, "base64");
1861
+ } catch {
1862
+ throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not decode as a HashIdPreimage");
1863
+ }
1864
+ if (preimage.switch() !== stellarSdk.xdr.EnvelopeType.envelopeTypeSorobanAuthorization()) {
1865
+ throw new NulthPolicyError(
1866
+ "auth_entry_mismatch",
1867
+ "authEntry is not a Soroban authorization preimage"
1868
+ );
1869
+ }
1870
+ const inv = preimage.sorobanAuthorization().invocation();
1871
+ if (inv.subInvocations().length !== 0) {
1872
+ throw new NulthPolicyError("auth_entry_mismatch", "authEntry has unexpected sub-invocations");
1873
+ }
1874
+ if (inv.function().switch() !== stellarSdk.xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn()) {
1875
+ throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not invoke a contract function");
1876
+ }
1877
+ const fn = inv.function().contractFn();
1878
+ const contractAddress = stellarSdk.Address.fromScAddress(fn.contractAddress()).toString();
1879
+ if (contractAddress !== ctx.assetContract) {
1880
+ throw new NulthPolicyError(
1881
+ "auth_entry_mismatch",
1882
+ `authEntry contract ${contractAddress} does not match assetContract ${ctx.assetContract}`
1883
+ );
1884
+ }
1885
+ const functionName = fn.functionName().toString();
1886
+ if (functionName !== "transfer") {
1887
+ throw new NulthPolicyError(
1888
+ "auth_entry_mismatch",
1889
+ `authEntry function ${functionName} does not match expected transfer`
1890
+ );
1891
+ }
1892
+ const args = fn.args();
1893
+ if (args.length !== 3) {
1894
+ throw new NulthPolicyError("auth_entry_mismatch", `authEntry transfer has ${args.length} args, expected 3`);
1895
+ }
1896
+ const entryFrom = stellarSdk.scValToNative(args[0]);
1897
+ if (entryFrom !== from) {
1898
+ throw new NulthPolicyError(
1899
+ "auth_entry_mismatch",
1900
+ `authEntry from ${entryFrom} does not match nulthAccount ${from}`
1901
+ );
1902
+ }
1903
+ const entryTo = stellarSdk.scValToNative(args[1]);
1904
+ if (entryTo !== ctx.payee) {
1905
+ throw new NulthPolicyError(
1906
+ "auth_entry_mismatch",
1907
+ `authEntry to ${entryTo} does not match paymentContext.payee ${ctx.payee}`
1908
+ );
1909
+ }
1910
+ const entryAmount = stellarSdk.scValToNative(args[2]);
1911
+ if (entryAmount !== ctx.amountStroops) {
1912
+ throw new NulthPolicyError(
1913
+ "auth_entry_mismatch",
1914
+ `authEntry amount ${entryAmount} does not match paymentContext.amountStroops ${ctx.amountStroops}`
1915
+ );
1916
+ }
1917
+ }
1918
+ function insecureMockProof(preimage) {
1919
+ return crypto$1.createHash("sha512").update(`nulth:${preimage}`).digest("base64");
1920
+ }
1921
+ function encodeAuthSignature(proof) {
1922
+ const payload = { nulth: "zk-v1", proof };
1923
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
1924
+ }
1925
+ function usdcToStroops2(amount) {
1926
+ const [whole = "0", frac = ""] = amount.split(".");
1927
+ const padded = (frac + "0000000").slice(0, 7);
1928
+ return BigInt(whole) * 10000000n + BigInt(padded);
1929
+ }
1930
+ var NulthClient = class {
1931
+ constructor(config) {
1932
+ this.config = config;
1933
+ this.policy = { ...config.policy };
1934
+ this.prover = config.prover ?? "mock";
1935
+ if (this.prover !== "mock") {
1936
+ throw new RouteDockManifestError(
1937
+ `NulthClient: unknown prover backend ${String(config.prover)}; only 'mock' is implemented`
1938
+ );
1939
+ }
1940
+ if (config.network !== "testnet") {
1941
+ throw new RouteDockManifestError(
1942
+ `NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
1943
+ );
1944
+ }
1945
+ console.warn("NulthClient is using the insecure mock prover; proofs are not cryptographically sound");
1946
+ }
1947
+ config;
1948
+ policy;
1949
+ prover;
1950
+ get proverBackend() {
1951
+ return this.prover;
1952
+ }
1953
+ get nulthAccount() {
1954
+ return this.config.nulthAccount;
1955
+ }
1956
+ get allowlistCommitment() {
1957
+ return this.policy.allowlistCommitment;
1958
+ }
1959
+ get capCommitment() {
1960
+ return commitDailyCap(this.policy.dailyCapStroops, this.policy.witnessSecret);
1961
+ }
1962
+ buildPaymentAuthProof(context) {
1963
+ this.enforcePolicy(context);
1964
+ const bucket = dayBucketFromLedger(context.ledgerSequence);
1965
+ if (bucket !== this.policy.dayBucket) {
1966
+ this.policy.dayBucket = bucket;
1967
+ this.policy.dailySpendStroops = 0n;
1968
+ }
1969
+ const authDigest = authDigestFromEntry(context.authEntry);
1970
+ const capCommitment = this.capCommitment;
1971
+ const payeeHash = hashPayee(context.payee);
1972
+ const preimage = [
1973
+ authDigest,
1974
+ this.policy.allowlistCommitment,
1975
+ capCommitment,
1976
+ payeeHash,
1977
+ context.amountStroops.toString(),
1978
+ bucket.toString(),
1979
+ this.config.nulthAccount
1980
+ ].join("|");
1981
+ const proof = {
1982
+ version: 1,
1983
+ proof: insecureMockProof(preimage),
1984
+ publicInputs: {
1985
+ authDigest,
1986
+ allowlistCommitment: this.policy.allowlistCommitment,
1987
+ capCommitment,
1988
+ payeeHash,
1989
+ amountStroops: context.amountStroops.toString(),
1990
+ dayBucket: bucket.toString()
1991
+ }
1992
+ };
1993
+ this.policy.dailySpendStroops += context.amountStroops;
1994
+ return proof;
1995
+ }
1996
+ enforcePolicy(context) {
1997
+ if (this.policy.expiryLedger !== void 0 && context.ledgerSequence > this.policy.expiryLedger) {
1998
+ throw new NulthPolicyError("session_expired", "Nulth session expired");
1999
+ }
2000
+ if (!this.policy.allowedPayees.includes(context.payee)) {
2001
+ throw new NulthPolicyError("payee_not_allowed", `Payee ${context.payee} not in off-chain allowlist`);
2002
+ }
2003
+ const bucket = dayBucketFromLedger(context.ledgerSequence);
2004
+ const spend = bucket === this.policy.dayBucket ? this.policy.dailySpendStroops : 0n;
2005
+ if (spend + context.amountStroops > this.policy.dailyCapStroops) {
2006
+ throw new NulthPolicyError("daily_cap_exceeded", "Nulth daily cap exceeded");
2007
+ }
2008
+ }
2009
+ };
2010
+ function createNulthSigner(config) {
2011
+ const client = new NulthClient(config);
2012
+ const nulthAccount = config.nulthAccount;
2013
+ return {
2014
+ address: nulthAccount,
2015
+ signAuthEntry: async (authEntry) => {
2016
+ const ctx = config.paymentContext;
2017
+ if (!ctx) throw new Error("Nulth paymentContext must be set before signing");
2018
+ assertAuthEntryMatchesContext(authEntry, nulthAccount, ctx);
2019
+ const proof = client.buildPaymentAuthProof({ authEntry, ...ctx });
2020
+ return { signedAuthEntry: encodeAuthSignature(proof), signerAddress: nulthAccount };
2021
+ }
2022
+ };
2023
+ }
2024
+ function setNulthPaymentContext(config, context) {
2025
+ config.paymentContext = context;
2026
+ }
2027
+ function paymentContextFromManifest(manifest, mode, ledgerSequence) {
2028
+ const pricing = manifest.pricing[mode];
2029
+ if (!pricing) throw new Error(`manifest.pricing.${mode} missing`);
2030
+ return {
2031
+ payee: manifest.payee,
2032
+ amountStroops: usdcToStroops2(pricing.amount),
2033
+ assetContract: manifest.asset_contract,
2034
+ ledgerSequence
2035
+ };
2036
+ }
2037
+ function createPolicyState(input) {
2038
+ const dailyCapStroops = usdcToStroops2(input.dailyCapUsdc);
2039
+ return {
2040
+ dailyCapStroops,
2041
+ allowlistCommitment: commitAllowlist(input.allowedPayees, input.witnessSecret),
2042
+ allowedPayees: input.allowedPayees,
2043
+ dailySpendStroops: 0n,
2044
+ dayBucket: dayBucketFromLedger(input.ledgerSequence ?? 0),
2045
+ witnessSecret: input.witnessSecret,
2046
+ ...input.expiryLedger !== void 0 ? { expiryLedger: input.expiryLedger } : {}
2047
+ };
2048
+ }
2049
+ function assertNulthVaultManifest(manifest, nulthAccount) {
2050
+ if (manifest.vault !== void 0 && manifest.vault !== "nulth") {
2051
+ throw new RouteDockManifestError(`Provider vault mode ${manifest.vault} does not match client nulth vault`);
2052
+ }
2053
+ if (manifest.nulth_account && manifest.nulth_account !== nulthAccount) {
2054
+ throw new RouteDockManifestError(`Manifest nulth_account ${manifest.nulth_account} does not match configured ${nulthAccount}`);
2055
+ }
2056
+ }
2057
+ async function fetchLedgerSequence(network) {
2058
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
2059
+ const server = new stellarSdk.rpc.Server(rpcUrl);
2060
+ const latest = await server.getLatestLedger();
2061
+ return latest.sequence;
2062
+ }
2063
+ async function prepareNulthSigner(vault, manifest, mode, network, ledgerSequenceOverride) {
2064
+ assertNulthVaultManifest(manifest, vault.nulthAccount);
2065
+ const prover = vault.prover ?? "mock";
2066
+ if (network === "mainnet" && prover === "mock") {
2067
+ throw new RouteDockManifestError(
2068
+ "nulth vault uses the insecure mock prover and cannot be used on mainnet"
2069
+ );
2070
+ }
2071
+ const ledgerSequence = ledgerSequenceOverride ?? await fetchLedgerSequence(network);
2072
+ const policy = createPolicyState({
2073
+ dailyCapUsdc: vault.dailyCapUsdc,
2074
+ allowedPayees: vault.allowedPayees,
2075
+ witnessSecret: vault.witnessSecret,
2076
+ ledgerSequence,
2077
+ ...vault.expiryLedger !== void 0 ? { expiryLedger: vault.expiryLedger } : {}
2078
+ });
2079
+ const config = {
2080
+ nulthAccount: vault.nulthAccount,
2081
+ network,
2082
+ prover,
2083
+ policy,
2084
+ ...vault.verifierContract !== void 0 ? { verifierContract: vault.verifierContract } : {}
2085
+ };
2086
+ if (mode === "x402" || mode === "mpp-charge") {
2087
+ setNulthPaymentContext(config, paymentContextFromManifest(manifest, mode, ledgerSequence));
2088
+ } else {
2089
+ throw new RouteDockManifestError(`Nulth ZK vault does not support payment mode: ${mode}`);
2090
+ }
2091
+ const signer = createNulthSigner(config);
2092
+ return { signer, config };
2093
+ }
2094
+ function decodeAuthSignature(encoded) {
2095
+ const raw = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
2096
+ if (typeof raw !== "object" || raw === null || raw.nulth !== "zk-v1") {
2097
+ throw new Error("Invalid Nulth ZK auth signature");
2098
+ }
2099
+ return raw;
2100
+ }
2101
+ var FileSpendStore = class {
2102
+ filePath;
2103
+ constructor(filePath) {
2104
+ this.filePath = filePath;
2105
+ }
2106
+ async read() {
2107
+ let data;
2108
+ try {
2109
+ data = await fs__default.default.readFile(this.filePath, "utf-8");
2110
+ } catch (err) {
2111
+ if (err.code === "ENOENT") {
2112
+ return null;
2113
+ }
2114
+ throw new Error(
2115
+ `[FileSpendStore] Failed to read spend file at ${this.filePath}: ${err.message}`
2116
+ );
2117
+ }
2118
+ try {
2119
+ return JSON.parse(data);
2120
+ } catch {
2121
+ throw new Error(
2122
+ `[FileSpendStore] Spend file at ${this.filePath} contains invalid JSON. Delete or repair it before resuming. Raw content: ${data.slice(0, 120)}`
2123
+ );
2124
+ }
2125
+ }
2126
+ async write(state) {
2127
+ const dir = path__default.default.dirname(this.filePath);
2128
+ await fs__default.default.mkdir(dir, { recursive: true });
2129
+ const tmpPath = `${this.filePath}.tmp`;
2130
+ await fs__default.default.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
2131
+ await fs__default.default.rename(tmpPath, this.filePath);
2132
+ }
2133
+ };
2134
+
2135
+ // src/store/SpendStore.ts
2136
+ var InMemorySpendStore = class {
2137
+ state = null;
2138
+ constructor(options = {}) {
2139
+ if (options.warn !== false) {
2140
+ console.warn(
2141
+ "[RouteDock] Using in-memory SpendStore: the daily spend cap is NOT durable and resets on every process restart. Inject a persistent SpendStore via RouteDockClientConfig.spendStore for production safety."
2142
+ );
2143
+ }
2144
+ }
2145
+ async read() {
2146
+ return this.state ? { ...this.state, endpoints: { ...this.state.endpoints } } : null;
2147
+ }
2148
+ async write(state) {
2149
+ this.state = { ...state, endpoints: { ...state.endpoints } };
2150
+ }
467
2151
  };
468
2152
 
469
2153
  // src/client/RouteDockClient.ts
470
- var RouteDockClient = class {
2154
+ var _secrets = /* @__PURE__ */ new WeakMap();
2155
+ function normalizeEndpointCaps(endpointCaps) {
2156
+ if (!endpointCaps) return void 0;
2157
+ const normalized = {};
2158
+ for (const [key, value] of Object.entries(endpointCaps)) {
2159
+ let url;
2160
+ try {
2161
+ url = new URL(key);
2162
+ } catch {
2163
+ throw new RouteDockManifestError(
2164
+ `spendCap.endpointCaps key "${key}" is not a valid URL \u2014 use an origin such as "https://api.example.com"`
2165
+ );
2166
+ }
2167
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
2168
+ throw new RouteDockManifestError(
2169
+ `spendCap.endpointCaps key "${key}" must be an origin only (no path, query, or hash) \u2014 use "${url.origin}"`
2170
+ );
2171
+ }
2172
+ const origin = url.origin;
2173
+ if (Object.prototype.hasOwnProperty.call(normalized, origin)) {
2174
+ throw new RouteDockManifestError(
2175
+ `spendCap.endpointCaps has two keys that both normalize to origin "${origin}" \u2014 remove the duplicate`
2176
+ );
2177
+ }
2178
+ normalized[origin] = value;
2179
+ }
2180
+ return normalized;
2181
+ }
2182
+ function normalizeSpendCap(spendCap) {
2183
+ const endpointCaps = normalizeEndpointCaps(spendCap.endpointCaps);
2184
+ return endpointCaps === void 0 ? spendCap : { ...spendCap, endpointCaps };
2185
+ }
2186
+ var usdcToMicros = nulthSdk.usdcToStroops;
2187
+ var ASSET_ISSUERS = {
2188
+ USDC: USDC_ISSUERS
2189
+ };
2190
+ function getAssetIssuer(asset, network) {
2191
+ return ASSET_ISSUERS[asset]?.[network] ?? "";
2192
+ }
2193
+ var TRUSTLINE_CACHE_TTL_MS = 3e5;
2194
+ var RouteDockClient = class _RouteDockClient {
2195
+ /** Per-(account,asset) trustline existence cache keyed by `${network}:${pubkey}:${asset}` */
2196
+ static _trustlineCache = /* @__PURE__ */ new Map();
471
2197
  keypair;
472
2198
  network;
473
2199
  spendCap;
474
- commitmentSecret;
475
- /** Local daily accumulator keyed by YYYY-MM-DD */
476
- dailySpend = { date: "", total: 0 };
2200
+ retryPolicy;
2201
+ logger;
2202
+ manifestTimeoutMs;
2203
+ expectedPayee;
2204
+ vault;
2205
+ /**
2206
+ * Durable backing store for the local daily spend accumulator (keyed by
2207
+ * YYYY-MM-DD). Totals are persisted as decimal strings of microUSDC
2208
+ * (1 USDC = 10^7) so stores stay JSON-safe with no precision loss.
2209
+ */
2210
+ spendStore;
477
2211
  x402;
478
2212
  charge;
479
2213
  session;
2214
+ /**
2215
+ * Promise-chain mutex for serializing read-modify-write on the spend
2216
+ * accumulator. Each pay() call awaits the previous one, preventing
2217
+ * concurrent read-modify-write races that could overrun the cap.
2218
+ */
2219
+ _spendMutex = Promise.resolve();
2220
+ /**
2221
+ * Pending spend reservations keyed by reserveId. Used by _rollbackSpend
2222
+ * to revert the accumulator when a payment fails after reservation.
2223
+ */
2224
+ _pendingReserves = /* @__PURE__ */ new Map();
480
2225
  constructor(config) {
481
2226
  this.keypair = typeof config.wallet === "string" ? stellarSdk.Keypair.fromSecret(config.wallet) : config.wallet;
482
2227
  this.network = config.network;
483
- this.spendCap = config.spendCap;
484
- this.commitmentSecret = config.commitmentSecret;
2228
+ this.spendCap = config.spendCap ? normalizeSpendCap(config.spendCap) : void 0;
2229
+ this.retryPolicy = config.retryPolicy;
2230
+ this.spendStore = config.spendStore ?? new InMemorySpendStore({ warn: !!config.spendCap });
2231
+ this.logger = config.logger;
2232
+ this.manifestTimeoutMs = config.manifestTimeoutMs;
2233
+ this.expectedPayee = config.expectedPayee;
2234
+ if (config.commitmentSecret) {
2235
+ _secrets.set(this, config.commitmentSecret);
2236
+ }
2237
+ this.vault = config.vault;
2238
+ if (config.commitmentSecret) {
2239
+ _secrets.set(this, config.commitmentSecret);
2240
+ }
485
2241
  const secretKey = this.keypair.secret();
486
- this.x402 = new X402Client(secretKey, this.network);
487
- this.charge = new MppChargeClient(this.keypair, this.network);
488
- this.session = new MppSessionClient(this.keypair, this.network);
2242
+ this.x402 = new X402Client(secretKey, this.network, this.retryPolicy);
2243
+ this.charge = new MppChargeClient(this.keypair, this.network, this.retryPolicy);
2244
+ this.session = new MppSessionClient(this.keypair, this.network, this.retryPolicy);
2245
+ }
2246
+ /** Fetch manifest and select mode — shared by pay() and estimateCost(). */
2247
+ async _resolveManifest(url, options) {
2248
+ const baseUrl = new URL(url).origin;
2249
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2250
+ assertEndpointActive(manifest, url, this.logger);
2251
+ const mode = selectMode(manifest, options);
2252
+ return { manifest, mode };
2253
+ }
2254
+ /**
2255
+ * Serialize async work through the per-instance spend mutex so concurrent
2256
+ * pay() calls cannot interleave read-modify-write on the accumulator.
2257
+ */
2258
+ _withSpendMutex(fn) {
2259
+ const prev = this._spendMutex;
2260
+ let next;
2261
+ next = prev.then(fn, fn);
2262
+ this._spendMutex = next.then(() => {
2263
+ }, () => {
2264
+ });
2265
+ return next;
2266
+ }
2267
+ /**
2268
+ * Check that the payer has a trustline for the payment asset and surface
2269
+ * manifest metadata (regions, latency hints, capabilities). Safe to
2270
+ * call before committing to a payment — for approval gates and manual
2271
+ * trustline remediation.
2272
+ */
2273
+ async preflight(manifest) {
2274
+ assertManifestValid(manifest);
2275
+ await this._checkTrustline(manifest);
2276
+ return {
2277
+ hasTrustline: true,
2278
+ asset: manifest.asset,
2279
+ modes: manifest.modes,
2280
+ ...manifest.regions && { regions: manifest.regions },
2281
+ ...manifest.latency_hints && { latency_hints: manifest.latency_hints },
2282
+ ...manifest.capabilities && { capabilities: manifest.capabilities }
2283
+ };
2284
+ }
2285
+ /**
2286
+ * Verify via Horizon that the payer account has a trustline for the
2287
+ * payment asset declared in the manifest. Throws RouteDockTrustlineError
2288
+ * with an exact Stellar CLI change-trust remediation command when the
2289
+ * trustline is missing. Results are cached per (account, asset) so the
2290
+ * per-payment overhead is one RPC call amortized to zero after the first.
2291
+ */
2292
+ async _checkTrustline(manifest) {
2293
+ const cacheKey = `${this.network}:${this.keypair.publicKey()}:${manifest.asset}`;
2294
+ const cached = _RouteDockClient._trustlineCache.get(cacheKey);
2295
+ if (cached && Date.now() < cached.expiresAt) return;
2296
+ const horizonUrl = this.network === "testnet" ? "https://horizon-testnet.stellar.org" : "https://horizon.stellar.org";
2297
+ const server = new stellarSdk.Horizon.Server(horizonUrl);
2298
+ try {
2299
+ const account = await server.loadAccount(this.keypair.publicKey());
2300
+ const balances = account.balances;
2301
+ const expectedIssuer = getAssetIssuer(manifest.asset, this.network);
2302
+ const hasTrustline = balances.some(
2303
+ (b) => typeof b === "object" && b !== null && "asset_code" in b && b.asset_code === manifest.asset && (!expectedIssuer || b.asset_issuer === expectedIssuer)
2304
+ );
2305
+ if (!hasTrustline) {
2306
+ const remediation = expectedIssuer ? `Run: stellar tx new --source ${this.keypair.publicKey()} --network ${this.network} change-trust --asset ${manifest.asset}:${expectedIssuer} --limit 100000` : `Establish a trustline for ${manifest.asset} with the appropriate issuer on ${this.network}`;
2307
+ throw new RouteDockTrustlineError(manifest.asset, expectedIssuer || "unknown", remediation);
2308
+ }
2309
+ _RouteDockClient._trustlineCache.set(cacheKey, {
2310
+ exists: true,
2311
+ expiresAt: Date.now() + TRUSTLINE_CACHE_TTL_MS
2312
+ });
2313
+ } catch (err) {
2314
+ if (err instanceof RouteDockTrustlineError) throw err;
2315
+ this.logger?.(
2316
+ `[RouteDock] Trustline preflight: could not verify trustline for ${manifest.asset} \u2014 continuing`
2317
+ );
2318
+ }
2319
+ }
2320
+ /**
2321
+ * Pay for one request at `url`. Fetches manifest, selects payment mode,
2322
+ * runs trustline preflight, reserves local spend cap BEFORE executing the
2323
+ * payment, then commits the spend record on success. Rolls back the
2324
+ * reservation if the on-chain payment fails. Concurrent pay() calls are
2325
+ * serialized through a per-instance mutex to prevent spend-cap overrun.
2326
+ */
2327
+ async pay(url, options) {
2328
+ const baseUrl = new URL(url).origin;
2329
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2330
+ assertEndpointActive(manifest, url, this.logger);
2331
+ const mode = selectMode(manifest, { ...options, ...this.logger && { logger: this.logger } });
2332
+ await this._checkTrustline(manifest);
2333
+ if (this.vault?.mode === "nulth") {
2334
+ return this._payWithNulthVault(url, manifest, mode);
2335
+ }
2336
+ let amount;
2337
+ switch (mode) {
2338
+ case "x402":
2339
+ amount = manifest.pricing.x402.amount;
2340
+ break;
2341
+ case "mpp-charge":
2342
+ amount = manifest.pricing["mpp-charge"].amount;
2343
+ break;
2344
+ case "mpp-session":
2345
+ case "mpp-session-ws":
2346
+ throw new RouteDockManifestError(
2347
+ `Use client.openSession() for ${mode} mode \u2014 client.pay() only handles discrete payments`
2348
+ );
2349
+ default:
2350
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2351
+ }
2352
+ const reserveId = await this._checkAndReserveSpend(amount, baseUrl);
2353
+ let result;
2354
+ try {
2355
+ switch (mode) {
2356
+ case "x402":
2357
+ result = await this.x402.pay(url, manifest);
2358
+ break;
2359
+ case "mpp-charge":
2360
+ result = await this.charge.pay(url, manifest);
2361
+ break;
2362
+ default:
2363
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2364
+ }
2365
+ } catch (err) {
2366
+ await this._rollbackSpend(reserveId).catch(() => {
2367
+ });
2368
+ throw err;
2369
+ }
2370
+ await this._commitSpend(reserveId);
2371
+ return result;
2372
+ }
2373
+ /** Nulth ZK vault path — proof built off-chain, attached as auth signature */
2374
+ async _payWithNulthVault(url, manifest, mode) {
2375
+ const prover = this.vault?.prover ?? "mock";
2376
+ if (this.network === "mainnet" && prover === "mock") {
2377
+ throw new RouteDockManifestError("nulth vault uses a MOCK Groth16 prover and cannot be used on mainnet");
2378
+ }
2379
+ if (mode !== "x402") {
2380
+ throw new RouteDockManifestError(
2381
+ 'nulth vault currently supports x402 mode \u2014 force x402 via { forceMode: "x402" }'
2382
+ );
2383
+ }
2384
+ try {
2385
+ const { signer } = await prepareNulthSigner(this.vault, manifest, mode, this.network);
2386
+ const x402 = this.x402.withSigner(signer);
2387
+ const result = await x402.pay(url, manifest);
2388
+ return result;
2389
+ } catch (err) {
2390
+ if (err instanceof NulthPolicyError) {
2391
+ throw new RouteDockPolicyRejectError(err.code);
2392
+ }
2393
+ throw err;
2394
+ }
489
2395
  }
490
2396
  /**
491
- * Pay for one request at `url`. Fetches manifest, selects payment mode,
492
- * checks local spend cap, executes payment, returns result.
2397
+ * Resolve manifest and compute the expected charge WITHOUT submitting any
2398
+ * transaction. Safe to call before committing — for approval gates and
2399
+ * budget-aware routing.
493
2400
  */
494
- async pay(url, options) {
495
- const baseUrl = new URL(url).origin;
496
- const manifest = await fetchManifest(baseUrl);
497
- const mode = selectMode(manifest, options);
498
- let result;
2401
+ async estimateCost(url, options) {
2402
+ const { manifest, mode } = await this._resolveManifest(url, options);
2403
+ let amount;
499
2404
  switch (mode) {
500
2405
  case "x402":
501
- result = await this.x402.pay(url, manifest);
2406
+ amount = manifest.pricing.x402.amount;
502
2407
  break;
503
2408
  case "mpp-charge":
504
- result = await this.charge.pay(url, manifest);
2409
+ amount = manifest.pricing["mpp-charge"].amount;
505
2410
  break;
506
2411
  case "mpp-session":
507
- throw new RouteDockManifestError(
508
- "Use client.openSession() for mpp-session mode \u2014 client.pay() only handles discrete payments"
509
- );
2412
+ amount = manifest.pricing["mpp-session"].rate;
2413
+ break;
2414
+ case "mpp-session-ws":
2415
+ amount = manifest.pricing["mpp-session-ws"].rate;
2416
+ break;
510
2417
  default:
511
2418
  throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
512
2419
  }
513
- this._checkAndRecordSpend(result.amount);
514
- return result;
2420
+ return {
2421
+ amount,
2422
+ asset: manifest.asset,
2423
+ mode,
2424
+ manifest,
2425
+ ...manifest.regions && { regions: manifest.regions },
2426
+ ...manifest.latency_hints && { latency_hints: manifest.latency_hints },
2427
+ ...manifest.capabilities && { capabilities: manifest.capabilities }
2428
+ };
515
2429
  }
516
2430
  /**
517
2431
  * Open a sustained MPP session at `url`. Verifies mpp-session is supported
518
2432
  * before opening a channel. Returns a SessionHandle for streaming + closing.
2433
+ *
2434
+ * By default the session auto-closes after a wall-clock timeout (1h) so an
2435
+ * orphaned channel cannot keep collateral locked on-chain — override or
2436
+ * disable via `options.maxDurationMs`.
519
2437
  */
520
- async openSession(url) {
2438
+ async openSession(url, options) {
521
2439
  const baseUrl = new URL(url).origin;
522
- const manifest = await fetchManifest(baseUrl);
523
- if (!manifest.modes.includes("mpp-session")) {
2440
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2441
+ assertEndpointActive(manifest, url, this.logger);
2442
+ const mode = options?.mode ?? "mpp-session";
2443
+ if (!manifest.modes.includes(mode)) {
524
2444
  throw new RouteDockManifestError(
525
- `Provider at ${baseUrl} does not support mpp-session mode`
2445
+ `Provider at ${baseUrl} does not support ${mode} mode`
526
2446
  );
527
2447
  }
528
- if (!this.commitmentSecret) {
2448
+ const secret = _secrets.get(this);
2449
+ if (!secret) {
529
2450
  throw new RouteDockManifestError(
530
2451
  "commitmentSecret is required in RouteDockClientConfig for mpp-session mode"
531
2452
  );
532
2453
  }
533
- return this.session.openSession(url, manifest, this.commitmentSecret);
2454
+ const endpointKey = new URL(url).origin;
2455
+ const onSpend = this.spendCap ? (amount) => this._recordSessionSpend(amount, endpointKey) : void 0;
2456
+ return this.session.openSession(url, manifest, secret, options, onSpend);
534
2457
  }
535
- /** Check local daily cap and record the spend. Throws if cap exceeded. */
536
- _checkAndRecordSpend(amount) {
537
- if (!this.spendCap) return;
538
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
539
- if (this.dailySpend.date !== today) {
540
- this.dailySpend = { date: today, total: 0 };
541
- }
542
- const amountNum = parseFloat(amount);
543
- const capNum = parseFloat(this.spendCap.daily);
544
- if (this.dailySpend.total + amountNum > capNum) {
545
- throw new RouteDockPolicyRejectedError("local_daily_cap_exceeded");
2458
+ /**
2459
+ * Record spend for an MPP session voucher. Called before each voucher
2460
+ * fetch in stream(). Enforces the same local daily and per-endpoint caps
2461
+ * as discrete pay() calls. Returns a commit function to clean up the
2462
+ * reservation after the voucher fetch succeeds.
2463
+ */
2464
+ async _recordSessionSpend(amount, endpointKey) {
2465
+ const reserveId = await this._checkAndReserveSpend(amount, endpointKey);
2466
+ if (reserveId) {
2467
+ await this._commitSpend(reserveId);
546
2468
  }
547
- this.dailySpend.total += amountNum;
2469
+ }
2470
+ /**
2471
+ * Remove the commitment secret from memory and make it eligible for GC.
2472
+ * Call when this client instance is no longer needed — particularly in
2473
+ * long-lived processes or Worker threads.
2474
+ */
2475
+ dispose() {
2476
+ _secrets.delete(this);
2477
+ }
2478
+ /**
2479
+ * Evict the cached manifest for `url` immediately, so the next pay() /
2480
+ * estimateCost() / openSession() call re-fetches it instead of routing on
2481
+ * the stale cached copy. Use when a provider signals their manifest
2482
+ * changed (e.g. after a config update) and the change must take effect
2483
+ * before the cache TTL expires.
2484
+ */
2485
+ invalidateManifest(url) {
2486
+ invalidateManifest(new URL(url).origin);
2487
+ }
2488
+ /**
2489
+ * Reserve spend against local daily caps BEFORE dispatching payment.
2490
+ * Returns a reserveId that must be passed to _commitSpend() on success
2491
+ * or _rollbackSpend() on failure.
2492
+ *
2493
+ * The entire read-check-write is serialized through the per-instance
2494
+ * mutex (_withSpendMutex) so concurrent pay() calls cannot interleave
2495
+ * and overrun the cap.
2496
+ *
2497
+ * Enforcement order:
2498
+ * 1. Per-endpoint cap (if `endpointCaps[endpointKey]` is set) — checked first.
2499
+ * 2. Global daily cap — checked second.
2500
+ *
2501
+ * Both limits are independent: a payment is only reserved after **both** pass.
2502
+ * All spend (regardless of endpoint) counts toward the global accumulator.
2503
+ */
2504
+ async _checkAndReserveSpend(amount, endpointKey) {
2505
+ if (!this.spendCap) return "";
2506
+ return this._withSpendMutex(async () => {
2507
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2508
+ const persisted = await this.spendStore.read();
2509
+ const current = persisted && persisted.date === today ? persisted : { date: today, totalMicros: "0", endpoints: {} };
2510
+ const amountMicros = nulthSdk.usdcToStroops(amount);
2511
+ const total = BigInt(current.totalMicros);
2512
+ const endpointCapStr = this.spendCap.endpointCaps?.[endpointKey];
2513
+ if (endpointCapStr !== void 0) {
2514
+ const endpointCapMicros = nulthSdk.usdcToStroops(endpointCapStr);
2515
+ const endpointTotal = BigInt(current.endpoints[endpointKey] ?? "0");
2516
+ if (endpointTotal + amountMicros > endpointCapMicros) {
2517
+ throw new RouteDockPolicyRejectError("local_endpoint_cap_exceeded");
2518
+ }
2519
+ }
2520
+ const globalCapMicros = nulthSdk.usdcToStroops(this.spendCap.daily);
2521
+ if (total + amountMicros > globalCapMicros) {
2522
+ throw new RouteDockPolicyRejectError("local_daily_cap_exceeded");
2523
+ }
2524
+ const reserveId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
2525
+ this._pendingReserves.set(reserveId, { endpointKey, amountMicros });
2526
+ current.totalMicros = (total + amountMicros).toString();
2527
+ if (endpointCapStr !== void 0) {
2528
+ current.endpoints[endpointKey] = (BigInt(current.endpoints[endpointKey] ?? "0") + amountMicros).toString();
2529
+ }
2530
+ await this.spendStore.write(current);
2531
+ return reserveId;
2532
+ });
2533
+ }
2534
+ /**
2535
+ * Commit a previously reserved spend after payment succeeds. For discrete
2536
+ * pay() calls this is a no-op (the spend was already recorded by reserve).
2537
+ * Reserved for future use if the committed amount differs from the reserved.
2538
+ */
2539
+ async _commitSpend(reserveId) {
2540
+ this._pendingReserves.delete(reserveId);
2541
+ }
2542
+ /**
2543
+ * Rollback a previously reserved spend when payment fails. Reverts the
2544
+ * tentative accumulator write done by _checkAndReserveSpend.
2545
+ */
2546
+ async _rollbackSpend(reserveId) {
2547
+ if (!this.spendCap || !reserveId) return;
2548
+ const reserve = this._pendingReserves.get(reserveId);
2549
+ if (!reserve) return;
2550
+ this._pendingReserves.delete(reserveId);
2551
+ const { endpointKey, amountMicros } = reserve;
2552
+ await this._withSpendMutex(async () => {
2553
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2554
+ const persisted = await this.spendStore.read();
2555
+ if (!persisted || persisted.date !== today) return;
2556
+ const currentTotal = BigInt(persisted.totalMicros);
2557
+ persisted.totalMicros = (currentTotal - amountMicros).toString();
2558
+ if (persisted.endpoints[endpointKey]) {
2559
+ const endpointTotal = BigInt(persisted.endpoints[endpointKey]);
2560
+ const newEndpointTotal = endpointTotal - amountMicros;
2561
+ if (newEndpointTotal <= 0n) {
2562
+ delete persisted.endpoints[endpointKey];
2563
+ } else {
2564
+ persisted.endpoints[endpointKey] = newEndpointTotal.toString();
2565
+ }
2566
+ }
2567
+ await this.spendStore.write(persisted);
2568
+ });
548
2569
  }
549
2570
  };
550
2571
 
@@ -556,12 +2577,16 @@ var SupabaseSessionStore = class {
556
2577
  supabase;
557
2578
  async get(channelId) {
558
2579
  const { data, error } = await this.supabase.from("sessions").select("*").eq("channel_id", channelId).maybeSingle();
559
- if (error) throw new RouteDockSessionError(`SessionStore.get failed: ${error.message}`);
2580
+ if (error) {
2581
+ throw new RouteDockNetworkError(`SessionStore.get failed: ${error.message}`);
2582
+ }
560
2583
  if (!data) return null;
561
2584
  return {
562
2585
  channel_id: data.channel_id,
563
2586
  payee: data.payee,
564
2587
  payer: data.payer,
2588
+ channel_contract: data.channel_contract,
2589
+ network: data.network,
565
2590
  cumulative_amount: String(data.cumulative_amount),
566
2591
  last_signature: data.last_signature,
567
2592
  status: data.status,
@@ -576,7 +2601,7 @@ var SupabaseSessionStore = class {
576
2601
  const prev = parseFloat(existing.cumulative_amount);
577
2602
  const next = parseFloat(state.cumulative_amount);
578
2603
  if (next <= prev) {
579
- throw new RouteDockSessionError(
2604
+ throw new RouteDockVoucherMonotonicityError(
580
2605
  `cumulative_amount must be strictly increasing: ${next} <= ${prev}`
581
2606
  );
582
2607
  }
@@ -586,6 +2611,8 @@ var SupabaseSessionStore = class {
586
2611
  channel_id: state.channel_id,
587
2612
  payee: state.payee,
588
2613
  payer: state.payer,
2614
+ channel_contract: state.channel_contract,
2615
+ network: state.network,
589
2616
  cumulative_amount: state.cumulative_amount,
590
2617
  last_signature: state.last_signature,
591
2618
  status: state.status,
@@ -597,18 +2624,117 @@ var SupabaseSessionStore = class {
597
2624
  );
598
2625
  if (error) {
599
2626
  if (error.message.includes("strictly increasing")) {
600
- throw new RouteDockSessionError(
2627
+ throw new RouteDockVoucherMonotonicityError(
601
2628
  `cumulative_amount must be strictly increasing (DB): ${error.message}`
602
2629
  );
603
2630
  }
604
- throw new RouteDockSessionError(`SessionStore.upsert failed: ${error.message}`);
2631
+ throw new RouteDockNetworkError(`SessionStore.upsert failed: ${error.message}`);
2632
+ }
2633
+ }
2634
+ async setStatus(channelId, status, settlementTxHash) {
2635
+ const payload = {
2636
+ status,
2637
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2638
+ };
2639
+ if (settlementTxHash !== void 0) {
2640
+ payload.settlement_tx_hash = settlementTxHash;
2641
+ }
2642
+ const { error } = await this.supabase.from("sessions").update(payload).eq("channel_id", channelId);
2643
+ if (error) {
2644
+ throw new RouteDockNetworkError(`SessionStore.setStatus failed: ${error.message}`);
605
2645
  }
606
2646
  }
607
2647
  async close(channelId) {
608
- const { error } = await this.supabase.from("sessions").update({ status: "closed", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("channel_id", channelId);
609
- if (error) throw new RouteDockSessionError(`SessionStore.close failed: ${error.message}`);
2648
+ await this.setStatus(channelId, "closed");
2649
+ }
2650
+ };
2651
+
2652
+ // src/store/SupabaseSeenTxStore.ts
2653
+ var SupabaseSeenTxStore = class {
2654
+ constructor(supabase) {
2655
+ this.supabase = supabase;
2656
+ }
2657
+ supabase;
2658
+ async get(key) {
2659
+ const { data, error } = await this.supabase.from("settlements").select("tx_hash, headers").eq("key", key).maybeSingle();
2660
+ if (error) {
2661
+ throw new RouteDockNetworkError(`SupabaseSeenTxStore.get failed: ${error.message}`);
2662
+ }
2663
+ if (!data) return void 0;
2664
+ const headers = data.headers;
2665
+ return {
2666
+ txHash: data.tx_hash ?? null,
2667
+ ...headers ? { headers } : {}
2668
+ };
2669
+ }
2670
+ async set(key, record) {
2671
+ const { error } = await this.supabase.from("settlements").upsert(
2672
+ {
2673
+ key,
2674
+ tx_hash: record.txHash,
2675
+ headers: record.headers ?? null
2676
+ },
2677
+ { onConflict: "key" }
2678
+ );
2679
+ if (error) {
2680
+ throw new RouteDockNetworkError(`SupabaseSeenTxStore.set failed: ${error.message}`);
2681
+ }
2682
+ }
2683
+ };
2684
+
2685
+ // src/provider/payee.ts
2686
+ function resolvePayee(manifest, mode) {
2687
+ const override = manifest.pricing[mode]?.payee;
2688
+ return override ?? manifest.payee;
2689
+ }
2690
+ function extractPayerAddress(key) {
2691
+ if (typeof key !== "string") return null;
2692
+ return stellarSdk.StrKey.isValidEd25519PublicKey(key) || stellarSdk.StrKey.isValidMed25519PublicKey(key) ? key : null;
2693
+ }
2694
+
2695
+ // src/provider/SeenTxStore.ts
2696
+ var InMemorySeenTxStore = class {
2697
+ map = /* @__PURE__ */ new Map();
2698
+ order = [];
2699
+ constructor(maxEntriesOrOptions = 1e4) {
2700
+ const options = typeof maxEntriesOrOptions === "number" ? { maxEntries: maxEntriesOrOptions } : maxEntriesOrOptions;
2701
+ this.maxEntries = options.maxEntries ?? 1e4;
2702
+ if (options.warn !== false) {
2703
+ const isServerless = typeof globalThis.navigator !== "undefined" && globalThis.navigator.userAgent === "Cloudflare-Workers";
2704
+ if (isServerless) {
2705
+ console.warn(
2706
+ "[RouteDock] Using in-memory SeenTxStore: settlement deduplication is NOT durable and resets on every isolate restart. Supply a SupabaseSeenTxStore (or other durable SeenTxStore) for production safety."
2707
+ );
2708
+ }
2709
+ }
2710
+ }
2711
+ maxEntries;
2712
+ get(key) {
2713
+ return this.map.get(key);
2714
+ }
2715
+ set(key, record) {
2716
+ if (!this.map.has(key)) {
2717
+ this.order.push(key);
2718
+ if (this.order.length > this.maxEntries) {
2719
+ const evicted = this.order.shift();
2720
+ if (evicted !== void 0) this.map.delete(evicted);
2721
+ }
2722
+ }
2723
+ this.map.set(key, record);
610
2724
  }
611
2725
  };
2726
+ async function sha256Hex2(input) {
2727
+ const buf = new TextEncoder().encode(input);
2728
+ const digest = await crypto.subtle.digest("SHA-256", buf);
2729
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
2730
+ }
2731
+ async function paymentIdempotencyKey(getHeader) {
2732
+ const material = getHeader("payment-signature") ?? getHeader("x-payment") ?? getHeader("authorization");
2733
+ if (!material) return null;
2734
+ return sha256Hex2(material);
2735
+ }
2736
+
2737
+ // src/provider/x402Handler.ts
612
2738
  var CAIP22 = {
613
2739
  testnet: "stellar:testnet",
614
2740
  mainnet: "stellar:pubnet"
@@ -618,6 +2744,7 @@ function createX402Handler(opts) {
618
2744
  const caip2 = CAIP22[opts.network];
619
2745
  const payeeKeypair = stellarSdk.Keypair.fromSecret(opts.payeeSecretKey);
620
2746
  const signer = stellar.createEd25519Signer(opts.payeeSecretKey, caip2);
2747
+ const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore();
621
2748
  const useOzFacilitator = opts.network === "mainnet" && opts.facilitatorApiKey;
622
2749
  const localFacilitator = new facilitator.ExactStellarScheme([signer], {
623
2750
  areFeesSponsored: true
@@ -636,13 +2763,14 @@ function createX402Handler(opts) {
636
2763
  ozServer = new server.x402ResourceServer(facilitator);
637
2764
  ozServer.register(caip2, new server$1.ExactStellarScheme());
638
2765
  }
639
- const amountInBaseUnits = String(Math.round(parseFloat(opts.amount) * 1e7));
2766
+ const amountInBaseUnits = String(usdcToUnits(opts.amount));
2767
+ const payTo = resolvePayee(opts.manifest, "x402");
640
2768
  const requirements = {
641
2769
  scheme: "exact",
642
2770
  network: caip2,
643
2771
  asset: opts.assetContract,
644
2772
  amount: amountInBaseUnits,
645
- payTo: opts.manifest.payee,
2773
+ payTo,
646
2774
  maxTimeoutSeconds: 60,
647
2775
  extra: {
648
2776
  areFeesSponsored: true,
@@ -676,8 +2804,31 @@ function createX402Handler(opts) {
676
2804
  }
677
2805
  return;
678
2806
  }
2807
+ const idempotencyKey = await paymentIdempotencyKey((name) => {
2808
+ const v = req.headers[name.toLowerCase()];
2809
+ return Array.isArray(v) ? v[0] : v;
2810
+ });
2811
+ if (idempotencyKey) {
2812
+ const cached = await seenTxStore.get(idempotencyKey);
2813
+ if (cached) {
2814
+ if (cached.headers) {
2815
+ for (const [k, val] of Object.entries(cached.headers)) {
2816
+ res.setHeader(k, val);
2817
+ }
2818
+ }
2819
+ next();
2820
+ return;
2821
+ }
2822
+ }
679
2823
  const payload = http.decodePaymentSignatureHeader(paymentHeader);
680
2824
  let txHash = null;
2825
+ let payerAddress = null;
2826
+ try {
2827
+ const creds = payload.authorization?.credentials;
2828
+ const key = Array.isArray(creds) ? creds[0]?.publicKey : void 0;
2829
+ payerAddress = extractPayerAddress(key);
2830
+ } catch {
2831
+ }
681
2832
  if (ozServer) {
682
2833
  const settleResult = await ozServer.settlePayment(payload, requirements);
683
2834
  txHash = settleResult.transaction ?? null;
@@ -715,8 +2866,19 @@ function createX402Handler(opts) {
715
2866
  );
716
2867
  }
717
2868
  }
2869
+ if (idempotencyKey) {
2870
+ const headers = {};
2871
+ const paymentResponse = res.getHeader("X-Payment-Response");
2872
+ if (typeof paymentResponse === "string") {
2873
+ headers["X-Payment-Response"] = paymentResponse;
2874
+ }
2875
+ await seenTxStore.set(idempotencyKey, { txHash, headers });
2876
+ }
718
2877
  if (txHash && opts.onSettled) {
719
- await opts.onSettled(txHash, opts.amount, "x402");
2878
+ Promise.resolve().then(() => opts.onSettled(txHash, opts.amount, "x402", payerAddress)).catch((err) => {
2879
+ console.error("[x402] onSettled callback error:", err);
2880
+ opts.onCallbackError?.(err, "onSettled");
2881
+ });
720
2882
  }
721
2883
  next();
722
2884
  } catch (err) {
@@ -732,11 +2894,13 @@ var MPP_NETWORK = {
732
2894
  function createMppChargeHandler(opts) {
733
2895
  const networkId = MPP_NETWORK[opts.network];
734
2896
  const amountHumanReadable = opts.amount;
2897
+ const recipient = resolvePayee(opts.manifest, "mpp-charge");
2898
+ const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore();
735
2899
  const mppx = server$2.Mppx.create({
736
2900
  secretKey: opts.payeeSecretKey,
737
2901
  methods: [
738
2902
  server$3.stellar.charge({
739
- recipient: opts.manifest.payee,
2903
+ recipient,
740
2904
  currency: opts.assetContract,
741
2905
  network: networkId,
742
2906
  feePayer: { envelopeSigner: opts.payeeSecretKey }
@@ -745,12 +2909,43 @@ function createMppChargeHandler(opts) {
745
2909
  });
746
2910
  return async (req, res, next) => {
747
2911
  try {
2912
+ let payerAddress = null;
2913
+ try {
2914
+ const authHeader = req.headers["authorization"];
2915
+ if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
2916
+ const credPart = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
2917
+ if (credPart) {
2918
+ const b64 = credPart.split("=").slice(1).join("=").replace(/^"|"$/g, "");
2919
+ const credJson = Buffer.from(b64, "base64").toString("utf8");
2920
+ const cred = JSON.parse(credJson);
2921
+ const key = cred.sender ?? cred.payload?.sender ?? cred.payload?.from;
2922
+ payerAddress = extractPayerAddress(key);
2923
+ }
2924
+ }
2925
+ } catch {
2926
+ }
2927
+ const idempotencyKey = await paymentIdempotencyKey((name) => {
2928
+ const v = req.headers[name.toLowerCase()];
2929
+ return Array.isArray(v) ? v[0] : v;
2930
+ });
2931
+ if (idempotencyKey) {
2932
+ const cached = await seenTxStore.get(idempotencyKey);
2933
+ if (cached) {
2934
+ if (cached.headers) {
2935
+ for (const [k, val] of Object.entries(cached.headers)) {
2936
+ res.setHeader(k, val);
2937
+ }
2938
+ }
2939
+ next();
2940
+ return;
2941
+ }
2942
+ }
748
2943
  const fetchReq = server$2.Request.fromNodeListener(req, res);
749
2944
  const handler = mppx["stellar/charge"];
750
2945
  const result = await handler({
751
2946
  amount: amountHumanReadable,
752
2947
  currency: opts.assetContract,
753
- recipient: opts.manifest.payee,
2948
+ recipient,
754
2949
  description: opts.manifest.name
755
2950
  })(fetchReq);
756
2951
  if (result.status === 402) {
@@ -761,25 +2956,123 @@ function createMppChargeHandler(opts) {
761
2956
  return;
762
2957
  }
763
2958
  const receipt = result.withReceipt(new Response(""));
764
- receipt.headers.forEach((v, k) => res.setHeader(k, v));
2959
+ const receiptHeaders = {};
2960
+ receipt.headers.forEach((v, k) => {
2961
+ res.setHeader(k, v);
2962
+ receiptHeaders[k] = v;
2963
+ });
765
2964
  const receiptHeader = receipt.headers.get("payment-receipt");
766
- if (receiptHeader && opts.onSettled) {
2965
+ let reference;
2966
+ if (receiptHeader) {
767
2967
  try {
768
2968
  const parsed = JSON.parse(
769
2969
  Buffer.from(receiptHeader, "base64").toString("utf8")
770
2970
  );
771
- if (parsed.reference) {
772
- await opts.onSettled(parsed.reference, opts.amount, "mpp-charge");
773
- }
2971
+ reference = parsed.reference;
774
2972
  } catch {
775
2973
  }
776
2974
  }
2975
+ if (idempotencyKey) {
2976
+ await seenTxStore.set(idempotencyKey, {
2977
+ txHash: reference ?? null,
2978
+ headers: receiptHeaders
2979
+ });
2980
+ }
2981
+ if (reference && opts.onSettled) {
2982
+ Promise.resolve().then(() => opts.onSettled(reference, opts.amount, "mpp-charge", payerAddress)).catch((err) => {
2983
+ console.error("[mpp-charge] onSettled callback error:", err);
2984
+ opts.onCallbackError?.(err, "onSettled");
2985
+ });
2986
+ }
777
2987
  next();
778
2988
  } catch (err) {
779
2989
  next(err);
780
2990
  }
781
2991
  };
782
2992
  }
2993
+ function resolveVaultSettlementAddresses(payer, payee) {
2994
+ if (!payer) return null;
2995
+ return { payer, payee };
2996
+ }
2997
+ function buildSessionSettlementTransaction(source, vaultContractId, channelId, addresses, amount, voucherCount, networkPassphrase) {
2998
+ const vault = new stellarSdk.Contract(vaultContractId);
2999
+ const op = vault.call(
3000
+ "record_session_settlement",
3001
+ stellarSdk.nativeToScVal(channelId, { type: "address" }),
3002
+ stellarSdk.nativeToScVal(addresses.payer, { type: "address" }),
3003
+ stellarSdk.nativeToScVal(addresses.payee, { type: "address" }),
3004
+ stellarSdk.nativeToScVal(amount, { type: "i128" }),
3005
+ stellarSdk.nativeToScVal(voucherCount, { type: "u32" })
3006
+ );
3007
+ return new stellarSdk.TransactionBuilder(source, {
3008
+ fee: stellarSdk.BASE_FEE,
3009
+ networkPassphrase
3010
+ }).addOperation(op).setTimeout(30).build();
3011
+ }
3012
+ function channelAuthorizer(envelopeSigner) {
3013
+ return { envelopeSigner };
3014
+ }
3015
+ function formatMppError(error) {
3016
+ if (error instanceof Error) return error.message;
3017
+ if (typeof error === "string") return error;
3018
+ try {
3019
+ const serialized = JSON.stringify(error);
3020
+ return serialized === void 0 ? String(error) : serialized;
3021
+ } catch {
3022
+ return String(error);
3023
+ }
3024
+ }
3025
+ function withTypedChannelErrors(method) {
3026
+ return {
3027
+ ...method,
3028
+ async verify(parameters) {
3029
+ try {
3030
+ return await method.verify(parameters);
3031
+ } catch (error) {
3032
+ throw mapChannelError(error);
3033
+ }
3034
+ }
3035
+ };
3036
+ }
3037
+ function onVerifiedCredential(method, commit) {
3038
+ return {
3039
+ ...method,
3040
+ async verify(parameters) {
3041
+ const receipt = await method.verify(parameters);
3042
+ await commit(parameters.credential);
3043
+ return receipt;
3044
+ }
3045
+ };
3046
+ }
3047
+ function mapChannelError(error) {
3048
+ const message = formatMppError(error);
3049
+ const details = error && typeof error === "object" && "details" in error ? error.details : void 0;
3050
+ const reason = details && Object.keys(details).length > 0 ? `${message} (${JSON.stringify(details)})` : message;
3051
+ if (/signer mismatch|authori[sz]er|unauthori[sz]ed/i.test(message)) {
3052
+ return new mppx.Errors.SignerMismatchError({ reason });
3053
+ }
3054
+ if (/already used|replay|challenge/i.test(message)) {
3055
+ return new mppx.Errors.InvalidChallengeError({ reason });
3056
+ }
3057
+ if (/channel has been closed|channel is closed|finalized/i.test(message)) {
3058
+ return new mppx.Errors.ChannelClosedError({ reason });
3059
+ }
3060
+ if (/exceeds channel balance|exceeds.*deposit/i.test(message)) {
3061
+ return new mppx.Errors.AmountExceedsDepositError({ reason });
3062
+ }
3063
+ if (/channel not found|no channel/i.test(message)) {
3064
+ return new mppx.Errors.ChannelNotFoundError({ reason });
3065
+ }
3066
+ if (/must be greater|does not cover|below.*minimum/i.test(message)) {
3067
+ return new mppx.Errors.DeltaTooSmallError({ reason });
3068
+ }
3069
+ if (/signature/i.test(message)) {
3070
+ return new mppx.Errors.InvalidSignatureError({ reason });
3071
+ }
3072
+ return new mppx.Errors.VerificationFailedError({ reason });
3073
+ }
3074
+
3075
+ // src/provider/MppSessionHandler.ts
783
3076
  var MPP_NETWORK2 = {
784
3077
  testnet: "stellar:testnet",
785
3078
  mainnet: "stellar:pubnet"
@@ -788,67 +3081,202 @@ function createMppSessionHandler(opts) {
788
3081
  const networkId = MPP_NETWORK2[opts.network];
789
3082
  const rateHuman = opts.rate;
790
3083
  const payeeKeypair = stellarSdk.Keypair.fromSecret(opts.payeeSecretKey);
791
- const cumulativeKey = `stellar:channel:cumulative:${opts.channelContract}`;
3084
+ const voucherRecordKey = `routedock:session:voucher:${opts.channelFactory}`;
792
3085
  const innerStore = server$4.Store.memory();
793
- let lastCumulativeAmount = 0n;
3086
+ let record = null;
794
3087
  let voucherCount = 0;
795
3088
  let sessionOpened = false;
3089
+ let idleTimer = null;
3090
+ let settledCleanly = false;
3091
+ let closeListenerArmed = false;
3092
+ function clearIdleTimer() {
3093
+ if (idleTimer) {
3094
+ clearTimeout(idleTimer);
3095
+ idleTimer = null;
3096
+ }
3097
+ }
3098
+ function armIdleTimer() {
3099
+ if (!opts.idleTimeoutMs) return;
3100
+ clearIdleTimer();
3101
+ idleTimer = setTimeout(() => {
3102
+ void flagOrphan("idle-timeout");
3103
+ }, opts.idleTimeoutMs);
3104
+ if (typeof idleTimer.unref === "function") idleTimer.unref();
3105
+ }
3106
+ async function loadPersistedRecord() {
3107
+ if (record) return record;
3108
+ try {
3109
+ const stored = await innerStore.get(voucherRecordKey);
3110
+ if (!stored || typeof stored.amount !== "string" || typeof stored.signature !== "string") {
3111
+ return null;
3112
+ }
3113
+ record = { amount: BigInt(stored.amount), signature: stored.signature, payer: stored.payer ?? null };
3114
+ } catch {
3115
+ return null;
3116
+ }
3117
+ return record;
3118
+ }
3119
+ async function flagOrphan(reason) {
3120
+ await loadPersistedRecord();
3121
+ if (!sessionOpened || settledCleanly) return;
3122
+ sessionOpened = false;
3123
+ closeListenerArmed = false;
3124
+ clearIdleTimer();
3125
+ const cumulativeAmount = record ? (Number(record.amount) / 1e7).toFixed(7) : "0.0000000";
3126
+ if (opts.onOrphaned) {
3127
+ try {
3128
+ await opts.onOrphaned(opts.channelFactory, {
3129
+ cumulativeAmount,
3130
+ lastSignature: record?.signature ?? "",
3131
+ voucherCount,
3132
+ reason
3133
+ });
3134
+ } catch (err) {
3135
+ console.error("[mpp-session] onOrphaned handler failed:", err);
3136
+ }
3137
+ }
3138
+ }
796
3139
  const wrappedStore = {
797
3140
  async get(key) {
798
3141
  return innerStore.get(key);
799
3142
  },
800
3143
  async put(key, value) {
801
- await innerStore.put(key, value);
802
- if (key === cumulativeKey && value && typeof value === "object" && "amount" in value) {
803
- lastCumulativeAmount = BigInt(value.amount);
804
- voucherCount++;
805
- if (!sessionOpened) {
806
- sessionOpened = true;
807
- if (opts.onSessionOpen) {
808
- await opts.onSessionOpen(opts.channelContract);
809
- }
810
- }
811
- if (opts.onVoucher) {
812
- const humanAmount = (Number(lastCumulativeAmount) / 1e7).toFixed(7);
813
- await opts.onVoucher(voucherCount, humanAmount);
814
- }
815
- }
3144
+ return innerStore.put(key, value);
816
3145
  },
817
3146
  async delete(key) {
818
3147
  return innerStore.delete(key);
3148
+ },
3149
+ async update(key, fn) {
3150
+ const storeWithUpdate = innerStore;
3151
+ if (typeof storeWithUpdate.update === "function") {
3152
+ return storeWithUpdate.update(key, fn);
3153
+ }
3154
+ throw new Error("Store does not support atomic update operations");
819
3155
  }
820
3156
  };
3157
+ async function commit(credential) {
3158
+ await loadPersistedRecord();
3159
+ const payload = credential.payload;
3160
+ if (typeof payload?.amount !== "string" || typeof payload.signature !== "string") return;
3161
+ let amount;
3162
+ try {
3163
+ amount = BigInt(payload.amount);
3164
+ } catch {
3165
+ return;
3166
+ }
3167
+ if (record && amount < record.amount) return;
3168
+ const payer = record?.payer ?? extractPayerAddress(credential.source);
3169
+ record = { amount, signature: payload.signature, payer };
3170
+ await innerStore.put(voucherRecordKey, {
3171
+ amount: record.amount.toString(),
3172
+ signature: record.signature,
3173
+ payer: record.payer
3174
+ });
3175
+ voucherCount++;
3176
+ settledCleanly = false;
3177
+ armIdleTimer();
3178
+ if (!sessionOpened) {
3179
+ sessionOpened = true;
3180
+ if (opts.onSessionOpen) {
3181
+ Promise.resolve().then(() => opts.onSessionOpen(opts.channelFactory, record.payer)).catch((err) => {
3182
+ console.error("[mpp-session] onSessionOpen callback error:", err);
3183
+ opts.onCallbackError?.(err, "onSessionOpen");
3184
+ });
3185
+ }
3186
+ }
3187
+ if (opts.onVoucher) {
3188
+ const humanAmount = (Number(record.amount) / 1e7).toFixed(7);
3189
+ Promise.resolve().then(() => opts.onVoucher(opts.channelFactory, voucherCount, humanAmount, record.signature)).catch((err) => {
3190
+ console.error("[mpp-session] onVoucher callback error:", err);
3191
+ opts.onCallbackError?.(err, "onVoucher");
3192
+ });
3193
+ }
3194
+ }
821
3195
  const mppx = server$2.Mppx.create({
822
3196
  secretKey: opts.payeeSecretKey,
823
3197
  methods: [
824
- server$4.stellar.channel({
825
- channel: opts.channelContract,
826
- commitmentKey: opts.commitmentPublicKey,
827
- network: networkId,
828
- store: wrappedStore,
829
- sourceAccount: payeeKeypair.publicKey(),
830
- feePayer: { envelopeSigner: payeeKeypair }
831
- })
3198
+ withTypedChannelErrors(
3199
+ onVerifiedCredential(
3200
+ server$4.stellar.channel({
3201
+ channel: opts.channelFactory,
3202
+ commitmentKey: opts.commitmentPublicKey,
3203
+ network: networkId,
3204
+ store: wrappedStore,
3205
+ sourceAccount: payeeKeypair.publicKey(),
3206
+ feePayer: channelAuthorizer(payeeKeypair)
3207
+ }),
3208
+ commit
3209
+ )
3210
+ )
832
3211
  ]
833
3212
  });
834
- let lastSignatureHex = "";
835
3213
  return async (req, res, next) => {
836
3214
  try {
837
3215
  if (req.method === "DELETE") {
3216
+ await loadPersistedRecord();
838
3217
  const body = req.body;
839
- const closeAmount = body?.amount ? BigInt(body.amount) : lastCumulativeAmount;
840
- const closeSig = body?.signature ?? lastSignatureHex;
3218
+ const bodyAmount = body?.amount ? BigInt(body.amount) : 0n;
3219
+ const recordAmount = record?.amount ?? 0n;
3220
+ let closeAmount;
3221
+ let closeSig;
3222
+ if (bodyAmount > recordAmount) {
3223
+ closeAmount = bodyAmount;
3224
+ closeSig = body?.signature ?? record?.signature ?? "";
3225
+ } else {
3226
+ closeAmount = recordAmount;
3227
+ closeSig = record?.signature ?? "";
3228
+ }
841
3229
  if (closeAmount > 0n && closeSig) {
3230
+ const closePayer = record?.payer ?? null;
842
3231
  const closeTxHash = await server$4.close({
843
- channel: opts.channelContract,
3232
+ channel: opts.channelFactory,
844
3233
  amount: closeAmount,
845
3234
  signature: Buffer.from(closeSig, "hex"),
846
- feePayer: { envelopeSigner: payeeKeypair },
3235
+ feePayer: channelAuthorizer(payeeKeypair),
847
3236
  network: networkId
848
3237
  });
3238
+ settledCleanly = true;
3239
+ clearIdleTimer();
849
3240
  if (opts.onSettled) {
850
- const totalPaid = (Number(lastCumulativeAmount) / 1e7).toFixed(7);
851
- await opts.onSettled(closeTxHash, totalPaid, "mpp-session");
3241
+ const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
3242
+ Promise.resolve().then(() => opts.onSettled(closeTxHash, totalPaid, "mpp-session", closePayer)).catch((err) => {
3243
+ console.error("[mpp-session] onSettled callback error:", err);
3244
+ opts.onCallbackError?.(err, "onSettled");
3245
+ });
3246
+ }
3247
+ const vaultContract = process.env.AGENT_VAULT_CONTRACT;
3248
+ if (vaultContract) {
3249
+ const settlementAddresses = resolveVaultSettlementAddresses(
3250
+ closePayer,
3251
+ payeeKeypair.publicKey()
3252
+ );
3253
+ if (!settlementAddresses) {
3254
+ console.error("[mpp-session] skipped session_settled vault record: payer address unavailable");
3255
+ } else {
3256
+ try {
3257
+ const { Networks: Networks2 } = await import('@stellar/stellar-sdk');
3258
+ const { Server } = await import('@stellar/stellar-sdk/rpc');
3259
+ const networkPassphrase = opts.network === "mainnet" ? Networks2.PUBLIC : Networks2.TESTNET;
3260
+ const rpcUrl = opts.network === "mainnet" ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org";
3261
+ const server = new Server(rpcUrl);
3262
+ const payeePublicKey = payeeKeypair.publicKey();
3263
+ const sourceAccount = await server.getAccount(payeePublicKey);
3264
+ const tx = buildSessionSettlementTransaction(
3265
+ sourceAccount,
3266
+ vaultContract,
3267
+ opts.channelFactory,
3268
+ settlementAddresses,
3269
+ closeAmount,
3270
+ voucherCount,
3271
+ networkPassphrase
3272
+ );
3273
+ const preparedTx = await server.prepareTransaction(tx);
3274
+ preparedTx.sign(payeeKeypair);
3275
+ await server.sendTransaction(preparedTx);
3276
+ } catch (recordErr) {
3277
+ console.error("[mpp-session] failed to record session_settled on vault:", recordErr);
3278
+ }
3279
+ }
852
3280
  }
853
3281
  res.json({ closeTxHash });
854
3282
  } else {
@@ -856,23 +3284,12 @@ function createMppSessionHandler(opts) {
856
3284
  }
857
3285
  sessionOpened = false;
858
3286
  voucherCount = 0;
859
- lastCumulativeAmount = 0n;
3287
+ record = null;
3288
+ await innerStore.delete(voucherRecordKey);
3289
+ closeListenerArmed = false;
3290
+ clearIdleTimer();
860
3291
  return;
861
3292
  }
862
- const authHeader = req.headers["authorization"];
863
- if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
864
- try {
865
- const credB64 = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
866
- if (credB64) {
867
- const credJson = Buffer.from(credB64.split("=").slice(1).join("=").replace(/^"|"$/g, ""), "base64").toString("utf8");
868
- const cred = JSON.parse(credJson);
869
- if (cred.payload?.signature) {
870
- lastSignatureHex = cred.payload.signature;
871
- }
872
- }
873
- } catch {
874
- }
875
- }
876
3293
  const fetchReq = server$2.Request.fromNodeListener(req, res);
877
3294
  const result = await mppx.channel({
878
3295
  amount: rateHuman,
@@ -886,6 +3303,12 @@ function createMppSessionHandler(opts) {
886
3303
  res.send(body);
887
3304
  return;
888
3305
  }
3306
+ if (!closeListenerArmed) {
3307
+ closeListenerArmed = true;
3308
+ req.on("close", () => {
3309
+ void flagOrphan("connection-closed");
3310
+ });
3311
+ }
889
3312
  next();
890
3313
  } catch (err) {
891
3314
  next(err);
@@ -894,20 +3317,26 @@ function createMppSessionHandler(opts) {
894
3317
  }
895
3318
 
896
3319
  // src/provider/routedockMiddleware.ts
3320
+ var MPP_MODES = ["mpp-session", "mpp-charge"];
897
3321
  function routedock(opts) {
898
- const handlers = [];
3322
+ const startTime = Date.now();
3323
+ const signedManifest = signManifest(opts.manifest, opts.payeeSecretKey);
3324
+ const handlerMap = /* @__PURE__ */ new Map();
899
3325
  if (opts.modes.includes("x402")) {
900
3326
  const x402Price = opts.pricing.x402;
901
3327
  if (x402Price) {
902
- handlers.push(
3328
+ handlerMap.set(
3329
+ "x402",
903
3330
  createX402Handler({
904
3331
  payeeSecretKey: opts.payeeSecretKey,
905
3332
  network: opts.network,
906
3333
  amount: x402Price,
907
3334
  assetContract: opts.assetContract,
908
3335
  ...opts.facilitatorApiKey ? { facilitatorApiKey: opts.facilitatorApiKey } : {},
909
- manifest: opts.manifest,
910
- ...opts.onSettled ? { onSettled: opts.onSettled } : {}
3336
+ manifest: signedManifest,
3337
+ ...opts.onSettled ? { onSettled: opts.onSettled } : {},
3338
+ ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
3339
+ ...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {}
911
3340
  })
912
3341
  );
913
3342
  }
@@ -915,14 +3344,17 @@ function routedock(opts) {
915
3344
  if (opts.modes.includes("mpp-charge")) {
916
3345
  const chargePrice = opts.pricing["mpp-charge"];
917
3346
  if (chargePrice) {
918
- handlers.push(
3347
+ handlerMap.set(
3348
+ "mpp-charge",
919
3349
  createMppChargeHandler({
920
3350
  payeeSecretKey: opts.payeeSecretKey,
921
3351
  network: opts.network,
922
3352
  amount: chargePrice,
923
3353
  assetContract: opts.assetContract,
924
- manifest: opts.manifest,
925
- ...opts.onSettled ? { onSettled: opts.onSettled } : {}
3354
+ manifest: signedManifest,
3355
+ ...opts.onSettled ? { onSettled: opts.onSettled } : {},
3356
+ ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
3357
+ ...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {}
926
3358
  })
927
3359
  );
928
3360
  }
@@ -933,39 +3365,52 @@ function routedock(opts) {
933
3365
  if (!opts.commitmentPublicKey) {
934
3366
  throw new Error("routedock: mpp-session mode requires commitmentPublicKey");
935
3367
  }
936
- handlers.push(
3368
+ handlerMap.set(
3369
+ "mpp-session",
937
3370
  createMppSessionHandler({
938
3371
  payeeSecretKey: opts.payeeSecretKey,
939
3372
  network: opts.network,
940
- channelContract: sessionPricing.channelContract,
3373
+ channelFactory: sessionPricing.channelFactory,
941
3374
  rate: sessionPricing.rate,
942
3375
  assetContract: opts.assetContract,
943
- manifest: opts.manifest,
3376
+ manifest: signedManifest,
944
3377
  commitmentPublicKey: opts.commitmentPublicKey,
945
3378
  ...opts.onSettled ? { onSettled: opts.onSettled } : {},
946
3379
  ...opts.onSessionOpen ? { onSessionOpen: opts.onSessionOpen } : {},
947
- ...opts.onVoucher ? { onVoucher: opts.onVoucher } : {}
3380
+ ...opts.onVoucher ? { onVoucher: opts.onVoucher } : {},
3381
+ ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
3382
+ ...opts.onOrphaned ? { onOrphaned: opts.onOrphaned } : {},
3383
+ ...opts.idleTimeoutMs != null ? { idleTimeoutMs: opts.idleTimeoutMs } : {}
948
3384
  })
949
3385
  );
950
3386
  }
951
3387
  }
3388
+ const defaultMode = MPP_MODES.find((mode) => handlerMap.has(mode)) ?? (handlerMap.has("x402") ? "x402" : void 0);
952
3389
  return (req, res, next) => {
953
3390
  if (req.path === "/.well-known/routedock.json") {
954
- res.json(opts.manifest);
3391
+ res.json(signedManifest);
3392
+ return;
3393
+ }
3394
+ if (req.path === "/health") {
3395
+ res.json({
3396
+ status: "ok",
3397
+ routedock: opts.manifest.routedock,
3398
+ network: opts.network,
3399
+ payee: opts.payee,
3400
+ modesActive: opts.modes,
3401
+ uptime: Math.floor((Date.now() - startTime) / 1e3)
3402
+ });
955
3403
  return;
956
3404
  }
957
- if (handlers.length === 0) {
3405
+ if (handlerMap.size === 0) {
958
3406
  next();
959
3407
  return;
960
3408
  }
961
3409
  const hasX402Header = !!(req.headers["payment-signature"] || req.headers["x-payment"]);
962
3410
  const prefersX402 = req.headers["x-preferred-mode"] === "x402";
963
- let handler;
964
- if (hasX402Header || prefersX402) {
965
- handler = handlers[0];
966
- } else {
967
- handler = handlers[handlers.length - 1];
968
- }
3411
+ const x402Handler = handlerMap.get("x402");
3412
+ const defaultHandler = defaultMode ? handlerMap.get(defaultMode) : void 0;
3413
+ const handler = hasX402Header || prefersX402 ? x402Handler ?? defaultHandler : defaultHandler;
969
3414
  if (!handler) {
970
3415
  next();
971
3416
  return;
@@ -979,16 +3424,353 @@ function routedock(opts) {
979
3424
  };
980
3425
  }
981
3426
 
3427
+ // src/provider/registerProvider.ts
3428
+ async function registerProvider(optionsOrSupabase, manifestArg, baseUrlArg, verifiedArg) {
3429
+ let supabase;
3430
+ let manifest;
3431
+ let baseUrl;
3432
+ let verified;
3433
+ if ("supabase" in optionsOrSupabase) {
3434
+ supabase = optionsOrSupabase.supabase;
3435
+ manifest = optionsOrSupabase.manifest;
3436
+ baseUrl = optionsOrSupabase.baseUrl;
3437
+ verified = optionsOrSupabase.verified ?? true;
3438
+ } else {
3439
+ supabase = optionsOrSupabase;
3440
+ if (!manifestArg || !baseUrlArg) {
3441
+ throw new RouteDockManifestError("manifest and baseUrl are required for registerProvider");
3442
+ }
3443
+ manifest = manifestArg;
3444
+ baseUrl = baseUrlArg;
3445
+ verified = verifiedArg ?? true;
3446
+ }
3447
+ verifyManifestSignature(manifest);
3448
+ const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
3449
+ const { error } = await supabase.from("providers").upsert(
3450
+ {
3451
+ name: manifest.name,
3452
+ description: manifest.description ?? null,
3453
+ base_url: normalizedBaseUrl,
3454
+ modes: manifest.modes,
3455
+ tags: manifest.tags ?? [],
3456
+ categories: manifest.categories ?? [],
3457
+ network: manifest.network,
3458
+ payee: manifest.payee,
3459
+ manifest,
3460
+ verified,
3461
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
3462
+ },
3463
+ { onConflict: "base_url" }
3464
+ );
3465
+ if (error) {
3466
+ throw new Error(`Failed to register provider in Supabase: ${error.message}`);
3467
+ }
3468
+ }
3469
+ var MPP_NETWORK3 = {
3470
+ testnet: "stellar:testnet",
3471
+ mainnet: "stellar:pubnet"
3472
+ };
3473
+ function decimalToStroops(amountStr) {
3474
+ return nulthSdk.usdcToStroops(amountStr);
3475
+ }
3476
+ async function reconcileAbandonedSessions(opts) {
3477
+ const stats = {
3478
+ orphanedCount: 0,
3479
+ recoveredCount: 0,
3480
+ skippedCount: 0,
3481
+ failedCount: 0,
3482
+ errors: []
3483
+ };
3484
+ const payeeKeypair = stellarSdk.Keypair.fromSecret(opts.payeeSecretKey);
3485
+ const networkId = MPP_NETWORK3[opts.network];
3486
+ try {
3487
+ const { data: closingSessions, error } = await opts.supabase.from("sessions").select("channel_id, channel_contract, cumulative_amount, last_signature, settlement_tx_hash").eq("status", "closing").eq("network", opts.network).eq("payee", payeeKeypair.publicKey()).is("settlement_tx_hash", null).not("last_signature", "is", null).order("updated_at", { ascending: true }).limit(100);
3488
+ if (error) {
3489
+ throw new Error(`Failed to query sessions: ${error.message}`);
3490
+ }
3491
+ stats.orphanedCount = closingSessions?.length ?? 0;
3492
+ if (!closingSessions || closingSessions.length === 0) {
3493
+ return stats;
3494
+ }
3495
+ for (const session of closingSessions) {
3496
+ try {
3497
+ const {
3498
+ channel_id: sessionId,
3499
+ channel_contract: channelContract,
3500
+ cumulative_amount,
3501
+ last_signature
3502
+ } = session;
3503
+ const channelId = channelContract || sessionId;
3504
+ if (!channelId || !cumulative_amount || !last_signature) {
3505
+ stats.skippedCount++;
3506
+ continue;
3507
+ }
3508
+ const closeAmount = decimalToStroops(cumulative_amount);
3509
+ const closeSig = Buffer.from(last_signature, "hex");
3510
+ const closeFn = opts.channelClose ?? (await import('@stellar/mpp/channel/server')).close;
3511
+ const closeTxHash = await closeFn({
3512
+ channel: channelId,
3513
+ amount: closeAmount,
3514
+ signature: closeSig,
3515
+ feePayer: channelAuthorizer(payeeKeypair),
3516
+ network: networkId
3517
+ });
3518
+ const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
3519
+ const { error: updateError } = await opts.supabase.from("sessions").update({
3520
+ status: "closed",
3521
+ settlement_tx_hash: closeTxHash,
3522
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
3523
+ }).eq("channel_id", sessionId);
3524
+ if (updateError) {
3525
+ throw new Error(`Failed to update session: ${updateError.message}`);
3526
+ }
3527
+ stats.recoveredCount++;
3528
+ if (opts.onRecovered) {
3529
+ await opts.onRecovered(channelId, closeTxHash, totalPaid);
3530
+ }
3531
+ } catch (err) {
3532
+ stats.failedCount++;
3533
+ stats.errors.push({
3534
+ channelId: session.channel_id,
3535
+ reason: formatMppError(err)
3536
+ });
3537
+ }
3538
+ }
3539
+ return stats;
3540
+ } catch (err) {
3541
+ throw new Error(
3542
+ `Session reconciliation failed: ${formatMppError(err)}`
3543
+ );
3544
+ }
3545
+ }
3546
+ async function runStartupReconciliation(opts, logger) {
3547
+ const log = logger || console.log;
3548
+ log("[SessionReconciler] Running startup reconciliation...");
3549
+ try {
3550
+ const stats = await reconcileAbandonedSessions(opts);
3551
+ if (stats.orphanedCount === 0) {
3552
+ log("[SessionReconciler] No orphaned sessions found");
3553
+ return;
3554
+ }
3555
+ log(
3556
+ `[SessionReconciler] Found ${stats.orphanedCount} orphaned session(s). Recovered: ${stats.recoveredCount}, Skipped: ${stats.skippedCount}, Failed: ${stats.failedCount}`
3557
+ );
3558
+ if (stats.errors.length > 0) {
3559
+ log("[SessionReconciler] Reconciliation errors:");
3560
+ for (const err of stats.errors) {
3561
+ log(` - ${err.channelId}: ${err.reason}`);
3562
+ }
3563
+ }
3564
+ } catch (err) {
3565
+ log(`[SessionReconciler] Startup reconciliation failed: ${formatMppError(err)}`);
3566
+ }
3567
+ }
3568
+ function tryDecodeBase64(value) {
3569
+ if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("/") || value.startsWith("[")) {
3570
+ return value;
3571
+ }
3572
+ try {
3573
+ const decoded = typeof Buffer !== "undefined" ? Buffer.from(value, "base64").toString("utf-8") : atob(value);
3574
+ if (decoded.length > 0 && decoded !== value) return decoded;
3575
+ } catch {
3576
+ }
3577
+ return value;
3578
+ }
3579
+ var OnChainRegistry = class {
3580
+ horizon;
3581
+ knownAccounts;
3582
+ timeoutMs;
3583
+ constructor(options) {
3584
+ this.horizon = new stellarSdk.Horizon.Server(options.horizonUrl);
3585
+ this.knownAccounts = options.knownAccounts;
3586
+ this.timeoutMs = options.timeoutMs ?? 1e4;
3587
+ }
3588
+ /**
3589
+ * stellar-sdk 14.6.1 has no way to cancel the underlying Horizon request, so
3590
+ * we stop waiting on it instead: the promise returned here rejects once
3591
+ * `timeoutMs` elapses, even though the request may still be in flight. The
3592
+ * timer is always cleared, including on the error path.
3593
+ */
3594
+ loadAccountWithTimeout(accountId) {
3595
+ let timer;
3596
+ const timeout = new Promise((_, reject) => {
3597
+ timer = setTimeout(
3598
+ () => reject(new Error(`Horizon loadAccount timed out after ${this.timeoutMs}ms`)),
3599
+ this.timeoutMs
3600
+ );
3601
+ });
3602
+ return Promise.race([this.horizon.loadAccount(accountId), timeout]).finally(() => {
3603
+ if (timer !== void 0) clearTimeout(timer);
3604
+ });
3605
+ }
3606
+ async listProviders() {
3607
+ const settled = await Promise.allSettled(
3608
+ this.knownAccounts.map((accountId) => this.loadAccountWithTimeout(accountId))
3609
+ );
3610
+ const results = [];
3611
+ for (let i = 0; i < settled.length; i++) {
3612
+ const outcome = settled[i];
3613
+ if (outcome.status !== "fulfilled") continue;
3614
+ const accountId = this.knownAccounts[i];
3615
+ const account = outcome.value;
3616
+ const dataEntries = account.data_attr;
3617
+ if (!dataEntries) continue;
3618
+ const rawEndpoint = dataEntries["routedock_endpoint"];
3619
+ if (!rawEndpoint) continue;
3620
+ const endpoint = tryDecodeBase64(rawEndpoint);
3621
+ if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) continue;
3622
+ let tags = [];
3623
+ const rawTags = dataEntries["routedock_tags"];
3624
+ if (rawTags) {
3625
+ const decoded = tryDecodeBase64(rawTags);
3626
+ try {
3627
+ const parsed = JSON.parse(decoded);
3628
+ if (Array.isArray(parsed)) tags = parsed;
3629
+ } catch {
3630
+ tags = decoded.split(",").map((t) => t.trim()).filter(Boolean);
3631
+ }
3632
+ }
3633
+ results.push({ account: accountId, endpoint, tags });
3634
+ }
3635
+ return results;
3636
+ }
3637
+ };
3638
+
3639
+ // src/registry/ProviderRegistry.ts
3640
+ function deriveNetwork(horizonUrl) {
3641
+ return horizonUrl.includes("testnet") ? "testnet" : "mainnet";
3642
+ }
3643
+ var ProviderRegistry = class {
3644
+ supabase;
3645
+ onChain;
3646
+ network;
3647
+ constructor(config) {
3648
+ this.supabase = config.supabase;
3649
+ this.network = config.onChain.network ?? deriveNetwork(config.onChain.horizonUrl);
3650
+ this.onChain = new OnChainRegistry({
3651
+ horizonUrl: config.onChain.horizonUrl,
3652
+ knownAccounts: config.onChain.knownAccounts,
3653
+ ...config.onChain.timeoutMs !== void 0 ? { timeoutMs: config.onChain.timeoutMs } : {}
3654
+ });
3655
+ }
3656
+ async listProviders() {
3657
+ const supabaseProviders = await this.trySupabase();
3658
+ if (supabaseProviders.length > 0) return supabaseProviders;
3659
+ return this.tryOnChain();
3660
+ }
3661
+ async trySupabase() {
3662
+ if (!this.supabase) return [];
3663
+ try {
3664
+ const { data, error } = await this.supabase.from("providers").select("*").eq("verified", true).eq("network", this.network).limit(100);
3665
+ if (error) return [];
3666
+ if (!data || data.length === 0) return [];
3667
+ return data.map((r) => ({
3668
+ name: r.name,
3669
+ description: r.description ?? void 0,
3670
+ base_url: r.base_url,
3671
+ modes: r.modes,
3672
+ tags: r.tags,
3673
+ network: r.network,
3674
+ payee: r.payee,
3675
+ source: "supabase",
3676
+ verified: r.verified === true
3677
+ }));
3678
+ } catch {
3679
+ return [];
3680
+ }
3681
+ }
3682
+ async tryOnChain() {
3683
+ const providers = await this.onChain.listProviders();
3684
+ return providers.map((p) => ({
3685
+ name: `On-chain provider (${p.account.slice(0, 8)}...)`,
3686
+ description: `Provider registered on Stellar account ${p.account}`,
3687
+ base_url: p.endpoint,
3688
+ modes: [],
3689
+ tags: p.tags,
3690
+ network: this.network,
3691
+ payee: p.account,
3692
+ source: "onchain",
3693
+ verified: true
3694
+ }));
3695
+ }
3696
+ /**
3697
+ * Fetch a provider's manifest bound to the record's payee. The record's
3698
+ * `payee` (from supabase `verified` rows or the on-chain registry account)
3699
+ * is the out-of-band trust anchor: the fetched manifest's `payee` must
3700
+ * equal it, otherwise `RouteDockSignatureError` is thrown. This closes the
3701
+ * gap where the registry never cross-checked the on-chain payee against
3702
+ * the manifest payee.
3703
+ */
3704
+ async fetchProviderManifest(record) {
3705
+ const baseUrl = new URL(record.base_url).origin;
3706
+ return fetchManifest(baseUrl, void 0, void 0, record.payee);
3707
+ }
3708
+ };
3709
+
3710
+ Object.defineProperty(exports, "USDC_DECIMALS", {
3711
+ enumerable: true,
3712
+ get: function () { return nulthSdk.USDC_DECIMALS; }
3713
+ });
3714
+ Object.defineProperty(exports, "usdcToStroops", {
3715
+ enumerable: true,
3716
+ get: function () { return nulthSdk.usdcToStroops; }
3717
+ });
3718
+ exports.DEFAULT_MAX_SESSION_DURATION_MS = DEFAULT_MAX_SESSION_DURATION_MS;
3719
+ exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
3720
+ exports.FileSpendStore = FileSpendStore;
3721
+ exports.InMemorySpendStore = InMemorySpendStore;
982
3722
  exports.MppChargeClient = MppChargeClient;
983
- exports.MppSessionClient = MppSessionClient;
3723
+ exports.NulthPolicyError = NulthPolicyError;
3724
+ exports.OnChainRegistry = OnChainRegistry;
3725
+ exports.ProviderRegistry = ProviderRegistry;
3726
+ exports.RouteDockChannelStateError = RouteDockChannelStateError;
984
3727
  exports.RouteDockClient = RouteDockClient;
3728
+ exports.RouteDockClientVersionError = RouteDockClientVersionError;
3729
+ exports.RouteDockDisputeError = RouteDockDisputeError;
985
3730
  exports.RouteDockError = RouteDockError;
3731
+ exports.RouteDockFacilitatorError = RouteDockFacilitatorError;
986
3732
  exports.RouteDockManifestError = RouteDockManifestError;
3733
+ exports.RouteDockManifestSunsetError = RouteDockManifestSunsetError;
3734
+ exports.RouteDockManifestTimeoutError = RouteDockManifestTimeoutError;
3735
+ exports.RouteDockNetworkError = RouteDockNetworkError;
987
3736
  exports.RouteDockNoSupportedModeError = RouteDockNoSupportedModeError;
3737
+ exports.RouteDockPolicyRejectError = RouteDockPolicyRejectError;
988
3738
  exports.RouteDockPolicyRejectedError = RouteDockPolicyRejectedError;
3739
+ exports.RouteDockRefundWindowError = RouteDockRefundWindowError;
989
3740
  exports.RouteDockSessionError = RouteDockSessionError;
3741
+ exports.RouteDockSignatureError = RouteDockSignatureError;
3742
+ exports.RouteDockTrustlineError = RouteDockTrustlineError;
3743
+ exports.RouteDockVoucherMonotonicityError = RouteDockVoucherMonotonicityError;
3744
+ exports.SupabaseSeenTxStore = SupabaseSeenTxStore;
990
3745
  exports.SupabaseSessionStore = SupabaseSessionStore;
991
3746
  exports.X402Client = X402Client;
3747
+ exports.assertEndpointActive = assertEndpointActive;
3748
+ exports.assertManifestValid = assertManifestValid;
3749
+ exports.assertNulthVaultManifest = assertNulthVaultManifest;
3750
+ exports.backoffDelayMs = backoffDelayMs;
3751
+ exports.configureManifestCache = configureManifestCache;
3752
+ exports.createNulthSigner = createNulthSigner;
3753
+ exports.createPolicyState = createPolicyState;
3754
+ exports.decimalToStroops = decimalToStroops;
3755
+ exports.decodeAuthSignature = decodeAuthSignature;
3756
+ exports.fetchLedgerSequence = fetchLedgerSequence;
992
3757
  exports.fetchManifest = fetchManifest;
3758
+ exports.httpStatusToError = httpStatusToError;
3759
+ exports.invalidateManifest = invalidateManifest;
3760
+ exports.manifestDigest = manifestDigest;
3761
+ exports.parseRetryAfterMs = parseRetryAfterMs;
3762
+ exports.paymentContextFromManifest = paymentContextFromManifest;
3763
+ exports.prepareNulthSigner = prepareNulthSigner;
3764
+ exports.rankProvidersByLatency = rankProvidersByLatency;
3765
+ exports.reconcileAbandonedSessions = reconcileAbandonedSessions;
3766
+ exports.registerProvider = registerProvider;
993
3767
  exports.routedock = routedock;
3768
+ exports.runStartupReconciliation = runStartupReconciliation;
994
3769
  exports.selectMode = selectMode;
3770
+ exports.setNulthPaymentContext = setNulthPaymentContext;
3771
+ exports.signManifest = signManifest;
3772
+ exports.usdcToMicros = usdcToMicros;
3773
+ exports.usdcToUnits = usdcToUnits;
3774
+ exports.verifyManifestSignature = verifyManifestSignature;
3775
+ exports.withRetry = withRetry;
3776
+ exports.wrapFetchError = wrapFetchError;