@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/client.js CHANGED
@@ -1,51 +1,221 @@
1
- import { Keypair } from '@stellar/stellar-sdk';
2
- import Ajv from 'ajv';
1
+ import { usdcToStroops } from '@routedock/nulth-sdk';
2
+ export { USDC_DECIMALS, usdcToStroops } from '@routedock/nulth-sdk';
3
+ import { Keypair, rpc, Horizon, Networks, xdr, Address, scValToNative, hash } from '@stellar/stellar-sdk';
4
+ import { Validator } from '@cfworker/json-schema';
5
+ import { createHash } from 'crypto';
3
6
  import { createEd25519Signer } from '@x402/stellar';
4
7
  import { ExactStellarScheme } from '@x402/stellar/exact/client';
5
8
  import { x402Client, x402HTTPClient } from '@x402/core/client';
6
9
  import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from '@x402/core/http';
7
10
  import { stellar } from '@stellar/mpp/charge/client';
8
11
  import { Mppx } from 'mppx/client';
12
+ import { STELLAR_PUBNET, STELLAR_TESTNET } from '@stellar/mpp';
9
13
  import { stellar as stellar$1 } from '@stellar/mpp/channel/client';
14
+ import { Challenge } from 'mppx';
15
+ import fs from 'fs/promises';
16
+ import path from 'path';
10
17
 
11
- // src/types.ts
18
+ // src/errors.ts
12
19
  var RouteDockError = class extends Error {
13
- constructor(message) {
14
- super(message);
20
+ code;
21
+ retryable;
22
+ retryAfterMs;
23
+ constructor(message, code, retryable, options = {}) {
24
+ super(message, { cause: options.cause });
15
25
  this.name = "RouteDockError";
26
+ this.code = code;
27
+ this.retryable = retryable;
28
+ if (options.retryAfterMs !== void 0) {
29
+ this.retryAfterMs = options.retryAfterMs;
30
+ }
16
31
  }
17
32
  };
18
33
  var RouteDockManifestError = class extends RouteDockError {
19
- constructor(message) {
20
- super(message);
34
+ constructor(message, options = {}) {
35
+ super(message, "MANIFEST", false, options);
21
36
  this.name = "RouteDockManifestError";
22
37
  }
23
38
  };
39
+ var RouteDockManifestTimeoutError = class extends RouteDockError {
40
+ constructor(message, options = {}) {
41
+ super(message, "MANIFEST_TIMEOUT", true, options);
42
+ this.name = "RouteDockManifestTimeoutError";
43
+ }
44
+ };
45
+ var RouteDockManifestSunsetError = class extends RouteDockError {
46
+ constructor(message, options = {}) {
47
+ super(message, "MANIFEST_SUNSET", false, options);
48
+ this.name = "RouteDockManifestSunsetError";
49
+ }
50
+ };
24
51
  var RouteDockNoSupportedModeError = class extends RouteDockError {
25
- constructor(message) {
26
- super(message);
52
+ constructor(message, options = {}) {
53
+ super(message, "NO_SUPPORTED_MODE", false, options);
27
54
  this.name = "RouteDockNoSupportedModeError";
28
55
  }
29
56
  };
30
- var RouteDockSessionError = class extends RouteDockError {
31
- constructor(message) {
32
- super(message);
33
- this.name = "RouteDockSessionError";
57
+ var RouteDockClientVersionError = class extends RouteDockError {
58
+ constructor(message, options = {}) {
59
+ super(message, "CLIENT_VERSION_TOO_OLD", false, options);
60
+ this.name = "RouteDockClientVersionError";
61
+ }
62
+ };
63
+ var RouteDockTrustlineError = class extends RouteDockError {
64
+ asset;
65
+ issuer;
66
+ remediation;
67
+ constructor(asset, issuer, remediation, options = {}) {
68
+ super(
69
+ `Missing trustline for ${asset} (issuer: ${issuer}). ${remediation}`,
70
+ "TRUSTLINE",
71
+ false,
72
+ options
73
+ );
74
+ this.name = "RouteDockTrustlineError";
75
+ this.asset = asset;
76
+ this.issuer = issuer;
77
+ this.remediation = remediation;
78
+ }
79
+ };
80
+ var RouteDockFacilitatorError = class extends RouteDockError {
81
+ status;
82
+ constructor(message, status, options = {}) {
83
+ super(message, "FACILITATOR", true, options);
84
+ this.name = "RouteDockFacilitatorError";
85
+ this.status = status;
86
+ }
87
+ };
88
+ var RouteDockNetworkError = class extends RouteDockError {
89
+ constructor(message, options = {}) {
90
+ super(message, "NETWORK", true, options);
91
+ this.name = "RouteDockNetworkError";
92
+ }
93
+ };
94
+ var RouteDockSignatureError = class extends RouteDockError {
95
+ constructor(message, options = {}) {
96
+ super(message, "SIGNATURE", false, options);
97
+ this.name = "RouteDockSignatureError";
98
+ }
99
+ };
100
+ var RouteDockVoucherMonotonicityError = class extends RouteDockError {
101
+ constructor(message, options = {}) {
102
+ super(message, "VOUCHER_MONOTONICITY", false, options);
103
+ this.name = "RouteDockVoucherMonotonicityError";
34
104
  }
35
105
  };
36
- var RouteDockPolicyRejectedError = class extends RouteDockError {
106
+ var RouteDockPolicyRejectError = class extends RouteDockError {
37
107
  reason;
38
- constructor(reason) {
39
- super(`Payment rejected by policy: ${reason}`);
40
- this.name = "RouteDockPolicyRejectedError";
108
+ constructor(reason, options = {}) {
109
+ super(`Payment rejected by policy: ${reason}`, "POLICY_REJECT", false, options);
110
+ this.name = "RouteDockPolicyRejectError";
41
111
  this.reason = reason;
42
112
  }
43
113
  };
114
+ var RouteDockChannelStateError = class extends RouteDockError {
115
+ constructor(message, options = {}) {
116
+ super(message, "CHANNEL_STATE", false, options);
117
+ this.name = "RouteDockChannelStateError";
118
+ }
119
+ };
120
+ var RouteDockDisputeError = class extends RouteDockError {
121
+ constructor(message, options = {}) {
122
+ super(message, "DISPUTE", false, options);
123
+ this.name = "RouteDockDisputeError";
124
+ }
125
+ };
126
+ var RouteDockRefundWindowError = class extends RouteDockError {
127
+ constructor(message, options = {}) {
128
+ super(message, "REFUND_WINDOW", false, options);
129
+ this.name = "RouteDockRefundWindowError";
130
+ }
131
+ };
132
+ var RouteDockPolicyRejectedError = RouteDockPolicyRejectError;
133
+ var RouteDockSessionError = RouteDockVoucherMonotonicityError;
134
+ function parseRetryAfterMs(response) {
135
+ const header = response.headers.get("Retry-After");
136
+ if (!header) return void 0;
137
+ const seconds = Number(header);
138
+ if (!Number.isNaN(seconds) && seconds >= 0) {
139
+ return seconds * 1e3;
140
+ }
141
+ const date = Date.parse(header);
142
+ if (!Number.isNaN(date)) {
143
+ return Math.max(0, date - Date.now());
144
+ }
145
+ return void 0;
146
+ }
147
+ function httpStatusToError(message, status, response) {
148
+ const retryAfterMs = response ? parseRetryAfterMs(response) : void 0;
149
+ const opts = retryAfterMs !== void 0 ? { retryAfterMs } : {};
150
+ if (status === 429 || status === 503 || status >= 500 && status < 600) {
151
+ return new RouteDockFacilitatorError(message, status, opts);
152
+ }
153
+ if (status >= 400) {
154
+ return new RouteDockManifestError(message, opts);
155
+ }
156
+ return new RouteDockNetworkError(message, opts);
157
+ }
158
+ function wrapFetchError(err, context) {
159
+ if (err instanceof RouteDockError) return err;
160
+ return new RouteDockNetworkError(`${context}: ${String(err)}`, { cause: err });
161
+ }
162
+
163
+ // src/types.ts
164
+ var DEFAULT_MAX_SESSION_DURATION_MS = 36e5;
165
+
166
+ // src/internal/retry.ts
167
+ var DEFAULT_RETRY_POLICY = {
168
+ maxAttempts: 4,
169
+ baseDelayMs: 250,
170
+ maxDelayMs: 3e4
171
+ };
172
+ function backoffDelayMs(attempt, baseDelayMs, maxDelayMs = DEFAULT_RETRY_POLICY.maxDelayMs) {
173
+ const cappedDelay = Math.min(maxDelayMs, baseDelayMs * 2 ** attempt);
174
+ return Math.floor(Math.random() * (cappedDelay + 1));
175
+ }
176
+ function sleep(ms) {
177
+ return new Promise((resolve) => setTimeout(resolve, ms));
178
+ }
179
+ async function withRetry(fn, policy = {}) {
180
+ const { maxAttempts, baseDelayMs, maxDelayMs, onRetry } = {
181
+ ...DEFAULT_RETRY_POLICY,
182
+ ...policy
183
+ };
184
+ let lastError;
185
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
186
+ try {
187
+ return await fn();
188
+ } catch (err) {
189
+ if (!(err instanceof RouteDockError)) {
190
+ throw err;
191
+ }
192
+ lastError = err;
193
+ if (!err.retryable || attempt >= maxAttempts - 1) {
194
+ throw err;
195
+ }
196
+ const delay = err.retryAfterMs !== void 0 ? err.retryAfterMs : backoffDelayMs(attempt, baseDelayMs, maxDelayMs);
197
+ if (onRetry) {
198
+ try {
199
+ await onRetry(attempt, err, delay);
200
+ } catch (cbErr) {
201
+ console.error("[withRetry] onRetry callback threw:", cbErr);
202
+ }
203
+ }
204
+ await sleep(delay);
205
+ }
206
+ }
207
+ throw lastError ?? new RouteDockError("Retry exhausted", "NETWORK", false);
208
+ }
209
+ var USDC_ISSUERS = {
210
+ testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
211
+ mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
212
+ };
213
+ var usdcToUnits = usdcToStroops;
44
214
 
45
215
  // src/schemas/routedock.schema.json
46
216
  var routedock_schema_default = {
47
217
  $schema: "http://json-schema.org/draft-07/schema#",
48
- $id: "https://routedock.dev/schemas/routedock.schema.json",
218
+ $id: "https://routedock.xyz/schemas/routedock.schema.json",
49
219
  title: "RouteDock Manifest",
50
220
  description: "Discovery manifest served at /.well-known/routedock.json on every RouteDock provider endpoint",
51
221
  type: "object",
@@ -66,8 +236,26 @@ var routedock_schema_default = {
66
236
  properties: {
67
237
  routedock: {
68
238
  type: "string",
69
- const: "1.0",
70
- description: "Manifest schema version. Always '1.0' for this version."
239
+ pattern: "^[0-9]+\\.[0-9]+$",
240
+ description: "Manifest schema version, e.g. '1.0', '1.1', '2.0'."
241
+ },
242
+ min_client_version: {
243
+ type: "string",
244
+ pattern: "^[0-9]+\\.[0-9]+$",
245
+ description: "Minimum SDK client version (major.minor) required to use this manifest. Clients below this version must refuse to pay."
246
+ },
247
+ deprecated_modes: {
248
+ type: "array",
249
+ items: {
250
+ $ref: "#/definitions/PaymentMode"
251
+ },
252
+ uniqueItems: true,
253
+ description: "Payment modes still listed in 'modes' for backwards compatibility but deprecated for new integrations."
254
+ },
255
+ sunset_at: {
256
+ type: "string",
257
+ format: "date-time",
258
+ description: "ISO 8601 timestamp after which this manifest version is no longer supported by the provider."
71
259
  },
72
260
  name: {
73
261
  type: "string",
@@ -120,18 +308,25 @@ var routedock_schema_default = {
120
308
  },
121
309
  "mpp-session": {
122
310
  $ref: "#/definitions/SessionPricingConfig"
311
+ },
312
+ "mpp-session-ws": {
313
+ $ref: "#/definitions/SessionPricingConfig",
314
+ 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."
123
315
  }
124
316
  },
125
317
  minProperties: 1
126
318
  },
127
319
  endpoints: {
128
320
  type: "object",
129
- description: "Map of endpoint name to HTTP method + path (e.g. { 'price': 'GET /price' })",
321
+ description: "Map of endpoint name to endpoint descriptors",
130
322
  additionalProperties: {
131
- type: "string"
323
+ $ref: "#/definitions/EndpointDescriptor"
132
324
  },
133
325
  minProperties: 1
134
326
  },
327
+ sla: {
328
+ $ref: "#/definitions/SLAConfig"
329
+ },
135
330
  tags: {
136
331
  type: "array",
137
332
  items: {
@@ -141,12 +336,91 @@ var routedock_schema_default = {
141
336
  },
142
337
  uniqueItems: true,
143
338
  description: "Capability tags indexed with trigram search in the provider registry"
339
+ },
340
+ vault: {
341
+ $ref: "#/definitions/VaultMode"
342
+ },
343
+ nulth_account: {
344
+ type: "string",
345
+ description: "Optional Nulth smart account address associated with this provider"
346
+ },
347
+ capabilities: {
348
+ type: "object",
349
+ description: "Optional protocol features this provider supports",
350
+ properties: {
351
+ streaming: {
352
+ type: "array",
353
+ items: {
354
+ type: "string",
355
+ enum: ["sse", "websocket"]
356
+ },
357
+ description: "Supported streaming transports"
358
+ },
359
+ webhooks: {
360
+ type: "boolean",
361
+ description: "Whether webhook callbacks are supported"
362
+ },
363
+ idempotency_keys: {
364
+ type: "boolean",
365
+ description: "Whether idempotency keys are supported"
366
+ },
367
+ content_types: {
368
+ type: "array",
369
+ items: {
370
+ type: "string"
371
+ },
372
+ description: "Supported content types for request/response"
373
+ }
374
+ },
375
+ additionalProperties: false
376
+ },
377
+ regions: {
378
+ type: "array",
379
+ items: {
380
+ type: "string",
381
+ minLength: 2,
382
+ maxLength: 10
383
+ },
384
+ uniqueItems: true,
385
+ 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."
386
+ },
387
+ latency_hints: {
388
+ type: "object",
389
+ additionalProperties: {
390
+ type: "number",
391
+ minimum: 0
392
+ },
393
+ description: "Provider-declared p50 latency in milliseconds per region code. Keys must be a subset of 'regions'. Example: { 'IAD': 14, 'AMS': 22 }."
394
+ },
395
+ signature: {
396
+ type: "string",
397
+ 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."
398
+ },
399
+ signature_version: {
400
+ type: "string",
401
+ const: "2",
402
+ description: "Manifest signature format. Version 2 recursively includes every nested manifest field in the signed digest."
403
+ },
404
+ categories: {
405
+ type: "array",
406
+ items: {
407
+ type: "string",
408
+ minLength: 1,
409
+ maxLength: 128
410
+ },
411
+ uniqueItems: true,
412
+ description: "Optional hierarchical categories from a published taxonomy (e.g. data/price/crypto)"
144
413
  }
145
414
  },
146
415
  definitions: {
416
+ VaultMode: {
417
+ type: "string",
418
+ enum: ["local-key", "agent-vault", "nulth"],
419
+ description: "Agent vault / custody mode for payments from this provider"
420
+ },
147
421
  PaymentMode: {
148
422
  type: "string",
149
- enum: ["x402", "mpp-charge", "mpp-session"],
423
+ enum: ["x402", "mpp-charge", "mpp-session", "mpp-session-ws"],
150
424
  description: "A supported RouteDock payment mode"
151
425
  },
152
426
  PricingConfig: {
@@ -167,13 +441,17 @@ var routedock_schema_default = {
167
441
  facilitator: {
168
442
  type: "string",
169
443
  description: "x402 facilitator URL (required for x402 mode). OpenZeppelin Channels endpoint."
444
+ },
445
+ payee: {
446
+ type: "string",
447
+ 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."
170
448
  }
171
449
  }
172
450
  },
173
451
  SessionPricingConfig: {
174
452
  type: "object",
175
453
  description: "Per-voucher pricing for mpp-session (one-way-channel) mode",
176
- required: ["rate", "per", "channel_contract", "min_deposit", "refund_waiting_period_ledgers"],
454
+ required: ["rate", "per", "channel_factory", "min_deposit", "refund_waiting_period_ledgers"],
177
455
  additionalProperties: false,
178
456
  properties: {
179
457
  rate: {
@@ -185,9 +463,9 @@ var routedock_schema_default = {
185
463
  const: "voucher",
186
464
  description: "Billing unit \u2014 always 'voucher' for session mode"
187
465
  },
188
- channel_contract: {
466
+ channel_factory: {
189
467
  type: "string",
190
- description: "Soroban contract address (C...) for the one-way-channel contract"
468
+ description: "Stable Soroban factory address (C...) used to deploy a per-session channel"
191
469
  },
192
470
  min_deposit: {
193
471
  type: "string",
@@ -199,88 +477,492 @@ var routedock_schema_default = {
199
477
  description: "Ledgers the server has to settle before the funder can reclaim. Default: 17280 (~24h). Do not reduce in production."
200
478
  }
201
479
  }
480
+ },
481
+ SLAConfig: {
482
+ type: "object",
483
+ description: "Service Level Agreement for the provider",
484
+ required: ["uptime_30d_percent", "p95_latency_ms"],
485
+ additionalProperties: false,
486
+ properties: {
487
+ uptime_30d_percent: {
488
+ type: "number",
489
+ description: "Uptime percentage over the last 30 days"
490
+ },
491
+ p95_latency_ms: {
492
+ type: "number",
493
+ description: "P95 latency in milliseconds"
494
+ },
495
+ maintenance_windows: {
496
+ type: "array",
497
+ items: {
498
+ type: "object",
499
+ required: ["cron", "duration_minutes"],
500
+ additionalProperties: false,
501
+ properties: {
502
+ cron: {
503
+ type: "string",
504
+ description: "Cron expression for the maintenance window"
505
+ },
506
+ duration_minutes: {
507
+ type: "number",
508
+ description: "Duration of the maintenance window in minutes"
509
+ }
510
+ }
511
+ }
512
+ }
513
+ }
514
+ },
515
+ EndpointDescriptor: {
516
+ type: "object",
517
+ description: "Descriptor for a provider endpoint",
518
+ required: ["method", "path"],
519
+ additionalProperties: false,
520
+ properties: {
521
+ method: {
522
+ type: "string",
523
+ description: "HTTP method for the endpoint"
524
+ },
525
+ path: {
526
+ type: "string",
527
+ description: "Path for the endpoint"
528
+ },
529
+ deprecated: {
530
+ type: "boolean",
531
+ description: "Whether this endpoint is retained only for backwards compatibility"
532
+ },
533
+ sunset_at: {
534
+ type: "string",
535
+ format: "date-time",
536
+ description: "ISO 8601 timestamp after which callers should stop using this endpoint"
537
+ },
538
+ headers: {
539
+ type: "object",
540
+ additionalProperties: {
541
+ type: "string"
542
+ },
543
+ description: "Expected request headers"
544
+ },
545
+ request_schema: {
546
+ description: "JSON schema for the request body"
547
+ },
548
+ response_schema: {
549
+ description: "JSON schema for the response body"
550
+ },
551
+ rate_limit: {
552
+ type: "object",
553
+ required: ["requests", "window_seconds"],
554
+ additionalProperties: false,
555
+ properties: {
556
+ requests: {
557
+ type: "number"
558
+ },
559
+ window_seconds: {
560
+ type: "number"
561
+ }
562
+ }
563
+ }
564
+ }
202
565
  }
203
566
  }
204
567
  };
205
568
 
569
+ // package.json
570
+ var package_default = {
571
+ version: "0.2.0"};
572
+ var MANIFEST_SIGNATURE_VERSION = "2";
573
+ function canonicalize(value) {
574
+ if (Array.isArray(value)) {
575
+ return value.map(canonicalize);
576
+ }
577
+ if (value !== null && typeof value === "object") {
578
+ return Object.fromEntries(
579
+ Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
580
+ );
581
+ }
582
+ return value;
583
+ }
584
+ function manifestDigest(manifest) {
585
+ const { signature: _omit, ...rest } = manifest;
586
+ const canonical = JSON.stringify(canonicalize(rest));
587
+ return createHash("sha256").update(canonical, "utf8").digest();
588
+ }
589
+ function verifyManifestSignature(manifest, expectedPayee) {
590
+ if (!manifest.signature) {
591
+ throw new RouteDockSignatureError(
592
+ "Manifest is missing a signature field \u2014 cannot verify payee authenticity"
593
+ );
594
+ }
595
+ if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
596
+ throw new RouteDockSignatureError(
597
+ `Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
598
+ );
599
+ }
600
+ if (expectedPayee !== void 0 && manifest.payee !== expectedPayee) {
601
+ throw new RouteDockSignatureError(
602
+ `Manifest payee ${manifest.payee} does not match expected payee ${expectedPayee}`
603
+ );
604
+ }
605
+ const keypair = Keypair.fromPublicKey(manifest.payee);
606
+ const digest = manifestDigest(manifest);
607
+ let sigBytes;
608
+ try {
609
+ sigBytes = Buffer.from(manifest.signature, "base64");
610
+ } catch {
611
+ throw new RouteDockSignatureError("Manifest signature is not valid base64");
612
+ }
613
+ let valid = false;
614
+ try {
615
+ valid = keypair.verify(digest, sigBytes);
616
+ } catch {
617
+ throw new RouteDockSignatureError(
618
+ `Manifest signature verification failed for payee ${manifest.payee}`
619
+ );
620
+ }
621
+ if (!valid) {
622
+ throw new RouteDockSignatureError(
623
+ `Manifest signature verification failed for payee ${manifest.payee}`
624
+ );
625
+ }
626
+ }
627
+
206
628
  // src/client/ModeRouter.ts
207
- var ajv = new Ajv();
208
- var validateManifest = ajv.compile(routedock_schema_default);
629
+ var validator = new Validator(routedock_schema_default, "7");
630
+ var SDK_VERSION = package_default.version;
631
+ function parseMajorMinor(version) {
632
+ const parts = version.split(".");
633
+ return [Number.parseInt(parts[0] ?? "0", 10), Number.parseInt(parts[1] ?? "0", 10)];
634
+ }
635
+ function isVersionBelow(a, b) {
636
+ const [aMajor, aMinor] = parseMajorMinor(a);
637
+ const [bMajor, bMinor] = parseMajorMinor(b);
638
+ return aMajor < bMajor || aMajor === bMajor && aMinor < bMinor;
639
+ }
640
+ function assertClientVersionSupported(manifest, baseUrl) {
641
+ const minVersion = manifest.min_client_version;
642
+ if (minVersion && isVersionBelow(SDK_VERSION, minVersion)) {
643
+ throw new RouteDockClientVersionError(
644
+ `SDK version ${SDK_VERSION} is below the minimum required version ${minVersion} for provider at ${baseUrl}. Please upgrade the SDK.`
645
+ );
646
+ }
647
+ }
648
+ function assertManifestActive(manifest, baseUrl) {
649
+ const sunsetAt = manifest.sunset_at;
650
+ if (!sunsetAt) return;
651
+ const sunsetTime = Date.parse(sunsetAt);
652
+ if (!Number.isFinite(sunsetTime)) {
653
+ throw new RouteDockManifestError(
654
+ `Manifest at ${baseUrl} contains an invalid sunset_at timestamp: ${sunsetAt}`
655
+ );
656
+ }
657
+ if (sunsetTime <= Date.now()) {
658
+ throw new RouteDockManifestSunsetError(
659
+ `Manifest for provider at ${baseUrl} sunset at ${sunsetAt} and can no longer be used`
660
+ );
661
+ }
662
+ }
663
+ function assertEndpointActive(manifest, url, logger) {
664
+ const pathname = new URL(url).pathname;
665
+ const matches = Object.entries(manifest.endpoints).filter(
666
+ ([, descriptor]) => descriptor.path === pathname
667
+ );
668
+ if (matches.length === 0) return;
669
+ const now = Date.now();
670
+ const allSunset = matches.every(([, descriptor]) => {
671
+ const sunsetAt = descriptor.sunset_at;
672
+ return typeof sunsetAt === "string" && Date.parse(sunsetAt) <= now;
673
+ });
674
+ if (allSunset) {
675
+ const [key, descriptor] = matches[0];
676
+ throw new RouteDockManifestSunsetError(
677
+ `Endpoint '${key}' (${pathname}) sunset at ${descriptor.sunset_at} and can no longer be used`
678
+ );
679
+ }
680
+ const deprecated = matches.find(([, descriptor]) => descriptor.deprecated === true);
681
+ if (deprecated && logger) {
682
+ const [key, descriptor] = deprecated;
683
+ const sunsetSuffix = descriptor.sunset_at ? `; sunset_at ${descriptor.sunset_at}` : "";
684
+ logger(
685
+ `[RouteDock] WARNING: ${manifest.name} \u2192 ${pathname}; endpoint '${key}' is deprecated${sunsetSuffix}`
686
+ );
687
+ }
688
+ }
689
+ function assertManifestValid(manifest, baseUrl) {
690
+ const context = baseUrl ? ` at ${baseUrl}` : "";
691
+ if (manifest.latency_hints !== void 0) {
692
+ if (!Array.isArray(manifest.regions) || manifest.regions.length === 0) {
693
+ throw new RouteDockManifestError(
694
+ `Invalid manifest${context}: latency_hints defined without declaring regions`
695
+ );
696
+ }
697
+ const regionSet = new Set(manifest.regions);
698
+ for (const region of Object.keys(manifest.latency_hints)) {
699
+ if (!regionSet.has(region)) {
700
+ throw new RouteDockManifestError(
701
+ `Invalid manifest${context}: latency_hints key '${region}' is not declared in regions (${manifest.regions.join(", ")})`
702
+ );
703
+ }
704
+ }
705
+ }
706
+ }
209
707
  var CACHE_TTL_MS = 6e4;
210
- var manifestCache = /* @__PURE__ */ new Map();
211
- async function fetchManifest(baseUrl) {
708
+ var DEFAULT_MANIFEST_CACHE_MAX_SIZE = 512;
709
+ var DEFAULT_MANIFEST_TIMEOUT_MS = 5e3;
710
+ var LruCache = class {
711
+ constructor(maxSize) {
712
+ this.maxSize = maxSize;
713
+ }
714
+ maxSize;
715
+ map = /* @__PURE__ */ new Map();
716
+ get(key) {
717
+ if (!this.map.has(key)) return void 0;
718
+ const value = this.map.get(key);
719
+ this.map.delete(key);
720
+ this.map.set(key, value);
721
+ return value;
722
+ }
723
+ set(key, value) {
724
+ if (this.map.has(key)) {
725
+ this.map.delete(key);
726
+ } else if (this.map.size >= this.maxSize) {
727
+ const oldestKey = this.map.keys().next().value;
728
+ if (oldestKey !== void 0) {
729
+ this.map.delete(oldestKey);
730
+ }
731
+ }
732
+ this.map.set(key, value);
733
+ }
734
+ setMaxSize(maxSize) {
735
+ this.maxSize = maxSize;
736
+ while (this.map.size > this.maxSize) {
737
+ const oldestKey = this.map.keys().next().value;
738
+ if (oldestKey === void 0) break;
739
+ this.map.delete(oldestKey);
740
+ }
741
+ }
742
+ delete(key) {
743
+ this.map.delete(key);
744
+ }
745
+ };
746
+ var manifestCache = new LruCache(DEFAULT_MANIFEST_CACHE_MAX_SIZE);
747
+ function configureManifestCache(maxSize) {
748
+ if (!Number.isInteger(maxSize) || maxSize <= 0) {
749
+ throw new RouteDockManifestError("Invalid manifest cache size: " + maxSize);
750
+ }
751
+ manifestCache.setMaxSize(maxSize);
752
+ }
753
+ function invalidateManifest(baseUrl) {
754
+ manifestCache.delete(baseUrl);
755
+ }
756
+ function ttlFromHeaders(headers, now) {
757
+ const cacheControl = headers.get("cache-control");
758
+ if (cacheControl) {
759
+ const directives = cacheControl.split(",").map((directive) => directive.trim().toLowerCase());
760
+ if (directives.includes("no-store") || directives.includes("no-cache")) {
761
+ return 0;
762
+ }
763
+ const maxAge = /max-age=(\d+)/i.exec(cacheControl);
764
+ if (maxAge) {
765
+ return Number.parseInt(maxAge[1], 10) * 1e3;
766
+ }
767
+ }
768
+ const expires = headers.get("expires");
769
+ if (expires) {
770
+ const expiresTime = Date.parse(expires);
771
+ if (Number.isFinite(expiresTime)) {
772
+ return expiresTime - now;
773
+ }
774
+ }
775
+ return CACHE_TTL_MS;
776
+ }
777
+ async function fetchManifest(baseUrl, retryPolicy, manifestTimeoutMs = DEFAULT_MANIFEST_TIMEOUT_MS, expectedPayee) {
212
778
  const cached = manifestCache.get(baseUrl);
213
- if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
779
+ if (cached && Date.now() < cached.expiresAt) {
780
+ verifyManifestSignature(cached.manifest, expectedPayee);
781
+ assertClientVersionSupported(cached.manifest, baseUrl);
782
+ assertManifestValid(cached.manifest, baseUrl);
783
+ assertManifestActive(cached.manifest, baseUrl);
214
784
  return cached.manifest;
215
785
  }
216
786
  const url = baseUrl.replace(/\/$/, "") + "/.well-known/routedock.json";
217
- let raw;
218
- try {
219
- const resp = await fetch(url);
220
- if (!resp.ok) {
221
- throw new RouteDockManifestError(`Manifest fetch failed: HTTP ${resp.status} from ${url}`);
787
+ return withRetry(async () => {
788
+ let raw;
789
+ let resp;
790
+ let expiresAt = 0;
791
+ try {
792
+ resp = await fetch(url, { signal: AbortSignal.timeout(manifestTimeoutMs) });
793
+ if (!resp.ok) {
794
+ if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
795
+ throw httpStatusToError(
796
+ `Manifest fetch failed: HTTP ${resp.status} from ${url}`,
797
+ resp.status,
798
+ resp
799
+ );
800
+ }
801
+ throw new RouteDockManifestError(
802
+ `Manifest fetch failed: HTTP ${resp.status} from ${url}`
803
+ );
804
+ }
805
+ } catch (err) {
806
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
807
+ throw new RouteDockManifestTimeoutError(
808
+ `Manifest fetch timed out after ${manifestTimeoutMs}ms from ${url}`,
809
+ { cause: err }
810
+ );
811
+ }
812
+ if (err instanceof RouteDockError) throw err;
813
+ throw wrapFetchError(err, `Manifest fetch error from ${url}`);
814
+ }
815
+ const now = Date.now();
816
+ expiresAt = now + ttlFromHeaders(resp.headers, now);
817
+ try {
818
+ raw = await resp.json();
819
+ } catch (err) {
820
+ throw new RouteDockManifestError(`Manifest at ${url} is not valid JSON`, { cause: err });
821
+ }
822
+ const result = validator.validate(raw);
823
+ if (!result.valid) {
824
+ const msgs = result.errors.map((err) => err.error).join("; ");
825
+ throw new RouteDockManifestError(`Invalid manifest at ${url}: ${msgs}`);
826
+ }
827
+ const manifest = raw;
828
+ assertManifestValid(manifest, baseUrl);
829
+ verifyManifestSignature(manifest, expectedPayee);
830
+ assertClientVersionSupported(manifest, baseUrl);
831
+ assertManifestActive(manifest, baseUrl);
832
+ manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now(), expiresAt });
833
+ return manifest;
834
+ }, retryPolicy);
835
+ }
836
+ function selectFromModes(modes, manifest, options) {
837
+ if (options.sustained || options.session) {
838
+ if (options.transport === "websocket") {
839
+ if (modes.includes("mpp-session-ws")) return { mode: "mpp-session-ws" };
840
+ if (modes.includes("mpp-session")) return { mode: "mpp-session" };
841
+ } else {
842
+ if (modes.includes("mpp-session")) return { mode: "mpp-session" };
843
+ if (modes.includes("mpp-session-ws")) return { mode: "mpp-session-ws" };
844
+ }
845
+ }
846
+ if (options.optimize === "cost") {
847
+ let budgetStroops = null;
848
+ if (options.budget_per_request !== void 0) {
849
+ try {
850
+ budgetStroops = usdcToStroops(options.budget_per_request);
851
+ } catch {
852
+ throw new RouteDockPolicyRejectError("invalid_budget_per_request");
853
+ }
854
+ }
855
+ const candidates = ["x402", "mpp-charge"].filter((mode) => modes.includes(mode)).flatMap((mode) => {
856
+ const amount = manifest.pricing[mode]?.amount;
857
+ if (typeof amount !== "string") return [];
858
+ try {
859
+ return [{ mode, amountStroops: usdcToStroops(amount) }];
860
+ } catch {
861
+ return [];
862
+ }
863
+ });
864
+ if (candidates.length > 0) {
865
+ const affordableCandidates = budgetStroops === null ? candidates : candidates.filter((candidate) => candidate.amountStroops <= budgetStroops);
866
+ const cheapestCandidate = [...affordableCandidates].sort(
867
+ (a, b) => a.amountStroops < b.amountStroops ? -1 : a.amountStroops > b.amountStroops ? 1 : 0
868
+ )[0];
869
+ if (cheapestCandidate) {
870
+ return { mode: cheapestCandidate.mode, reason: "cost-optimized" };
871
+ }
872
+ if (budgetStroops !== null) {
873
+ throw new RouteDockPolicyRejectError("budget_per_request_exceeded");
874
+ }
222
875
  }
223
- raw = await resp.json();
224
- } catch (err) {
225
- if (err instanceof RouteDockManifestError) throw err;
226
- throw new RouteDockManifestError(`Manifest fetch error: ${String(err)}`);
227
876
  }
228
- if (!validateManifest(raw)) {
229
- const msgs = ajv.errorsText(validateManifest.errors);
230
- throw new RouteDockManifestError(`Invalid manifest at ${url}: ${msgs}`);
877
+ if (modes.includes("mpp-charge")) return { mode: "mpp-charge" };
878
+ if (modes.includes("x402")) return { mode: "x402" };
879
+ return void 0;
880
+ }
881
+ function logSelection(manifest, selection, log, deprecated) {
882
+ const reason = selection.reason ? ` (${selection.reason})` : "";
883
+ if (deprecated) {
884
+ log(
885
+ `[RouteDock] WARNING: ${manifest.name} \u2192 ${selection.mode}${reason}; selected deprecated mode because no active supported mode is available`
886
+ );
887
+ return;
231
888
  }
232
- const manifest = raw;
233
- manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now() });
234
- return manifest;
889
+ log(`[RouteDock] ${manifest.name} \u2192 ${selection.mode}${reason}`);
235
890
  }
236
891
  function selectMode(manifest, options = {}) {
237
892
  const modes = manifest.modes;
893
+ const log = options.logger ?? (() => {
894
+ });
895
+ const deprecatedSet = new Set(manifest.deprecated_modes ?? []);
238
896
  if (options.forceMode) {
239
897
  if (!modes.includes(options.forceMode)) {
240
898
  throw new RouteDockNoSupportedModeError(
241
899
  `Provider does not support forced mode: ${options.forceMode} (available: ${modes.join(", ")})`
242
900
  );
243
901
  }
244
- console.log(`[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
902
+ if (deprecatedSet.has(options.forceMode)) {
903
+ log(
904
+ `[RouteDock] WARNING: ${manifest.name} \u2192 ${options.forceMode} (forced); selected mode is deprecated`
905
+ );
906
+ } else {
907
+ log(`[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
908
+ }
245
909
  return options.forceMode;
246
910
  }
247
- if ((options.sustained || options.session) && modes.includes("mpp-session")) {
248
- const mode = "mpp-session";
249
- console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
250
- return mode;
911
+ const activeModes = modes.filter((mode) => !deprecatedSet.has(mode));
912
+ const deprecatedModes = modes.filter((mode) => deprecatedSet.has(mode));
913
+ const activeSelection = selectFromModes(activeModes, manifest, options);
914
+ if (activeSelection) {
915
+ logSelection(manifest, activeSelection, log, false);
916
+ return activeSelection.mode;
251
917
  }
252
- if (modes.includes("mpp-charge")) {
253
- const mode = "mpp-charge";
254
- console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
255
- return mode;
256
- }
257
- if (modes.includes("x402")) {
258
- const mode = "x402";
259
- console.log(`[RouteDock] ${manifest.name} \u2192 ${mode}`);
260
- return mode;
918
+ const deprecatedSelection = selectFromModes(deprecatedModes, manifest, options);
919
+ if (deprecatedSelection) {
920
+ logSelection(manifest, deprecatedSelection, log, true);
921
+ return deprecatedSelection.mode;
261
922
  }
262
923
  throw new RouteDockNoSupportedModeError(
263
924
  `No supported payment mode found in manifest (modes: ${modes.join(", ")})`
264
925
  );
265
926
  }
927
+ function rankProvidersByLatency(manifests, targetRegion) {
928
+ return [...manifests].sort((a, b) => {
929
+ const latA = a.latency_hints?.[targetRegion];
930
+ const latB = b.latency_hints?.[targetRegion];
931
+ const inRegionsA = a.regions?.includes(targetRegion) ?? false;
932
+ const inRegionsB = b.regions?.includes(targetRegion) ?? false;
933
+ if (latA !== void 0 && latB !== void 0) {
934
+ return latA - latB;
935
+ }
936
+ if (latA !== void 0) return -1;
937
+ if (latB !== void 0) return 1;
938
+ if (inRegionsA && !inRegionsB) return -1;
939
+ if (!inRegionsA && inRegionsB) return 1;
940
+ return 0;
941
+ });
942
+ }
266
943
  var CAIP2 = {
267
944
  testnet: "stellar:testnet",
268
945
  mainnet: "stellar:pubnet"
269
946
  };
270
- var X402Client = class {
271
- constructor(secretKey, network) {
272
- this.secretKey = secretKey;
947
+ var X402Client = class _X402Client {
948
+ constructor(secretKeyOrSigner, network, retryPolicy) {
273
949
  this.network = network;
950
+ this.retryPolicy = retryPolicy;
274
951
  const caip2 = CAIP2[network];
275
- const signer = createEd25519Signer(secretKey, caip2);
276
- const scheme = new ExactStellarScheme(signer);
952
+ this.signer = typeof secretKeyOrSigner === "string" ? createEd25519Signer(secretKeyOrSigner, caip2) : secretKeyOrSigner;
953
+ const scheme = new ExactStellarScheme(this.signer);
277
954
  const core = new x402Client();
278
955
  core.register(caip2, scheme);
279
956
  this.httpClient = new x402HTTPClient(core);
280
957
  }
281
- secretKey;
282
958
  network;
959
+ retryPolicy;
283
960
  httpClient;
961
+ signer;
962
+ /** Replace signer (e.g. swap to Nulth ZK account payer before pay) */
963
+ withSigner(signer) {
964
+ return new _X402Client(signer, this.network, this.retryPolicy);
965
+ }
284
966
  async pay(url, manifest) {
285
967
  const pricing = manifest.pricing.x402;
286
968
  if (!pricing) {
@@ -289,82 +971,194 @@ var X402Client = class {
289
971
  if (!pricing.facilitator) {
290
972
  throw new RouteDockManifestError("manifest.pricing.x402.facilitator missing");
291
973
  }
292
- const init = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
293
- if (init.status !== 402) {
294
- const data2 = await init.json();
295
- return { data: data2, txHash: null, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
296
- }
297
- const reqHeader = init.headers.get("X-Payment-Requirements");
298
- if (!reqHeader) {
299
- throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
300
- }
301
- const paymentRequired = decodePaymentRequiredHeader(reqHeader);
302
- const paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
303
- const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
304
- const settled = await fetch(url, { headers: paymentHeaders });
305
- if (!settled.ok) {
306
- throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
307
- }
308
- let txHash = null;
309
- const responseHeader = settled.headers.get("X-Payment-Response");
310
- if (responseHeader) {
974
+ return withRetry(async () => {
975
+ let init;
976
+ try {
977
+ init = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
978
+ } catch (err) {
979
+ throw wrapFetchError(err, "x402 initial request");
980
+ }
981
+ if (init.status !== 402) {
982
+ if (!init.ok) {
983
+ throw httpStatusToError(
984
+ `x402 initial request failed: HTTP ${init.status}`,
985
+ init.status,
986
+ init
987
+ );
988
+ }
989
+ let data2;
990
+ try {
991
+ data2 = await init.json();
992
+ } catch {
993
+ throw new RouteDockManifestError(`Failed to parse JSON from non-402 response (HTTP ${init.status})`);
994
+ }
995
+ return { data: data2, txHash: null, mode: "x402", amount: "0", timestamp: Date.now() };
996
+ }
997
+ const reqHeader = init.headers.get("X-Payment-Requirements");
998
+ if (!reqHeader) {
999
+ throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
1000
+ }
1001
+ let paymentRequired;
311
1002
  try {
312
- const settleResponse = decodePaymentResponseHeader(responseHeader);
313
- txHash = settleResponse.transaction ?? null;
1003
+ paymentRequired = decodePaymentRequiredHeader(reqHeader);
1004
+ } catch (err) {
1005
+ throw new RouteDockManifestError("402 X-Payment-Requirements header is malformed", {
1006
+ cause: err
1007
+ });
1008
+ }
1009
+ let paymentPayload;
1010
+ try {
1011
+ paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
1012
+ } catch (err) {
1013
+ throw new RouteDockSignatureError(`x402 payment signing failed: ${String(err)}`, {
1014
+ cause: err
1015
+ });
1016
+ }
1017
+ const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
1018
+ let settled;
1019
+ try {
1020
+ settled = await fetch(url, { headers: paymentHeaders });
1021
+ } catch (err) {
1022
+ throw wrapFetchError(err, "x402 settlement request");
1023
+ }
1024
+ if (!settled.ok) {
1025
+ if (settled.status >= 500 || settled.status === 429 || settled.status === 503) {
1026
+ throw httpStatusToError(
1027
+ `x402 payment failed: HTTP ${settled.status}`,
1028
+ settled.status,
1029
+ settled
1030
+ );
1031
+ }
1032
+ throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
1033
+ }
1034
+ let txHash = null;
1035
+ const responseHeader = settled.headers.get("X-Payment-Response");
1036
+ if (responseHeader) {
1037
+ try {
1038
+ const settleResponse = decodePaymentResponseHeader(responseHeader);
1039
+ txHash = settleResponse.transaction ?? null;
1040
+ } catch {
1041
+ }
1042
+ }
1043
+ let data;
1044
+ try {
1045
+ data = await settled.json();
314
1046
  } catch {
1047
+ throw new RouteDockManifestError("Failed to parse JSON from settled response");
315
1048
  }
316
- }
317
- const data = await settled.json();
318
- return { data, txHash, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
1049
+ return { data, txHash, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
1050
+ }, this.retryPolicy);
319
1051
  }
320
1052
  };
321
1053
  var MppChargeClient = class {
322
- constructor(keypair, network) {
1054
+ constructor(keypair, network, retryPolicy) {
323
1055
  this.keypair = keypair;
324
1056
  this.network = network;
1057
+ this.retryPolicy = retryPolicy;
325
1058
  }
326
1059
  keypair;
327
1060
  network;
1061
+ retryPolicy;
328
1062
  async pay(url, manifest) {
329
1063
  const pricing = manifest.pricing["mpp-charge"];
330
1064
  if (!pricing) {
331
1065
  throw new RouteDockManifestError("manifest.pricing.mpp-charge missing");
332
1066
  }
333
- let txHash = null;
334
- const mppx = Mppx.create({
335
- polyfill: false,
336
- methods: [
337
- stellar.charge({
338
- keypair: this.keypair,
339
- mode: "pull",
340
- onProgress(event) {
341
- if (event.type === "paid") {
342
- txHash = event.hash;
1067
+ return withRetry(async () => {
1068
+ let txHash = null;
1069
+ const mppx = Mppx.create({
1070
+ polyfill: false,
1071
+ methods: [
1072
+ stellar.charge({
1073
+ keypair: this.keypair,
1074
+ mode: "pull",
1075
+ onProgress(event) {
1076
+ if (event.type === "paid") {
1077
+ txHash = event.hash;
1078
+ }
343
1079
  }
344
- }
345
- })
346
- ]
347
- });
348
- const response = await mppx.fetch(url);
349
- if (!response.ok) {
350
- throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`);
351
- }
352
- const data = await response.json();
353
- return { data, txHash, mode: "mpp-charge", amount: pricing.amount, timestamp: Date.now() };
1080
+ })
1081
+ ]
1082
+ });
1083
+ let response;
1084
+ try {
1085
+ response = await mppx.fetch(url);
1086
+ } catch (err) {
1087
+ throw wrapFetchError(err, "MPP charge request");
1088
+ }
1089
+ if (!response.ok) {
1090
+ if (response.status >= 500 || response.status === 429 || response.status === 503) {
1091
+ throw httpStatusToError(
1092
+ `MPP charge failed: HTTP ${response.status}`,
1093
+ response.status,
1094
+ response
1095
+ );
1096
+ }
1097
+ throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`);
1098
+ }
1099
+ let data;
1100
+ try {
1101
+ data = await response.json();
1102
+ } catch (cause) {
1103
+ throw new RouteDockManifestError(
1104
+ `Failed to parse JSON from response (HTTP ${response.status})`,
1105
+ { cause }
1106
+ );
1107
+ }
1108
+ return { data, txHash, mode: "mpp-charge", amount: pricing.amount, timestamp: Date.now() };
1109
+ }, this.retryPolicy);
354
1110
  }
355
1111
  };
356
1112
  var MIN_REFUND_WAITING_PERIOD = 17280;
357
- var MppSessionClient = class {
358
- constructor(keypair, network) {
359
- this.keypair = keypair;
1113
+ var STREAM_CLEANUP_TIMEOUT_MS = 3e3;
1114
+ var WS_CONNECTING = 0;
1115
+ var WS_OPEN = 1;
1116
+ function defaultWebSocketFactory(url, init, handlers) {
1117
+ const socket = new WebSocket(
1118
+ url,
1119
+ { headers: init.headers }
1120
+ );
1121
+ socket.onopen = () => handlers.onOpen();
1122
+ socket.onmessage = (event) => {
1123
+ const data = event.data;
1124
+ if (typeof data === "string") {
1125
+ handlers.onMessage(data);
1126
+ } else if (data instanceof ArrayBuffer) {
1127
+ handlers.onMessage(Buffer.from(data).toString("utf8"));
1128
+ } else {
1129
+ void data.arrayBuffer().then((buffer) => handlers.onMessage(Buffer.from(buffer).toString("utf8"))).catch(() => {
1130
+ });
1131
+ }
1132
+ };
1133
+ socket.onclose = (event) => handlers.onClose(event.code, event.reason);
1134
+ socket.onerror = () => handlers.onError();
1135
+ return socket;
1136
+ }
1137
+ function parseIntegerString(value) {
1138
+ return /^\d+$/.test(value) ? BigInt(value) : null;
1139
+ }
1140
+ function clientCumulativeStoreKey(networkId, channel) {
1141
+ return `stellar:channel:client:${networkId}:${channel}:cumulative`;
1142
+ }
1143
+ function resolveStoreNetworkId(network) {
1144
+ return network === STELLAR_PUBNET ? STELLAR_PUBNET : STELLAR_TESTNET;
1145
+ }
1146
+ var MppSessionClient = class {
1147
+ constructor(keypair, network, retryPolicy, webSocketFactory = defaultWebSocketFactory) {
1148
+ this.keypair = keypair;
360
1149
  this.network = network;
1150
+ this.retryPolicy = retryPolicy;
1151
+ this.webSocketFactory = webSocketFactory;
361
1152
  }
362
1153
  keypair;
363
1154
  network;
364
- async openSession(url, manifest, commitmentSecret) {
365
- const pricing = manifest.pricing["mpp-session"];
1155
+ retryPolicy;
1156
+ webSocketFactory;
1157
+ async openSession(url, manifest, commitmentSecret, options, onSpend) {
1158
+ const mode = options?.mode ?? "mpp-session";
1159
+ const pricing = manifest.pricing[mode];
366
1160
  if (!pricing) {
367
- throw new RouteDockManifestError("manifest.pricing.mpp-session missing");
1161
+ throw new RouteDockManifestError(`manifest.pricing.${mode} missing`);
368
1162
  }
369
1163
  const refundPeriod = pricing.refund_waiting_period_ledgers;
370
1164
  if (refundPeriod < MIN_REFUND_WAITING_PERIOD) {
@@ -373,166 +1167,1383 @@ var MppSessionClient = class {
373
1167
  );
374
1168
  }
375
1169
  const commitmentKey = Keypair.fromSecret(commitmentSecret);
376
- const channelContract = pricing.channel_contract;
1170
+ const channelFactory = pricing.channel_factory;
377
1171
  const agentPublicKey = this.keypair.publicKey();
1172
+ const agentKeypair = this.keypair;
378
1173
  let currentCumulative = 0n;
379
1174
  let vouchersIssued = 0;
1175
+ let rateStroops;
1176
+ try {
1177
+ rateStroops = usdcToStroops(pricing.rate);
1178
+ } catch (err) {
1179
+ throw new RouteDockManifestError(
1180
+ `manifest.pricing.${mode}.rate is not a valid USDC amount: "${pricing.rate}"`,
1181
+ { cause: err }
1182
+ );
1183
+ }
1184
+ const store = options?.store;
1185
+ let reservedCumulative = null;
1186
+ let storeSeed;
1187
+ const seedFromStore = (networkId, channel) => {
1188
+ storeSeed ??= Promise.resolve(
1189
+ store.get(clientCumulativeStoreKey(networkId, channel))
1190
+ ).then((stored) => {
1191
+ if (stored && typeof stored === "object" && "amount" in stored) {
1192
+ const raw = stored.amount;
1193
+ const amount = typeof raw === "string" ? parseIntegerString(raw) : null;
1194
+ if (amount === null) {
1195
+ throw new RouteDockChannelStateError(
1196
+ `Stored cumulative for channel ${channel} is not an integer string: ${JSON.stringify(raw)}`
1197
+ );
1198
+ }
1199
+ return amount;
1200
+ }
1201
+ return 0n;
1202
+ });
1203
+ return storeSeed;
1204
+ };
1205
+ const reserveNextCumulative = async (challenge) => {
1206
+ const request = challenge.request;
1207
+ const channelValue = request["channel"];
1208
+ if (typeof channelValue !== "string" || channelValue !== channelFactory) {
1209
+ throw new RouteDockChannelStateError(
1210
+ `Challenge names channel ${String(channelValue)}, but the manifest channel_factory is ${channelFactory}`
1211
+ );
1212
+ }
1213
+ const amountValue = request["amount"];
1214
+ const amount = typeof amountValue === "string" ? parseIntegerString(amountValue) : null;
1215
+ if (amount === null) {
1216
+ throw new RouteDockChannelStateError(
1217
+ `Challenge request.amount is not a base-10 integer string: ${JSON.stringify(amountValue)}`
1218
+ );
1219
+ }
1220
+ if (amount !== rateStroops) {
1221
+ throw new RouteDockChannelStateError(
1222
+ `Challenge request.amount ${amount} does not match the manifest rate ${rateStroops} stroops per voucher`
1223
+ );
1224
+ }
1225
+ const methodDetailsValue = request["methodDetails"];
1226
+ const methodDetails = methodDetailsValue !== null && typeof methodDetailsValue === "object" ? methodDetailsValue : void 0;
1227
+ let serverReported = 0n;
1228
+ if (methodDetails && "cumulativeAmount" in methodDetails) {
1229
+ const rawCumulative = methodDetails["cumulativeAmount"];
1230
+ const parsed = typeof rawCumulative === "string" ? parseIntegerString(rawCumulative) : null;
1231
+ if (parsed === null) {
1232
+ throw new RouteDockChannelStateError(
1233
+ `Challenge methodDetails.cumulativeAmount is not a base-10 integer string: ${JSON.stringify(rawCumulative)}`
1234
+ );
1235
+ }
1236
+ serverReported = parsed;
1237
+ }
1238
+ let baseline = reservedCumulative;
1239
+ if (baseline === null) {
1240
+ if (store) {
1241
+ const challengedNetwork = methodDetails?.["network"];
1242
+ const seeded = await seedFromStore(
1243
+ resolveStoreNetworkId(
1244
+ typeof challengedNetwork === "string" ? challengedNetwork : void 0
1245
+ ),
1246
+ channelValue
1247
+ );
1248
+ baseline = reservedCumulative ?? seeded;
1249
+ } else if (serverReported === 0n) {
1250
+ baseline = 0n;
1251
+ } else {
1252
+ throw new RouteDockChannelStateError(
1253
+ `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.`
1254
+ );
1255
+ }
1256
+ }
1257
+ if (serverReported > baseline) {
1258
+ throw new RouteDockChannelStateError(
1259
+ `Challenge reports cumulative ${serverReported}, above the locally reserved baseline ${baseline}`
1260
+ );
1261
+ }
1262
+ const next = baseline + rateStroops;
1263
+ reservedCumulative = next;
1264
+ return next;
1265
+ };
1266
+ const releaseReservation = (reserved) => {
1267
+ if (reservedCumulative === reserved) {
1268
+ reservedCumulative = reserved - rateStroops;
1269
+ }
1270
+ };
1271
+ const createGuardedCredential = async (challenge, create) => {
1272
+ const next = await reserveNextCumulative(challenge);
1273
+ try {
1274
+ return await create(next.toString());
1275
+ } catch (err) {
1276
+ releaseReservation(next);
1277
+ throw err;
1278
+ }
1279
+ };
1280
+ const streamWs = (u, m, createCredential, onSigned) => this.streamWebSocket(u, m, createCredential, onSigned);
380
1281
  const mppx = Mppx.create({
381
1282
  polyfill: false,
382
1283
  methods: [
383
1284
  stellar$1.channel({
384
1285
  commitmentKey,
385
1286
  sourceAccount: this.keypair.publicKey(),
1287
+ ...store ? { store } : {},
386
1288
  onProgress(event) {
387
1289
  if (event.type === "signed") {
388
1290
  currentCumulative = BigInt(event.cumulativeAmount);
389
1291
  }
390
1292
  }
391
1293
  })
392
- ]
1294
+ ],
1295
+ // mppx runs onChallenge on the HTTP path only (createCredential skips it),
1296
+ // so the WebSocket path runs the same guard inside streamWebSocket.
1297
+ onChallenge: (challenge, helpers) => createGuardedCredential(
1298
+ challenge,
1299
+ (cumulativeAmount) => helpers.createCredential({ cumulativeAmount })
1300
+ )
393
1301
  });
394
- const openTxHash = channelContract;
1302
+ const retryPolicy = this.retryPolicy;
1303
+ const maxDurationMs = options?.maxDurationMs ?? DEFAULT_MAX_SESSION_DURATION_MS;
1304
+ const listeners = /* @__PURE__ */ new Map();
1305
+ let timeoutId;
1306
+ let closed = false;
1307
+ const emit = (event, payload) => {
1308
+ const set = listeners.get(event);
1309
+ if (!set) return;
1310
+ for (const listener of set) {
1311
+ try {
1312
+ const returned = listener(payload);
1313
+ if (returned && typeof returned.then === "function") {
1314
+ void Promise.resolve(returned).catch(() => {
1315
+ });
1316
+ }
1317
+ } catch {
1318
+ }
1319
+ }
1320
+ };
1321
+ const clearSessionTimer = () => {
1322
+ if (timeoutId !== void 0) {
1323
+ clearTimeout(timeoutId);
1324
+ timeoutId = void 0;
1325
+ }
1326
+ };
1327
+ const network = this.network;
395
1328
  const handle = {
396
- channelId: channelContract,
397
- openTxHash,
398
- async *stream() {
399
- while (true) {
400
- let resp = null;
401
- for (let attempt = 0; attempt < 3; attempt++) {
402
- try {
403
- resp = await mppx.fetch(url);
404
- if (resp.ok) break;
405
- resp = null;
406
- } catch {
407
- if (attempt === 2) throw new RouteDockSessionError("Voucher request failed after 3 retries");
408
- await new Promise((r) => setTimeout(r, 1e3 * (attempt + 1)));
1329
+ channelId: channelFactory,
1330
+ // The channel is pre-deployed and funded before the agent runs, so the
1331
+ // client never issues the channel-open transaction and has no hash for
1332
+ // it. Report null rather than the contract address (a non-transaction
1333
+ // identifier that produces broken explorer links downstream).
1334
+ openTxHash: null,
1335
+ /**
1336
+ * Live session snapshot. vouchersIssued increments once per signed voucher
1337
+ * and currentCumulative is updated by the channel client's onProgress when
1338
+ * a voucher is signed, so a stats() call immediately after a yield always
1339
+ * reflects everything consumed so far.
1340
+ */
1341
+ stats() {
1342
+ return {
1343
+ vouchersIssued,
1344
+ // Same conversion as SessionCloseResult.totalPaid so a per-session
1345
+ // sub-cap can be compared against stats().currentCumulative directly.
1346
+ currentCumulative: (Number(currentCumulative) / 1e7).toFixed(7),
1347
+ channelId: channelFactory,
1348
+ openTxHash: null
1349
+ };
1350
+ },
1351
+ async *stream(options2) {
1352
+ const checkSpend = () => {
1353
+ if (!onSpend) return Promise.resolve();
1354
+ return onSpend(pricing.rate);
1355
+ };
1356
+ if (mode === "mpp-session-ws") {
1357
+ await checkSpend();
1358
+ const onSigned = () => {
1359
+ vouchersIssued++;
1360
+ };
1361
+ for await (const item of streamWs(url, mppx, createGuardedCredential, onSigned)) {
1362
+ yield item;
1363
+ }
1364
+ return;
1365
+ }
1366
+ const concurrency = Math.max(1, options2?.concurrency ?? 1);
1367
+ const assertOpen = () => {
1368
+ if (closed) {
1369
+ throw new RouteDockChannelStateError("session closed");
1370
+ }
1371
+ };
1372
+ const abortController = new AbortController();
1373
+ const doFetch = () => withRetry(async () => {
1374
+ let resp;
1375
+ try {
1376
+ resp = await mppx.fetch(url, { signal: abortController.signal });
1377
+ } catch (err) {
1378
+ if (abortController.signal.aborted) {
1379
+ throw err;
1380
+ }
1381
+ throw wrapFetchError(err, "Voucher request");
1382
+ }
1383
+ if (!resp.ok) {
1384
+ if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
1385
+ throw httpStatusToError(
1386
+ `Voucher request failed: HTTP ${resp.status}`,
1387
+ resp.status,
1388
+ resp
1389
+ );
409
1390
  }
1391
+ throw new RouteDockChannelStateError(
1392
+ `Voucher request failed: HTTP ${resp.status}`
1393
+ );
410
1394
  }
411
- if (!resp || !resp.ok) {
412
- throw new RouteDockSessionError(`Voucher request failed: HTTP ${resp?.status ?? "unknown"}`);
1395
+ try {
1396
+ return await resp.json();
1397
+ } catch (cause) {
1398
+ throw new RouteDockChannelStateError(
1399
+ `Voucher response was not valid JSON (HTTP ${resp.status})`,
1400
+ { cause }
1401
+ );
1402
+ }
1403
+ }, retryPolicy);
1404
+ if (concurrency === 1) {
1405
+ try {
1406
+ while (true) {
1407
+ assertOpen();
1408
+ await checkSpend();
1409
+ const data = await doFetch();
1410
+ vouchersIssued++;
1411
+ yield data;
1412
+ }
1413
+ } finally {
1414
+ abortController.abort();
1415
+ }
1416
+ } else {
1417
+ const queue = [];
1418
+ try {
1419
+ for (let i = 0; i < concurrency; i++) {
1420
+ assertOpen();
1421
+ await checkSpend();
1422
+ const p = doFetch();
1423
+ p.catch(() => {
1424
+ });
1425
+ queue.push(p);
1426
+ }
1427
+ while (true) {
1428
+ assertOpen();
1429
+ const data = await queue.shift();
1430
+ await checkSpend();
1431
+ const p = doFetch();
1432
+ p.catch(() => {
1433
+ });
1434
+ queue.push(p);
1435
+ vouchersIssued++;
1436
+ yield data;
1437
+ }
1438
+ } finally {
1439
+ abortController.abort();
1440
+ let timer;
1441
+ try {
1442
+ await Promise.race([
1443
+ Promise.allSettled(queue),
1444
+ new Promise((resolve) => {
1445
+ timer = setTimeout(resolve, STREAM_CLEANUP_TIMEOUT_MS);
1446
+ timer.unref?.();
1447
+ })
1448
+ ]);
1449
+ } finally {
1450
+ if (timer) {
1451
+ clearTimeout(timer);
1452
+ }
1453
+ }
1454
+ queue.length = 0;
413
1455
  }
414
- vouchersIssued++;
415
- yield await resp.json();
416
1456
  }
417
1457
  },
418
1458
  async close() {
1459
+ clearSessionTimer();
1460
+ closed = true;
419
1461
  const { rpc: rpcMod, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } = await import('@stellar/stellar-sdk');
420
- const rpcUrl = "https://soroban-testnet.stellar.org";
1462
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
421
1463
  const server = new rpcMod.Server(rpcUrl);
422
- const contract = new Contract(channelContract);
423
- const passphrase = "Test SDF Network ; September 2015";
424
- const account = await server.getAccount(agentPublicKey);
425
- const simTx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: passphrase }).addOperation(contract.call("prepare_commitment", nativeToScVal(currentCumulative, { type: "i128" }))).setTimeout(30).build();
426
- const simResult = await server.simulateTransaction(simTx);
1464
+ const contract = new Contract(channelFactory);
1465
+ const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1466
+ const account = await withRetry(async () => {
1467
+ try {
1468
+ return await server.getAccount(agentPublicKey);
1469
+ } catch (err) {
1470
+ throw wrapFetchError(err, "Horizon getAccount");
1471
+ }
1472
+ }, retryPolicy);
1473
+ const simResult = await withRetry(async () => {
1474
+ try {
1475
+ const simTx = new TransactionBuilder(account, {
1476
+ fee: BASE_FEE,
1477
+ networkPassphrase: passphrase
1478
+ }).addOperation(
1479
+ contract.call(
1480
+ "prepare_commitment",
1481
+ nativeToScVal(currentCumulative, { type: "i128" })
1482
+ )
1483
+ ).setTimeout(30).build();
1484
+ return await server.simulateTransaction(simTx);
1485
+ } catch (err) {
1486
+ throw wrapFetchError(err, "prepare_commitment RPC");
1487
+ }
1488
+ }, retryPolicy);
427
1489
  if (rpcMod.Api.isSimulationError(simResult)) {
428
- throw new RouteDockSessionError(`prepare_commitment simulation failed: ${simResult.error}`);
1490
+ throw new RouteDockChannelStateError(
1491
+ `prepare_commitment simulation failed: ${simResult.error}`
1492
+ );
429
1493
  }
430
1494
  const commitmentBytes = simResult.result?.retval?.bytes();
431
- if (!commitmentBytes) throw new RouteDockSessionError("prepare_commitment returned no bytes");
432
- const signature = commitmentKey.sign(Buffer.from(commitmentBytes));
433
- const closeResp = await fetch(url, {
434
- method: "DELETE",
435
- headers: { "Content-Type": "application/json" },
436
- body: JSON.stringify({
437
- amount: currentCumulative.toString(),
438
- signature: Buffer.from(signature).toString("hex")
439
- })
440
- });
441
- if (!closeResp.ok) {
442
- throw new RouteDockSessionError(`Channel close failed: HTTP ${closeResp.status}`);
1495
+ if (!commitmentBytes) {
1496
+ throw new RouteDockChannelStateError("prepare_commitment returned no bytes");
443
1497
  }
444
- const closeData = await closeResp.json();
445
- const closeTxHash = closeData.closeTxHash ?? null;
446
- if (!closeTxHash) {
447
- throw new RouteDockSessionError("Channel close response missing closeTxHash");
1498
+ let signature;
1499
+ try {
1500
+ signature = commitmentKey.sign(Buffer.from(commitmentBytes));
1501
+ } catch (err) {
1502
+ throw new RouteDockSignatureError("Channel close commitment signing failed", {
1503
+ cause: err
1504
+ });
448
1505
  }
1506
+ const closeData = await withRetry(async () => {
1507
+ let closeResp;
1508
+ try {
1509
+ closeResp = await fetch(url, {
1510
+ method: "DELETE",
1511
+ headers: { "Content-Type": "application/json" },
1512
+ body: JSON.stringify({
1513
+ amount: currentCumulative.toString(),
1514
+ signature: Buffer.from(signature).toString("hex")
1515
+ })
1516
+ });
1517
+ } catch (err) {
1518
+ throw wrapFetchError(err, "Channel close request");
1519
+ }
1520
+ if (!closeResp.ok) {
1521
+ if (closeResp.status >= 500 || closeResp.status === 429 || closeResp.status === 503) {
1522
+ throw httpStatusToError(
1523
+ `Channel close failed: HTTP ${closeResp.status}`,
1524
+ closeResp.status,
1525
+ closeResp
1526
+ );
1527
+ }
1528
+ throw new RouteDockChannelStateError(
1529
+ `Channel close failed: HTTP ${closeResp.status}`
1530
+ );
1531
+ }
1532
+ let body;
1533
+ try {
1534
+ body = await closeResp.json();
1535
+ } catch (cause) {
1536
+ throw new RouteDockChannelStateError(
1537
+ `Channel close response was not valid JSON (HTTP ${closeResp.status})`,
1538
+ { cause }
1539
+ );
1540
+ }
1541
+ const closeTxHash = body.closeTxHash ?? null;
1542
+ if (!closeTxHash) {
1543
+ throw new RouteDockChannelStateError(
1544
+ "Channel close response missing closeTxHash"
1545
+ );
1546
+ }
1547
+ return { closeTxHash, body };
1548
+ }, retryPolicy);
449
1549
  const totalPaid = (Number(currentCumulative) / 1e7).toFixed(7);
450
- return { closeTxHash, totalPaid, vouchersIssued };
1550
+ return {
1551
+ closeTxHash: closeData.closeTxHash,
1552
+ totalPaid,
1553
+ vouchersIssued
1554
+ };
1555
+ },
1556
+ async requestRefund() {
1557
+ const { rpc: rpcMod, Contract, TransactionBuilder, BASE_FEE } = await import('@stellar/stellar-sdk');
1558
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1559
+ const server = new rpcMod.Server(rpcUrl);
1560
+ const contract = new Contract(channelFactory);
1561
+ const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1562
+ try {
1563
+ const account = await server.getAccount(agentPublicKey);
1564
+ const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: passphrase }).addOperation(contract.call("request_refund")).setTimeout(30).build();
1565
+ const simResult = await server.simulateTransaction(tx);
1566
+ if (rpcMod.Api.isSimulationError(simResult)) {
1567
+ throw new RouteDockDisputeError(`request_refund simulation failed: ${simResult.error}`);
1568
+ }
1569
+ const preparedTx = await server.prepareTransaction(tx);
1570
+ preparedTx.sign(agentKeypair);
1571
+ const result = await server.sendTransaction(preparedTx);
1572
+ if (result.status === "ERROR") {
1573
+ const errDetail = result.errorResult ? JSON.stringify(result.errorResult) : "status ERROR";
1574
+ throw new RouteDockDisputeError(`Refund request transaction failed: ${errDetail}`);
1575
+ }
1576
+ if (!result.hash) {
1577
+ throw new RouteDockDisputeError("Refund request transaction not sent");
1578
+ }
1579
+ return result.hash;
1580
+ } catch (err) {
1581
+ if (err instanceof RouteDockDisputeError) throw err;
1582
+ throw new RouteDockDisputeError(`Failed to request refund: ${err instanceof Error ? err.message : String(err)}`);
1583
+ }
1584
+ },
1585
+ async settleWithLatestVoucher() {
1586
+ const { rpc: rpcMod, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } = await import('@stellar/stellar-sdk');
1587
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1588
+ const server = new rpcMod.Server(rpcUrl);
1589
+ const contract = new Contract(channelFactory);
1590
+ const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1591
+ try {
1592
+ const account = await server.getAccount(agentPublicKey);
1593
+ const simTx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: passphrase }).addOperation(contract.call("prepare_commitment", nativeToScVal(currentCumulative, { type: "i128" }))).setTimeout(30).build();
1594
+ const simResult = await server.simulateTransaction(simTx);
1595
+ if (rpcMod.Api.isSimulationError(simResult)) {
1596
+ throw new RouteDockDisputeError(`prepare_commitment simulation failed: ${simResult.error}`);
1597
+ }
1598
+ const commitmentBytes = simResult.result?.retval?.bytes();
1599
+ if (!commitmentBytes) throw new RouteDockDisputeError("prepare_commitment returned no bytes");
1600
+ const signature = commitmentKey.sign(Buffer.from(commitmentBytes));
1601
+ const settleAccount = await server.getAccount(agentPublicKey);
1602
+ const settleTx = new TransactionBuilder(settleAccount, { fee: BASE_FEE, networkPassphrase: passphrase }).addOperation(
1603
+ contract.call(
1604
+ "settle_with_signature",
1605
+ nativeToScVal(currentCumulative, { type: "i128" }),
1606
+ nativeToScVal(Buffer.from(signature))
1607
+ )
1608
+ ).setTimeout(30).build();
1609
+ const preparedSettleTx = await server.prepareTransaction(settleTx);
1610
+ preparedSettleTx.sign(agentKeypair);
1611
+ const settleResult = await server.sendTransaction(preparedSettleTx);
1612
+ if (settleResult.status === "ERROR") {
1613
+ const errDetail = settleResult.errorResult ? JSON.stringify(settleResult.errorResult) : "status ERROR";
1614
+ throw new RouteDockDisputeError(`Settlement transaction failed: ${errDetail}`);
1615
+ }
1616
+ if (!settleResult.hash) {
1617
+ throw new RouteDockDisputeError("Settlement transaction not sent");
1618
+ }
1619
+ return settleResult.hash;
1620
+ } catch (err) {
1621
+ if (err instanceof RouteDockDisputeError) throw err;
1622
+ throw new RouteDockDisputeError(`Failed to settle with latest voucher: ${err instanceof Error ? err.message : String(err)}`);
1623
+ }
1624
+ },
1625
+ async getDisputeStatus() {
1626
+ const { rpc: rpcMod, Contract, TransactionBuilder, BASE_FEE } = await import('@stellar/stellar-sdk');
1627
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1628
+ const server = new rpcMod.Server(rpcUrl);
1629
+ const contract = new Contract(channelFactory);
1630
+ const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1631
+ try {
1632
+ const account = await server.getAccount(agentPublicKey);
1633
+ const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: passphrase }).addOperation(contract.call("get_channel_state")).setTimeout(30).build();
1634
+ const simResult = await server.simulateTransaction(tx);
1635
+ if (rpcMod.Api.isSimulationError(simResult)) {
1636
+ throw new RouteDockChannelStateError(`Failed to query channel state: ${simResult.error}`);
1637
+ }
1638
+ const retval = simResult.result?.retval;
1639
+ if (!retval) {
1640
+ throw new RouteDockChannelStateError("No channel state returned");
1641
+ }
1642
+ if (typeof retval === "object" && retval !== null) {
1643
+ const status = retval.status;
1644
+ if (status === "open") return "open";
1645
+ if (status === "in_refund_window") return "in-refund-window";
1646
+ if (status === "refundable") return "refundable";
1647
+ if (status === "settled") return "settled";
1648
+ }
1649
+ return "open";
1650
+ } catch (err) {
1651
+ if (err instanceof RouteDockChannelStateError) throw err;
1652
+ throw new RouteDockChannelStateError(`Failed to get dispute status: ${err instanceof Error ? err.message : String(err)}`);
1653
+ }
1654
+ },
1655
+ on(event, listener) {
1656
+ let set = listeners.get(event);
1657
+ if (!set) {
1658
+ set = /* @__PURE__ */ new Set();
1659
+ listeners.set(event, set);
1660
+ }
1661
+ const stored = listener;
1662
+ set.add(stored);
1663
+ return () => {
1664
+ set?.delete(stored);
1665
+ };
451
1666
  }
452
1667
  };
1668
+ if (Number.isFinite(maxDurationMs) && maxDurationMs > 0) {
1669
+ timeoutId = setTimeout(() => {
1670
+ if (closed) return;
1671
+ emit("session:timeout", { maxDurationMs });
1672
+ void handle.close().catch((error) => {
1673
+ emit("session:close-failed", { maxDurationMs, error });
1674
+ console.warn(
1675
+ `RouteDock: maxDuration auto-close failed after ${maxDurationMs}ms \u2014 the channel may still hold collateral; retry close() or call requestRefund().`,
1676
+ error
1677
+ );
1678
+ });
1679
+ }, maxDurationMs);
1680
+ timeoutId.unref?.();
1681
+ }
453
1682
  return handle;
454
1683
  }
1684
+ /**
1685
+ * mpp-session-ws streaming transport.
1686
+ *
1687
+ * Mirrors the issue's three-step sequence:
1688
+ * 1. open the channel — probe the provider URL over HTTP;
1689
+ * 2. negotiate the voucher — sign the mppx 402 challenge into a credential;
1690
+ * 3. upgrade the HTTP connection to WebSocket — hand the signed voucher to
1691
+ * the provider as the Authorization header of the WebSocket handshake.
1692
+ *
1693
+ * Each server frame is yielded (JSON frames parsed; raw strings yielded
1694
+ * as-is). The stream ends when the server closes the socket with a normal
1695
+ * close code (1000/1001); abnormal closes and connection failures surface as
1696
+ * RouteDockChannelStateError.
1697
+ */
1698
+ async *streamWebSocket(url, mppx, createCredential, onSigned) {
1699
+ const request = { method: "GET" };
1700
+ let probe;
1701
+ try {
1702
+ probe = await mppx.rawFetch(url, request);
1703
+ } catch (err) {
1704
+ throw wrapFetchError(err, "Voucher request");
1705
+ }
1706
+ if (probe.status !== 402) {
1707
+ if (probe.status >= 500 || probe.status === 429 || probe.status === 503) {
1708
+ throw httpStatusToError(
1709
+ `Voucher request failed: HTTP ${probe.status}`,
1710
+ probe.status,
1711
+ probe
1712
+ );
1713
+ }
1714
+ throw new RouteDockChannelStateError(
1715
+ `Voucher request failed: expected HTTP 402 challenge, got HTTP ${probe.status}`
1716
+ );
1717
+ }
1718
+ let credential;
1719
+ try {
1720
+ credential = await createCredential(
1721
+ Challenge.fromResponse(probe),
1722
+ (cumulativeAmount) => mppx.createCredential(probe, { cumulativeAmount })
1723
+ );
1724
+ onSigned?.();
1725
+ } catch (err) {
1726
+ throw wrapFetchError(err, "Voucher credential");
1727
+ }
1728
+ const wsUrl = url.replace(/^http/, "ws");
1729
+ const queue = [];
1730
+ const waiters = [];
1731
+ const drain = () => {
1732
+ while (queue.length > 0 && waiters.length > 0) {
1733
+ const waiter = waiters.shift();
1734
+ waiter(queue.shift());
1735
+ }
1736
+ };
1737
+ const nextEvent = () => queue.length > 0 ? Promise.resolve(queue.shift()) : new Promise((resolve) => {
1738
+ waiters.push(resolve);
1739
+ });
1740
+ const socket = this.webSocketFactory(
1741
+ wsUrl,
1742
+ { headers: { authorization: credential } },
1743
+ {
1744
+ onOpen: () => {
1745
+ queue.push({ type: "open" });
1746
+ drain();
1747
+ },
1748
+ onMessage: (data) => {
1749
+ queue.push({ type: "message", data });
1750
+ drain();
1751
+ },
1752
+ onClose: (code, reason) => {
1753
+ queue.push({ type: "close", code, reason });
1754
+ drain();
1755
+ },
1756
+ onError: () => {
1757
+ queue.push({ type: "error" });
1758
+ drain();
1759
+ }
1760
+ }
1761
+ );
1762
+ try {
1763
+ for (; ; ) {
1764
+ const event = await nextEvent();
1765
+ if (event.type === "open") break;
1766
+ if (event.type === "error") {
1767
+ throw new RouteDockChannelStateError(
1768
+ "WebSocket upgrade failed: connection error"
1769
+ );
1770
+ }
1771
+ if (event.type === "close") {
1772
+ throw new RouteDockChannelStateError(
1773
+ `WebSocket upgrade failed: server closed the connection before upgrading (code ${event.code})`
1774
+ );
1775
+ }
1776
+ }
1777
+ for (; ; ) {
1778
+ const event = await nextEvent();
1779
+ if (event.type === "message") {
1780
+ let parsed = event.data;
1781
+ try {
1782
+ parsed = JSON.parse(event.data);
1783
+ } catch {
1784
+ }
1785
+ yield parsed;
1786
+ continue;
1787
+ }
1788
+ if (event.type === "error") {
1789
+ throw new RouteDockChannelStateError("WebSocket connection error");
1790
+ }
1791
+ if (event.type === "open") continue;
1792
+ if (event.code === 1e3 || event.code === 1001) {
1793
+ return;
1794
+ }
1795
+ throw new RouteDockChannelStateError(
1796
+ `WebSocket stream ended abnormally (code ${event.code}${event.reason ? `: ${event.reason}` : ""})`
1797
+ );
1798
+ }
1799
+ } finally {
1800
+ if (socket.readyState === WS_CONNECTING || socket.readyState === WS_OPEN) {
1801
+ socket.close(1e3, "stream ended");
1802
+ }
1803
+ }
1804
+ }
1805
+ };
1806
+ var NulthPolicyError = class extends Error {
1807
+ code;
1808
+ constructor(code, message) {
1809
+ super(message);
1810
+ this.name = "NulthPolicyError";
1811
+ this.code = code;
1812
+ }
1813
+ };
1814
+ function sha256Hex(data) {
1815
+ const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
1816
+ return createHash("sha256").update(buf).digest("hex");
1817
+ }
1818
+ function commitDailyCap(dailyCapStroops, witnessSecret) {
1819
+ return sha256Hex(`${witnessSecret}:cap:${dailyCapStroops.toString()}`);
1820
+ }
1821
+ function commitAllowlist(payees, witnessSecret) {
1822
+ const sorted = [...payees].sort().join(",");
1823
+ return sha256Hex(`${witnessSecret}:allowlist:${sorted}`);
1824
+ }
1825
+ function hashPayee(payee) {
1826
+ return sha256Hex(`payee:${payee}`);
1827
+ }
1828
+ function dayBucketFromLedger(ledgerSequence) {
1829
+ return Math.floor(ledgerSequence / 17280);
1830
+ }
1831
+ function authDigestFromEntry(authEntryBase64) {
1832
+ const entryHash = hash(Buffer.from(authEntryBase64, "base64"));
1833
+ return sha256Hex(entryHash);
1834
+ }
1835
+ function assertAuthEntryMatchesContext(authEntry, from, ctx) {
1836
+ let preimage;
1837
+ try {
1838
+ preimage = xdr.HashIdPreimage.fromXDR(authEntry, "base64");
1839
+ } catch {
1840
+ throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not decode as a HashIdPreimage");
1841
+ }
1842
+ if (preimage.switch() !== xdr.EnvelopeType.envelopeTypeSorobanAuthorization()) {
1843
+ throw new NulthPolicyError(
1844
+ "auth_entry_mismatch",
1845
+ "authEntry is not a Soroban authorization preimage"
1846
+ );
1847
+ }
1848
+ const inv = preimage.sorobanAuthorization().invocation();
1849
+ if (inv.subInvocations().length !== 0) {
1850
+ throw new NulthPolicyError("auth_entry_mismatch", "authEntry has unexpected sub-invocations");
1851
+ }
1852
+ if (inv.function().switch() !== xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn()) {
1853
+ throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not invoke a contract function");
1854
+ }
1855
+ const fn = inv.function().contractFn();
1856
+ const contractAddress = Address.fromScAddress(fn.contractAddress()).toString();
1857
+ if (contractAddress !== ctx.assetContract) {
1858
+ throw new NulthPolicyError(
1859
+ "auth_entry_mismatch",
1860
+ `authEntry contract ${contractAddress} does not match assetContract ${ctx.assetContract}`
1861
+ );
1862
+ }
1863
+ const functionName = fn.functionName().toString();
1864
+ if (functionName !== "transfer") {
1865
+ throw new NulthPolicyError(
1866
+ "auth_entry_mismatch",
1867
+ `authEntry function ${functionName} does not match expected transfer`
1868
+ );
1869
+ }
1870
+ const args = fn.args();
1871
+ if (args.length !== 3) {
1872
+ throw new NulthPolicyError("auth_entry_mismatch", `authEntry transfer has ${args.length} args, expected 3`);
1873
+ }
1874
+ const entryFrom = scValToNative(args[0]);
1875
+ if (entryFrom !== from) {
1876
+ throw new NulthPolicyError(
1877
+ "auth_entry_mismatch",
1878
+ `authEntry from ${entryFrom} does not match nulthAccount ${from}`
1879
+ );
1880
+ }
1881
+ const entryTo = scValToNative(args[1]);
1882
+ if (entryTo !== ctx.payee) {
1883
+ throw new NulthPolicyError(
1884
+ "auth_entry_mismatch",
1885
+ `authEntry to ${entryTo} does not match paymentContext.payee ${ctx.payee}`
1886
+ );
1887
+ }
1888
+ const entryAmount = scValToNative(args[2]);
1889
+ if (entryAmount !== ctx.amountStroops) {
1890
+ throw new NulthPolicyError(
1891
+ "auth_entry_mismatch",
1892
+ `authEntry amount ${entryAmount} does not match paymentContext.amountStroops ${ctx.amountStroops}`
1893
+ );
1894
+ }
1895
+ }
1896
+ function insecureMockProof(preimage) {
1897
+ return createHash("sha512").update(`nulth:${preimage}`).digest("base64");
1898
+ }
1899
+ function encodeAuthSignature(proof) {
1900
+ const payload = { nulth: "zk-v1", proof };
1901
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
1902
+ }
1903
+ function usdcToStroops2(amount) {
1904
+ const [whole = "0", frac = ""] = amount.split(".");
1905
+ const padded = (frac + "0000000").slice(0, 7);
1906
+ return BigInt(whole) * 10000000n + BigInt(padded);
1907
+ }
1908
+ var NulthClient = class {
1909
+ constructor(config) {
1910
+ this.config = config;
1911
+ this.policy = { ...config.policy };
1912
+ this.prover = config.prover ?? "mock";
1913
+ if (this.prover !== "mock") {
1914
+ throw new RouteDockManifestError(
1915
+ `NulthClient: unknown prover backend ${String(config.prover)}; only 'mock' is implemented`
1916
+ );
1917
+ }
1918
+ if (config.network !== "testnet") {
1919
+ throw new RouteDockManifestError(
1920
+ `NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
1921
+ );
1922
+ }
1923
+ console.warn("NulthClient is using the insecure mock prover; proofs are not cryptographically sound");
1924
+ }
1925
+ config;
1926
+ policy;
1927
+ prover;
1928
+ get proverBackend() {
1929
+ return this.prover;
1930
+ }
1931
+ get nulthAccount() {
1932
+ return this.config.nulthAccount;
1933
+ }
1934
+ get allowlistCommitment() {
1935
+ return this.policy.allowlistCommitment;
1936
+ }
1937
+ get capCommitment() {
1938
+ return commitDailyCap(this.policy.dailyCapStroops, this.policy.witnessSecret);
1939
+ }
1940
+ buildPaymentAuthProof(context) {
1941
+ this.enforcePolicy(context);
1942
+ const bucket = dayBucketFromLedger(context.ledgerSequence);
1943
+ if (bucket !== this.policy.dayBucket) {
1944
+ this.policy.dayBucket = bucket;
1945
+ this.policy.dailySpendStroops = 0n;
1946
+ }
1947
+ const authDigest = authDigestFromEntry(context.authEntry);
1948
+ const capCommitment = this.capCommitment;
1949
+ const payeeHash = hashPayee(context.payee);
1950
+ const preimage = [
1951
+ authDigest,
1952
+ this.policy.allowlistCommitment,
1953
+ capCommitment,
1954
+ payeeHash,
1955
+ context.amountStroops.toString(),
1956
+ bucket.toString(),
1957
+ this.config.nulthAccount
1958
+ ].join("|");
1959
+ const proof = {
1960
+ version: 1,
1961
+ proof: insecureMockProof(preimage),
1962
+ publicInputs: {
1963
+ authDigest,
1964
+ allowlistCommitment: this.policy.allowlistCommitment,
1965
+ capCommitment,
1966
+ payeeHash,
1967
+ amountStroops: context.amountStroops.toString(),
1968
+ dayBucket: bucket.toString()
1969
+ }
1970
+ };
1971
+ this.policy.dailySpendStroops += context.amountStroops;
1972
+ return proof;
1973
+ }
1974
+ enforcePolicy(context) {
1975
+ if (this.policy.expiryLedger !== void 0 && context.ledgerSequence > this.policy.expiryLedger) {
1976
+ throw new NulthPolicyError("session_expired", "Nulth session expired");
1977
+ }
1978
+ if (!this.policy.allowedPayees.includes(context.payee)) {
1979
+ throw new NulthPolicyError("payee_not_allowed", `Payee ${context.payee} not in off-chain allowlist`);
1980
+ }
1981
+ const bucket = dayBucketFromLedger(context.ledgerSequence);
1982
+ const spend = bucket === this.policy.dayBucket ? this.policy.dailySpendStroops : 0n;
1983
+ if (spend + context.amountStroops > this.policy.dailyCapStroops) {
1984
+ throw new NulthPolicyError("daily_cap_exceeded", "Nulth daily cap exceeded");
1985
+ }
1986
+ }
1987
+ };
1988
+ function createNulthSigner(config) {
1989
+ const client = new NulthClient(config);
1990
+ const nulthAccount = config.nulthAccount;
1991
+ return {
1992
+ address: nulthAccount,
1993
+ signAuthEntry: async (authEntry) => {
1994
+ const ctx = config.paymentContext;
1995
+ if (!ctx) throw new Error("Nulth paymentContext must be set before signing");
1996
+ assertAuthEntryMatchesContext(authEntry, nulthAccount, ctx);
1997
+ const proof = client.buildPaymentAuthProof({ authEntry, ...ctx });
1998
+ return { signedAuthEntry: encodeAuthSignature(proof), signerAddress: nulthAccount };
1999
+ }
2000
+ };
2001
+ }
2002
+ function setNulthPaymentContext(config, context) {
2003
+ config.paymentContext = context;
2004
+ }
2005
+ function paymentContextFromManifest(manifest, mode, ledgerSequence) {
2006
+ const pricing = manifest.pricing[mode];
2007
+ if (!pricing) throw new Error(`manifest.pricing.${mode} missing`);
2008
+ return {
2009
+ payee: manifest.payee,
2010
+ amountStroops: usdcToStroops2(pricing.amount),
2011
+ assetContract: manifest.asset_contract,
2012
+ ledgerSequence
2013
+ };
2014
+ }
2015
+ function createPolicyState(input) {
2016
+ const dailyCapStroops = usdcToStroops2(input.dailyCapUsdc);
2017
+ return {
2018
+ dailyCapStroops,
2019
+ allowlistCommitment: commitAllowlist(input.allowedPayees, input.witnessSecret),
2020
+ allowedPayees: input.allowedPayees,
2021
+ dailySpendStroops: 0n,
2022
+ dayBucket: dayBucketFromLedger(input.ledgerSequence ?? 0),
2023
+ witnessSecret: input.witnessSecret,
2024
+ ...input.expiryLedger !== void 0 ? { expiryLedger: input.expiryLedger } : {}
2025
+ };
2026
+ }
2027
+ function assertNulthVaultManifest(manifest, nulthAccount) {
2028
+ if (manifest.vault !== void 0 && manifest.vault !== "nulth") {
2029
+ throw new RouteDockManifestError(`Provider vault mode ${manifest.vault} does not match client nulth vault`);
2030
+ }
2031
+ if (manifest.nulth_account && manifest.nulth_account !== nulthAccount) {
2032
+ throw new RouteDockManifestError(`Manifest nulth_account ${manifest.nulth_account} does not match configured ${nulthAccount}`);
2033
+ }
2034
+ }
2035
+ async function fetchLedgerSequence(network) {
2036
+ const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
2037
+ const server = new rpc.Server(rpcUrl);
2038
+ const latest = await server.getLatestLedger();
2039
+ return latest.sequence;
2040
+ }
2041
+ async function prepareNulthSigner(vault, manifest, mode, network, ledgerSequenceOverride) {
2042
+ assertNulthVaultManifest(manifest, vault.nulthAccount);
2043
+ const prover = vault.prover ?? "mock";
2044
+ if (network === "mainnet" && prover === "mock") {
2045
+ throw new RouteDockManifestError(
2046
+ "nulth vault uses the insecure mock prover and cannot be used on mainnet"
2047
+ );
2048
+ }
2049
+ const ledgerSequence = ledgerSequenceOverride ?? await fetchLedgerSequence(network);
2050
+ const policy = createPolicyState({
2051
+ dailyCapUsdc: vault.dailyCapUsdc,
2052
+ allowedPayees: vault.allowedPayees,
2053
+ witnessSecret: vault.witnessSecret,
2054
+ ledgerSequence,
2055
+ ...vault.expiryLedger !== void 0 ? { expiryLedger: vault.expiryLedger } : {}
2056
+ });
2057
+ const config = {
2058
+ nulthAccount: vault.nulthAccount,
2059
+ network,
2060
+ prover,
2061
+ policy,
2062
+ ...vault.verifierContract !== void 0 ? { verifierContract: vault.verifierContract } : {}
2063
+ };
2064
+ if (mode === "x402" || mode === "mpp-charge") {
2065
+ setNulthPaymentContext(config, paymentContextFromManifest(manifest, mode, ledgerSequence));
2066
+ } else {
2067
+ throw new RouteDockManifestError(`Nulth ZK vault does not support payment mode: ${mode}`);
2068
+ }
2069
+ const signer = createNulthSigner(config);
2070
+ return { signer, config };
2071
+ }
2072
+ function decodeAuthSignature(encoded) {
2073
+ const raw = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
2074
+ if (typeof raw !== "object" || raw === null || raw.nulth !== "zk-v1") {
2075
+ throw new Error("Invalid Nulth ZK auth signature");
2076
+ }
2077
+ return raw;
2078
+ }
2079
+ var FileSpendStore = class {
2080
+ filePath;
2081
+ constructor(filePath) {
2082
+ this.filePath = filePath;
2083
+ }
2084
+ async read() {
2085
+ let data;
2086
+ try {
2087
+ data = await fs.readFile(this.filePath, "utf-8");
2088
+ } catch (err) {
2089
+ if (err.code === "ENOENT") {
2090
+ return null;
2091
+ }
2092
+ throw new Error(
2093
+ `[FileSpendStore] Failed to read spend file at ${this.filePath}: ${err.message}`
2094
+ );
2095
+ }
2096
+ try {
2097
+ return JSON.parse(data);
2098
+ } catch {
2099
+ throw new Error(
2100
+ `[FileSpendStore] Spend file at ${this.filePath} contains invalid JSON. Delete or repair it before resuming. Raw content: ${data.slice(0, 120)}`
2101
+ );
2102
+ }
2103
+ }
2104
+ async write(state) {
2105
+ const dir = path.dirname(this.filePath);
2106
+ await fs.mkdir(dir, { recursive: true });
2107
+ const tmpPath = `${this.filePath}.tmp`;
2108
+ await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
2109
+ await fs.rename(tmpPath, this.filePath);
2110
+ }
2111
+ };
2112
+
2113
+ // src/store/SpendStore.ts
2114
+ var InMemorySpendStore = class {
2115
+ state = null;
2116
+ constructor(options = {}) {
2117
+ if (options.warn !== false) {
2118
+ console.warn(
2119
+ "[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."
2120
+ );
2121
+ }
2122
+ }
2123
+ async read() {
2124
+ return this.state ? { ...this.state, endpoints: { ...this.state.endpoints } } : null;
2125
+ }
2126
+ async write(state) {
2127
+ this.state = { ...state, endpoints: { ...state.endpoints } };
2128
+ }
455
2129
  };
456
2130
 
457
2131
  // src/client/RouteDockClient.ts
458
- var RouteDockClient = class {
2132
+ var _secrets = /* @__PURE__ */ new WeakMap();
2133
+ function normalizeEndpointCaps(endpointCaps) {
2134
+ if (!endpointCaps) return void 0;
2135
+ const normalized = {};
2136
+ for (const [key, value] of Object.entries(endpointCaps)) {
2137
+ let url;
2138
+ try {
2139
+ url = new URL(key);
2140
+ } catch {
2141
+ throw new RouteDockManifestError(
2142
+ `spendCap.endpointCaps key "${key}" is not a valid URL \u2014 use an origin such as "https://api.example.com"`
2143
+ );
2144
+ }
2145
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") {
2146
+ throw new RouteDockManifestError(
2147
+ `spendCap.endpointCaps key "${key}" must be an origin only (no path, query, or hash) \u2014 use "${url.origin}"`
2148
+ );
2149
+ }
2150
+ const origin = url.origin;
2151
+ if (Object.prototype.hasOwnProperty.call(normalized, origin)) {
2152
+ throw new RouteDockManifestError(
2153
+ `spendCap.endpointCaps has two keys that both normalize to origin "${origin}" \u2014 remove the duplicate`
2154
+ );
2155
+ }
2156
+ normalized[origin] = value;
2157
+ }
2158
+ return normalized;
2159
+ }
2160
+ function normalizeSpendCap(spendCap) {
2161
+ const endpointCaps = normalizeEndpointCaps(spendCap.endpointCaps);
2162
+ return endpointCaps === void 0 ? spendCap : { ...spendCap, endpointCaps };
2163
+ }
2164
+ var usdcToMicros = usdcToStroops;
2165
+ var ASSET_ISSUERS = {
2166
+ USDC: USDC_ISSUERS
2167
+ };
2168
+ function getAssetIssuer(asset, network) {
2169
+ return ASSET_ISSUERS[asset]?.[network] ?? "";
2170
+ }
2171
+ var TRUSTLINE_CACHE_TTL_MS = 3e5;
2172
+ var RouteDockClient = class _RouteDockClient {
2173
+ /** Per-(account,asset) trustline existence cache keyed by `${network}:${pubkey}:${asset}` */
2174
+ static _trustlineCache = /* @__PURE__ */ new Map();
459
2175
  keypair;
460
2176
  network;
461
2177
  spendCap;
462
- commitmentSecret;
463
- /** Local daily accumulator keyed by YYYY-MM-DD */
464
- dailySpend = { date: "", total: 0 };
2178
+ retryPolicy;
2179
+ logger;
2180
+ manifestTimeoutMs;
2181
+ expectedPayee;
2182
+ vault;
2183
+ /**
2184
+ * Durable backing store for the local daily spend accumulator (keyed by
2185
+ * YYYY-MM-DD). Totals are persisted as decimal strings of microUSDC
2186
+ * (1 USDC = 10^7) so stores stay JSON-safe with no precision loss.
2187
+ */
2188
+ spendStore;
465
2189
  x402;
466
2190
  charge;
467
2191
  session;
2192
+ /**
2193
+ * Promise-chain mutex for serializing read-modify-write on the spend
2194
+ * accumulator. Each pay() call awaits the previous one, preventing
2195
+ * concurrent read-modify-write races that could overrun the cap.
2196
+ */
2197
+ _spendMutex = Promise.resolve();
2198
+ /**
2199
+ * Pending spend reservations keyed by reserveId. Used by _rollbackSpend
2200
+ * to revert the accumulator when a payment fails after reservation.
2201
+ */
2202
+ _pendingReserves = /* @__PURE__ */ new Map();
468
2203
  constructor(config) {
469
2204
  this.keypair = typeof config.wallet === "string" ? Keypair.fromSecret(config.wallet) : config.wallet;
470
2205
  this.network = config.network;
471
- this.spendCap = config.spendCap;
472
- this.commitmentSecret = config.commitmentSecret;
2206
+ this.spendCap = config.spendCap ? normalizeSpendCap(config.spendCap) : void 0;
2207
+ this.retryPolicy = config.retryPolicy;
2208
+ this.spendStore = config.spendStore ?? new InMemorySpendStore({ warn: !!config.spendCap });
2209
+ this.logger = config.logger;
2210
+ this.manifestTimeoutMs = config.manifestTimeoutMs;
2211
+ this.expectedPayee = config.expectedPayee;
2212
+ if (config.commitmentSecret) {
2213
+ _secrets.set(this, config.commitmentSecret);
2214
+ }
2215
+ this.vault = config.vault;
2216
+ if (config.commitmentSecret) {
2217
+ _secrets.set(this, config.commitmentSecret);
2218
+ }
473
2219
  const secretKey = this.keypair.secret();
474
- this.x402 = new X402Client(secretKey, this.network);
475
- this.charge = new MppChargeClient(this.keypair, this.network);
476
- this.session = new MppSessionClient(this.keypair, this.network);
2220
+ this.x402 = new X402Client(secretKey, this.network, this.retryPolicy);
2221
+ this.charge = new MppChargeClient(this.keypair, this.network, this.retryPolicy);
2222
+ this.session = new MppSessionClient(this.keypair, this.network, this.retryPolicy);
2223
+ }
2224
+ /** Fetch manifest and select mode — shared by pay() and estimateCost(). */
2225
+ async _resolveManifest(url, options) {
2226
+ const baseUrl = new URL(url).origin;
2227
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2228
+ assertEndpointActive(manifest, url, this.logger);
2229
+ const mode = selectMode(manifest, options);
2230
+ return { manifest, mode };
2231
+ }
2232
+ /**
2233
+ * Serialize async work through the per-instance spend mutex so concurrent
2234
+ * pay() calls cannot interleave read-modify-write on the accumulator.
2235
+ */
2236
+ _withSpendMutex(fn) {
2237
+ const prev = this._spendMutex;
2238
+ let next;
2239
+ next = prev.then(fn, fn);
2240
+ this._spendMutex = next.then(() => {
2241
+ }, () => {
2242
+ });
2243
+ return next;
2244
+ }
2245
+ /**
2246
+ * Check that the payer has a trustline for the payment asset and surface
2247
+ * manifest metadata (regions, latency hints, capabilities). Safe to
2248
+ * call before committing to a payment — for approval gates and manual
2249
+ * trustline remediation.
2250
+ */
2251
+ async preflight(manifest) {
2252
+ assertManifestValid(manifest);
2253
+ await this._checkTrustline(manifest);
2254
+ return {
2255
+ hasTrustline: true,
2256
+ asset: manifest.asset,
2257
+ modes: manifest.modes,
2258
+ ...manifest.regions && { regions: manifest.regions },
2259
+ ...manifest.latency_hints && { latency_hints: manifest.latency_hints },
2260
+ ...manifest.capabilities && { capabilities: manifest.capabilities }
2261
+ };
2262
+ }
2263
+ /**
2264
+ * Verify via Horizon that the payer account has a trustline for the
2265
+ * payment asset declared in the manifest. Throws RouteDockTrustlineError
2266
+ * with an exact Stellar CLI change-trust remediation command when the
2267
+ * trustline is missing. Results are cached per (account, asset) so the
2268
+ * per-payment overhead is one RPC call amortized to zero after the first.
2269
+ */
2270
+ async _checkTrustline(manifest) {
2271
+ const cacheKey = `${this.network}:${this.keypair.publicKey()}:${manifest.asset}`;
2272
+ const cached = _RouteDockClient._trustlineCache.get(cacheKey);
2273
+ if (cached && Date.now() < cached.expiresAt) return;
2274
+ const horizonUrl = this.network === "testnet" ? "https://horizon-testnet.stellar.org" : "https://horizon.stellar.org";
2275
+ const server = new Horizon.Server(horizonUrl);
2276
+ try {
2277
+ const account = await server.loadAccount(this.keypair.publicKey());
2278
+ const balances = account.balances;
2279
+ const expectedIssuer = getAssetIssuer(manifest.asset, this.network);
2280
+ const hasTrustline = balances.some(
2281
+ (b) => typeof b === "object" && b !== null && "asset_code" in b && b.asset_code === manifest.asset && (!expectedIssuer || b.asset_issuer === expectedIssuer)
2282
+ );
2283
+ if (!hasTrustline) {
2284
+ 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}`;
2285
+ throw new RouteDockTrustlineError(manifest.asset, expectedIssuer || "unknown", remediation);
2286
+ }
2287
+ _RouteDockClient._trustlineCache.set(cacheKey, {
2288
+ exists: true,
2289
+ expiresAt: Date.now() + TRUSTLINE_CACHE_TTL_MS
2290
+ });
2291
+ } catch (err) {
2292
+ if (err instanceof RouteDockTrustlineError) throw err;
2293
+ this.logger?.(
2294
+ `[RouteDock] Trustline preflight: could not verify trustline for ${manifest.asset} \u2014 continuing`
2295
+ );
2296
+ }
477
2297
  }
478
2298
  /**
479
2299
  * Pay for one request at `url`. Fetches manifest, selects payment mode,
480
- * checks local spend cap, executes payment, returns result.
2300
+ * runs trustline preflight, reserves local spend cap BEFORE executing the
2301
+ * payment, then commits the spend record on success. Rolls back the
2302
+ * reservation if the on-chain payment fails. Concurrent pay() calls are
2303
+ * serialized through a per-instance mutex to prevent spend-cap overrun.
481
2304
  */
482
2305
  async pay(url, options) {
483
2306
  const baseUrl = new URL(url).origin;
484
- const manifest = await fetchManifest(baseUrl);
485
- const mode = selectMode(manifest, options);
486
- let result;
2307
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2308
+ assertEndpointActive(manifest, url, this.logger);
2309
+ const mode = selectMode(manifest, { ...options, ...this.logger && { logger: this.logger } });
2310
+ await this._checkTrustline(manifest);
2311
+ if (this.vault?.mode === "nulth") {
2312
+ return this._payWithNulthVault(url, manifest, mode);
2313
+ }
2314
+ let amount;
487
2315
  switch (mode) {
488
2316
  case "x402":
489
- result = await this.x402.pay(url, manifest);
2317
+ amount = manifest.pricing.x402.amount;
490
2318
  break;
491
2319
  case "mpp-charge":
492
- result = await this.charge.pay(url, manifest);
2320
+ amount = manifest.pricing["mpp-charge"].amount;
493
2321
  break;
494
2322
  case "mpp-session":
2323
+ case "mpp-session-ws":
495
2324
  throw new RouteDockManifestError(
496
- "Use client.openSession() for mpp-session mode \u2014 client.pay() only handles discrete payments"
2325
+ `Use client.openSession() for ${mode} mode \u2014 client.pay() only handles discrete payments`
497
2326
  );
498
2327
  default:
499
2328
  throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
500
2329
  }
501
- this._checkAndRecordSpend(result.amount);
2330
+ const reserveId = await this._checkAndReserveSpend(amount, baseUrl);
2331
+ let result;
2332
+ try {
2333
+ switch (mode) {
2334
+ case "x402":
2335
+ result = await this.x402.pay(url, manifest);
2336
+ break;
2337
+ case "mpp-charge":
2338
+ result = await this.charge.pay(url, manifest);
2339
+ break;
2340
+ default:
2341
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2342
+ }
2343
+ } catch (err) {
2344
+ await this._rollbackSpend(reserveId).catch(() => {
2345
+ });
2346
+ throw err;
2347
+ }
2348
+ await this._commitSpend(reserveId);
502
2349
  return result;
503
2350
  }
2351
+ /** Nulth ZK vault path — proof built off-chain, attached as auth signature */
2352
+ async _payWithNulthVault(url, manifest, mode) {
2353
+ const prover = this.vault?.prover ?? "mock";
2354
+ if (this.network === "mainnet" && prover === "mock") {
2355
+ throw new RouteDockManifestError("nulth vault uses a MOCK Groth16 prover and cannot be used on mainnet");
2356
+ }
2357
+ if (mode !== "x402") {
2358
+ throw new RouteDockManifestError(
2359
+ 'nulth vault currently supports x402 mode \u2014 force x402 via { forceMode: "x402" }'
2360
+ );
2361
+ }
2362
+ try {
2363
+ const { signer } = await prepareNulthSigner(this.vault, manifest, mode, this.network);
2364
+ const x402 = this.x402.withSigner(signer);
2365
+ const result = await x402.pay(url, manifest);
2366
+ return result;
2367
+ } catch (err) {
2368
+ if (err instanceof NulthPolicyError) {
2369
+ throw new RouteDockPolicyRejectError(err.code);
2370
+ }
2371
+ throw err;
2372
+ }
2373
+ }
2374
+ /**
2375
+ * Resolve manifest and compute the expected charge WITHOUT submitting any
2376
+ * transaction. Safe to call before committing — for approval gates and
2377
+ * budget-aware routing.
2378
+ */
2379
+ async estimateCost(url, options) {
2380
+ const { manifest, mode } = await this._resolveManifest(url, options);
2381
+ let amount;
2382
+ switch (mode) {
2383
+ case "x402":
2384
+ amount = manifest.pricing.x402.amount;
2385
+ break;
2386
+ case "mpp-charge":
2387
+ amount = manifest.pricing["mpp-charge"].amount;
2388
+ break;
2389
+ case "mpp-session":
2390
+ amount = manifest.pricing["mpp-session"].rate;
2391
+ break;
2392
+ case "mpp-session-ws":
2393
+ amount = manifest.pricing["mpp-session-ws"].rate;
2394
+ break;
2395
+ default:
2396
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2397
+ }
2398
+ return {
2399
+ amount,
2400
+ asset: manifest.asset,
2401
+ mode,
2402
+ manifest,
2403
+ ...manifest.regions && { regions: manifest.regions },
2404
+ ...manifest.latency_hints && { latency_hints: manifest.latency_hints },
2405
+ ...manifest.capabilities && { capabilities: manifest.capabilities }
2406
+ };
2407
+ }
504
2408
  /**
505
2409
  * Open a sustained MPP session at `url`. Verifies mpp-session is supported
506
2410
  * before opening a channel. Returns a SessionHandle for streaming + closing.
2411
+ *
2412
+ * By default the session auto-closes after a wall-clock timeout (1h) so an
2413
+ * orphaned channel cannot keep collateral locked on-chain — override or
2414
+ * disable via `options.maxDurationMs`.
507
2415
  */
508
- async openSession(url) {
2416
+ async openSession(url, options) {
509
2417
  const baseUrl = new URL(url).origin;
510
- const manifest = await fetchManifest(baseUrl);
511
- if (!manifest.modes.includes("mpp-session")) {
2418
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2419
+ assertEndpointActive(manifest, url, this.logger);
2420
+ const mode = options?.mode ?? "mpp-session";
2421
+ if (!manifest.modes.includes(mode)) {
512
2422
  throw new RouteDockManifestError(
513
- `Provider at ${baseUrl} does not support mpp-session mode`
2423
+ `Provider at ${baseUrl} does not support ${mode} mode`
514
2424
  );
515
2425
  }
516
- if (!this.commitmentSecret) {
2426
+ const secret = _secrets.get(this);
2427
+ if (!secret) {
517
2428
  throw new RouteDockManifestError(
518
2429
  "commitmentSecret is required in RouteDockClientConfig for mpp-session mode"
519
2430
  );
520
2431
  }
521
- return this.session.openSession(url, manifest, this.commitmentSecret);
2432
+ const endpointKey = new URL(url).origin;
2433
+ const onSpend = this.spendCap ? (amount) => this._recordSessionSpend(amount, endpointKey) : void 0;
2434
+ return this.session.openSession(url, manifest, secret, options, onSpend);
522
2435
  }
523
- /** Check local daily cap and record the spend. Throws if cap exceeded. */
524
- _checkAndRecordSpend(amount) {
525
- if (!this.spendCap) return;
526
- const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
527
- if (this.dailySpend.date !== today) {
528
- this.dailySpend = { date: today, total: 0 };
529
- }
530
- const amountNum = parseFloat(amount);
531
- const capNum = parseFloat(this.spendCap.daily);
532
- if (this.dailySpend.total + amountNum > capNum) {
533
- throw new RouteDockPolicyRejectedError("local_daily_cap_exceeded");
2436
+ /**
2437
+ * Record spend for an MPP session voucher. Called before each voucher
2438
+ * fetch in stream(). Enforces the same local daily and per-endpoint caps
2439
+ * as discrete pay() calls. Returns a commit function to clean up the
2440
+ * reservation after the voucher fetch succeeds.
2441
+ */
2442
+ async _recordSessionSpend(amount, endpointKey) {
2443
+ const reserveId = await this._checkAndReserveSpend(amount, endpointKey);
2444
+ if (reserveId) {
2445
+ await this._commitSpend(reserveId);
534
2446
  }
535
- this.dailySpend.total += amountNum;
2447
+ }
2448
+ /**
2449
+ * Remove the commitment secret from memory and make it eligible for GC.
2450
+ * Call when this client instance is no longer needed — particularly in
2451
+ * long-lived processes or Worker threads.
2452
+ */
2453
+ dispose() {
2454
+ _secrets.delete(this);
2455
+ }
2456
+ /**
2457
+ * Evict the cached manifest for `url` immediately, so the next pay() /
2458
+ * estimateCost() / openSession() call re-fetches it instead of routing on
2459
+ * the stale cached copy. Use when a provider signals their manifest
2460
+ * changed (e.g. after a config update) and the change must take effect
2461
+ * before the cache TTL expires.
2462
+ */
2463
+ invalidateManifest(url) {
2464
+ invalidateManifest(new URL(url).origin);
2465
+ }
2466
+ /**
2467
+ * Reserve spend against local daily caps BEFORE dispatching payment.
2468
+ * Returns a reserveId that must be passed to _commitSpend() on success
2469
+ * or _rollbackSpend() on failure.
2470
+ *
2471
+ * The entire read-check-write is serialized through the per-instance
2472
+ * mutex (_withSpendMutex) so concurrent pay() calls cannot interleave
2473
+ * and overrun the cap.
2474
+ *
2475
+ * Enforcement order:
2476
+ * 1. Per-endpoint cap (if `endpointCaps[endpointKey]` is set) — checked first.
2477
+ * 2. Global daily cap — checked second.
2478
+ *
2479
+ * Both limits are independent: a payment is only reserved after **both** pass.
2480
+ * All spend (regardless of endpoint) counts toward the global accumulator.
2481
+ */
2482
+ async _checkAndReserveSpend(amount, endpointKey) {
2483
+ if (!this.spendCap) return "";
2484
+ return this._withSpendMutex(async () => {
2485
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2486
+ const persisted = await this.spendStore.read();
2487
+ const current = persisted && persisted.date === today ? persisted : { date: today, totalMicros: "0", endpoints: {} };
2488
+ const amountMicros = usdcToStroops(amount);
2489
+ const total = BigInt(current.totalMicros);
2490
+ const endpointCapStr = this.spendCap.endpointCaps?.[endpointKey];
2491
+ if (endpointCapStr !== void 0) {
2492
+ const endpointCapMicros = usdcToStroops(endpointCapStr);
2493
+ const endpointTotal = BigInt(current.endpoints[endpointKey] ?? "0");
2494
+ if (endpointTotal + amountMicros > endpointCapMicros) {
2495
+ throw new RouteDockPolicyRejectError("local_endpoint_cap_exceeded");
2496
+ }
2497
+ }
2498
+ const globalCapMicros = usdcToStroops(this.spendCap.daily);
2499
+ if (total + amountMicros > globalCapMicros) {
2500
+ throw new RouteDockPolicyRejectError("local_daily_cap_exceeded");
2501
+ }
2502
+ const reserveId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
2503
+ this._pendingReserves.set(reserveId, { endpointKey, amountMicros });
2504
+ current.totalMicros = (total + amountMicros).toString();
2505
+ if (endpointCapStr !== void 0) {
2506
+ current.endpoints[endpointKey] = (BigInt(current.endpoints[endpointKey] ?? "0") + amountMicros).toString();
2507
+ }
2508
+ await this.spendStore.write(current);
2509
+ return reserveId;
2510
+ });
2511
+ }
2512
+ /**
2513
+ * Commit a previously reserved spend after payment succeeds. For discrete
2514
+ * pay() calls this is a no-op (the spend was already recorded by reserve).
2515
+ * Reserved for future use if the committed amount differs from the reserved.
2516
+ */
2517
+ async _commitSpend(reserveId) {
2518
+ this._pendingReserves.delete(reserveId);
2519
+ }
2520
+ /**
2521
+ * Rollback a previously reserved spend when payment fails. Reverts the
2522
+ * tentative accumulator write done by _checkAndReserveSpend.
2523
+ */
2524
+ async _rollbackSpend(reserveId) {
2525
+ if (!this.spendCap || !reserveId) return;
2526
+ const reserve = this._pendingReserves.get(reserveId);
2527
+ if (!reserve) return;
2528
+ this._pendingReserves.delete(reserveId);
2529
+ const { endpointKey, amountMicros } = reserve;
2530
+ await this._withSpendMutex(async () => {
2531
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2532
+ const persisted = await this.spendStore.read();
2533
+ if (!persisted || persisted.date !== today) return;
2534
+ const currentTotal = BigInt(persisted.totalMicros);
2535
+ persisted.totalMicros = (currentTotal - amountMicros).toString();
2536
+ if (persisted.endpoints[endpointKey]) {
2537
+ const endpointTotal = BigInt(persisted.endpoints[endpointKey]);
2538
+ const newEndpointTotal = endpointTotal - amountMicros;
2539
+ if (newEndpointTotal <= 0n) {
2540
+ delete persisted.endpoints[endpointKey];
2541
+ } else {
2542
+ persisted.endpoints[endpointKey] = newEndpointTotal.toString();
2543
+ }
2544
+ }
2545
+ await this.spendStore.write(persisted);
2546
+ });
536
2547
  }
537
2548
  };
538
2549
 
@@ -544,12 +2555,16 @@ var SupabaseSessionStore = class {
544
2555
  supabase;
545
2556
  async get(channelId) {
546
2557
  const { data, error } = await this.supabase.from("sessions").select("*").eq("channel_id", channelId).maybeSingle();
547
- if (error) throw new RouteDockSessionError(`SessionStore.get failed: ${error.message}`);
2558
+ if (error) {
2559
+ throw new RouteDockNetworkError(`SessionStore.get failed: ${error.message}`);
2560
+ }
548
2561
  if (!data) return null;
549
2562
  return {
550
2563
  channel_id: data.channel_id,
551
2564
  payee: data.payee,
552
2565
  payer: data.payer,
2566
+ channel_contract: data.channel_contract,
2567
+ network: data.network,
553
2568
  cumulative_amount: String(data.cumulative_amount),
554
2569
  last_signature: data.last_signature,
555
2570
  status: data.status,
@@ -564,7 +2579,7 @@ var SupabaseSessionStore = class {
564
2579
  const prev = parseFloat(existing.cumulative_amount);
565
2580
  const next = parseFloat(state.cumulative_amount);
566
2581
  if (next <= prev) {
567
- throw new RouteDockSessionError(
2582
+ throw new RouteDockVoucherMonotonicityError(
568
2583
  `cumulative_amount must be strictly increasing: ${next} <= ${prev}`
569
2584
  );
570
2585
  }
@@ -574,6 +2589,8 @@ var SupabaseSessionStore = class {
574
2589
  channel_id: state.channel_id,
575
2590
  payee: state.payee,
576
2591
  payer: state.payer,
2592
+ channel_contract: state.channel_contract,
2593
+ network: state.network,
577
2594
  cumulative_amount: state.cumulative_amount,
578
2595
  last_signature: state.last_signature,
579
2596
  status: state.status,
@@ -585,17 +2602,170 @@ var SupabaseSessionStore = class {
585
2602
  );
586
2603
  if (error) {
587
2604
  if (error.message.includes("strictly increasing")) {
588
- throw new RouteDockSessionError(
2605
+ throw new RouteDockVoucherMonotonicityError(
589
2606
  `cumulative_amount must be strictly increasing (DB): ${error.message}`
590
2607
  );
591
2608
  }
592
- throw new RouteDockSessionError(`SessionStore.upsert failed: ${error.message}`);
2609
+ throw new RouteDockNetworkError(`SessionStore.upsert failed: ${error.message}`);
2610
+ }
2611
+ }
2612
+ async setStatus(channelId, status, settlementTxHash) {
2613
+ const payload = {
2614
+ status,
2615
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2616
+ };
2617
+ if (settlementTxHash !== void 0) {
2618
+ payload.settlement_tx_hash = settlementTxHash;
2619
+ }
2620
+ const { error } = await this.supabase.from("sessions").update(payload).eq("channel_id", channelId);
2621
+ if (error) {
2622
+ throw new RouteDockNetworkError(`SessionStore.setStatus failed: ${error.message}`);
593
2623
  }
594
2624
  }
595
2625
  async close(channelId) {
596
- const { error } = await this.supabase.from("sessions").update({ status: "closed", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("channel_id", channelId);
597
- if (error) throw new RouteDockSessionError(`SessionStore.close failed: ${error.message}`);
2626
+ await this.setStatus(channelId, "closed");
2627
+ }
2628
+ };
2629
+ function tryDecodeBase64(value) {
2630
+ if (value.startsWith("http://") || value.startsWith("https://") || value.startsWith("/") || value.startsWith("[")) {
2631
+ return value;
2632
+ }
2633
+ try {
2634
+ const decoded = typeof Buffer !== "undefined" ? Buffer.from(value, "base64").toString("utf-8") : atob(value);
2635
+ if (decoded.length > 0 && decoded !== value) return decoded;
2636
+ } catch {
2637
+ }
2638
+ return value;
2639
+ }
2640
+ var OnChainRegistry = class {
2641
+ horizon;
2642
+ knownAccounts;
2643
+ timeoutMs;
2644
+ constructor(options) {
2645
+ this.horizon = new Horizon.Server(options.horizonUrl);
2646
+ this.knownAccounts = options.knownAccounts;
2647
+ this.timeoutMs = options.timeoutMs ?? 1e4;
2648
+ }
2649
+ /**
2650
+ * stellar-sdk 14.6.1 has no way to cancel the underlying Horizon request, so
2651
+ * we stop waiting on it instead: the promise returned here rejects once
2652
+ * `timeoutMs` elapses, even though the request may still be in flight. The
2653
+ * timer is always cleared, including on the error path.
2654
+ */
2655
+ loadAccountWithTimeout(accountId) {
2656
+ let timer;
2657
+ const timeout = new Promise((_, reject) => {
2658
+ timer = setTimeout(
2659
+ () => reject(new Error(`Horizon loadAccount timed out after ${this.timeoutMs}ms`)),
2660
+ this.timeoutMs
2661
+ );
2662
+ });
2663
+ return Promise.race([this.horizon.loadAccount(accountId), timeout]).finally(() => {
2664
+ if (timer !== void 0) clearTimeout(timer);
2665
+ });
2666
+ }
2667
+ async listProviders() {
2668
+ const settled = await Promise.allSettled(
2669
+ this.knownAccounts.map((accountId) => this.loadAccountWithTimeout(accountId))
2670
+ );
2671
+ const results = [];
2672
+ for (let i = 0; i < settled.length; i++) {
2673
+ const outcome = settled[i];
2674
+ if (outcome.status !== "fulfilled") continue;
2675
+ const accountId = this.knownAccounts[i];
2676
+ const account = outcome.value;
2677
+ const dataEntries = account.data_attr;
2678
+ if (!dataEntries) continue;
2679
+ const rawEndpoint = dataEntries["routedock_endpoint"];
2680
+ if (!rawEndpoint) continue;
2681
+ const endpoint = tryDecodeBase64(rawEndpoint);
2682
+ if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) continue;
2683
+ let tags = [];
2684
+ const rawTags = dataEntries["routedock_tags"];
2685
+ if (rawTags) {
2686
+ const decoded = tryDecodeBase64(rawTags);
2687
+ try {
2688
+ const parsed = JSON.parse(decoded);
2689
+ if (Array.isArray(parsed)) tags = parsed;
2690
+ } catch {
2691
+ tags = decoded.split(",").map((t) => t.trim()).filter(Boolean);
2692
+ }
2693
+ }
2694
+ results.push({ account: accountId, endpoint, tags });
2695
+ }
2696
+ return results;
2697
+ }
2698
+ };
2699
+
2700
+ // src/registry/ProviderRegistry.ts
2701
+ function deriveNetwork(horizonUrl) {
2702
+ return horizonUrl.includes("testnet") ? "testnet" : "mainnet";
2703
+ }
2704
+ var ProviderRegistry = class {
2705
+ supabase;
2706
+ onChain;
2707
+ network;
2708
+ constructor(config) {
2709
+ this.supabase = config.supabase;
2710
+ this.network = config.onChain.network ?? deriveNetwork(config.onChain.horizonUrl);
2711
+ this.onChain = new OnChainRegistry({
2712
+ horizonUrl: config.onChain.horizonUrl,
2713
+ knownAccounts: config.onChain.knownAccounts,
2714
+ ...config.onChain.timeoutMs !== void 0 ? { timeoutMs: config.onChain.timeoutMs } : {}
2715
+ });
2716
+ }
2717
+ async listProviders() {
2718
+ const supabaseProviders = await this.trySupabase();
2719
+ if (supabaseProviders.length > 0) return supabaseProviders;
2720
+ return this.tryOnChain();
2721
+ }
2722
+ async trySupabase() {
2723
+ if (!this.supabase) return [];
2724
+ try {
2725
+ const { data, error } = await this.supabase.from("providers").select("*").eq("verified", true).eq("network", this.network).limit(100);
2726
+ if (error) return [];
2727
+ if (!data || data.length === 0) return [];
2728
+ return data.map((r) => ({
2729
+ name: r.name,
2730
+ description: r.description ?? void 0,
2731
+ base_url: r.base_url,
2732
+ modes: r.modes,
2733
+ tags: r.tags,
2734
+ network: r.network,
2735
+ payee: r.payee,
2736
+ source: "supabase",
2737
+ verified: r.verified === true
2738
+ }));
2739
+ } catch {
2740
+ return [];
2741
+ }
2742
+ }
2743
+ async tryOnChain() {
2744
+ const providers = await this.onChain.listProviders();
2745
+ return providers.map((p) => ({
2746
+ name: `On-chain provider (${p.account.slice(0, 8)}...)`,
2747
+ description: `Provider registered on Stellar account ${p.account}`,
2748
+ base_url: p.endpoint,
2749
+ modes: [],
2750
+ tags: p.tags,
2751
+ network: this.network,
2752
+ payee: p.account,
2753
+ source: "onchain",
2754
+ verified: true
2755
+ }));
2756
+ }
2757
+ /**
2758
+ * Fetch a provider's manifest bound to the record's payee. The record's
2759
+ * `payee` (from supabase `verified` rows or the on-chain registry account)
2760
+ * is the out-of-band trust anchor: the fetched manifest's `payee` must
2761
+ * equal it, otherwise `RouteDockSignatureError` is thrown. This closes the
2762
+ * gap where the registry never cross-checked the on-chain payee against
2763
+ * the manifest payee.
2764
+ */
2765
+ async fetchProviderManifest(record) {
2766
+ const baseUrl = new URL(record.base_url).origin;
2767
+ return fetchManifest(baseUrl, void 0, void 0, record.payee);
598
2768
  }
599
2769
  };
600
2770
 
601
- export { MppChargeClient, MppSessionClient, RouteDockClient, RouteDockError, RouteDockManifestError, RouteDockNoSupportedModeError, RouteDockPolicyRejectedError, RouteDockSessionError, SupabaseSessionStore, X402Client, fetchManifest, selectMode };
2771
+ export { DEFAULT_MAX_SESSION_DURATION_MS, DEFAULT_RETRY_POLICY, FileSpendStore, InMemorySpendStore, MppChargeClient, MppSessionClient, NulthPolicyError, OnChainRegistry, ProviderRegistry, RouteDockChannelStateError, RouteDockClient, RouteDockClientVersionError, RouteDockDisputeError, RouteDockError, RouteDockFacilitatorError, RouteDockManifestError, RouteDockManifestSunsetError, RouteDockManifestTimeoutError, RouteDockNetworkError, RouteDockNoSupportedModeError, RouteDockPolicyRejectError, RouteDockPolicyRejectedError, RouteDockRefundWindowError, RouteDockSessionError, RouteDockSignatureError, RouteDockTrustlineError, RouteDockVoucherMonotonicityError, SupabaseSessionStore, X402Client, assertEndpointActive, assertManifestValid, assertNulthVaultManifest, backoffDelayMs, configureManifestCache, createNulthSigner, createPolicyState, decodeAuthSignature, fetchLedgerSequence, fetchManifest, httpStatusToError, invalidateManifest, parseRetryAfterMs, paymentContextFromManifest, prepareNulthSigner, rankProvidersByLatency, selectMode, setNulthPaymentContext, usdcToMicros, usdcToUnits, withRetry, wrapFetchError };