@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/provider.cjs CHANGED
@@ -6,43 +6,308 @@ var server$1 = require('@x402/stellar/exact/server');
6
6
  var server = require('@x402/core/server');
7
7
  var stellar = require('@x402/stellar');
8
8
  var http = require('@x402/core/http');
9
- var server$3 = require('@stellar/mpp/charge/server');
9
+ var nulthSdk = require('@routedock/nulth-sdk');
10
+ var server$4 = require('@stellar/mpp/charge/server');
10
11
  var server$2 = require('mppx/server');
11
- var server$4 = require('@stellar/mpp/channel/server');
12
+ var server$3 = require('@stellar/mpp/channel/server');
13
+ var mppx = require('mppx');
14
+ var crypto$1 = require('crypto');
12
15
 
13
- // src/types.ts
16
+ // src/errors.ts
14
17
  var RouteDockError = class extends Error {
15
- constructor(message) {
16
- super(message);
18
+ code;
19
+ retryable;
20
+ retryAfterMs;
21
+ constructor(message, code, retryable, options = {}) {
22
+ super(message, { cause: options.cause });
17
23
  this.name = "RouteDockError";
24
+ this.code = code;
25
+ this.retryable = retryable;
26
+ if (options.retryAfterMs !== void 0) {
27
+ this.retryAfterMs = options.retryAfterMs;
28
+ }
18
29
  }
19
30
  };
20
31
  var RouteDockManifestError = class extends RouteDockError {
21
- constructor(message) {
22
- super(message);
32
+ constructor(message, options = {}) {
33
+ super(message, "MANIFEST", false, options);
23
34
  this.name = "RouteDockManifestError";
24
35
  }
25
36
  };
26
37
  var RouteDockNoSupportedModeError = class extends RouteDockError {
27
- constructor(message) {
28
- super(message);
38
+ constructor(message, options = {}) {
39
+ super(message, "NO_SUPPORTED_MODE", false, options);
29
40
  this.name = "RouteDockNoSupportedModeError";
30
41
  }
31
42
  };
32
- var RouteDockSessionError = class extends RouteDockError {
33
- constructor(message) {
34
- super(message);
35
- this.name = "RouteDockSessionError";
43
+ var RouteDockClientVersionError = class extends RouteDockError {
44
+ constructor(message, options = {}) {
45
+ super(message, "CLIENT_VERSION_TOO_OLD", false, options);
46
+ this.name = "RouteDockClientVersionError";
47
+ }
48
+ };
49
+ var RouteDockTrustlineError = class extends RouteDockError {
50
+ asset;
51
+ issuer;
52
+ remediation;
53
+ constructor(asset, issuer, remediation, options = {}) {
54
+ super(
55
+ `Missing trustline for ${asset} (issuer: ${issuer}). ${remediation}`,
56
+ "TRUSTLINE",
57
+ false,
58
+ options
59
+ );
60
+ this.name = "RouteDockTrustlineError";
61
+ this.asset = asset;
62
+ this.issuer = issuer;
63
+ this.remediation = remediation;
64
+ }
65
+ };
66
+ var RouteDockFacilitatorError = class extends RouteDockError {
67
+ status;
68
+ constructor(message, status, options = {}) {
69
+ super(message, "FACILITATOR", true, options);
70
+ this.name = "RouteDockFacilitatorError";
71
+ this.status = status;
72
+ }
73
+ };
74
+ var RouteDockNetworkError = class extends RouteDockError {
75
+ constructor(message, options = {}) {
76
+ super(message, "NETWORK", true, options);
77
+ this.name = "RouteDockNetworkError";
36
78
  }
37
79
  };
38
- var RouteDockPolicyRejectedError = class extends RouteDockError {
80
+ var RouteDockSignatureError = class extends RouteDockError {
81
+ constructor(message, options = {}) {
82
+ super(message, "SIGNATURE", false, options);
83
+ this.name = "RouteDockSignatureError";
84
+ }
85
+ };
86
+ var RouteDockVoucherMonotonicityError = class extends RouteDockError {
87
+ constructor(message, options = {}) {
88
+ super(message, "VOUCHER_MONOTONICITY", false, options);
89
+ this.name = "RouteDockVoucherMonotonicityError";
90
+ }
91
+ };
92
+ var RouteDockPolicyRejectError = class extends RouteDockError {
39
93
  reason;
40
- constructor(reason) {
41
- super(`Payment rejected by policy: ${reason}`);
42
- this.name = "RouteDockPolicyRejectedError";
94
+ constructor(reason, options = {}) {
95
+ super(`Payment rejected by policy: ${reason}`, "POLICY_REJECT", false, options);
96
+ this.name = "RouteDockPolicyRejectError";
43
97
  this.reason = reason;
44
98
  }
45
99
  };
100
+ var RouteDockChannelStateError = class extends RouteDockError {
101
+ constructor(message, options = {}) {
102
+ super(message, "CHANNEL_STATE", false, options);
103
+ this.name = "RouteDockChannelStateError";
104
+ }
105
+ };
106
+ var RouteDockDisputeError = class extends RouteDockError {
107
+ constructor(message, options = {}) {
108
+ super(message, "DISPUTE", false, options);
109
+ this.name = "RouteDockDisputeError";
110
+ }
111
+ };
112
+ var RouteDockRefundWindowError = class extends RouteDockError {
113
+ constructor(message, options = {}) {
114
+ super(message, "REFUND_WINDOW", false, options);
115
+ this.name = "RouteDockRefundWindowError";
116
+ }
117
+ };
118
+ var RouteDockPolicyRejectedError = RouteDockPolicyRejectError;
119
+ var RouteDockSessionError = RouteDockVoucherMonotonicityError;
120
+
121
+ // src/types.ts
122
+ var DEFAULT_MAX_SESSION_DURATION_MS = 36e5;
123
+
124
+ // src/internal/assetUtils.ts
125
+ function normalizeManifestAssets(manifest) {
126
+ if (manifest.assets && manifest.assets.length > 0) {
127
+ return manifest.assets;
128
+ }
129
+ if (manifest.asset && manifest.asset_contract) {
130
+ return [
131
+ {
132
+ asset: manifest.asset,
133
+ asset_contract: manifest.asset_contract
134
+ }
135
+ ];
136
+ }
137
+ throw new RouteDockManifestError(
138
+ "Manifest must define either assets[] or root asset/asset_contract fields"
139
+ );
140
+ }
141
+ function getEligibleAssets(manifest, mode, endpoint) {
142
+ const assets = normalizeManifestAssets(manifest);
143
+ return assets.filter((asset) => {
144
+ if (asset.modes && !asset.modes.includes(mode)) {
145
+ return false;
146
+ }
147
+ if (endpoint && asset.endpoints && asset.endpoints.length > 0) {
148
+ const trimmedEndpoint = endpoint.replace(/^\//, "");
149
+ if (asset.endpoints.includes(endpoint) || asset.endpoints.includes(trimmedEndpoint)) {
150
+ return true;
151
+ }
152
+ if (manifest.endpoints) {
153
+ let pathname = endpoint;
154
+ if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
155
+ try {
156
+ pathname = new URL(endpoint).pathname;
157
+ } catch {
158
+ }
159
+ }
160
+ const trimmedPathname = pathname.replace(/^\//, "");
161
+ const matchesEndpoint = Object.entries(manifest.endpoints).some(([key, desc]) => {
162
+ const pathMatches = desc.path === pathname || desc.path === endpoint || desc.path.replace(/\/+$/, "") === pathname.replace(/\/+$/, "") || desc.path.replace(/^\//, "") === trimmedPathname;
163
+ const keyMatches = key === endpoint || key === trimmedEndpoint;
164
+ if (pathMatches || keyMatches) {
165
+ return asset.endpoints.includes(key) || asset.endpoints.includes(desc.path) || asset.endpoints.includes(desc.path.replace(/^\//, ""));
166
+ }
167
+ return false;
168
+ });
169
+ if (!matchesEndpoint) {
170
+ return false;
171
+ }
172
+ } else {
173
+ return false;
174
+ }
175
+ }
176
+ return true;
177
+ });
178
+ }
179
+ function resolveAssetContract(manifest, mode, endpoint, explicitContract) {
180
+ if (manifest.assets && manifest.assets.length > 0) {
181
+ return selectAsset(manifest, mode, endpoint).asset_contract;
182
+ }
183
+ return explicitContract ?? selectAsset(manifest, mode, endpoint).asset_contract;
184
+ }
185
+ function selectAsset(manifest, mode, endpoint) {
186
+ const eligible = getEligibleAssets(manifest, mode, endpoint);
187
+ if (eligible.length === 0) {
188
+ const endpointMsg = endpoint ? ` for endpoint '${endpoint}'` : "";
189
+ throw new RouteDockManifestError(
190
+ `No eligible assets found for mode '${mode}'${endpointMsg}`
191
+ );
192
+ }
193
+ return eligible[0];
194
+ }
195
+ function isAssetEligible(manifest, assetIdentifier, mode, endpoint) {
196
+ const eligible = getEligibleAssets(manifest, mode, endpoint);
197
+ return eligible.some(
198
+ (asset) => asset.asset === assetIdentifier || asset.asset_contract === assetIdentifier
199
+ );
200
+ }
201
+
202
+ // src/internal/payee.ts
203
+ function resolvePayee(manifest, mode) {
204
+ const override = manifest.pricing[mode]?.payee;
205
+ return override ?? manifest.payee;
206
+ }
207
+ var usdcToUnits = nulthSdk.usdcToStroops;
208
+ function extractPayerAddress(key) {
209
+ if (typeof key !== "string") return null;
210
+ return stellarSdk.StrKey.isValidEd25519PublicKey(key) || stellarSdk.StrKey.isValidMed25519PublicKey(key) ? key : null;
211
+ }
212
+
213
+ // src/internal/logger.ts
214
+ function createConsoleLogger() {
215
+ return (level, message, fields) => {
216
+ const values = fields ? Object.values(fields) : [];
217
+ if (values.length > 0) {
218
+ console[level](message, ...values);
219
+ } else {
220
+ console[level](message);
221
+ }
222
+ };
223
+ }
224
+ var consoleLogger = createConsoleLogger();
225
+ function resolveLogger(logger, fallback = consoleLogger) {
226
+ return logger ?? fallback;
227
+ }
228
+
229
+ // src/provider/SeenTxStore.ts
230
+ var SETTLEMENT_REPLAY_WINDOW_MS = 6e4;
231
+ var MAX_SETTLEMENT_REPLAYS = 1;
232
+ async function checkSettlementReplay(store, key, now = Date.now()) {
233
+ const record = await store.get(key);
234
+ if (!record) return { kind: "miss" };
235
+ const { createdAt } = record;
236
+ if (createdAt !== void 0 && now - createdAt > SETTLEMENT_REPLAY_WINDOW_MS) {
237
+ return { kind: "spent" };
238
+ }
239
+ if (store.claimReplay) {
240
+ const replayed = await store.claimReplay(key);
241
+ if (replayed > MAX_SETTLEMENT_REPLAYS) return { kind: "spent" };
242
+ }
243
+ return { kind: "replay", record };
244
+ }
245
+ var InMemorySeenTxStore = class {
246
+ map = /* @__PURE__ */ new Map();
247
+ replayCounts = /* @__PURE__ */ new Map();
248
+ order = [];
249
+ constructor(maxEntriesOrOptions = 1e4) {
250
+ const options = typeof maxEntriesOrOptions === "number" ? { maxEntries: maxEntriesOrOptions } : maxEntriesOrOptions;
251
+ this.maxEntries = options.maxEntries ?? 1e4;
252
+ if (options.warn !== false) {
253
+ const isServerless = typeof globalThis.navigator !== "undefined" && globalThis.navigator.userAgent === "Cloudflare-Workers";
254
+ if (isServerless) {
255
+ resolveLogger(options.logger)(
256
+ "warn",
257
+ "[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."
258
+ );
259
+ }
260
+ }
261
+ }
262
+ maxEntries;
263
+ get(key) {
264
+ return this.map.get(key);
265
+ }
266
+ set(key, record) {
267
+ if (!this.map.has(key)) {
268
+ this.order.push(key);
269
+ if (this.order.length > this.maxEntries) {
270
+ const evicted = this.order.shift();
271
+ if (evicted !== void 0) {
272
+ this.map.delete(evicted);
273
+ this.replayCounts.delete(evicted);
274
+ }
275
+ }
276
+ }
277
+ this.map.set(key, record);
278
+ }
279
+ claimReplay(key) {
280
+ if (!this.map.has(key)) return Number.POSITIVE_INFINITY;
281
+ const next = (this.replayCounts.get(key) ?? 0) + 1;
282
+ this.replayCounts.set(key, next);
283
+ return next;
284
+ }
285
+ };
286
+ async function sha256Hex(input) {
287
+ const buf = new TextEncoder().encode(input);
288
+ const digest = await crypto.subtle.digest("SHA-256", buf);
289
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
290
+ }
291
+ async function paymentIdempotencyKey(getHeader, scope) {
292
+ const material = getHeader("payment-signature") ?? getHeader("x-payment") ?? getHeader("authorization");
293
+ if (!material) return null;
294
+ return sha256Hex(
295
+ [scope.method, scope.path, scope.amount, scope.payTo, material].join("\n")
296
+ );
297
+ }
298
+
299
+ // src/provider/settleResult.ts
300
+ function readSettleResult(result) {
301
+ const settle = result;
302
+ const failed = (reason) => reason ? { ok: false, reason } : { ok: false };
303
+ if (!settle || settle.success !== true) return failed(settle?.errorReason);
304
+ if (typeof settle.transaction !== "string" || settle.transaction.length === 0) {
305
+ return failed(settle.errorReason);
306
+ }
307
+ return { ok: true, txHash: settle.transaction };
308
+ }
309
+
310
+ // src/provider/x402Handler.ts
46
311
  var CAIP2 = {
47
312
  testnet: "stellar:testnet",
48
313
  mainnet: "stellar:pubnet"
@@ -52,6 +317,8 @@ function createX402Handler(opts) {
52
317
  const caip2 = CAIP2[opts.network];
53
318
  const payeeKeypair = stellarSdk.Keypair.fromSecret(opts.payeeSecretKey);
54
319
  const signer = stellar.createEd25519Signer(opts.payeeSecretKey, caip2);
320
+ const logger = resolveLogger(opts.logger);
321
+ const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore({ logger });
55
322
  const useOzFacilitator = opts.network === "mainnet" && opts.facilitatorApiKey;
56
323
  const localFacilitator = new facilitator.ExactStellarScheme([signer], {
57
324
  areFeesSponsored: true
@@ -70,23 +337,31 @@ function createX402Handler(opts) {
70
337
  ozServer = new server.x402ResourceServer(facilitator);
71
338
  ozServer.register(caip2, new server$1.ExactStellarScheme());
72
339
  }
73
- const amountInBaseUnits = String(Math.round(parseFloat(opts.amount) * 1e7));
74
- const requirements = {
75
- scheme: "exact",
76
- network: caip2,
77
- asset: opts.assetContract,
78
- amount: amountInBaseUnits,
79
- payTo: opts.manifest.payee,
80
- maxTimeoutSeconds: 60,
81
- extra: {
82
- areFeesSponsored: true,
83
- ...useOzFacilitator ? {} : { facilitatorAddresses: [payeeKeypair.publicKey()] }
84
- }
85
- };
340
+ const amountInBaseUnits = String(usdcToUnits(opts.amount));
341
+ const payTo = resolvePayee(opts.manifest, "x402");
86
342
  return async (req, res, next) => {
87
343
  try {
88
- const paymentHeader = req.headers["payment-signature"] ?? req.headers["x-payment"];
89
- if (!paymentHeader) {
344
+ const endpoint = req.path || req.originalUrl;
345
+ const assetContract = resolveAssetContract(
346
+ opts.manifest,
347
+ "x402",
348
+ endpoint,
349
+ opts.assetContract
350
+ );
351
+ const requirements = {
352
+ scheme: "exact",
353
+ network: caip2,
354
+ asset: assetContract,
355
+ amount: amountInBaseUnits,
356
+ payTo,
357
+ maxTimeoutSeconds: 60,
358
+ extra: {
359
+ areFeesSponsored: true,
360
+ ...useOzFacilitator ? {} : { facilitatorAddresses: [payeeKeypair.publicKey()] }
361
+ }
362
+ };
363
+ const respondPaymentRequired = async (error, reason) => {
364
+ const body = { error, ...reason ? { reason } : {} };
90
365
  if (ozServer) {
91
366
  const resourceInfo = {
92
367
  url: `${req.protocol}://${req.get("host") ?? ""}${req.originalUrl}`,
@@ -96,7 +371,7 @@ function createX402Handler(opts) {
96
371
  [requirements],
97
372
  resourceInfo
98
373
  );
99
- res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", http.encodePaymentRequiredHeader(paymentRequired)).json({ error: "Payment Required" });
374
+ res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", http.encodePaymentRequiredHeader(paymentRequired)).json(body);
100
375
  } else {
101
376
  const x402Response = {
102
377
  x402Version: 2,
@@ -106,23 +381,65 @@ function createX402Handler(opts) {
106
381
  },
107
382
  accepts: [requirements]
108
383
  };
109
- res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", http.encodePaymentRequiredHeader(x402Response)).json({ error: "Payment Required" });
384
+ res.status(402).setHeader("Content-Type", "application/json").setHeader("X-Payment-Requirements", http.encodePaymentRequiredHeader(x402Response)).json(body);
110
385
  }
386
+ };
387
+ const paymentHeader = req.headers["payment-signature"] ?? req.headers["x-payment"];
388
+ if (!paymentHeader) {
389
+ await respondPaymentRequired("Payment Required");
111
390
  return;
112
391
  }
392
+ const idempotencyKey = await paymentIdempotencyKey(
393
+ (name) => {
394
+ const v = req.headers[name.toLowerCase()];
395
+ return Array.isArray(v) ? v[0] : v;
396
+ },
397
+ {
398
+ method: req.method,
399
+ path: req.originalUrl.split("?")[0],
400
+ amount: requirements.amount,
401
+ payTo: requirements.payTo
402
+ }
403
+ );
404
+ if (idempotencyKey) {
405
+ const replayCheck = await checkSettlementReplay(seenTxStore, idempotencyKey);
406
+ if (replayCheck.kind === "spent") {
407
+ res.status(402).json({ error: "Payment already used" });
408
+ return;
409
+ }
410
+ if (replayCheck.kind === "replay") {
411
+ if (replayCheck.record.headers) {
412
+ for (const [k, val] of Object.entries(replayCheck.record.headers)) {
413
+ res.setHeader(k, val);
414
+ }
415
+ }
416
+ next();
417
+ return;
418
+ }
419
+ }
113
420
  const payload = http.decodePaymentSignatureHeader(paymentHeader);
114
421
  let txHash = null;
422
+ let payerAddress = null;
423
+ try {
424
+ const creds = payload.authorization?.credentials;
425
+ const key = Array.isArray(creds) ? creds[0]?.publicKey : void 0;
426
+ payerAddress = extractPayerAddress(key);
427
+ } catch {
428
+ }
115
429
  if (ozServer) {
116
430
  const settleResult = await ozServer.settlePayment(payload, requirements);
117
- txHash = settleResult.transaction ?? null;
118
- if (settleResult) {
119
- res.setHeader(
120
- "X-Payment-Response",
121
- http.encodePaymentResponseHeader(
122
- settleResult
123
- )
124
- );
431
+ const outcome = readSettleResult(settleResult);
432
+ if (!outcome.ok) {
433
+ await respondPaymentRequired("Payment settlement failed", outcome.reason);
434
+ return;
125
435
  }
436
+ txHash = outcome.txHash;
437
+ res.setHeader(
438
+ "X-Payment-Response",
439
+ http.encodePaymentResponseHeader(
440
+ settleResult
441
+ )
442
+ );
126
443
  } else {
127
444
  const verifyResult = await localFacilitator.verify(
128
445
  payload,
@@ -139,22 +456,40 @@ function createX402Handler(opts) {
139
456
  payload,
140
457
  requirements
141
458
  );
142
- txHash = settleResult.transaction ?? null;
143
- if (settleResult) {
144
- res.setHeader(
145
- "X-Payment-Response",
146
- http.encodePaymentResponseHeader(
147
- settleResult
148
- )
149
- );
459
+ const outcome = readSettleResult(settleResult);
460
+ if (!outcome.ok) {
461
+ await respondPaymentRequired("Payment settlement failed", outcome.reason);
462
+ return;
463
+ }
464
+ txHash = outcome.txHash;
465
+ res.setHeader(
466
+ "X-Payment-Response",
467
+ http.encodePaymentResponseHeader(
468
+ settleResult
469
+ )
470
+ );
471
+ }
472
+ if (idempotencyKey) {
473
+ const headers = {};
474
+ const paymentResponse = res.getHeader("X-Payment-Response");
475
+ if (typeof paymentResponse === "string") {
476
+ headers["X-Payment-Response"] = paymentResponse;
150
477
  }
478
+ await seenTxStore.set(idempotencyKey, { txHash, headers, createdAt: Date.now() });
151
479
  }
152
480
  if (txHash && opts.onSettled) {
153
- await opts.onSettled(txHash, opts.amount, "x402");
481
+ Promise.resolve().then(() => opts.onSettled(txHash, opts.amount, "x402", payerAddress)).catch((err) => {
482
+ logger("error", "[x402] onSettled callback error", { error: err });
483
+ opts.onCallbackError?.(err, "onSettled");
484
+ });
154
485
  }
155
486
  next();
156
487
  } catch (err) {
157
- console.error("[x402] Settlement error:", err);
488
+ if (err instanceof RouteDockManifestError) {
489
+ next(err);
490
+ return;
491
+ }
492
+ logger("error", "[x402] Settlement error", { error: err });
158
493
  res.status(500).json({ error: "Payment settlement failed" });
159
494
  }
160
495
  };
@@ -166,25 +501,87 @@ var MPP_NETWORK = {
166
501
  function createMppChargeHandler(opts) {
167
502
  const networkId = MPP_NETWORK[opts.network];
168
503
  const amountHumanReadable = opts.amount;
169
- const mppx = server$2.Mppx.create({
170
- secretKey: opts.payeeSecretKey,
171
- methods: [
172
- server$3.stellar.charge({
173
- recipient: opts.manifest.payee,
174
- currency: opts.assetContract,
175
- network: networkId,
176
- feePayer: { envelopeSigner: opts.payeeSecretKey }
177
- })
178
- ]
179
- });
504
+ const recipient = resolvePayee(opts.manifest, "mpp-charge");
505
+ const logger = resolveLogger(opts.logger);
506
+ const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore({ logger });
507
+ const mppxInstances = /* @__PURE__ */ new Map();
508
+ function getMppx(contract) {
509
+ let instance = mppxInstances.get(contract);
510
+ if (!instance) {
511
+ instance = server$2.Mppx.create({
512
+ secretKey: opts.payeeSecretKey,
513
+ methods: [
514
+ server$4.stellar.charge({
515
+ recipient,
516
+ currency: contract,
517
+ network: networkId,
518
+ feePayer: { envelopeSigner: opts.payeeSecretKey }
519
+ })
520
+ ]
521
+ });
522
+ mppxInstances.set(contract, instance);
523
+ }
524
+ return instance;
525
+ }
180
526
  return async (req, res, next) => {
181
527
  try {
528
+ const endpoint = req.path || req.originalUrl;
529
+ const assetContract = resolveAssetContract(
530
+ opts.manifest,
531
+ "mpp-charge",
532
+ endpoint,
533
+ opts.assetContract
534
+ );
535
+ const mppx = getMppx(assetContract);
536
+ let payerAddress = null;
537
+ try {
538
+ const authHeader = req.headers["authorization"];
539
+ if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
540
+ const credPart = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
541
+ if (credPart) {
542
+ const b64 = credPart.split("=").slice(1).join("=").replace(/^"|"$/g, "");
543
+ const credJson = Buffer.from(b64, "base64").toString("utf8");
544
+ const cred = JSON.parse(credJson);
545
+ const key = cred.sender ?? cred.payload?.sender ?? cred.payload?.from;
546
+ payerAddress = extractPayerAddress(key);
547
+ }
548
+ }
549
+ } catch {
550
+ }
551
+ const idempotencyKey = await paymentIdempotencyKey(
552
+ (name) => {
553
+ const v = req.headers[name.toLowerCase()];
554
+ return Array.isArray(v) ? v[0] : v;
555
+ },
556
+ {
557
+ method: req.method,
558
+ path: req.originalUrl.split("?")[0],
559
+ amount: amountHumanReadable,
560
+ payTo: recipient
561
+ }
562
+ );
563
+ if (idempotencyKey) {
564
+ const replayCheck = await checkSettlementReplay(seenTxStore, idempotencyKey);
565
+ if (replayCheck.kind === "spent") {
566
+ res.status(402).json({ error: "Payment already used" });
567
+ return;
568
+ }
569
+ if (replayCheck.kind === "replay") {
570
+ if (replayCheck.record.headers) {
571
+ for (const [k, val] of Object.entries(replayCheck.record.headers)) {
572
+ res.setHeader(k, val);
573
+ }
574
+ }
575
+ next();
576
+ return;
577
+ }
578
+ }
182
579
  const fetchReq = server$2.Request.fromNodeListener(req, res);
183
580
  const handler = mppx["stellar/charge"];
184
581
  const result = await handler({
185
582
  amount: amountHumanReadable,
186
- currency: opts.assetContract,
187
- recipient: opts.manifest.payee,
583
+ currency: assetContract,
584
+ recipient,
188
585
  description: opts.manifest.name
189
586
  })(fetchReq);
190
587
  if (result.status === 402) {
@@ -195,25 +592,131 @@ function createMppChargeHandler(opts) {
195
592
  return;
196
593
  }
197
594
  const receipt = result.withReceipt(new Response(""));
198
- receipt.headers.forEach((v, k) => res.setHeader(k, v));
595
+ const receiptHeaders = {};
596
+ receipt.headers.forEach((v, k) => {
597
+ res.setHeader(k, v);
598
+ receiptHeaders[k] = v;
599
+ });
199
600
  const receiptHeader = receipt.headers.get("payment-receipt");
200
- if (receiptHeader && opts.onSettled) {
601
+ let reference;
602
+ if (receiptHeader) {
201
603
  try {
202
604
  const parsed = JSON.parse(
203
605
  Buffer.from(receiptHeader, "base64").toString("utf8")
204
606
  );
205
- if (parsed.reference) {
206
- await opts.onSettled(parsed.reference, opts.amount, "mpp-charge");
207
- }
607
+ reference = parsed.reference;
208
608
  } catch {
209
609
  }
210
610
  }
611
+ if (idempotencyKey) {
612
+ await seenTxStore.set(idempotencyKey, {
613
+ txHash: reference ?? null,
614
+ headers: receiptHeaders,
615
+ createdAt: Date.now()
616
+ });
617
+ }
618
+ if (reference && opts.onSettled) {
619
+ Promise.resolve().then(() => opts.onSettled(reference, opts.amount, "mpp-charge", payerAddress)).catch((err) => {
620
+ logger("error", "[mpp-charge] onSettled callback error", { error: err });
621
+ opts.onCallbackError?.(err, "onSettled");
622
+ });
623
+ }
211
624
  next();
212
625
  } catch (err) {
213
626
  next(err);
214
627
  }
215
628
  };
216
629
  }
630
+ function resolveVaultSettlementAddresses(payer, payee) {
631
+ if (!payer) return null;
632
+ return { payer, payee };
633
+ }
634
+ function buildSessionSettlementTransaction(source, vaultContractId, channelId, addresses, amount, voucherCount, networkPassphrase) {
635
+ const vault = new stellarSdk.Contract(vaultContractId);
636
+ const op = vault.call(
637
+ "record_session_settlement",
638
+ stellarSdk.nativeToScVal(channelId, { type: "address" }),
639
+ stellarSdk.nativeToScVal(addresses.payer, { type: "address" }),
640
+ stellarSdk.nativeToScVal(addresses.payee, { type: "address" }),
641
+ stellarSdk.nativeToScVal(amount, { type: "i128" }),
642
+ stellarSdk.nativeToScVal(voucherCount, { type: "u32" })
643
+ );
644
+ return new stellarSdk.TransactionBuilder(source, {
645
+ fee: stellarSdk.BASE_FEE,
646
+ networkPassphrase
647
+ }).addOperation(op).setTimeout(30).build();
648
+ }
649
+ function channelAuthorizer(envelopeSigner) {
650
+ return { envelopeSigner };
651
+ }
652
+ function formatMppError(error) {
653
+ if (error instanceof Error) return error.message;
654
+ if (typeof error === "string") return error;
655
+ try {
656
+ const serialized = JSON.stringify(error);
657
+ return serialized === void 0 ? String(error) : serialized;
658
+ } catch {
659
+ return String(error);
660
+ }
661
+ }
662
+ function withTypedChannelErrors(method) {
663
+ return {
664
+ ...method,
665
+ async verify(parameters) {
666
+ try {
667
+ return await method.verify(parameters);
668
+ } catch (error) {
669
+ throw mapChannelError(error);
670
+ }
671
+ }
672
+ };
673
+ }
674
+ function onVerifiedCredential(method, commit) {
675
+ return {
676
+ ...method,
677
+ async verify(parameters) {
678
+ const receipt = await method.verify(parameters);
679
+ await commit(parameters.credential);
680
+ return receipt;
681
+ }
682
+ };
683
+ }
684
+ function mapChannelError(error) {
685
+ const message = formatMppError(error);
686
+ const details = error && typeof error === "object" && "details" in error ? error.details : void 0;
687
+ const reason = details && Object.keys(details).length > 0 ? `${message} (${JSON.stringify(details)})` : message;
688
+ if (/signer mismatch|authori[sz]er|unauthori[sz]ed/i.test(message)) {
689
+ return new mppx.Errors.SignerMismatchError({ reason });
690
+ }
691
+ if (/already used|replay|challenge/i.test(message)) {
692
+ return new mppx.Errors.InvalidChallengeError({ reason });
693
+ }
694
+ if (/channel has been closed|channel is closed|finalized/i.test(message)) {
695
+ return new mppx.Errors.ChannelClosedError({ reason });
696
+ }
697
+ if (/exceeds channel balance|exceeds.*deposit/i.test(message)) {
698
+ return new mppx.Errors.AmountExceedsDepositError({ reason });
699
+ }
700
+ if (/channel not found|no channel/i.test(message)) {
701
+ return new mppx.Errors.ChannelNotFoundError({ reason });
702
+ }
703
+ if (/must be greater|does not cover|below.*minimum/i.test(message)) {
704
+ return new mppx.Errors.DeltaTooSmallError({ reason });
705
+ }
706
+ if (/signature/i.test(message)) {
707
+ return new mppx.Errors.InvalidSignatureError({ reason });
708
+ }
709
+ return new mppx.Errors.VerificationFailedError({ reason });
710
+ }
711
+
712
+ // src/provider/MppSessionHandler.ts
713
+ function isVoucherStoreValue(value) {
714
+ if (typeof value !== "object" || value === null) {
715
+ return false;
716
+ }
717
+ const candidate = value;
718
+ return typeof candidate.amount === "string" && /^\d+$/.test(candidate.amount);
719
+ }
217
720
  var MPP_NETWORK2 = {
218
721
  testnet: "stellar:testnet",
219
722
  mainnet: "stellar:pubnet"
@@ -222,67 +725,203 @@ function createMppSessionHandler(opts) {
222
725
  const networkId = MPP_NETWORK2[opts.network];
223
726
  const rateHuman = opts.rate;
224
727
  const payeeKeypair = stellarSdk.Keypair.fromSecret(opts.payeeSecretKey);
225
- const cumulativeKey = `stellar:channel:cumulative:${opts.channelContract}`;
226
- const innerStore = server$4.Store.memory();
227
- let lastCumulativeAmount = 0n;
728
+ const logger = resolveLogger(opts.logger);
729
+ const voucherRecordKey = `routedock:session:voucher:${opts.channelFactory}`;
730
+ const innerStore = server$3.Store.memory();
731
+ let record = null;
228
732
  let voucherCount = 0;
229
733
  let sessionOpened = false;
734
+ let idleTimer = null;
735
+ let settledCleanly = false;
736
+ let closeListenerArmed = false;
737
+ function clearIdleTimer() {
738
+ if (idleTimer) {
739
+ clearTimeout(idleTimer);
740
+ idleTimer = null;
741
+ }
742
+ }
743
+ function armIdleTimer() {
744
+ if (!opts.idleTimeoutMs) return;
745
+ clearIdleTimer();
746
+ idleTimer = setTimeout(() => {
747
+ void flagOrphan("idle-timeout");
748
+ }, opts.idleTimeoutMs);
749
+ if (typeof idleTimer.unref === "function") idleTimer.unref();
750
+ }
751
+ async function loadPersistedRecord() {
752
+ if (record) return record;
753
+ try {
754
+ const stored = await innerStore.get(voucherRecordKey);
755
+ if (!stored || typeof stored.amount !== "string" || typeof stored.signature !== "string") {
756
+ return null;
757
+ }
758
+ record = { amount: BigInt(stored.amount), signature: stored.signature, payer: stored.payer ?? null };
759
+ } catch {
760
+ return null;
761
+ }
762
+ return record;
763
+ }
764
+ async function flagOrphan(reason) {
765
+ await loadPersistedRecord();
766
+ if (!sessionOpened || settledCleanly) return;
767
+ sessionOpened = false;
768
+ closeListenerArmed = false;
769
+ clearIdleTimer();
770
+ const cumulativeAmount = record ? (Number(record.amount) / 1e7).toFixed(7) : "0.0000000";
771
+ if (opts.onOrphaned) {
772
+ try {
773
+ await opts.onOrphaned(opts.channelFactory, {
774
+ cumulativeAmount,
775
+ lastSignature: record?.signature ?? "",
776
+ voucherCount,
777
+ reason
778
+ });
779
+ } catch (err) {
780
+ logger("error", "[mpp-session] onOrphaned handler failed", { error: err });
781
+ }
782
+ }
783
+ }
230
784
  const wrappedStore = {
231
785
  async get(key) {
232
786
  return innerStore.get(key);
233
787
  },
234
788
  async put(key, value) {
235
- await innerStore.put(key, value);
236
- if (key === cumulativeKey && value && typeof value === "object" && "amount" in value) {
237
- lastCumulativeAmount = BigInt(value.amount);
238
- voucherCount++;
239
- if (!sessionOpened) {
240
- sessionOpened = true;
241
- if (opts.onSessionOpen) {
242
- await opts.onSessionOpen(opts.channelContract);
243
- }
244
- }
245
- if (opts.onVoucher) {
246
- const humanAmount = (Number(lastCumulativeAmount) / 1e7).toFixed(7);
247
- await opts.onVoucher(voucherCount, humanAmount);
248
- }
249
- }
789
+ return innerStore.put(key, value);
250
790
  },
251
791
  async delete(key) {
252
792
  return innerStore.delete(key);
793
+ },
794
+ async update(key, fn) {
795
+ const storeWithUpdate = innerStore;
796
+ if (typeof storeWithUpdate.update === "function") {
797
+ return storeWithUpdate.update(key, fn);
798
+ }
799
+ throw new Error("Store does not support atomic update operations");
253
800
  }
254
801
  };
802
+ async function commit(credential) {
803
+ await loadPersistedRecord();
804
+ const payload = credential.payload;
805
+ if (typeof payload?.amount !== "string" || typeof payload.signature !== "string") return;
806
+ let amount;
807
+ try {
808
+ amount = BigInt(payload.amount);
809
+ } catch {
810
+ return;
811
+ }
812
+ if (record && amount < record.amount) return;
813
+ const payer = record?.payer ?? extractPayerAddress(credential.source);
814
+ record = { amount, signature: payload.signature, payer };
815
+ await innerStore.put(voucherRecordKey, {
816
+ amount: record.amount.toString(),
817
+ signature: record.signature,
818
+ payer: record.payer
819
+ });
820
+ voucherCount++;
821
+ settledCleanly = false;
822
+ armIdleTimer();
823
+ if (!sessionOpened) {
824
+ sessionOpened = true;
825
+ if (opts.onSessionOpen) {
826
+ Promise.resolve().then(() => opts.onSessionOpen(opts.channelFactory, record.payer)).catch((err) => {
827
+ logger("error", "[mpp-session] onSessionOpen callback error", { error: err });
828
+ opts.onCallbackError?.(err, "onSessionOpen");
829
+ });
830
+ }
831
+ }
832
+ if (opts.onVoucher) {
833
+ const humanAmount = (Number(record.amount) / 1e7).toFixed(7);
834
+ Promise.resolve().then(() => opts.onVoucher(opts.channelFactory, voucherCount, humanAmount, record.signature)).catch((err) => {
835
+ logger("error", "[mpp-session] onVoucher callback error", { error: err });
836
+ opts.onCallbackError?.(err, "onVoucher");
837
+ });
838
+ }
839
+ }
255
840
  const mppx = server$2.Mppx.create({
256
841
  secretKey: opts.payeeSecretKey,
257
842
  methods: [
258
- server$4.stellar.channel({
259
- channel: opts.channelContract,
260
- commitmentKey: opts.commitmentPublicKey,
261
- network: networkId,
262
- store: wrappedStore,
263
- sourceAccount: payeeKeypair.publicKey(),
264
- feePayer: { envelopeSigner: payeeKeypair }
265
- })
843
+ withTypedChannelErrors(
844
+ onVerifiedCredential(
845
+ server$3.stellar.channel({
846
+ channel: opts.channelFactory,
847
+ commitmentKey: opts.commitmentPublicKey,
848
+ network: networkId,
849
+ store: wrappedStore,
850
+ sourceAccount: payeeKeypair.publicKey(),
851
+ feePayer: channelAuthorizer(payeeKeypair)
852
+ }),
853
+ commit
854
+ )
855
+ )
266
856
  ]
267
857
  });
268
- let lastSignatureHex = "";
269
858
  return async (req, res, next) => {
270
859
  try {
271
860
  if (req.method === "DELETE") {
861
+ await loadPersistedRecord();
272
862
  const body = req.body;
273
- const closeAmount = body?.amount ? BigInt(body.amount) : lastCumulativeAmount;
274
- const closeSig = body?.signature ?? lastSignatureHex;
863
+ const bodyAmount = body?.amount ? BigInt(body.amount) : 0n;
864
+ const recordAmount = record?.amount ?? 0n;
865
+ let closeAmount;
866
+ let closeSig;
867
+ if (bodyAmount > recordAmount) {
868
+ closeAmount = bodyAmount;
869
+ closeSig = body?.signature ?? record?.signature ?? "";
870
+ } else {
871
+ closeAmount = recordAmount;
872
+ closeSig = record?.signature ?? "";
873
+ }
275
874
  if (closeAmount > 0n && closeSig) {
276
- const closeTxHash = await server$4.close({
277
- channel: opts.channelContract,
875
+ const closePayer = record?.payer ?? null;
876
+ const closeTxHash = await server$3.close({
877
+ channel: opts.channelFactory,
278
878
  amount: closeAmount,
279
879
  signature: Buffer.from(closeSig, "hex"),
280
- feePayer: { envelopeSigner: payeeKeypair },
880
+ feePayer: channelAuthorizer(payeeKeypair),
281
881
  network: networkId
282
882
  });
883
+ settledCleanly = true;
884
+ clearIdleTimer();
283
885
  if (opts.onSettled) {
284
- const totalPaid = (Number(lastCumulativeAmount) / 1e7).toFixed(7);
285
- await opts.onSettled(closeTxHash, totalPaid, "mpp-session");
886
+ const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
887
+ Promise.resolve().then(() => opts.onSettled(closeTxHash, totalPaid, "mpp-session", closePayer)).catch((err) => {
888
+ logger("error", "[mpp-session] onSettled callback error", { error: err });
889
+ opts.onCallbackError?.(err, "onSettled");
890
+ });
891
+ }
892
+ const vaultContract = process.env.AGENT_VAULT_CONTRACT;
893
+ if (vaultContract) {
894
+ const settlementAddresses = resolveVaultSettlementAddresses(
895
+ closePayer,
896
+ payeeKeypair.publicKey()
897
+ );
898
+ if (!settlementAddresses) {
899
+ logger("warn", "[mpp-session] skipped session_settled vault record: payer address unavailable");
900
+ } else {
901
+ try {
902
+ const { Networks } = await import('@stellar/stellar-sdk');
903
+ const { Server } = await import('@stellar/stellar-sdk/rpc');
904
+ const networkPassphrase = opts.network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
905
+ const rpcUrl = opts.network === "mainnet" ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org";
906
+ const server = new Server(rpcUrl);
907
+ const payeePublicKey = payeeKeypair.publicKey();
908
+ const sourceAccount = await server.getAccount(payeePublicKey);
909
+ const tx = buildSessionSettlementTransaction(
910
+ sourceAccount,
911
+ vaultContract,
912
+ opts.channelFactory,
913
+ settlementAddresses,
914
+ closeAmount,
915
+ voucherCount,
916
+ networkPassphrase
917
+ );
918
+ const preparedTx = await server.prepareTransaction(tx);
919
+ preparedTx.sign(payeeKeypair);
920
+ await server.sendTransaction(preparedTx);
921
+ } catch (recordErr) {
922
+ logger("error", "[mpp-session] failed to record session_settled on vault", { error: recordErr });
923
+ }
924
+ }
286
925
  }
287
926
  res.json({ closeTxHash });
288
927
  } else {
@@ -290,23 +929,12 @@ function createMppSessionHandler(opts) {
290
929
  }
291
930
  sessionOpened = false;
292
931
  voucherCount = 0;
293
- lastCumulativeAmount = 0n;
932
+ record = null;
933
+ await innerStore.delete(voucherRecordKey);
934
+ closeListenerArmed = false;
935
+ clearIdleTimer();
294
936
  return;
295
937
  }
296
- const authHeader = req.headers["authorization"];
297
- if (typeof authHeader === "string" && authHeader.startsWith("Payment ")) {
298
- try {
299
- const credB64 = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
300
- if (credB64) {
301
- const credJson = Buffer.from(credB64.split("=").slice(1).join("=").replace(/^"|"$/g, ""), "base64").toString("utf8");
302
- const cred = JSON.parse(credJson);
303
- if (cred.payload?.signature) {
304
- lastSignatureHex = cred.payload.signature;
305
- }
306
- }
307
- } catch {
308
- }
309
- }
310
938
  const fetchReq = server$2.Request.fromNodeListener(req, res);
311
939
  const result = await mppx.channel({
312
940
  amount: rateHuman,
@@ -320,28 +948,101 @@ function createMppSessionHandler(opts) {
320
948
  res.send(body);
321
949
  return;
322
950
  }
951
+ if (!closeListenerArmed) {
952
+ closeListenerArmed = true;
953
+ req.on("close", () => {
954
+ void flagOrphan("connection-closed");
955
+ });
956
+ }
323
957
  next();
324
958
  } catch (err) {
325
959
  next(err);
326
960
  }
327
961
  };
328
962
  }
963
+ var MANIFEST_SIGNATURE_VERSION = "2";
964
+ function canonicalize(value) {
965
+ if (Array.isArray(value)) {
966
+ return value.map(canonicalize);
967
+ }
968
+ if (value !== null && typeof value === "object") {
969
+ return Object.fromEntries(
970
+ Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
971
+ );
972
+ }
973
+ return value;
974
+ }
975
+ function manifestDigest(manifest) {
976
+ const { signature: _omit, ...rest } = manifest;
977
+ const canonical = JSON.stringify(canonicalize(rest));
978
+ return crypto$1.createHash("sha256").update(canonical, "utf8").digest();
979
+ }
980
+ function signManifest(manifest, payeeSecretKey) {
981
+ const keypair = stellarSdk.Keypair.fromSecret(payeeSecretKey);
982
+ const versionedManifest = {
983
+ ...manifest,
984
+ signature_version: MANIFEST_SIGNATURE_VERSION
985
+ };
986
+ const digest = manifestDigest(versionedManifest);
987
+ const sig = keypair.sign(digest);
988
+ return { ...versionedManifest, signature: Buffer.from(sig).toString("base64") };
989
+ }
990
+ function verifyManifestSignature(manifest, expectedPayee) {
991
+ if (!manifest.signature) {
992
+ throw new RouteDockSignatureError(
993
+ "Manifest is missing a signature field \u2014 cannot verify payee authenticity"
994
+ );
995
+ }
996
+ if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
997
+ throw new RouteDockSignatureError(
998
+ `Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
999
+ );
1000
+ }
1001
+ const keypair = stellarSdk.Keypair.fromPublicKey(manifest.payee);
1002
+ const digest = manifestDigest(manifest);
1003
+ let sigBytes;
1004
+ try {
1005
+ sigBytes = Buffer.from(manifest.signature, "base64");
1006
+ } catch {
1007
+ throw new RouteDockSignatureError("Manifest signature is not valid base64");
1008
+ }
1009
+ let valid = false;
1010
+ try {
1011
+ valid = keypair.verify(digest, sigBytes);
1012
+ } catch {
1013
+ throw new RouteDockSignatureError(
1014
+ `Manifest signature verification failed for payee ${manifest.payee}`
1015
+ );
1016
+ }
1017
+ if (!valid) {
1018
+ throw new RouteDockSignatureError(
1019
+ `Manifest signature verification failed for payee ${manifest.payee}`
1020
+ );
1021
+ }
1022
+ }
329
1023
 
330
1024
  // src/provider/routedockMiddleware.ts
1025
+ var MPP_MODES = ["mpp-session", "mpp-charge"];
331
1026
  function routedock(opts) {
332
- const handlers = [];
1027
+ const startTime = Date.now();
1028
+ const signedManifest = signManifest(opts.manifest, opts.payeeSecretKey);
1029
+ const handlerMap = /* @__PURE__ */ new Map();
333
1030
  if (opts.modes.includes("x402")) {
334
1031
  const x402Price = opts.pricing.x402;
335
1032
  if (x402Price) {
336
- handlers.push(
1033
+ handlerMap.set(
1034
+ "x402",
337
1035
  createX402Handler({
338
1036
  payeeSecretKey: opts.payeeSecretKey,
339
1037
  network: opts.network,
340
1038
  amount: x402Price,
341
- assetContract: opts.assetContract,
1039
+ ...opts.assetContract ? { assetContract: opts.assetContract } : {},
342
1040
  ...opts.facilitatorApiKey ? { facilitatorApiKey: opts.facilitatorApiKey } : {},
343
- manifest: opts.manifest,
344
- ...opts.onSettled ? { onSettled: opts.onSettled } : {}
1041
+ manifest: signedManifest,
1042
+ ...opts.onSettled ? { onSettled: opts.onSettled } : {},
1043
+ ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
1044
+ ...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {},
1045
+ ...opts.logger ? { logger: opts.logger } : {}
345
1046
  })
346
1047
  );
347
1048
  }
@@ -349,14 +1050,18 @@ function routedock(opts) {
349
1050
  if (opts.modes.includes("mpp-charge")) {
350
1051
  const chargePrice = opts.pricing["mpp-charge"];
351
1052
  if (chargePrice) {
352
- handlers.push(
1053
+ handlerMap.set(
1054
+ "mpp-charge",
353
1055
  createMppChargeHandler({
354
1056
  payeeSecretKey: opts.payeeSecretKey,
355
1057
  network: opts.network,
356
1058
  amount: chargePrice,
357
- assetContract: opts.assetContract,
358
- manifest: opts.manifest,
359
- ...opts.onSettled ? { onSettled: opts.onSettled } : {}
1059
+ ...opts.assetContract ? { assetContract: opts.assetContract } : {},
1060
+ manifest: signedManifest,
1061
+ ...opts.onSettled ? { onSettled: opts.onSettled } : {},
1062
+ ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
1063
+ ...opts.seenTxStore ? { seenTxStore: opts.seenTxStore } : {},
1064
+ ...opts.logger ? { logger: opts.logger } : {}
360
1065
  })
361
1066
  );
362
1067
  }
@@ -367,39 +1072,53 @@ function routedock(opts) {
367
1072
  if (!opts.commitmentPublicKey) {
368
1073
  throw new Error("routedock: mpp-session mode requires commitmentPublicKey");
369
1074
  }
370
- handlers.push(
1075
+ handlerMap.set(
1076
+ "mpp-session",
371
1077
  createMppSessionHandler({
372
1078
  payeeSecretKey: opts.payeeSecretKey,
373
1079
  network: opts.network,
374
- channelContract: sessionPricing.channelContract,
1080
+ channelFactory: sessionPricing.channelFactory,
375
1081
  rate: sessionPricing.rate,
376
- assetContract: opts.assetContract,
377
- manifest: opts.manifest,
1082
+ ...opts.assetContract ? { assetContract: opts.assetContract } : {},
1083
+ manifest: signedManifest,
378
1084
  commitmentPublicKey: opts.commitmentPublicKey,
379
1085
  ...opts.onSettled ? { onSettled: opts.onSettled } : {},
380
1086
  ...opts.onSessionOpen ? { onSessionOpen: opts.onSessionOpen } : {},
381
- ...opts.onVoucher ? { onVoucher: opts.onVoucher } : {}
1087
+ ...opts.onVoucher ? { onVoucher: opts.onVoucher } : {},
1088
+ ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
1089
+ ...opts.onOrphaned ? { onOrphaned: opts.onOrphaned } : {},
1090
+ ...opts.idleTimeoutMs != null ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
1091
+ ...opts.logger ? { logger: opts.logger } : {}
382
1092
  })
383
1093
  );
384
1094
  }
385
1095
  }
1096
+ const defaultMode = MPP_MODES.find((mode) => handlerMap.has(mode)) ?? (handlerMap.has("x402") ? "x402" : void 0);
386
1097
  return (req, res, next) => {
387
1098
  if (req.path === "/.well-known/routedock.json") {
388
- res.json(opts.manifest);
1099
+ res.json(signedManifest);
389
1100
  return;
390
1101
  }
391
- if (handlers.length === 0) {
1102
+ if (req.path === "/health") {
1103
+ res.json({
1104
+ status: "ok",
1105
+ routedock: opts.manifest.routedock,
1106
+ network: opts.network,
1107
+ payee: opts.payee,
1108
+ modesActive: opts.modes,
1109
+ uptime: Math.floor((Date.now() - startTime) / 1e3)
1110
+ });
1111
+ return;
1112
+ }
1113
+ if (handlerMap.size === 0) {
392
1114
  next();
393
1115
  return;
394
1116
  }
395
1117
  const hasX402Header = !!(req.headers["payment-signature"] || req.headers["x-payment"]);
396
1118
  const prefersX402 = req.headers["x-preferred-mode"] === "x402";
397
- let handler;
398
- if (hasX402Header || prefersX402) {
399
- handler = handlers[0];
400
- } else {
401
- handler = handlers[handlers.length - 1];
402
- }
1119
+ const x402Handler = handlerMap.get("x402");
1120
+ const defaultHandler = defaultMode ? handlerMap.get(defaultMode) : void 0;
1121
+ const handler = hasX402Header || prefersX402 ? x402Handler ?? defaultHandler : defaultHandler;
403
1122
  if (!handler) {
404
1123
  next();
405
1124
  return;
@@ -412,6 +1131,149 @@ function routedock(opts) {
412
1131
  })).then(() => next()).catch((err) => next(err));
413
1132
  };
414
1133
  }
1134
+ var MPP_NETWORK3 = {
1135
+ testnet: "stellar:testnet",
1136
+ mainnet: "stellar:pubnet"
1137
+ };
1138
+ function decimalToStroops(amountStr) {
1139
+ return nulthSdk.usdcToStroops(amountStr);
1140
+ }
1141
+ async function reconcileAbandonedSessions(opts) {
1142
+ const stats = {
1143
+ orphanedCount: 0,
1144
+ recoveredCount: 0,
1145
+ skippedCount: 0,
1146
+ failedCount: 0,
1147
+ errors: []
1148
+ };
1149
+ const payeeKeypair = stellarSdk.Keypair.fromSecret(opts.payeeSecretKey);
1150
+ const networkId = MPP_NETWORK3[opts.network];
1151
+ try {
1152
+ 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);
1153
+ if (error) {
1154
+ throw new Error(`Failed to query sessions: ${error.message}`);
1155
+ }
1156
+ stats.orphanedCount = closingSessions?.length ?? 0;
1157
+ if (!closingSessions || closingSessions.length === 0) {
1158
+ return stats;
1159
+ }
1160
+ for (const session of closingSessions) {
1161
+ try {
1162
+ const {
1163
+ channel_id: sessionId,
1164
+ channel_contract: channelContract,
1165
+ cumulative_amount,
1166
+ last_signature
1167
+ } = session;
1168
+ const channelId = channelContract || sessionId;
1169
+ if (!channelId || !cumulative_amount || !last_signature) {
1170
+ stats.skippedCount++;
1171
+ continue;
1172
+ }
1173
+ const closeAmount = decimalToStroops(cumulative_amount);
1174
+ const closeSig = Buffer.from(last_signature, "hex");
1175
+ const closeFn = opts.channelClose ?? (await import('@stellar/mpp/channel/server')).close;
1176
+ const closeTxHash = await closeFn({
1177
+ channel: channelId,
1178
+ amount: closeAmount,
1179
+ signature: closeSig,
1180
+ feePayer: channelAuthorizer(payeeKeypair),
1181
+ network: networkId
1182
+ });
1183
+ const totalPaid = (Number(closeAmount) / 1e7).toFixed(7);
1184
+ const { error: updateError } = await opts.supabase.from("sessions").update({
1185
+ status: "closed",
1186
+ settlement_tx_hash: closeTxHash,
1187
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1188
+ }).eq("channel_id", sessionId);
1189
+ if (updateError) {
1190
+ throw new Error(`Failed to update session: ${updateError.message}`);
1191
+ }
1192
+ stats.recoveredCount++;
1193
+ if (opts.onRecovered) {
1194
+ await opts.onRecovered(channelId, closeTxHash, totalPaid);
1195
+ }
1196
+ } catch (err) {
1197
+ stats.failedCount++;
1198
+ stats.errors.push({
1199
+ channelId: session.channel_id,
1200
+ reason: formatMppError(err)
1201
+ });
1202
+ }
1203
+ }
1204
+ return stats;
1205
+ } catch (err) {
1206
+ throw new Error(
1207
+ `Session reconciliation failed: ${formatMppError(err)}`
1208
+ );
1209
+ }
1210
+ }
1211
+ async function runStartupReconciliation(opts, logger) {
1212
+ const log = resolveLogger(logger);
1213
+ log("info", "[SessionReconciler] Running startup reconciliation...");
1214
+ try {
1215
+ const stats = await reconcileAbandonedSessions(opts);
1216
+ if (stats.orphanedCount === 0) {
1217
+ log("info", "[SessionReconciler] No orphaned sessions found");
1218
+ return;
1219
+ }
1220
+ log(
1221
+ "info",
1222
+ `[SessionReconciler] Found ${stats.orphanedCount} orphaned session(s). Recovered: ${stats.recoveredCount}, Skipped: ${stats.skippedCount}, Failed: ${stats.failedCount}`
1223
+ );
1224
+ if (stats.errors.length > 0) {
1225
+ log("warn", "[SessionReconciler] Reconciliation errors:");
1226
+ for (const err of stats.errors) {
1227
+ log("warn", ` - ${err.channelId}: ${err.reason}`);
1228
+ }
1229
+ }
1230
+ } catch (err) {
1231
+ log("error", `[SessionReconciler] Startup reconciliation failed: ${formatMppError(err)}`);
1232
+ }
1233
+ }
1234
+
1235
+ // src/provider/registerProvider.ts
1236
+ async function registerProvider(optionsOrSupabase, manifestArg, baseUrlArg, verifiedArg) {
1237
+ let supabase;
1238
+ let manifest;
1239
+ let baseUrl;
1240
+ let verified;
1241
+ if ("supabase" in optionsOrSupabase) {
1242
+ supabase = optionsOrSupabase.supabase;
1243
+ manifest = optionsOrSupabase.manifest;
1244
+ baseUrl = optionsOrSupabase.baseUrl;
1245
+ verified = optionsOrSupabase.verified ?? true;
1246
+ } else {
1247
+ supabase = optionsOrSupabase;
1248
+ if (!manifestArg || !baseUrlArg) {
1249
+ throw new RouteDockManifestError("manifest and baseUrl are required for registerProvider");
1250
+ }
1251
+ manifest = manifestArg;
1252
+ baseUrl = baseUrlArg;
1253
+ verified = verifiedArg ?? true;
1254
+ }
1255
+ verifyManifestSignature(manifest);
1256
+ const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
1257
+ const { error } = await supabase.from("providers").upsert(
1258
+ {
1259
+ name: manifest.name,
1260
+ description: manifest.description ?? null,
1261
+ base_url: normalizedBaseUrl,
1262
+ modes: manifest.modes,
1263
+ tags: manifest.tags ?? [],
1264
+ categories: manifest.categories ?? [],
1265
+ network: manifest.network,
1266
+ payee: manifest.payee,
1267
+ manifest,
1268
+ verified,
1269
+ registered_at: (/* @__PURE__ */ new Date()).toISOString()
1270
+ },
1271
+ { onConflict: "base_url" }
1272
+ );
1273
+ if (error) {
1274
+ throw new Error(`Failed to register provider in Supabase: ${error.message}`);
1275
+ }
1276
+ }
415
1277
 
416
1278
  // src/store/SessionStore.ts
417
1279
  var SupabaseSessionStore = class {
@@ -421,12 +1283,16 @@ var SupabaseSessionStore = class {
421
1283
  supabase;
422
1284
  async get(channelId) {
423
1285
  const { data, error } = await this.supabase.from("sessions").select("*").eq("channel_id", channelId).maybeSingle();
424
- if (error) throw new RouteDockSessionError(`SessionStore.get failed: ${error.message}`);
1286
+ if (error) {
1287
+ throw new RouteDockNetworkError(`SessionStore.get failed: ${error.message}`);
1288
+ }
425
1289
  if (!data) return null;
426
1290
  return {
427
1291
  channel_id: data.channel_id,
428
1292
  payee: data.payee,
429
1293
  payer: data.payer,
1294
+ channel_contract: data.channel_contract,
1295
+ network: data.network,
430
1296
  cumulative_amount: String(data.cumulative_amount),
431
1297
  last_signature: data.last_signature,
432
1298
  status: data.status,
@@ -441,7 +1307,7 @@ var SupabaseSessionStore = class {
441
1307
  const prev = parseFloat(existing.cumulative_amount);
442
1308
  const next = parseFloat(state.cumulative_amount);
443
1309
  if (next <= prev) {
444
- throw new RouteDockSessionError(
1310
+ throw new RouteDockVoucherMonotonicityError(
445
1311
  `cumulative_amount must be strictly increasing: ${next} <= ${prev}`
446
1312
  );
447
1313
  }
@@ -451,6 +1317,8 @@ var SupabaseSessionStore = class {
451
1317
  channel_id: state.channel_id,
452
1318
  payee: state.payee,
453
1319
  payer: state.payer,
1320
+ channel_contract: state.channel_contract,
1321
+ network: state.network,
454
1322
  cumulative_amount: state.cumulative_amount,
455
1323
  last_signature: state.last_signature,
456
1324
  status: state.status,
@@ -462,26 +1330,116 @@ var SupabaseSessionStore = class {
462
1330
  );
463
1331
  if (error) {
464
1332
  if (error.message.includes("strictly increasing")) {
465
- throw new RouteDockSessionError(
1333
+ throw new RouteDockVoucherMonotonicityError(
466
1334
  `cumulative_amount must be strictly increasing (DB): ${error.message}`
467
1335
  );
468
1336
  }
469
- throw new RouteDockSessionError(`SessionStore.upsert failed: ${error.message}`);
1337
+ throw new RouteDockNetworkError(`SessionStore.upsert failed: ${error.message}`);
1338
+ }
1339
+ }
1340
+ async setStatus(channelId, status, settlementTxHash) {
1341
+ const payload = {
1342
+ status,
1343
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1344
+ };
1345
+ if (settlementTxHash !== void 0) {
1346
+ payload.settlement_tx_hash = settlementTxHash;
1347
+ }
1348
+ const { error } = await this.supabase.from("sessions").update(payload).eq("channel_id", channelId);
1349
+ if (error) {
1350
+ throw new RouteDockNetworkError(`SessionStore.setStatus failed: ${error.message}`);
470
1351
  }
471
1352
  }
472
1353
  async close(channelId) {
473
- const { error } = await this.supabase.from("sessions").update({ status: "closed", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("channel_id", channelId);
474
- if (error) throw new RouteDockSessionError(`SessionStore.close failed: ${error.message}`);
1354
+ await this.setStatus(channelId, "closed");
1355
+ }
1356
+ };
1357
+
1358
+ // src/store/SupabaseSeenTxStore.ts
1359
+ var SupabaseSeenTxStore = class {
1360
+ constructor(supabase) {
1361
+ this.supabase = supabase;
1362
+ }
1363
+ supabase;
1364
+ async get(key) {
1365
+ const { data, error } = await this.supabase.from("settlements").select("tx_hash, headers, created_at").eq("key", key).maybeSingle();
1366
+ if (error) {
1367
+ throw new RouteDockNetworkError(`SupabaseSeenTxStore.get failed: ${error.message}`);
1368
+ }
1369
+ if (!data) return void 0;
1370
+ const headers = data.headers;
1371
+ return {
1372
+ txHash: data.tx_hash ?? null,
1373
+ ...headers ? { headers } : {},
1374
+ createdAt: Date.parse(data.created_at)
1375
+ };
1376
+ }
1377
+ async set(key, record) {
1378
+ const { error } = await this.supabase.from("settlements").upsert(
1379
+ {
1380
+ key,
1381
+ tx_hash: record.txHash,
1382
+ headers: record.headers ?? null,
1383
+ created_at: new Date(record.createdAt ?? Date.now()).toISOString()
1384
+ },
1385
+ { onConflict: "key" }
1386
+ );
1387
+ if (error) {
1388
+ throw new RouteDockNetworkError(`SupabaseSeenTxStore.set failed: ${error.message}`);
1389
+ }
1390
+ }
1391
+ /**
1392
+ * Atomically increment the settlement's replay counter through the
1393
+ * `claim_settlement_replay` Postgres function (migration 006). The UPDATE's
1394
+ * row lock gives concurrent claims distinct counts. Returns `Infinity` when
1395
+ * the key no longer exists, so the caller treats it as spent.
1396
+ */
1397
+ async claimReplay(key) {
1398
+ const { data, error } = await this.supabase.rpc("claim_settlement_replay", {
1399
+ p_key: key
1400
+ });
1401
+ if (error) {
1402
+ throw new RouteDockNetworkError(`SupabaseSeenTxStore.claimReplay failed: ${error.message}`);
1403
+ }
1404
+ if (data == null) return Number.POSITIVE_INFINITY;
1405
+ return data;
475
1406
  }
476
1407
  };
477
1408
 
1409
+ exports.DEFAULT_MAX_SESSION_DURATION_MS = DEFAULT_MAX_SESSION_DURATION_MS;
1410
+ exports.InMemorySeenTxStore = InMemorySeenTxStore;
1411
+ exports.MAX_SETTLEMENT_REPLAYS = MAX_SETTLEMENT_REPLAYS;
1412
+ exports.RouteDockChannelStateError = RouteDockChannelStateError;
1413
+ exports.RouteDockClientVersionError = RouteDockClientVersionError;
1414
+ exports.RouteDockDisputeError = RouteDockDisputeError;
478
1415
  exports.RouteDockError = RouteDockError;
1416
+ exports.RouteDockFacilitatorError = RouteDockFacilitatorError;
479
1417
  exports.RouteDockManifestError = RouteDockManifestError;
1418
+ exports.RouteDockNetworkError = RouteDockNetworkError;
480
1419
  exports.RouteDockNoSupportedModeError = RouteDockNoSupportedModeError;
1420
+ exports.RouteDockPolicyRejectError = RouteDockPolicyRejectError;
481
1421
  exports.RouteDockPolicyRejectedError = RouteDockPolicyRejectedError;
1422
+ exports.RouteDockRefundWindowError = RouteDockRefundWindowError;
482
1423
  exports.RouteDockSessionError = RouteDockSessionError;
1424
+ exports.RouteDockSignatureError = RouteDockSignatureError;
1425
+ exports.RouteDockTrustlineError = RouteDockTrustlineError;
1426
+ exports.RouteDockVoucherMonotonicityError = RouteDockVoucherMonotonicityError;
1427
+ exports.SETTLEMENT_REPLAY_WINDOW_MS = SETTLEMENT_REPLAY_WINDOW_MS;
1428
+ exports.SupabaseSeenTxStore = SupabaseSeenTxStore;
483
1429
  exports.SupabaseSessionStore = SupabaseSessionStore;
1430
+ exports.checkSettlementReplay = checkSettlementReplay;
484
1431
  exports.createMppChargeHandler = createMppChargeHandler;
485
1432
  exports.createMppSessionHandler = createMppSessionHandler;
486
1433
  exports.createX402Handler = createX402Handler;
1434
+ exports.decimalToStroops = decimalToStroops;
1435
+ exports.getEligibleAssets = getEligibleAssets;
1436
+ exports.isAssetEligible = isAssetEligible;
1437
+ exports.isVoucherStoreValue = isVoucherStoreValue;
1438
+ exports.normalizeManifestAssets = normalizeManifestAssets;
1439
+ exports.paymentIdempotencyKey = paymentIdempotencyKey;
1440
+ exports.reconcileAbandonedSessions = reconcileAbandonedSessions;
1441
+ exports.registerProvider = registerProvider;
1442
+ exports.resolveAssetContract = resolveAssetContract;
487
1443
  exports.routedock = routedock;
1444
+ exports.runStartupReconciliation = runStartupReconciliation;
1445
+ exports.selectAsset = selectAsset;