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