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