@routedock/routedock 0.3.1 → 0.4.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 (58) hide show
  1. package/README.md +113 -12
  2. package/dist/{MppSessionHandler-1EL8QBAn.d.cts → MppSessionHandler-3QfCHI5x.d.ts} +40 -2
  3. package/dist/{MppSessionHandler-Bq4S-JTE.d.ts → MppSessionHandler-xu4Kxu6N.d.cts} +40 -2
  4. package/dist/{ProviderRegistry-w4_hkI_1.d.ts → ProviderRegistry-D9KqyuH-.d.cts} +16 -36
  5. package/dist/{ProviderRegistry-CyZ5eryO.d.cts → ProviderRegistry-DVi2uzgk.d.ts} +16 -36
  6. package/dist/{RouteDockClient-CjM2VyOd.d.ts → RouteDockClient-6rmgkbYb.d.cts} +124 -144
  7. package/dist/{RouteDockClient-CXvWXix5.d.cts → RouteDockClient-CnAkxwrW.d.ts} +124 -144
  8. package/dist/{SessionReconciler-Bw-PoBVl.d.ts → SessionReconciler-Cce9MXO1.d.ts} +10 -3
  9. package/dist/{SessionReconciler-Cz2nuOlJ.d.cts → SessionReconciler-Dpe9qXdD.d.cts} +10 -3
  10. package/dist/{SessionStore-BaYiegP0.d.ts → SessionStore-DkLBxFQu.d.cts} +28 -3
  11. package/dist/{SessionStore-B4-Hi4H8.d.cts → SessionStore-_ZkoBzFY.d.ts} +28 -3
  12. package/dist/SpendStore-BUclV02i.d.ts +73 -0
  13. package/dist/SpendStore-s_dfcemj.d.cts +73 -0
  14. package/dist/client.cjs +767 -482
  15. package/dist/client.d.cts +80 -10
  16. package/dist/client.d.ts +80 -10
  17. package/dist/client.js +733 -463
  18. package/dist/index.cjs +1295 -585
  19. package/dist/index.d.cts +13 -10
  20. package/dist/index.d.ts +13 -10
  21. package/dist/index.js +1247 -554
  22. package/dist/logger-DVa7VT4c.d.cts +41 -0
  23. package/dist/logger-DVa7VT4c.d.ts +41 -0
  24. package/dist/network.cjs +57 -0
  25. package/dist/network.d.cts +72 -0
  26. package/dist/network.d.ts +72 -0
  27. package/dist/network.js +49 -0
  28. package/dist/node.cjs +166 -0
  29. package/dist/node.d.cts +77 -0
  30. package/dist/node.d.ts +77 -0
  31. package/dist/node.js +159 -0
  32. package/dist/provider/fastify.cjs +847 -62
  33. package/dist/provider/fastify.d.cts +10 -3
  34. package/dist/provider/fastify.d.ts +10 -3
  35. package/dist/provider/fastify.js +848 -63
  36. package/dist/provider/hono.cjs +936 -60
  37. package/dist/provider/hono.d.cts +23 -8
  38. package/dist/provider/hono.d.ts +23 -8
  39. package/dist/provider/hono.js +937 -61
  40. package/dist/provider.cjs +894 -70
  41. package/dist/provider.d.cts +9 -8
  42. package/dist/provider.d.ts +9 -8
  43. package/dist/provider.js +895 -72
  44. package/dist/react.cjs +736 -431
  45. package/dist/react.d.cts +7 -5
  46. package/dist/react.d.ts +7 -5
  47. package/dist/react.js +725 -420
  48. package/dist/{routedockMiddleware-57gUaEMf.d.ts → routedockMiddleware-Cq9HOJEa.d.ts} +9 -2
  49. package/dist/{routedockMiddleware-DlyLrxp7.d.cts → routedockMiddleware-CxsWkSKa.d.cts} +9 -2
  50. package/dist/schema.cjs +7 -0
  51. package/dist/schema.js +7 -0
  52. package/dist/{sign-AKH67sft.d.cts → sign-Bq08SeFq.d.ts} +2 -2
  53. package/dist/{sign-BEq-OsSq.d.ts → sign-DqGE0Plv.d.cts} +2 -2
  54. package/dist/testing.d.cts +4 -3
  55. package/dist/testing.d.ts +4 -3
  56. package/dist/{logger-D8_Q2ytp.d.cts → types-DXaS5Wip.d.cts} +85 -47
  57. package/dist/{logger-D8_Q2ytp.d.ts → types-DXaS5Wip.d.ts} +85 -47
  58. package/package.json +50 -4
package/dist/index.js CHANGED
@@ -1,17 +1,17 @@
1
- import { usdcToStroops, stroopsToUsdc } from '@routedock/nulth-sdk';
2
- export { USDC_DECIMALS, usdcToStroops } from '@routedock/nulth-sdk';
3
- import { Keypair, rpc, Horizon, xdr, Address, scValToNative, hash, Networks, StrKey, Contract, nativeToScVal, TransactionBuilder, BASE_FEE } from '@stellar/stellar-sdk';
1
+ import { usdcToStroops, stroopsToUsdc, createNulthSigner as createNulthSigner$1, NulthConfigError, createPolicyState, setNulthPaymentContext, paymentContextFromManifest, NulthPolicyError, isNulthStellarSigner, nulthAuthorizeEntry } from '@routedock/nulth-sdk';
2
+ export { NulthPolicyError, USDC_DECIMALS, createPolicyState, decodeAuthSignature, paymentContextFromManifest, setNulthPaymentContext, usdcToStroops } from '@routedock/nulth-sdk';
3
+ import { Keypair, rpc, Horizon, Networks, StrKey, contract, nativeToScVal, Contract, TransactionBuilder, BASE_FEE } from '@stellar/stellar-sdk';
4
+ import { sha256 as sha256$1 } from '@noble/hashes/sha2.js';
4
5
  import { Validator } from '@cfworker/json-schema';
5
- import { createHash } from 'crypto';
6
- import { createEd25519Signer } from '@x402/stellar';
7
- import { ExactStellarScheme } from '@x402/stellar/exact/client';
6
+ import { createEd25519Signer, ExactStellarScheme, getRpcUrl, getNetworkPassphrase, handleSimulationResult, isStellarNetwork, validateStellarDestinationAddress, validateStellarAssetAddress, getRpcClient, getEstimatedLedgerCloseTimeSeconds } from '@x402/stellar';
8
7
  import { x402Client, x402HTTPClient } from '@x402/core/client';
9
8
  import { decodePaymentRequiredHeader, decodePaymentResponseHeader, encodePaymentRequiredHeader, decodePaymentSignatureHeader, encodePaymentResponseHeader } from '@x402/core/http';
10
9
  import { stellar } from '@stellar/mpp/charge/client';
11
10
  import { Mppx } from 'mppx/client';
11
+ import { Receipt, Challenge, Errors } from 'mppx';
12
12
  import { STELLAR_PUBNET, STELLAR_TESTNET } from '@stellar/mpp';
13
13
  import { stellar as stellar$1 } from '@stellar/mpp/channel/client';
14
- import { Challenge, Errors } from 'mppx';
14
+ import { randomBytes } from 'crypto';
15
15
  import fs from 'fs/promises';
16
16
  import path from 'path';
17
17
  import { ExactStellarScheme as ExactStellarScheme$1 } from '@x402/stellar/exact/facilitator';
@@ -117,6 +117,20 @@ var RouteDockPolicyRejectError = class extends RouteDockError {
117
117
  this.reason = reason;
118
118
  }
119
119
  };
120
+ var RouteDockPaymentRejectedError = class extends RouteDockError {
121
+ status;
122
+ /** The provider's `reason` field, if its response body was JSON and had one. */
123
+ reason;
124
+ /** The provider's `error` field (short summary), if present. */
125
+ providerError;
126
+ constructor(message, status, details = {}, options = {}) {
127
+ super(message, "PAYMENT_REJECTED", false, options);
128
+ this.name = "RouteDockPaymentRejectedError";
129
+ this.status = status;
130
+ if (details.reason !== void 0) this.reason = details.reason;
131
+ if (details.providerError !== void 0) this.providerError = details.providerError;
132
+ }
133
+ };
120
134
  var RouteDockChannelStateError = class extends RouteDockError {
121
135
  constructor(message, options = {}) {
122
136
  super(message, "CHANNEL_STATE", false, options);
@@ -161,6 +175,59 @@ function httpStatusToError(message, status, response) {
161
175
  }
162
176
  return new RouteDockNetworkError(message, opts);
163
177
  }
178
+ var MAX_REJECTION_BODY_BYTES = 64 * 1024;
179
+ var MAX_REJECTION_FIELD_CHARS = 500;
180
+ async function readBoundedText(response, limit) {
181
+ const body = response.body;
182
+ if (!body) return null;
183
+ const reader = body.getReader();
184
+ const chunks = [];
185
+ let total = 0;
186
+ try {
187
+ for (; ; ) {
188
+ const { done, value } = await reader.read();
189
+ if (done) break;
190
+ total += value.byteLength;
191
+ if (total > limit) {
192
+ await reader.cancel().catch(() => void 0);
193
+ return null;
194
+ }
195
+ chunks.push(value);
196
+ }
197
+ } catch {
198
+ return null;
199
+ }
200
+ const bytes = new Uint8Array(total);
201
+ let offset = 0;
202
+ for (const chunk of chunks) {
203
+ bytes.set(chunk, offset);
204
+ offset += chunk.byteLength;
205
+ }
206
+ return new TextDecoder().decode(bytes);
207
+ }
208
+ async function paymentRejectedError(label, status, response) {
209
+ let reason;
210
+ let providerError;
211
+ try {
212
+ const text = await readBoundedText(response, MAX_REJECTION_BODY_BYTES);
213
+ const body = text === null ? null : JSON.parse(text);
214
+ if (body !== null && typeof body === "object") {
215
+ const { reason: r, error: e } = body;
216
+ if (typeof r === "string" && r.length > 0) reason = r.slice(0, MAX_REJECTION_FIELD_CHARS);
217
+ if (typeof e === "string" && e.length > 0) providerError = e.slice(0, MAX_REJECTION_FIELD_CHARS);
218
+ }
219
+ } catch {
220
+ }
221
+ const detail = reason ?? providerError;
222
+ return new RouteDockPaymentRejectedError(
223
+ `${label}: HTTP ${status}${detail ? ` \u2014 ${detail}` : ""}`,
224
+ status,
225
+ {
226
+ ...reason !== void 0 ? { reason } : {},
227
+ ...providerError !== void 0 ? { providerError } : {}
228
+ }
229
+ );
230
+ }
164
231
  function wrapFetchError(err, context) {
165
232
  if (err instanceof RouteDockError) return err;
166
233
  return new RouteDockNetworkError(`${context}: ${String(err)}`, { cause: err });
@@ -225,23 +292,34 @@ async function withRetry(fn, policy = {}) {
225
292
  if (!err.retryable || attempt >= maxAttempts - 1) {
226
293
  throw err;
227
294
  }
228
- const delay = err.retryAfterMs !== void 0 ? err.retryAfterMs : backoffDelayMs(attempt, baseDelayMs, maxDelayMs);
295
+ const delay2 = Math.min(
296
+ err.retryAfterMs ?? backoffDelayMs(attempt, baseDelayMs, maxDelayMs),
297
+ maxDelayMs
298
+ );
229
299
  if (onRetry) {
230
300
  try {
231
- await onRetry(attempt, err, delay);
301
+ await onRetry(attempt, err, delay2);
232
302
  } catch (cbErr) {
233
303
  log("error", "[withRetry] onRetry callback threw:", { error: cbErr });
234
304
  }
235
305
  }
236
- await sleep(delay);
306
+ await sleep(delay2);
237
307
  }
238
308
  }
239
309
  throw lastError ?? new RouteDockError("Retry exhausted", "NETWORK", false);
240
310
  }
311
+
312
+ // src/network.ts
241
313
  var USDC_ISSUERS = {
242
314
  testnet: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5",
243
315
  mainnet: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"
244
316
  };
317
+ var HORIZON_URLS = {
318
+ testnet: "https://horizon-testnet.stellar.org",
319
+ mainnet: "https://horizon.stellar.org"
320
+ };
321
+
322
+ // src/internal/usdc.ts
245
323
  var usdcToUnits = usdcToStroops;
246
324
 
247
325
  // src/internal/assetUtils.ts
@@ -322,6 +400,138 @@ function isAssetEligible(manifest, assetIdentifier, mode, endpoint) {
322
400
  );
323
401
  }
324
402
 
403
+ // package.json
404
+ var package_default = {
405
+ version: "0.4.0"};
406
+
407
+ // src/internal/encoding.ts
408
+ var encoder = new TextEncoder();
409
+ var decoder = new TextDecoder();
410
+ function utf8ToBytes(text) {
411
+ return encoder.encode(text);
412
+ }
413
+ function bytesToUtf8(bytes) {
414
+ return decoder.decode(bytes);
415
+ }
416
+ function bytesToBase64(bytes) {
417
+ const chunk = 32768;
418
+ let binary = "";
419
+ for (let i = 0; i < bytes.length; i += chunk) {
420
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
421
+ }
422
+ return btoa(binary);
423
+ }
424
+ function base64ToBytes(b64) {
425
+ const end = b64.indexOf("=");
426
+ let clean = (end === -1 ? b64 : b64.slice(0, end)).replace(/-/g, "+").replace(/_/g, "/").replace(/[^A-Za-z0-9+/]/g, "");
427
+ if (clean.length % 4 === 1) clean = clean.slice(0, -1);
428
+ const binary = atob(clean);
429
+ const bytes = new Uint8Array(binary.length);
430
+ for (let i = 0; i < binary.length; i++) {
431
+ bytes[i] = binary.charCodeAt(i);
432
+ }
433
+ return bytes;
434
+ }
435
+ function base64ToUtf8(b64) {
436
+ return bytesToUtf8(base64ToBytes(b64));
437
+ }
438
+ function hexToBytes(hex) {
439
+ const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
440
+ if (clean.length % 2 !== 0) {
441
+ throw new Error("hexToBytes: hex string must have an even length");
442
+ }
443
+ const bytes = new Uint8Array(clean.length / 2);
444
+ for (let i = 0; i < bytes.length; i++) {
445
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
446
+ }
447
+ return bytes;
448
+ }
449
+ function bytesToHex(bytes) {
450
+ let out = "";
451
+ for (let i = 0; i < bytes.length; i++) {
452
+ out += bytes[i].toString(16).padStart(2, "0");
453
+ }
454
+ return out;
455
+ }
456
+ function sha256(data) {
457
+ return sha256$1(typeof data === "string" ? utf8ToBytes(data) : data);
458
+ }
459
+
460
+ // src/manifest/sign.ts
461
+ var MANIFEST_SIGNATURE_VERSION = "2";
462
+ function canonicalize(value) {
463
+ if (Array.isArray(value)) {
464
+ return value.map(canonicalize);
465
+ }
466
+ if (value !== null && typeof value === "object") {
467
+ return Object.fromEntries(
468
+ Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
469
+ );
470
+ }
471
+ return value;
472
+ }
473
+ function manifestDigest(manifest) {
474
+ const { signature: _omit, ...rest } = manifest;
475
+ const canonical = JSON.stringify(canonicalize(rest));
476
+ return sha256(utf8ToBytes(canonical));
477
+ }
478
+ function signManifest(manifest, payeeSecretKey) {
479
+ const keypair = Keypair.fromSecret(payeeSecretKey);
480
+ const versionedManifest = {
481
+ ...manifest,
482
+ signature_version: MANIFEST_SIGNATURE_VERSION
483
+ };
484
+ const digest = manifestDigest(versionedManifest);
485
+ const sig = keypair.sign(digest);
486
+ return { ...versionedManifest, signature: bytesToBase64(sig) };
487
+ }
488
+ function verifyManifestSignature(manifest, expectedPayee) {
489
+ if (!manifest.signature) {
490
+ throw new RouteDockSignatureError(
491
+ "Manifest is missing a signature field \u2014 cannot verify payee authenticity"
492
+ );
493
+ }
494
+ if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
495
+ throw new RouteDockSignatureError(
496
+ `Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
497
+ );
498
+ }
499
+ if (expectedPayee !== void 0 && manifest.payee !== expectedPayee) {
500
+ throw new RouteDockSignatureError(
501
+ `Manifest payee ${manifest.payee} does not match expected payee ${expectedPayee}`
502
+ );
503
+ }
504
+ let keypair;
505
+ try {
506
+ keypair = Keypair.fromPublicKey(manifest.payee);
507
+ } catch (err) {
508
+ throw new RouteDockSignatureError(
509
+ `Manifest payee ${manifest.payee} is not a valid Stellar public key`,
510
+ { cause: err }
511
+ );
512
+ }
513
+ const digest = manifestDigest(manifest);
514
+ let sigBytes;
515
+ try {
516
+ sigBytes = base64ToBytes(manifest.signature);
517
+ } catch {
518
+ throw new RouteDockSignatureError("Manifest signature is not valid base64");
519
+ }
520
+ let valid = false;
521
+ try {
522
+ valid = keypair.verify(digest, sigBytes);
523
+ } catch {
524
+ throw new RouteDockSignatureError(
525
+ `Manifest signature verification failed for payee ${manifest.payee}`
526
+ );
527
+ }
528
+ if (!valid) {
529
+ throw new RouteDockSignatureError(
530
+ `Manifest signature verification failed for payee ${manifest.payee}`
531
+ );
532
+ }
533
+ }
534
+
325
535
  // src/schemas/routedock.schema.json
326
536
  var routedock_schema_default = {
327
537
  $schema: "http://json-schema.org/draft-07/schema#",
@@ -399,6 +609,7 @@ var routedock_schema_default = {
399
609
  },
400
610
  asset_contract: {
401
611
  type: "string",
612
+ pattern: "^C[A-Z2-7]{55}$",
402
613
  description: "Stellar Asset Contract (SAC) address for the payment asset"
403
614
  },
404
615
  assets: {
@@ -411,6 +622,7 @@ var routedock_schema_default = {
411
622
  },
412
623
  payee: {
413
624
  type: "string",
625
+ pattern: "^G[A-Z2-7]{55}$",
414
626
  description: "Stellar address (G...) that receives payments"
415
627
  },
416
628
  pricing: {
@@ -581,6 +793,7 @@ var routedock_schema_default = {
581
793
  properties: {
582
794
  amount: {
583
795
  type: "string",
796
+ pattern: "^[0-9]+(\\.[0-9]{1,7})?$",
584
797
  description: "Cost per request in the payment asset (decimal string, e.g. '0.001')"
585
798
  },
586
799
  per: {
@@ -594,6 +807,7 @@ var routedock_schema_default = {
594
807
  },
595
808
  payee: {
596
809
  type: "string",
810
+ pattern: "^G[A-Z2-7]{55}$",
597
811
  description: "Optional per-mode payee override (Stellar G... address). Directs settlements for this mode to a separate account for treasury separation. Falls back to the top-level 'payee' when omitted."
598
812
  }
599
813
  }
@@ -606,6 +820,7 @@ var routedock_schema_default = {
606
820
  properties: {
607
821
  rate: {
608
822
  type: "string",
823
+ pattern: "^[0-9]+(\\.[0-9]{1,7})?$",
609
824
  description: "Cost per voucher in the payment asset (decimal string, e.g. '0.0001')"
610
825
  },
611
826
  per: {
@@ -615,10 +830,12 @@ var routedock_schema_default = {
615
830
  },
616
831
  channel_factory: {
617
832
  type: "string",
833
+ pattern: "^C[A-Z2-7]{55}$",
618
834
  description: "Stable Soroban factory address (C...) used to deploy a per-session channel"
619
835
  },
620
836
  min_deposit: {
621
837
  type: "string",
838
+ pattern: "^[0-9]+(\\.[0-9]{1,7})?$",
622
839
  description: "Minimum initial deposit to open a channel (decimal string)"
623
840
  },
624
841
  refund_waiting_period_ledgers: {
@@ -716,78 +933,110 @@ var routedock_schema_default = {
716
933
  }
717
934
  };
718
935
 
719
- // package.json
720
- var package_default = {
721
- version: "0.3.1"};
722
- var MANIFEST_SIGNATURE_VERSION = "2";
723
- function canonicalize(value) {
724
- if (Array.isArray(value)) {
725
- return value.map(canonicalize);
726
- }
727
- if (value !== null && typeof value === "object") {
728
- return Object.fromEntries(
729
- Object.keys(value).sort().map((key) => [key, canonicalize(value[key])])
730
- );
936
+ // src/manifest/validate.ts
937
+ var validator = new Validator(routedock_schema_default, "7");
938
+ function validateManifestSchema(manifest, source) {
939
+ const result = validator.validate(manifest);
940
+ if (!result.valid) {
941
+ const msgs = result.errors.map((err) => err.error).join("; ");
942
+ throw new RouteDockManifestError(`Invalid manifest${source ? ` at ${source}` : ""}: ${msgs}`);
731
943
  }
732
- return value;
733
- }
734
- function manifestDigest(manifest) {
735
- const { signature: _omit, ...rest } = manifest;
736
- const canonical = JSON.stringify(canonicalize(rest));
737
- return createHash("sha256").update(canonical, "utf8").digest();
738
- }
739
- function signManifest(manifest, payeeSecretKey) {
740
- const keypair = Keypair.fromSecret(payeeSecretKey);
741
- const versionedManifest = {
742
- ...manifest,
743
- signature_version: MANIFEST_SIGNATURE_VERSION
744
- };
745
- const digest = manifestDigest(versionedManifest);
746
- const sig = keypair.sign(digest);
747
- return { ...versionedManifest, signature: Buffer.from(sig).toString("base64") };
748
944
  }
749
- function verifyManifestSignature(manifest, expectedPayee) {
750
- if (!manifest.signature) {
751
- throw new RouteDockSignatureError(
752
- "Manifest is missing a signature field \u2014 cannot verify payee authenticity"
753
- );
754
- }
755
- if (manifest.signature_version !== MANIFEST_SIGNATURE_VERSION) {
756
- throw new RouteDockSignatureError(
757
- `Manifest signature version must be ${MANIFEST_SIGNATURE_VERSION}; legacy signatures are not safe because nested fields were excluded from their digest`
758
- );
945
+ function assertManifestValid(manifest, baseUrl) {
946
+ const context = baseUrl ? ` at ${baseUrl}` : "";
947
+ if (manifest.latency_hints !== void 0) {
948
+ if (!Array.isArray(manifest.regions) || manifest.regions.length === 0) {
949
+ throw new RouteDockManifestError(
950
+ `Invalid manifest${context}: latency_hints defined without declaring regions`
951
+ );
952
+ }
953
+ const regionSet = new Set(manifest.regions);
954
+ for (const region of Object.keys(manifest.latency_hints)) {
955
+ if (!regionSet.has(region)) {
956
+ throw new RouteDockManifestError(
957
+ `Invalid manifest${context}: latency_hints key '${region}' is not declared in regions (${manifest.regions.join(", ")})`
958
+ );
959
+ }
960
+ }
759
961
  }
760
- if (expectedPayee !== void 0 && manifest.payee !== expectedPayee) {
761
- throw new RouteDockSignatureError(
762
- `Manifest payee ${manifest.payee} does not match expected payee ${expectedPayee}`
763
- );
962
+ if (manifest.assets !== void 0) {
963
+ if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
964
+ throw new RouteDockManifestError(
965
+ `Invalid manifest${context}: assets must be a non-empty array when defined`
966
+ );
967
+ }
968
+ for (let i = 0; i < manifest.assets.length; i++) {
969
+ const a = manifest.assets[i];
970
+ if (!a || typeof a.asset !== "string" || typeof a.asset_contract !== "string") {
971
+ throw new RouteDockManifestError(
972
+ `Invalid manifest${context}: assets[${i}] must define asset and asset_contract strings`
973
+ );
974
+ }
975
+ }
976
+ const first = manifest.assets[0];
977
+ if (first.asset !== manifest.asset || first.asset_contract !== manifest.asset_contract) {
978
+ throw new RouteDockManifestError(
979
+ `Invalid manifest${context}: assets[0] (${first.asset}:${first.asset_contract}) must match root asset fields (${manifest.asset}:${manifest.asset_contract})`
980
+ );
981
+ }
764
982
  }
765
- const keypair = Keypair.fromPublicKey(manifest.payee);
766
- const digest = manifestDigest(manifest);
767
- let sigBytes;
768
- try {
769
- sigBytes = Buffer.from(manifest.signature, "base64");
770
- } catch {
771
- throw new RouteDockSignatureError("Manifest signature is not valid base64");
983
+ assertPricingMatchesModes(manifest, context);
984
+ assertDeprecatedModesListed(manifest, context);
985
+ assertPricesParse(manifest, context);
986
+ }
987
+ function assertPricingMatchesModes(manifest, context) {
988
+ const pricing = manifest.pricing ?? {};
989
+ for (const mode of manifest.modes ?? []) {
990
+ if (pricing[mode] === void 0 || pricing[mode] === null) {
991
+ throw new RouteDockManifestError(
992
+ `Invalid manifest${context}: mode '${mode}' is listed in modes but pricing.${mode} is missing`
993
+ );
994
+ }
772
995
  }
773
- let valid = false;
774
- try {
775
- valid = keypair.verify(digest, sigBytes);
776
- } catch {
777
- throw new RouteDockSignatureError(
778
- `Manifest signature verification failed for payee ${manifest.payee}`
779
- );
996
+ }
997
+ function assertDeprecatedModesListed(manifest, context) {
998
+ if (manifest.deprecated_modes === void 0) return;
999
+ const modes = new Set(manifest.modes ?? []);
1000
+ for (const mode of manifest.deprecated_modes) {
1001
+ if (!modes.has(mode)) {
1002
+ throw new RouteDockManifestError(
1003
+ `Invalid manifest${context}: deprecated_modes entry '${mode}' is not listed in modes`
1004
+ );
1005
+ }
780
1006
  }
781
- if (!valid) {
782
- throw new RouteDockSignatureError(
783
- `Manifest signature verification failed for payee ${manifest.payee}`
784
- );
1007
+ }
1008
+ var PRICE_FIELDS = {
1009
+ x402: ["amount"],
1010
+ "mpp-charge": ["amount"],
1011
+ "mpp-session": ["rate", "min_deposit"],
1012
+ "mpp-session-ws": ["rate", "min_deposit"]
1013
+ };
1014
+ function assertPricesParse(manifest, context) {
1015
+ const pricing = manifest.pricing ?? {};
1016
+ for (const [mode, fields] of Object.entries(PRICE_FIELDS)) {
1017
+ const entry = pricing[mode];
1018
+ if (entry === void 0 || entry === null) continue;
1019
+ for (const field of fields) {
1020
+ const value = entry[field];
1021
+ try {
1022
+ if (typeof value !== "string") {
1023
+ throw new RangeError(`expected a decimal string, got ${typeof value}`);
1024
+ }
1025
+ usdcToStroops(value);
1026
+ } catch (err) {
1027
+ const detail = err instanceof Error ? err.message : String(err);
1028
+ throw new RouteDockManifestError(
1029
+ `Invalid manifest${context}: pricing.${mode}.${field} ${JSON.stringify(value)} is not a valid amount (${detail})`,
1030
+ { cause: err }
1031
+ );
1032
+ }
1033
+ }
785
1034
  }
786
1035
  }
787
1036
 
788
1037
  // src/client/ModeRouter.ts
789
- var validator = new Validator(routedock_schema_default, "7");
790
1038
  var SDK_VERSION = package_default.version;
1039
+ var SUPPORTED_MANIFEST_MAJOR = 1;
791
1040
  function parseMajorMinor(version) {
792
1041
  const parts = version.split(".");
793
1042
  return [Number.parseInt(parts[0] ?? "0", 10), Number.parseInt(parts[1] ?? "0", 10)];
@@ -805,6 +1054,14 @@ function assertClientVersionSupported(manifest, baseUrl) {
805
1054
  );
806
1055
  }
807
1056
  }
1057
+ function assertManifestVersionSupported(manifest, baseUrl) {
1058
+ const [major] = parseMajorMinor(manifest.routedock);
1059
+ if (major > SUPPORTED_MANIFEST_MAJOR) {
1060
+ throw new RouteDockClientVersionError(
1061
+ `Manifest version ${manifest.routedock} from provider at ${baseUrl} has schema major version ${major}, which is newer than the highest version this SDK supports (${SUPPORTED_MANIFEST_MAJOR}.x). Please upgrade the SDK.`
1062
+ );
1063
+ }
1064
+ }
808
1065
  function assertManifestActive(manifest, baseUrl) {
809
1066
  const sunsetAt = manifest.sunset_at;
810
1067
  if (!sunsetAt) return;
@@ -820,11 +1077,16 @@ function assertManifestActive(manifest, baseUrl) {
820
1077
  );
821
1078
  }
822
1079
  }
823
- function assertEndpointActive(manifest, url, logger) {
1080
+ function assertEndpointActive(manifest, url, logger, method) {
824
1081
  const pathname = new URL(url).pathname;
825
- const matches = Object.entries(manifest.endpoints).filter(
1082
+ const pathMatches = Object.entries(manifest.endpoints).filter(
826
1083
  ([, descriptor]) => descriptor.path === pathname
827
1084
  );
1085
+ if (pathMatches.length === 0) return;
1086
+ const requestedMethod = method?.toUpperCase();
1087
+ const matches = requestedMethod ? pathMatches.filter(
1088
+ ([, descriptor]) => descriptor.method.toUpperCase() === requestedMethod
1089
+ ) : pathMatches;
828
1090
  if (matches.length === 0) return;
829
1091
  const now = Date.now();
830
1092
  const allSunset = matches.every(([, descriptor]) => {
@@ -847,45 +1109,6 @@ function assertEndpointActive(manifest, url, logger) {
847
1109
  );
848
1110
  }
849
1111
  }
850
- function assertManifestValid(manifest, baseUrl) {
851
- const context = baseUrl ? ` at ${baseUrl}` : "";
852
- if (manifest.latency_hints !== void 0) {
853
- if (!Array.isArray(manifest.regions) || manifest.regions.length === 0) {
854
- throw new RouteDockManifestError(
855
- `Invalid manifest${context}: latency_hints defined without declaring regions`
856
- );
857
- }
858
- const regionSet = new Set(manifest.regions);
859
- for (const region of Object.keys(manifest.latency_hints)) {
860
- if (!regionSet.has(region)) {
861
- throw new RouteDockManifestError(
862
- `Invalid manifest${context}: latency_hints key '${region}' is not declared in regions (${manifest.regions.join(", ")})`
863
- );
864
- }
865
- }
866
- }
867
- if (manifest.assets !== void 0) {
868
- if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
869
- throw new RouteDockManifestError(
870
- `Invalid manifest${context}: assets must be a non-empty array when defined`
871
- );
872
- }
873
- for (let i = 0; i < manifest.assets.length; i++) {
874
- const a = manifest.assets[i];
875
- if (!a || typeof a.asset !== "string" || typeof a.asset_contract !== "string") {
876
- throw new RouteDockManifestError(
877
- `Invalid manifest${context}: assets[${i}] must define asset and asset_contract strings`
878
- );
879
- }
880
- }
881
- const first = manifest.assets[0];
882
- if (first.asset !== manifest.asset || first.asset_contract !== manifest.asset_contract) {
883
- throw new RouteDockManifestError(
884
- `Invalid manifest${context}: assets[0] (${first.asset}:${first.asset_contract}) must match root asset fields (${manifest.asset}:${manifest.asset_contract})`
885
- );
886
- }
887
- }
888
- }
889
1112
  var CACHE_TTL_MS = 6e4;
890
1113
  var DEFAULT_MANIFEST_CACHE_MAX_SIZE = 512;
891
1114
  var DEFAULT_MANIFEST_TIMEOUT_MS = 5e3;
@@ -926,6 +1149,7 @@ var LruCache = class {
926
1149
  }
927
1150
  };
928
1151
  var manifestCache = new LruCache(DEFAULT_MANIFEST_CACHE_MAX_SIZE);
1152
+ var inflightManifestFetches = /* @__PURE__ */ new Map();
929
1153
  function configureManifestCache(maxSize) {
930
1154
  if (!Number.isInteger(maxSize) || maxSize <= 0) {
931
1155
  throw new RouteDockManifestError("Invalid manifest cache size: " + maxSize);
@@ -961,10 +1185,27 @@ async function fetchManifest(baseUrl, retryPolicy, manifestTimeoutMs = DEFAULT_M
961
1185
  if (cached && Date.now() < cached.expiresAt) {
962
1186
  verifyManifestSignature(cached.manifest, expectedPayee);
963
1187
  assertClientVersionSupported(cached.manifest, baseUrl);
1188
+ assertManifestVersionSupported(cached.manifest, baseUrl);
964
1189
  assertManifestValid(cached.manifest, baseUrl);
965
1190
  assertManifestActive(cached.manifest, baseUrl);
966
1191
  return cached.manifest;
967
1192
  }
1193
+ let inflight = inflightManifestFetches.get(baseUrl);
1194
+ if (!inflight) {
1195
+ inflight = fetchAndCacheManifest(baseUrl, retryPolicy, manifestTimeoutMs).finally(() => {
1196
+ inflightManifestFetches.delete(baseUrl);
1197
+ });
1198
+ inflightManifestFetches.set(baseUrl, inflight);
1199
+ }
1200
+ const manifest = await inflight;
1201
+ verifyManifestSignature(manifest, expectedPayee);
1202
+ assertClientVersionSupported(manifest, baseUrl);
1203
+ assertManifestVersionSupported(manifest, baseUrl);
1204
+ assertManifestValid(manifest, baseUrl);
1205
+ assertManifestActive(manifest, baseUrl);
1206
+ return manifest;
1207
+ }
1208
+ async function fetchAndCacheManifest(baseUrl, retryPolicy, manifestTimeoutMs = DEFAULT_MANIFEST_TIMEOUT_MS) {
968
1209
  const url = baseUrl.replace(/\/$/, "") + "/.well-known/routedock.json";
969
1210
  return withRetry(async () => {
970
1211
  let raw;
@@ -1001,15 +1242,12 @@ async function fetchManifest(baseUrl, retryPolicy, manifestTimeoutMs = DEFAULT_M
1001
1242
  } catch (err) {
1002
1243
  throw new RouteDockManifestError(`Manifest at ${url} is not valid JSON`, { cause: err });
1003
1244
  }
1004
- const result = validator.validate(raw);
1005
- if (!result.valid) {
1006
- const msgs = result.errors.map((err) => err.error).join("; ");
1007
- throw new RouteDockManifestError(`Invalid manifest at ${url}: ${msgs}`);
1008
- }
1245
+ validateManifestSchema(raw, url);
1009
1246
  const manifest = raw;
1010
1247
  assertManifestValid(manifest, baseUrl);
1011
- verifyManifestSignature(manifest, expectedPayee);
1248
+ verifyManifestSignature(manifest);
1012
1249
  assertClientVersionSupported(manifest, baseUrl);
1250
+ assertManifestVersionSupported(manifest, baseUrl);
1013
1251
  assertManifestActive(manifest, baseUrl);
1014
1252
  manifestCache.set(baseUrl, { manifest, fetchedAt: Date.now(), expiresAt });
1015
1253
  return manifest;
@@ -1124,6 +1362,48 @@ function rankProvidersByLatency(manifests, targetRegion) {
1124
1362
  });
1125
1363
  }
1126
1364
 
1365
+ // src/internal/settleResult.ts
1366
+ function readSettleResult(result) {
1367
+ const settle = result;
1368
+ const failed = (reason) => reason ? { ok: false, reason } : { ok: false };
1369
+ if (!settle || settle.success !== true) return failed(settle?.errorReason);
1370
+ if (typeof settle.transaction !== "string" || settle.transaction.length === 0) {
1371
+ return failed(settle.errorReason);
1372
+ }
1373
+ return { ok: true, txHash: settle.transaction };
1374
+ }
1375
+
1376
+ // src/internal/request.ts
1377
+ var DEFAULT_METHOD = "GET";
1378
+ var BODYLESS_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD"]);
1379
+ function isReplayable(body) {
1380
+ return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body) || typeof Blob !== "undefined" && body instanceof Blob || typeof FormData !== "undefined" && body instanceof FormData || typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams;
1381
+ }
1382
+ async function bufferBody(body) {
1383
+ if (isReplayable(body)) return body;
1384
+ return new Response(body).arrayBuffer();
1385
+ }
1386
+ async function prepareRequest(request) {
1387
+ const method = (request?.method ?? DEFAULT_METHOD).toUpperCase();
1388
+ const headers = new Headers(request?.headers);
1389
+ const body = request?.body;
1390
+ if (body === void 0 || BODYLESS_METHODS.has(method)) {
1391
+ return { method, headers };
1392
+ }
1393
+ return { method, headers, body: await bufferBody(body) };
1394
+ }
1395
+ function toRequestInit(prepared, overrides) {
1396
+ const headers = new Headers(prepared.headers);
1397
+ if (overrides) {
1398
+ new Headers(overrides).forEach((value, name) => headers.set(name, value));
1399
+ }
1400
+ const init = { method: prepared.method, headers };
1401
+ if (prepared.body !== void 0) {
1402
+ init.body = prepared.body;
1403
+ }
1404
+ return init;
1405
+ }
1406
+
1127
1407
  // src/internal/payee.ts
1128
1408
  function resolvePayee(manifest, mode) {
1129
1409
  const override = manifest.pricing[mode]?.payee;
@@ -1184,6 +1464,102 @@ function checkChargeChallenge(request, manifest, network, maxAmount) {
1184
1464
  }
1185
1465
  return checkAmount(request["amount"], maxAmount);
1186
1466
  }
1467
+ var defaultIo = {
1468
+ async expirationLedger(network, maxTimeoutSeconds) {
1469
+ const { sequence } = await getRpcClient(network).getLatestLedger();
1470
+ const secondsPerLedger = await getEstimatedLedgerCloseTimeSeconds(network);
1471
+ return sequence + Math.ceil(maxTimeoutSeconds / secondsPerLedger);
1472
+ },
1473
+ async assemble({ asset, from, payTo, amount, networkPassphrase, rpcUrl }) {
1474
+ return contract.AssembledTransaction.build({
1475
+ contractId: asset,
1476
+ method: "transfer",
1477
+ // SEP-41: https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0041.md#interface
1478
+ args: [
1479
+ nativeToScVal(from, { type: "address" }),
1480
+ nativeToScVal(payTo, { type: "address" }),
1481
+ nativeToScVal(amount, { type: "i128" })
1482
+ ],
1483
+ networkPassphrase,
1484
+ rpcUrl,
1485
+ parseResultXdr: (result) => result
1486
+ });
1487
+ }
1488
+ };
1489
+ var NulthStellarScheme = class {
1490
+ constructor(signer, io = defaultIo) {
1491
+ this.signer = signer;
1492
+ this.io = io;
1493
+ }
1494
+ signer;
1495
+ io;
1496
+ scheme = "exact";
1497
+ async createPaymentPayload(x402Version, paymentRequirements) {
1498
+ try {
1499
+ this.validateRequirements(paymentRequirements);
1500
+ } catch (err) {
1501
+ throw new Error(
1502
+ `Invalid input parameters for creating Nulth Stellar payment, cause: ${String(err)}`
1503
+ );
1504
+ }
1505
+ const from = this.signer.address;
1506
+ const { network, payTo, asset, amount, maxTimeoutSeconds } = paymentRequirements;
1507
+ if (!paymentRequirements.extra.areFeesSponsored) {
1508
+ throw new Error("Nulth exact scheme requires areFeesSponsored to be true");
1509
+ }
1510
+ const maxLedger = await this.io.expirationLedger(network, maxTimeoutSeconds);
1511
+ const tx = await this.io.assemble({
1512
+ asset,
1513
+ from,
1514
+ payTo,
1515
+ amount,
1516
+ networkPassphrase: getNetworkPassphrase(network),
1517
+ rpcUrl: getRpcUrl(network)
1518
+ });
1519
+ handleSimulationResult(tx.simulation);
1520
+ const signers = tx.needsNonInvokerSigningBy();
1521
+ if (!signers.includes(from) || signers.length > 1) {
1522
+ throw new Error(`Expected to sign with [${from}], but got [${signers.join(", ")}]`);
1523
+ }
1524
+ await tx.signAuthEntries({
1525
+ address: from,
1526
+ signAuthEntry: this.signer.signAuthEntry,
1527
+ expiration: maxLedger,
1528
+ authorizeEntry: nulthAuthorizeEntry
1529
+ });
1530
+ await tx.simulate();
1531
+ handleSimulationResult(tx.simulation);
1532
+ const remaining = tx.needsNonInvokerSigningBy();
1533
+ if (remaining.length > 0) {
1534
+ throw new Error(`unexpected signer(s) required: [${remaining.join(", ")}]`);
1535
+ }
1536
+ if (!tx.built) {
1537
+ throw new Error("Nulth payment transaction has no assembled envelope to send");
1538
+ }
1539
+ return { x402Version, payload: { transaction: tx.built.toXDR() } };
1540
+ }
1541
+ validateRequirements(requirements) {
1542
+ const { scheme, network, payTo, asset, amount } = requirements;
1543
+ if (typeof amount !== "string" || !Number.isInteger(Number(amount)) || Number(amount) <= 0) {
1544
+ throw new Error(`Invalid amount: ${amount}. Amount must be a positive integer.`);
1545
+ }
1546
+ if (scheme !== this.scheme) {
1547
+ throw new Error(`Unsupported scheme: ${scheme}`);
1548
+ }
1549
+ if (!isStellarNetwork(network)) {
1550
+ throw new Error(`Unsupported Stellar network: ${network}`);
1551
+ }
1552
+ if (!validateStellarDestinationAddress(payTo)) {
1553
+ throw new Error(`Invalid Stellar destination address: ${payTo}`);
1554
+ }
1555
+ if (!validateStellarAssetAddress(asset)) {
1556
+ throw new Error(`Invalid Stellar asset address: ${asset}`);
1557
+ }
1558
+ }
1559
+ };
1560
+ function selectStellarScheme(signer) {
1561
+ return isNulthStellarSigner(signer) ? new NulthStellarScheme(signer) : new ExactStellarScheme(signer);
1562
+ }
1187
1563
 
1188
1564
  // src/client/x402Client.ts
1189
1565
  var CAIP2 = {
@@ -1191,25 +1567,29 @@ var CAIP2 = {
1191
1567
  mainnet: "stellar:pubnet"
1192
1568
  };
1193
1569
  var X402Client = class _X402Client {
1194
- constructor(secretKeyOrSigner, network, retryPolicy) {
1570
+ constructor(secretKeyOrSigner, network, retryPolicy, preSignHook, logger) {
1195
1571
  this.network = network;
1196
1572
  this.retryPolicy = retryPolicy;
1573
+ this.preSignHook = preSignHook;
1197
1574
  const caip2 = CAIP2[network];
1198
1575
  this.signer = typeof secretKeyOrSigner === "string" ? createEd25519Signer(secretKeyOrSigner, caip2) : secretKeyOrSigner;
1199
- const scheme = new ExactStellarScheme(this.signer);
1576
+ const scheme = selectStellarScheme(this.signer);
1200
1577
  const core = new x402Client();
1201
1578
  core.register(caip2, scheme);
1202
1579
  this.httpClient = new x402HTTPClient(core);
1580
+ this.logger = logger;
1203
1581
  }
1204
1582
  network;
1205
1583
  retryPolicy;
1584
+ preSignHook;
1206
1585
  httpClient;
1207
1586
  signer;
1587
+ logger;
1208
1588
  /** Replace signer (e.g. swap to Nulth ZK account payer before pay) */
1209
1589
  withSigner(signer) {
1210
- return new _X402Client(signer, this.network, this.retryPolicy);
1590
+ return new _X402Client(signer, this.network, this.retryPolicy, this.preSignHook, this.logger);
1211
1591
  }
1212
- async pay(url, manifest) {
1592
+ async pay(url, manifest, request) {
1213
1593
  const pricing = manifest.pricing.x402;
1214
1594
  if (!pricing) {
1215
1595
  throw new RouteDockManifestError("manifest.pricing.x402 missing");
@@ -1218,13 +1598,15 @@ var X402Client = class _X402Client {
1218
1598
  throw new RouteDockManifestError("manifest.pricing.x402.facilitator missing");
1219
1599
  }
1220
1600
  const caip2 = CAIP2[this.network];
1601
+ const prepared = await prepareRequest(request);
1221
1602
  const probe = await withRetry(async () => {
1222
1603
  let init2;
1223
1604
  try {
1224
- init2 = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
1605
+ init2 = await fetch(url, toRequestInit(prepared, { "X-Preferred-Mode": "x402" }));
1225
1606
  } catch (err) {
1226
1607
  throw wrapFetchError(err, "x402 initial request");
1227
1608
  }
1609
+ this.logger?.("debug", "[x402] unpaid probe response", { status: init2.status });
1228
1610
  if (init2.status !== 402) {
1229
1611
  if (!init2.ok) {
1230
1612
  throw httpStatusToError(
@@ -1241,6 +1623,11 @@ var X402Client = class _X402Client {
1241
1623
  `Failed to parse JSON from non-402 response (HTTP ${init2.status})`
1242
1624
  );
1243
1625
  }
1626
+ this.logger?.(
1627
+ "debug",
1628
+ "[x402] unpaid probe returned non-402 success \u2014 serving as free response",
1629
+ { status: init2.status }
1630
+ );
1244
1631
  return { kind: "free", data: data2 };
1245
1632
  }
1246
1633
  return { kind: "payment-required", init: init2 };
@@ -1269,6 +1656,18 @@ var X402Client = class _X402Client {
1269
1656
  if (allowed.length === 0) {
1270
1657
  throw checkX402Accept(firstAccept, manifest, caip2, pricing.amount);
1271
1658
  }
1659
+ if (this.preSignHook) {
1660
+ let decision;
1661
+ try {
1662
+ decision = await this.preSignHook({ url, manifest, accepts: allowed });
1663
+ } catch (err) {
1664
+ throw new RouteDockPolicyRejectError("pre_sign_hook_error", { cause: err });
1665
+ }
1666
+ if (!decision || decision.approve !== true) {
1667
+ const reason = decision && decision.approve === false ? decision.reason : void 0;
1668
+ throw new RouteDockPolicyRejectError(reason ?? "pre_sign_rejected");
1669
+ }
1670
+ }
1272
1671
  let paymentPayload;
1273
1672
  try {
1274
1673
  paymentPayload = await this.httpClient.createPaymentPayload({
@@ -1282,13 +1681,15 @@ var X402Client = class _X402Client {
1282
1681
  }
1283
1682
  const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
1284
1683
  const signedAmount = stroopsToUsdc(BigInt(paymentPayload.accepted.amount));
1684
+ this.logger?.("debug", "[x402] sending paid request with payment header");
1285
1685
  const settled = await withRetry(async () => {
1286
1686
  let response;
1287
1687
  try {
1288
- response = await fetch(url, { headers: paymentHeaders });
1688
+ response = await fetch(url, toRequestInit(prepared, paymentHeaders));
1289
1689
  } catch (err) {
1290
1690
  throw wrapFetchError(err, "x402 settlement request");
1291
1691
  }
1692
+ this.logger?.("debug", "[x402] paid request response", { status: response.status });
1292
1693
  if (!response.ok) {
1293
1694
  if (response.status >= 500 || response.status === 429 || response.status === 503) {
1294
1695
  throw httpStatusToError(
@@ -1297,17 +1698,28 @@ var X402Client = class _X402Client {
1297
1698
  response
1298
1699
  );
1299
1700
  }
1300
- throw new RouteDockManifestError(`x402 payment failed: HTTP ${response.status}`);
1701
+ throw await paymentRejectedError("x402 payment failed", response.status, response);
1301
1702
  }
1302
1703
  return response;
1303
1704
  }, this.retryPolicy);
1304
1705
  let txHash = null;
1305
1706
  const responseHeader = settled.headers.get("X-Payment-Response");
1306
- if (responseHeader) {
1707
+ if (!responseHeader) {
1708
+ this.logger?.("debug", "[x402] settled 2xx carried no X-Payment-Response header");
1709
+ } else {
1307
1710
  try {
1308
- const settleResponse = decodePaymentResponseHeader(responseHeader);
1309
- txHash = settleResponse.transaction ?? null;
1310
- } catch {
1711
+ const outcome = readSettleResult(decodePaymentResponseHeader(responseHeader));
1712
+ if (outcome.ok) {
1713
+ txHash = outcome.txHash;
1714
+ } else {
1715
+ this.logger?.("debug", "[x402] X-Payment-Response reports an unsettled payment", {
1716
+ ...outcome.reason !== void 0 ? { reason: outcome.reason } : {}
1717
+ });
1718
+ }
1719
+ } catch (err) {
1720
+ this.logger?.("debug", "[x402] X-Payment-Response header present but undecodable", {
1721
+ error: err
1722
+ });
1311
1723
  }
1312
1724
  }
1313
1725
  let data;
@@ -1328,13 +1740,13 @@ var MppChargeClient = class {
1328
1740
  keypair;
1329
1741
  network;
1330
1742
  retryPolicy;
1331
- async pay(url, manifest) {
1743
+ async pay(url, manifest, request) {
1332
1744
  const pricing = manifest.pricing["mpp-charge"];
1333
1745
  if (!pricing) {
1334
1746
  throw new RouteDockManifestError("manifest.pricing.mpp-charge missing");
1335
1747
  }
1336
1748
  const caip2 = this.network === "mainnet" ? "stellar:pubnet" : "stellar:testnet";
1337
- let txHash = null;
1749
+ const prepared = await prepareRequest(request);
1338
1750
  let signedAmount;
1339
1751
  let credential;
1340
1752
  const mppx = Mppx.create({
@@ -1342,19 +1754,14 @@ var MppChargeClient = class {
1342
1754
  methods: [
1343
1755
  stellar.charge({
1344
1756
  keypair: this.keypair,
1345
- mode: "pull",
1346
- onProgress(event) {
1347
- if (event.type === "paid") {
1348
- txHash = event.hash;
1349
- }
1350
- }
1757
+ mode: "pull"
1351
1758
  })
1352
1759
  ],
1353
1760
  onChallenge: async (challenge, { createCredential }) => {
1354
- const request = challenge.request;
1355
- const rejection = checkChargeChallenge(request, manifest, caip2, pricing.amount);
1761
+ const request2 = challenge.request;
1762
+ const rejection = checkChargeChallenge(request2, manifest, caip2, pricing.amount);
1356
1763
  if (rejection) throw rejection;
1357
- const amount = request["amount"];
1764
+ const amount = request2["amount"];
1358
1765
  if (typeof amount !== "string") {
1359
1766
  throw new RouteDockPolicyRejectError("challenge_amount_invalid");
1360
1767
  }
@@ -1366,7 +1773,7 @@ var MppChargeClient = class {
1366
1773
  return withRetry(async () => {
1367
1774
  let response;
1368
1775
  try {
1369
- response = credential ? await mppx.rawFetch(url, { headers: { Authorization: credential } }) : await mppx.fetch(url);
1776
+ response = credential ? await mppx.rawFetch(url, toRequestInit(prepared, { Authorization: credential })) : await mppx.fetch(url, toRequestInit(prepared));
1370
1777
  } catch (err) {
1371
1778
  throw wrapMppError(err, "MPP charge request");
1372
1779
  }
@@ -1378,8 +1785,9 @@ var MppChargeClient = class {
1378
1785
  response
1379
1786
  );
1380
1787
  }
1381
- throw new RouteDockManifestError(`MPP charge failed: HTTP ${response.status}`);
1788
+ throw await paymentRejectedError("MPP charge failed", response.status, response);
1382
1789
  }
1790
+ const txHash = readSettlementHash(response);
1383
1791
  let data;
1384
1792
  try {
1385
1793
  data = await response.json();
@@ -1399,8 +1807,40 @@ var MppChargeClient = class {
1399
1807
  }, this.retryPolicy);
1400
1808
  }
1401
1809
  };
1810
+ function readSettlementHash(response) {
1811
+ try {
1812
+ return Receipt.fromResponse(response).reference;
1813
+ } catch {
1814
+ return null;
1815
+ }
1816
+ }
1402
1817
  var MIN_REFUND_WAITING_PERIOD = 17280;
1403
1818
  var STREAM_CLEANUP_TIMEOUT_MS = 3e3;
1819
+ var TX_CONFIRM_INTERVAL_MS = 1e3;
1820
+ var TX_CONFIRM_TIMEOUT_MS = 3e4;
1821
+ async function confirmTransaction(server, hash, label, timeoutMs = TX_CONFIRM_TIMEOUT_MS, intervalMs = TX_CONFIRM_INTERVAL_MS) {
1822
+ const deadline = Date.now() + timeoutMs;
1823
+ while (true) {
1824
+ const txResponse = await server.getTransaction(hash);
1825
+ const status = txResponse?.status;
1826
+ if (status === "SUCCESS") {
1827
+ return hash;
1828
+ }
1829
+ if (status === "FAILED") {
1830
+ throw new RouteDockDisputeError(
1831
+ `${label} transaction failed on-chain: status FAILED (resultXdr: ${txResponse.resultXdr.toXDR("base64")})`
1832
+ );
1833
+ }
1834
+ if (Date.now() >= deadline) {
1835
+ break;
1836
+ }
1837
+ const remaining = deadline - Date.now();
1838
+ await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, Math.max(1, remaining))));
1839
+ }
1840
+ throw new RouteDockDisputeError(
1841
+ `${label} transaction confirmation timed out after ${timeoutMs}ms (hash: ${hash})`
1842
+ );
1843
+ }
1404
1844
  var WS_CONNECTING = 0;
1405
1845
  var WS_OPEN = 1;
1406
1846
  function defaultWebSocketFactory(url, init, handlers) {
@@ -1414,9 +1854,9 @@ function defaultWebSocketFactory(url, init, handlers) {
1414
1854
  if (typeof data === "string") {
1415
1855
  handlers.onMessage(data);
1416
1856
  } else if (data instanceof ArrayBuffer) {
1417
- handlers.onMessage(Buffer.from(data).toString("utf8"));
1857
+ handlers.onMessage(bytesToUtf8(new Uint8Array(data)));
1418
1858
  } else {
1419
- void data.arrayBuffer().then((buffer) => handlers.onMessage(Buffer.from(buffer).toString("utf8"))).catch(() => {
1859
+ void data.arrayBuffer().then((buffer) => handlers.onMessage(bytesToUtf8(new Uint8Array(buffer)))).catch(() => {
1420
1860
  });
1421
1861
  }
1422
1862
  };
@@ -1569,7 +2009,7 @@ var MppSessionClient = class {
1569
2009
  throw err;
1570
2010
  }
1571
2011
  };
1572
- const streamWs = (u, m, createCredential, onSigned) => this.streamWebSocket(u, m, createCredential, onSigned);
2012
+ const streamWs = (u, m, createCredential, onSigned, request) => this.streamWebSocket(u, m, createCredential, onSigned, request);
1573
2013
  const mppx = Mppx.create({
1574
2014
  polyfill: false,
1575
2015
  methods: [
@@ -1641,6 +2081,7 @@ var MppSessionClient = class {
1641
2081
  };
1642
2082
  },
1643
2083
  async *stream(options2) {
2084
+ const prepared = await prepareRequest(options2?.request);
1644
2085
  const checkSpend = () => {
1645
2086
  if (!onSpend) return Promise.resolve();
1646
2087
  return onSpend(pricing.rate);
@@ -1650,7 +2091,13 @@ var MppSessionClient = class {
1650
2091
  const onSigned = () => {
1651
2092
  vouchersIssued++;
1652
2093
  };
1653
- for await (const item of streamWs(url, mppx, createGuardedCredential, onSigned)) {
2094
+ for await (const item of streamWs(
2095
+ url,
2096
+ mppx,
2097
+ createGuardedCredential,
2098
+ onSigned,
2099
+ prepared
2100
+ )) {
1654
2101
  yield item;
1655
2102
  }
1656
2103
  return;
@@ -1665,7 +2112,10 @@ var MppSessionClient = class {
1665
2112
  const doFetch = () => withRetry(async () => {
1666
2113
  let resp;
1667
2114
  try {
1668
- resp = await mppx.fetch(url, { signal: abortController.signal });
2115
+ resp = await mppx.fetch(url, {
2116
+ ...toRequestInit(prepared),
2117
+ signal: abortController.signal
2118
+ });
1669
2119
  } catch (err) {
1670
2120
  if (abortController.signal.aborted) {
1671
2121
  throw err;
@@ -1750,10 +2200,10 @@ var MppSessionClient = class {
1750
2200
  async close() {
1751
2201
  clearSessionTimer();
1752
2202
  closed = true;
1753
- const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
2203
+ const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal3, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1754
2204
  const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1755
2205
  const server = new rpcMod.Server(rpcUrl);
1756
- const contract = new Contract2(channelFactory);
2206
+ const contract2 = new Contract2(channelFactory);
1757
2207
  const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1758
2208
  const account = await withRetry(async () => {
1759
2209
  try {
@@ -1768,9 +2218,9 @@ var MppSessionClient = class {
1768
2218
  fee: BASE_FEE2,
1769
2219
  networkPassphrase: passphrase
1770
2220
  }).addOperation(
1771
- contract.call(
2221
+ contract2.call(
1772
2222
  "prepare_commitment",
1773
- nativeToScVal2(currentCumulative, { type: "i128" })
2223
+ nativeToScVal3(currentCumulative, { type: "i128" })
1774
2224
  )
1775
2225
  ).setTimeout(30).build();
1776
2226
  return await server.simulateTransaction(simTx);
@@ -1789,7 +2239,7 @@ var MppSessionClient = class {
1789
2239
  }
1790
2240
  let signature;
1791
2241
  try {
1792
- signature = commitmentKey.sign(Buffer.from(commitmentBytes));
2242
+ signature = commitmentKey.sign(commitmentBytes);
1793
2243
  } catch (err) {
1794
2244
  throw new RouteDockSignatureError("Channel close commitment signing failed", {
1795
2245
  cause: err
@@ -1803,7 +2253,7 @@ var MppSessionClient = class {
1803
2253
  headers: { "Content-Type": "application/json" },
1804
2254
  body: JSON.stringify({
1805
2255
  amount: currentCumulative.toString(),
1806
- signature: Buffer.from(signature).toString("hex")
2256
+ signature: bytesToHex(signature)
1807
2257
  })
1808
2258
  });
1809
2259
  } catch (err) {
@@ -1845,15 +2295,19 @@ var MppSessionClient = class {
1845
2295
  vouchersIssued
1846
2296
  };
1847
2297
  },
1848
- async requestRefund() {
2298
+ /**
2299
+ * Request refund from the channel contract (initiates dispute).
2300
+ * Returns the transaction hash only after on-chain confirmation (SUCCESS).
2301
+ */
2302
+ async requestRefund(options2) {
1849
2303
  const { rpc: rpcMod, Contract: Contract2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1850
2304
  const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1851
2305
  const server = new rpcMod.Server(rpcUrl);
1852
- const contract = new Contract2(channelFactory);
2306
+ const contract2 = new Contract2(channelFactory);
1853
2307
  const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1854
2308
  try {
1855
2309
  const account = await server.getAccount(agentPublicKey);
1856
- const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("request_refund")).setTimeout(30).build();
2310
+ const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract2.call("request_refund")).setTimeout(30).build();
1857
2311
  const simResult = await server.simulateTransaction(tx);
1858
2312
  if (rpcMod.Api.isSimulationError(simResult)) {
1859
2313
  throw new RouteDockDisputeError(`request_refund simulation failed: ${simResult.error}`);
@@ -1865,37 +2319,50 @@ var MppSessionClient = class {
1865
2319
  const errDetail = result.errorResult ? JSON.stringify(result.errorResult) : "status ERROR";
1866
2320
  throw new RouteDockDisputeError(`Refund request transaction failed: ${errDetail}`);
1867
2321
  }
2322
+ if (result.status === "TRY_AGAIN_LATER") {
2323
+ throw new RouteDockDisputeError("Refund request transaction rejected with TRY_AGAIN_LATER: transaction not queued");
2324
+ }
1868
2325
  if (!result.hash) {
1869
2326
  throw new RouteDockDisputeError("Refund request transaction not sent");
1870
2327
  }
1871
- return result.hash;
2328
+ return await confirmTransaction(
2329
+ server,
2330
+ result.hash,
2331
+ "Refund request",
2332
+ options2?.timeoutMs,
2333
+ options2?.intervalMs
2334
+ );
1872
2335
  } catch (err) {
1873
2336
  if (err instanceof RouteDockDisputeError) throw err;
1874
2337
  throw new RouteDockDisputeError(`Failed to request refund: ${err instanceof Error ? err.message : String(err)}`);
1875
2338
  }
1876
2339
  },
1877
- async settleWithLatestVoucher() {
1878
- const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
2340
+ /**
2341
+ * Server-side counter-mechanism to settle with latest voucher before refund window expires.
2342
+ * Returns the transaction hash only after on-chain confirmation (SUCCESS).
2343
+ */
2344
+ async settleWithLatestVoucher(options2) {
2345
+ const { rpc: rpcMod, Contract: Contract2, nativeToScVal: nativeToScVal3, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1879
2346
  const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1880
2347
  const server = new rpcMod.Server(rpcUrl);
1881
- const contract = new Contract2(channelFactory);
2348
+ const contract2 = new Contract2(channelFactory);
1882
2349
  const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1883
2350
  try {
1884
2351
  const account = await server.getAccount(agentPublicKey);
1885
- const simTx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("prepare_commitment", nativeToScVal2(currentCumulative, { type: "i128" }))).setTimeout(30).build();
2352
+ const simTx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract2.call("prepare_commitment", nativeToScVal3(currentCumulative, { type: "i128" }))).setTimeout(30).build();
1886
2353
  const simResult = await server.simulateTransaction(simTx);
1887
2354
  if (rpcMod.Api.isSimulationError(simResult)) {
1888
2355
  throw new RouteDockDisputeError(`prepare_commitment simulation failed: ${simResult.error}`);
1889
2356
  }
1890
2357
  const commitmentBytes = simResult.result?.retval?.bytes();
1891
2358
  if (!commitmentBytes) throw new RouteDockDisputeError("prepare_commitment returned no bytes");
1892
- const signature = commitmentKey.sign(Buffer.from(commitmentBytes));
2359
+ const signature = commitmentKey.sign(commitmentBytes);
1893
2360
  const settleAccount = await server.getAccount(agentPublicKey);
1894
2361
  const settleTx = new TransactionBuilder2(settleAccount, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(
1895
- contract.call(
2362
+ contract2.call(
1896
2363
  "settle_with_signature",
1897
- nativeToScVal2(currentCumulative, { type: "i128" }),
1898
- nativeToScVal2(Buffer.from(signature))
2364
+ nativeToScVal3(currentCumulative, { type: "i128" }),
2365
+ nativeToScVal3(signature)
1899
2366
  )
1900
2367
  ).setTimeout(30).build();
1901
2368
  const preparedSettleTx = await server.prepareTransaction(settleTx);
@@ -1905,10 +2372,19 @@ var MppSessionClient = class {
1905
2372
  const errDetail = settleResult.errorResult ? JSON.stringify(settleResult.errorResult) : "status ERROR";
1906
2373
  throw new RouteDockDisputeError(`Settlement transaction failed: ${errDetail}`);
1907
2374
  }
2375
+ if (settleResult.status === "TRY_AGAIN_LATER") {
2376
+ throw new RouteDockDisputeError("Settlement transaction rejected with TRY_AGAIN_LATER: transaction not queued");
2377
+ }
1908
2378
  if (!settleResult.hash) {
1909
2379
  throw new RouteDockDisputeError("Settlement transaction not sent");
1910
2380
  }
1911
- return settleResult.hash;
2381
+ return await confirmTransaction(
2382
+ server,
2383
+ settleResult.hash,
2384
+ "Settlement",
2385
+ options2?.timeoutMs,
2386
+ options2?.intervalMs
2387
+ );
1912
2388
  } catch (err) {
1913
2389
  if (err instanceof RouteDockDisputeError) throw err;
1914
2390
  throw new RouteDockDisputeError(`Failed to settle with latest voucher: ${err instanceof Error ? err.message : String(err)}`);
@@ -1918,11 +2394,11 @@ var MppSessionClient = class {
1918
2394
  const { rpc: rpcMod, Contract: Contract2, TransactionBuilder: TransactionBuilder2, BASE_FEE: BASE_FEE2 } = await import('@stellar/stellar-sdk');
1919
2395
  const rpcUrl = network === "testnet" ? "https://soroban-testnet.stellar.org" : "https://soroban.stellar.org";
1920
2396
  const server = new rpcMod.Server(rpcUrl);
1921
- const contract = new Contract2(channelFactory);
2397
+ const contract2 = new Contract2(channelFactory);
1922
2398
  const passphrase = network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
1923
2399
  try {
1924
2400
  const account = await server.getAccount(agentPublicKey);
1925
- const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract.call("get_channel_state")).setTimeout(30).build();
2401
+ const tx = new TransactionBuilder2(account, { fee: BASE_FEE2, networkPassphrase: passphrase }).addOperation(contract2.call("get_channel_state")).setTimeout(30).build();
1926
2402
  const simResult = await server.simulateTransaction(tx);
1927
2403
  if (rpcMod.Api.isSimulationError(simResult)) {
1928
2404
  throw new RouteDockChannelStateError(`Failed to query channel state: ${simResult.error}`);
@@ -1988,11 +2464,11 @@ var MppSessionClient = class {
1988
2464
  * close code (1000/1001); abnormal closes and connection failures surface as
1989
2465
  * RouteDockChannelStateError.
1990
2466
  */
1991
- async *streamWebSocket(url, mppx, createCredential, onSigned) {
1992
- const request = { method: "GET" };
2467
+ async *streamWebSocket(url, mppx, createCredential, onSigned, request) {
2468
+ const probeInit = toRequestInit(request);
1993
2469
  let probe;
1994
2470
  try {
1995
- probe = await mppx.rawFetch(url, request);
2471
+ probe = await mppx.rawFetch(url, probeInit);
1996
2472
  } catch (err) {
1997
2473
  throw wrapFetchError(err, "Voucher request");
1998
2474
  }
@@ -2096,230 +2572,19 @@ var MppSessionClient = class {
2096
2572
  }
2097
2573
  }
2098
2574
  };
2099
- var NulthPolicyError = class extends Error {
2100
- code;
2101
- constructor(code, message) {
2102
- super(message);
2103
- this.name = "NulthPolicyError";
2104
- this.code = code;
2105
- }
2106
- };
2107
- function sha256Hex(data) {
2108
- const buf = typeof data === "string" ? Buffer.from(data, "utf8") : data;
2109
- return createHash("sha256").update(buf).digest("hex");
2110
- }
2111
- function commitDailyCap(dailyCapStroops, witnessSecret) {
2112
- return sha256Hex(`${witnessSecret}:cap:${dailyCapStroops.toString()}`);
2113
- }
2114
- function commitAllowlist(payees, witnessSecret) {
2115
- const sorted = [...payees].sort().join(",");
2116
- return sha256Hex(`${witnessSecret}:allowlist:${sorted}`);
2117
- }
2118
- function hashPayee(payee) {
2119
- return sha256Hex(`payee:${payee}`);
2120
- }
2121
- function dayBucketFromLedger(ledgerSequence) {
2122
- return Math.floor(ledgerSequence / 17280);
2123
- }
2124
- function authDigestFromEntry(authEntryBase64) {
2125
- const entryHash = hash(Buffer.from(authEntryBase64, "base64"));
2126
- return sha256Hex(entryHash);
2127
- }
2128
- function assertAuthEntryMatchesContext(authEntry, from, ctx) {
2129
- let preimage;
2130
- try {
2131
- preimage = xdr.HashIdPreimage.fromXDR(authEntry, "base64");
2132
- } catch {
2133
- throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not decode as a HashIdPreimage");
2134
- }
2135
- if (preimage.switch() !== xdr.EnvelopeType.envelopeTypeSorobanAuthorization()) {
2136
- throw new NulthPolicyError(
2137
- "auth_entry_mismatch",
2138
- "authEntry is not a Soroban authorization preimage"
2139
- );
2140
- }
2141
- const inv = preimage.sorobanAuthorization().invocation();
2142
- if (inv.subInvocations().length !== 0) {
2143
- throw new NulthPolicyError("auth_entry_mismatch", "authEntry has unexpected sub-invocations");
2144
- }
2145
- if (inv.function().switch() !== xdr.SorobanAuthorizedFunctionType.sorobanAuthorizedFunctionTypeContractFn()) {
2146
- throw new NulthPolicyError("auth_entry_mismatch", "authEntry does not invoke a contract function");
2147
- }
2148
- const fn = inv.function().contractFn();
2149
- const contractAddress = Address.fromScAddress(fn.contractAddress()).toString();
2150
- if (contractAddress !== ctx.assetContract) {
2151
- throw new NulthPolicyError(
2152
- "auth_entry_mismatch",
2153
- `authEntry contract ${contractAddress} does not match assetContract ${ctx.assetContract}`
2154
- );
2155
- }
2156
- const functionName = fn.functionName().toString();
2157
- if (functionName !== "transfer") {
2158
- throw new NulthPolicyError(
2159
- "auth_entry_mismatch",
2160
- `authEntry function ${functionName} does not match expected transfer`
2161
- );
2162
- }
2163
- const args = fn.args();
2164
- if (args.length !== 3) {
2165
- throw new NulthPolicyError("auth_entry_mismatch", `authEntry transfer has ${args.length} args, expected 3`);
2166
- }
2167
- const entryFrom = scValToNative(args[0]);
2168
- if (entryFrom !== from) {
2169
- throw new NulthPolicyError(
2170
- "auth_entry_mismatch",
2171
- `authEntry from ${entryFrom} does not match nulthAccount ${from}`
2172
- );
2173
- }
2174
- const entryTo = scValToNative(args[1]);
2175
- if (entryTo !== ctx.payee) {
2176
- throw new NulthPolicyError(
2177
- "auth_entry_mismatch",
2178
- `authEntry to ${entryTo} does not match paymentContext.payee ${ctx.payee}`
2179
- );
2180
- }
2181
- const entryAmount = scValToNative(args[2]);
2182
- if (entryAmount !== ctx.amountStroops) {
2183
- throw new NulthPolicyError(
2184
- "auth_entry_mismatch",
2185
- `authEntry amount ${entryAmount} does not match paymentContext.amountStroops ${ctx.amountStroops}`
2186
- );
2187
- }
2188
- }
2189
- function insecureMockProof(preimage) {
2190
- return createHash("sha512").update(`nulth:${preimage}`).digest("base64");
2191
- }
2192
- function encodeAuthSignature(proof) {
2193
- const payload = { nulth: "zk-v1", proof };
2194
- return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
2195
- }
2196
- var NulthClient = class {
2197
- constructor(config) {
2198
- this.config = config;
2199
- this.policy = { ...config.policy };
2200
- this.prover = config.prover ?? "mock";
2201
- if (this.prover !== "mock") {
2202
- throw new RouteDockManifestError(
2203
- `NulthClient: unknown prover backend ${String(config.prover)}; only 'mock' is implemented`
2204
- );
2205
- }
2206
- if (config.network !== "testnet") {
2207
- throw new RouteDockManifestError(
2208
- `NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
2209
- );
2210
- }
2211
- resolveLogger(config.logger)(
2212
- "warn",
2213
- "NulthClient is using the insecure mock prover; proofs are not cryptographically sound"
2214
- );
2215
- }
2216
- config;
2217
- policy;
2218
- prover;
2219
- get proverBackend() {
2220
- return this.prover;
2221
- }
2222
- get nulthAccount() {
2223
- return this.config.nulthAccount;
2224
- }
2225
- get allowlistCommitment() {
2226
- return this.policy.allowlistCommitment;
2227
- }
2228
- get capCommitment() {
2229
- return commitDailyCap(this.policy.dailyCapStroops, this.policy.witnessSecret);
2230
- }
2231
- buildPaymentAuthProof(context) {
2232
- this.enforcePolicy(context);
2233
- const bucket = dayBucketFromLedger(context.ledgerSequence);
2234
- if (bucket !== this.policy.dayBucket) {
2235
- this.policy.dayBucket = bucket;
2236
- this.policy.dailySpendStroops = 0n;
2237
- }
2238
- const authDigest = authDigestFromEntry(context.authEntry);
2239
- const capCommitment = this.capCommitment;
2240
- const payeeHash = hashPayee(context.payee);
2241
- const preimage = [
2242
- authDigest,
2243
- this.policy.allowlistCommitment,
2244
- capCommitment,
2245
- payeeHash,
2246
- context.amountStroops.toString(),
2247
- bucket.toString(),
2248
- this.config.nulthAccount
2249
- ].join("|");
2250
- const proof = {
2251
- version: 1,
2252
- proof: insecureMockProof(preimage),
2253
- publicInputs: {
2254
- authDigest,
2255
- allowlistCommitment: this.policy.allowlistCommitment,
2256
- capCommitment,
2257
- payeeHash,
2258
- amountStroops: context.amountStroops.toString(),
2259
- dayBucket: bucket.toString()
2260
- }
2261
- };
2262
- this.policy.dailySpendStroops += context.amountStroops;
2263
- return proof;
2264
- }
2265
- enforcePolicy(context) {
2266
- if (context.amountStroops < 0n) {
2267
- throw new RangeError(`Invalid amountStroops: ${context.amountStroops}`);
2268
- }
2269
- if (!Number.isSafeInteger(context.ledgerSequence) || context.ledgerSequence < 0) {
2270
- throw new RangeError(`Invalid ledgerSequence: ${context.ledgerSequence}`);
2271
- }
2272
- if (this.policy.expiryLedger !== void 0 && context.ledgerSequence > this.policy.expiryLedger) {
2273
- throw new NulthPolicyError("session_expired", "Nulth session expired");
2274
- }
2275
- if (!this.policy.allowedPayees.includes(context.payee)) {
2276
- throw new NulthPolicyError("payee_not_allowed", `Payee ${context.payee} not in off-chain allowlist`);
2277
- }
2278
- const bucket = dayBucketFromLedger(context.ledgerSequence);
2279
- const spend = bucket === this.policy.dayBucket ? this.policy.dailySpendStroops : 0n;
2280
- if (spend + context.amountStroops > this.policy.dailyCapStroops) {
2281
- throw new NulthPolicyError("daily_cap_exceeded", "Nulth daily cap exceeded");
2282
- }
2283
- }
2284
- };
2285
2575
  function createNulthSigner(config) {
2286
- const client = new NulthClient(config);
2287
- const nulthAccount = config.nulthAccount;
2288
- return {
2289
- address: nulthAccount,
2290
- signAuthEntry: async (authEntry) => {
2291
- const ctx = config.paymentContext;
2292
- if (!ctx) throw new Error("Nulth paymentContext must be set before signing");
2293
- assertAuthEntryMatchesContext(authEntry, nulthAccount, ctx);
2294
- const proof = client.buildPaymentAuthProof({ authEntry, ...ctx });
2295
- return { signedAuthEntry: encodeAuthSignature(proof), signerAddress: nulthAccount };
2296
- }
2297
- };
2298
- }
2299
- function setNulthPaymentContext(config, context) {
2300
- config.paymentContext = context;
2301
- }
2302
- function paymentContextFromManifest(manifest, mode, ledgerSequence) {
2303
- const pricing = manifest.pricing[mode];
2304
- if (!pricing) throw new Error(`manifest.pricing.${mode} missing`);
2305
- return {
2306
- payee: pricing.payee ?? manifest.payee,
2307
- amountStroops: usdcToStroops(pricing.amount),
2308
- assetContract: manifest.asset_contract,
2309
- ledgerSequence
2310
- };
2311
- }
2312
- function createPolicyState(input) {
2313
- const dailyCapStroops = usdcToStroops(input.dailyCapUsdc);
2314
- return {
2315
- dailyCapStroops,
2316
- allowlistCommitment: commitAllowlist(input.allowedPayees, input.witnessSecret),
2317
- allowedPayees: input.allowedPayees,
2318
- dailySpendStroops: 0n,
2319
- dayBucket: dayBucketFromLedger(input.ledgerSequence ?? 0),
2320
- witnessSecret: input.witnessSecret,
2321
- ...input.expiryLedger !== void 0 ? { expiryLedger: input.expiryLedger } : {}
2322
- };
2576
+ const sharedConfig = { ...config, logger: resolveLogger(config.logger) };
2577
+ Object.defineProperty(sharedConfig, "paymentContext", {
2578
+ get: () => config.paymentContext
2579
+ });
2580
+ try {
2581
+ return createNulthSigner$1(sharedConfig);
2582
+ } catch (error) {
2583
+ if (error instanceof NulthConfigError) {
2584
+ throw new RouteDockManifestError(error.message, { cause: error });
2585
+ }
2586
+ throw error;
2587
+ }
2323
2588
  }
2324
2589
  function assertNulthVaultManifest(manifest, nulthAccount) {
2325
2590
  if (manifest.vault !== void 0 && manifest.vault !== "nulth") {
@@ -2366,46 +2631,6 @@ async function prepareNulthSigner(vault, manifest, mode, network, ledgerSequence
2366
2631
  const signer = createNulthSigner(config);
2367
2632
  return { signer, config };
2368
2633
  }
2369
- function decodeAuthSignature(encoded) {
2370
- const raw = JSON.parse(Buffer.from(encoded, "base64").toString("utf8"));
2371
- if (typeof raw !== "object" || raw === null || raw.nulth !== "zk-v1") {
2372
- throw new Error("Invalid Nulth ZK auth signature");
2373
- }
2374
- return raw;
2375
- }
2376
- var FileSpendStore = class {
2377
- filePath;
2378
- constructor(filePath) {
2379
- this.filePath = filePath;
2380
- }
2381
- async read() {
2382
- let data;
2383
- try {
2384
- data = await fs.readFile(this.filePath, "utf-8");
2385
- } catch (err) {
2386
- if (err.code === "ENOENT") {
2387
- return null;
2388
- }
2389
- throw new Error(
2390
- `[FileSpendStore] Failed to read spend file at ${this.filePath}: ${err.message}`
2391
- );
2392
- }
2393
- try {
2394
- return JSON.parse(data);
2395
- } catch {
2396
- throw new Error(
2397
- `[FileSpendStore] Spend file at ${this.filePath} contains invalid JSON. Delete or repair it before resuming. Raw content: ${data.slice(0, 120)}`
2398
- );
2399
- }
2400
- }
2401
- async write(state) {
2402
- const dir = path.dirname(this.filePath);
2403
- await fs.mkdir(dir, { recursive: true });
2404
- const tmpPath = `${this.filePath}.tmp`;
2405
- await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
2406
- await fs.rename(tmpPath, this.filePath);
2407
- }
2408
- };
2409
2634
 
2410
2635
  // src/store/SpendStore.ts
2411
2636
  var InMemorySpendStore = class {
@@ -2519,7 +2744,7 @@ var RouteDockClient = class _RouteDockClient {
2519
2744
  }
2520
2745
  const secretKey = this.keypair.secret();
2521
2746
  const retryPolicy = this.logger ? { ...this.retryPolicy, logger: this.logger } : this.retryPolicy;
2522
- this.x402 = new X402Client(secretKey, this.network, retryPolicy);
2747
+ this.x402 = new X402Client(secretKey, this.network, retryPolicy, config.preSignHook, this.logger);
2523
2748
  this.charge = new MppChargeClient(this.keypair, this.network, retryPolicy);
2524
2749
  this.session = new MppSessionClient(this.keypair, this.network, retryPolicy, void 0, this.logger);
2525
2750
  }
@@ -2527,7 +2752,7 @@ var RouteDockClient = class _RouteDockClient {
2527
2752
  async _resolveManifest(url, options) {
2528
2753
  const baseUrl = new URL(url).origin;
2529
2754
  const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2530
- assertEndpointActive(manifest, url, this.logger);
2755
+ assertEndpointActive(manifest, url, this.logger, options?.request?.method);
2531
2756
  const mode = selectMode(manifest, options);
2532
2757
  return { manifest, mode };
2533
2758
  }
@@ -2544,13 +2769,9 @@ var RouteDockClient = class _RouteDockClient {
2544
2769
  });
2545
2770
  return next;
2546
2771
  }
2547
- /**
2548
- * Check that the payer has a trustline for the payment asset and surface
2549
- * manifest metadata (regions, latency hints, capabilities). Safe to
2550
- * call before committing to a payment — for approval gates and manual
2551
- * trustline remediation.
2552
- */
2553
- async preflight(manifest, mode, endpoint) {
2772
+ async preflight(target, mode, endpoint) {
2773
+ if (typeof target === "string") return this._preflightUrl(target);
2774
+ const manifest = target;
2554
2775
  assertManifestValid(manifest);
2555
2776
  const selectedAsset = mode ? selectAsset(manifest, mode, endpoint).asset : manifest.asset;
2556
2777
  await this._checkTrustline(manifest, selectedAsset);
@@ -2563,6 +2784,42 @@ var RouteDockClient = class _RouteDockClient {
2563
2784
  ...manifest.capabilities && { capabilities: manifest.capabilities }
2564
2785
  };
2565
2786
  }
2787
+ /** Manifest-only preflight for a URL — no trustline check, no payment. */
2788
+ async _preflightUrl(url) {
2789
+ const baseUrl = new URL(url).origin;
2790
+ const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2791
+ assertEndpointActive(manifest, url);
2792
+ const costs = manifest.modes.map((mode) => {
2793
+ const asset = selectAsset(manifest, mode, url).asset;
2794
+ switch (mode) {
2795
+ case "x402":
2796
+ case "mpp-charge": {
2797
+ const pricing = manifest.pricing[mode];
2798
+ return { mode, amount: pricing.amount, per: "request", asset };
2799
+ }
2800
+ case "mpp-session":
2801
+ case "mpp-session-ws": {
2802
+ const pricing = manifest.pricing[mode];
2803
+ return { mode, amount: pricing.rate, per: "voucher", asset, min_deposit: pricing.min_deposit };
2804
+ }
2805
+ default:
2806
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2807
+ }
2808
+ });
2809
+ const pathname = new URL(url).pathname;
2810
+ const matches = Object.values(manifest.endpoints).filter((descriptor) => descriptor.path === pathname);
2811
+ const endpointSunsets = matches.map((descriptor) => descriptor.sunset_at).filter((value) => typeof value === "string").sort((a, b) => Date.parse(a) - Date.parse(b));
2812
+ return {
2813
+ modes: manifest.modes,
2814
+ costs,
2815
+ compliance: {
2816
+ deprecated_modes: manifest.deprecated_modes ?? [],
2817
+ ...manifest.sunset_at && { sunset_at: manifest.sunset_at },
2818
+ endpoint_deprecated: matches.some((descriptor) => descriptor.deprecated === true),
2819
+ ...endpointSunsets[0] && { endpoint_sunset_at: endpointSunsets[0] }
2820
+ }
2821
+ };
2822
+ }
2566
2823
  /**
2567
2824
  * Verify via Horizon that the payer account has a trustline for the
2568
2825
  * payment asset declared in the manifest. Throws RouteDockTrustlineError
@@ -2574,8 +2831,7 @@ var RouteDockClient = class _RouteDockClient {
2574
2831
  const cacheKey = `${this.network}:${this.keypair.publicKey()}:${targetAsset}`;
2575
2832
  const cached = _RouteDockClient._trustlineCache.get(cacheKey);
2576
2833
  if (cached && Date.now() < cached.expiresAt) return;
2577
- const horizonUrl = this.network === "testnet" ? "https://horizon-testnet.stellar.org" : "https://horizon.stellar.org";
2578
- const server = new Horizon.Server(horizonUrl);
2834
+ const server = new Horizon.Server(HORIZON_URLS[this.network]);
2579
2835
  try {
2580
2836
  const account = await server.loadAccount(this.keypair.publicKey());
2581
2837
  const balances = account.balances;
@@ -2611,9 +2867,11 @@ var RouteDockClient = class _RouteDockClient {
2611
2867
  async pay(url, options) {
2612
2868
  const baseUrl = new URL(url).origin;
2613
2869
  const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2614
- assertEndpointActive(manifest, url, this.logger);
2870
+ const request = options?.request;
2871
+ assertEndpointActive(manifest, url, this.logger, request?.method);
2615
2872
  const mode = selectMode(manifest, { ...options, ...this.logger && { logger: this.logger } });
2616
2873
  const selectedAsset = selectAsset(manifest, mode, url).asset;
2874
+ this._assertCapAsset(selectedAsset);
2617
2875
  await this._checkTrustline(manifest, selectedAsset);
2618
2876
  let amount;
2619
2877
  switch (mode) {
@@ -2631,18 +2889,18 @@ var RouteDockClient = class _RouteDockClient {
2631
2889
  default:
2632
2890
  throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2633
2891
  }
2634
- const reserveId = await this._checkAndReserveSpend(amount, baseUrl);
2892
+ const reserveId = await this._checkAndReserveSpend(amount, baseUrl, selectedAsset);
2635
2893
  let result;
2636
2894
  try {
2637
2895
  if (this.vault?.mode === "nulth") {
2638
- result = await this._payWithNulthVault(url, manifest, mode);
2896
+ result = await this._payWithNulthVault(url, manifest, mode, request);
2639
2897
  } else {
2640
2898
  switch (mode) {
2641
2899
  case "x402":
2642
- result = await this.x402.pay(url, manifest);
2900
+ result = await this.x402.pay(url, manifest, request);
2643
2901
  break;
2644
2902
  case "mpp-charge":
2645
- result = await this.charge.pay(url, manifest);
2903
+ result = await this.charge.pay(url, manifest, request);
2646
2904
  break;
2647
2905
  default:
2648
2906
  throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
@@ -2661,7 +2919,7 @@ var RouteDockClient = class _RouteDockClient {
2661
2919
  return result;
2662
2920
  }
2663
2921
  /** Nulth ZK vault path — proof built off-chain, attached as auth signature */
2664
- async _payWithNulthVault(url, manifest, mode) {
2922
+ async _payWithNulthVault(url, manifest, mode, request) {
2665
2923
  const prover = this.vault?.prover ?? "mock";
2666
2924
  if (this.network === "mainnet" && prover === "mock") {
2667
2925
  throw new RouteDockManifestError("nulth vault uses a MOCK Groth16 prover and cannot be used on mainnet");
@@ -2674,7 +2932,7 @@ var RouteDockClient = class _RouteDockClient {
2674
2932
  try {
2675
2933
  const { signer } = await prepareNulthSigner(this.vault, manifest, mode, this.network);
2676
2934
  const x402 = this.x402.withSigner(signer);
2677
- const result = await x402.pay(url, manifest);
2935
+ const result = await x402.pay(url, manifest, request);
2678
2936
  return result;
2679
2937
  } catch (err) {
2680
2938
  if (err instanceof NulthPolicyError) {
@@ -2743,17 +3001,35 @@ var RouteDockClient = class _RouteDockClient {
2743
3001
  );
2744
3002
  }
2745
3003
  const endpointKey = new URL(url).origin;
2746
- const onSpend = this.spendCap ? (amount) => this._recordSessionSpend(amount, endpointKey) : void 0;
3004
+ let onSpend;
3005
+ if (this.spendCap) {
3006
+ const sessionAsset = selectAsset(manifest, mode, url).asset;
3007
+ this._assertCapAsset(sessionAsset);
3008
+ onSpend = (amount) => this._recordSessionSpend(amount, endpointKey, sessionAsset);
3009
+ }
2747
3010
  return this.session.openSession(url, manifest, secret, options, onSpend);
2748
3011
  }
3012
+ /**
3013
+ * The local cap accumulates one USDC total. Refuse to count any other asset
3014
+ * against it (or let it through uncounted) when a cap is configured.
3015
+ */
3016
+ _assertCapAsset(asset) {
3017
+ if (this.spendCap && asset !== this.spendCap.asset) {
3018
+ throw new RouteDockPolicyRejectError("local_cap_asset_mismatch", {
3019
+ cause: new Error(
3020
+ `spendCap is denominated in ${this.spendCap.asset} but the selected payment asset is ${asset}`
3021
+ )
3022
+ });
3023
+ }
3024
+ }
2749
3025
  /**
2750
3026
  * Record spend for an MPP session voucher. Called before each voucher
2751
3027
  * fetch in stream(). Enforces the same local daily and per-endpoint caps
2752
3028
  * as discrete pay() calls. Returns a commit function to clean up the
2753
3029
  * reservation after the voucher fetch succeeds.
2754
3030
  */
2755
- async _recordSessionSpend(amount, endpointKey) {
2756
- const reserveId = await this._checkAndReserveSpend(amount, endpointKey);
3031
+ async _recordSessionSpend(amount, endpointKey, asset) {
3032
+ const reserveId = await this._checkAndReserveSpend(amount, endpointKey, asset);
2757
3033
  if (reserveId) {
2758
3034
  await this._commitSpend(reserveId);
2759
3035
  }
@@ -2792,15 +3068,16 @@ var RouteDockClient = class _RouteDockClient {
2792
3068
  * Both limits are independent: a payment is only reserved after **both** pass.
2793
3069
  * All spend (regardless of endpoint) counts toward the global accumulator.
2794
3070
  */
2795
- async _checkAndReserveSpend(amount, endpointKey) {
3071
+ async _checkAndReserveSpend(amount, endpointKey, asset) {
2796
3072
  if (!this.spendCap) return "";
2797
- return this._withSpendMutex(async () => {
3073
+ this._assertCapAsset(asset);
3074
+ const spendCap = this.spendCap;
3075
+ const amountMicros = usdcToStroops(amount);
3076
+ const reserve = (prev) => {
2798
3077
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2799
- const persisted = await this.spendStore.read();
2800
- const current = persisted && persisted.date === today ? persisted : { date: today, totalMicros: "0", endpoints: {} };
2801
- const amountMicros = usdcToStroops(amount);
3078
+ const current = prev && prev.date === today ? { ...prev, endpoints: { ...prev.endpoints } } : { date: today, totalMicros: "0", endpoints: {} };
2802
3079
  const total = BigInt(current.totalMicros);
2803
- const endpointCapStr = this.spendCap.endpointCaps?.[endpointKey];
3080
+ const endpointCapStr = spendCap.endpointCaps?.[endpointKey];
2804
3081
  if (endpointCapStr !== void 0) {
2805
3082
  const endpointCapMicros = usdcToStroops(endpointCapStr);
2806
3083
  const endpointTotal = BigInt(current.endpoints[endpointKey] ?? "0");
@@ -2808,17 +3085,25 @@ var RouteDockClient = class _RouteDockClient {
2808
3085
  throw new RouteDockPolicyRejectError("local_endpoint_cap_exceeded");
2809
3086
  }
2810
3087
  }
2811
- const globalCapMicros = usdcToStroops(this.spendCap.daily);
3088
+ const globalCapMicros = usdcToStroops(spendCap.daily);
2812
3089
  if (total + amountMicros > globalCapMicros) {
2813
3090
  throw new RouteDockPolicyRejectError("local_daily_cap_exceeded");
2814
3091
  }
2815
- const reserveId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
2816
- this._pendingReserves.set(reserveId, { endpointKey, amountMicros });
2817
3092
  current.totalMicros = (total + amountMicros).toString();
2818
3093
  if (endpointCapStr !== void 0) {
2819
3094
  current.endpoints[endpointKey] = (BigInt(current.endpoints[endpointKey] ?? "0") + amountMicros).toString();
2820
3095
  }
2821
- await this.spendStore.write(current);
3096
+ return current;
3097
+ };
3098
+ return this._withSpendMutex(async () => {
3099
+ const store = this.spendStore;
3100
+ if (store.update) {
3101
+ await store.update(reserve);
3102
+ } else {
3103
+ await store.write(reserve(await store.read()));
3104
+ }
3105
+ const reserveId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
3106
+ this._pendingReserves.set(reserveId, { endpointKey, amountMicros });
2822
3107
  return reserveId;
2823
3108
  });
2824
3109
  }
@@ -2840,22 +3125,29 @@ var RouteDockClient = class _RouteDockClient {
2840
3125
  if (!reserve) return;
2841
3126
  this._pendingReserves.delete(reserveId);
2842
3127
  const { endpointKey, amountMicros } = reserve;
2843
- await this._withSpendMutex(async () => {
3128
+ const release = (prev) => {
2844
3129
  const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
2845
- const persisted = await this.spendStore.read();
2846
- if (!persisted || persisted.date !== today) return;
2847
- const currentTotal = BigInt(persisted.totalMicros);
2848
- persisted.totalMicros = (currentTotal - amountMicros).toString();
3130
+ if (!prev || prev.date !== today) return void 0;
3131
+ const persisted = { ...prev, endpoints: { ...prev.endpoints } };
3132
+ persisted.totalMicros = (BigInt(persisted.totalMicros) - amountMicros).toString();
2849
3133
  if (persisted.endpoints[endpointKey]) {
2850
- const endpointTotal = BigInt(persisted.endpoints[endpointKey]);
2851
- const newEndpointTotal = endpointTotal - amountMicros;
3134
+ const newEndpointTotal = BigInt(persisted.endpoints[endpointKey]) - amountMicros;
2852
3135
  if (newEndpointTotal <= 0n) {
2853
3136
  delete persisted.endpoints[endpointKey];
2854
3137
  } else {
2855
3138
  persisted.endpoints[endpointKey] = newEndpointTotal.toString();
2856
3139
  }
2857
3140
  }
2858
- await this.spendStore.write(persisted);
3141
+ return persisted;
3142
+ };
3143
+ await this._withSpendMutex(async () => {
3144
+ const store = this.spendStore;
3145
+ if (store.update) {
3146
+ await store.update(release);
3147
+ } else {
3148
+ const next = release(await store.read());
3149
+ if (next) await store.write(next);
3150
+ }
2859
3151
  });
2860
3152
  }
2861
3153
  };
@@ -2939,6 +3231,158 @@ var SupabaseSessionStore = class {
2939
3231
  await this.setStatus(channelId, "closed");
2940
3232
  }
2941
3233
  };
3234
+ var DEFAULT_LOCK_STALE_MS = 1e4;
3235
+ var DEFAULT_LOCK_TIMEOUT_MS = 15e3;
3236
+ var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3237
+ var FileSpendStore = class {
3238
+ filePath;
3239
+ lockPath;
3240
+ lockStaleMs;
3241
+ lockTimeoutMs;
3242
+ constructor(filePath, options = {}) {
3243
+ this.filePath = filePath;
3244
+ this.lockPath = `${filePath}.lock`;
3245
+ this.lockStaleMs = options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
3246
+ this.lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
3247
+ }
3248
+ async read() {
3249
+ let data;
3250
+ try {
3251
+ data = await fs.readFile(this.filePath, "utf-8");
3252
+ } catch (err) {
3253
+ if (err.code === "ENOENT") {
3254
+ return null;
3255
+ }
3256
+ throw new Error(
3257
+ `[FileSpendStore] Failed to read spend file at ${this.filePath}: ${err.message}`
3258
+ );
3259
+ }
3260
+ try {
3261
+ return JSON.parse(data);
3262
+ } catch {
3263
+ throw new Error(
3264
+ `[FileSpendStore] Spend file at ${this.filePath} contains invalid JSON. Delete or repair it before resuming. Raw content: ${data.slice(0, 120)}`
3265
+ );
3266
+ }
3267
+ }
3268
+ async write(state) {
3269
+ await this.withLock(async (assertHeld) => {
3270
+ await this.writeFile(state, assertHeld);
3271
+ });
3272
+ }
3273
+ /**
3274
+ * Atomically read-modify-write the accumulator under the cross-process lock.
3275
+ * `fn` receives the current state (or null) and returns the state to persist,
3276
+ * or `undefined` to leave the file untouched. If `fn` throws, nothing is
3277
+ * written and the lock is released.
3278
+ */
3279
+ async update(fn) {
3280
+ return this.withLock(async (assertHeld) => {
3281
+ const prev = await this.read();
3282
+ const next = fn(prev);
3283
+ if (next === void 0) return prev;
3284
+ await this.writeFile(next, assertHeld);
3285
+ return next;
3286
+ });
3287
+ }
3288
+ // ── internals ──────────────────────────────────────────────────────────────
3289
+ async writeFile(state, assertHeld) {
3290
+ const tmpPath = `${this.filePath}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
3291
+ try {
3292
+ await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
3293
+ await assertHeld();
3294
+ await fs.rename(tmpPath, this.filePath);
3295
+ } catch (err) {
3296
+ await fs.rm(tmpPath, { force: true }).catch(() => {
3297
+ });
3298
+ throw err;
3299
+ }
3300
+ }
3301
+ /** Run `fn` while holding the lock file; always releases it. */
3302
+ async withLock(fn) {
3303
+ await fs.mkdir(path.dirname(this.filePath), { recursive: true });
3304
+ const token = `${process.pid}:${randomBytes(8).toString("hex")}`;
3305
+ await this.acquireLock(token);
3306
+ const heartbeat = setInterval(() => {
3307
+ const now = /* @__PURE__ */ new Date();
3308
+ fs.utimes(this.lockPath, now, now).catch(() => {
3309
+ });
3310
+ }, Math.max(10, Math.floor(this.lockStaleMs / 3)));
3311
+ heartbeat.unref();
3312
+ const assertHeld = async () => {
3313
+ let current;
3314
+ try {
3315
+ current = await fs.readFile(this.lockPath, "utf-8");
3316
+ } catch {
3317
+ current = "";
3318
+ }
3319
+ if (current !== token) {
3320
+ throw new Error(
3321
+ `[FileSpendStore] Lost the lock on ${this.lockPath} before committing; refusing to overwrite the spend file. Retry the operation.`
3322
+ );
3323
+ }
3324
+ };
3325
+ try {
3326
+ return await fn(assertHeld);
3327
+ } finally {
3328
+ clearInterval(heartbeat);
3329
+ await this.releaseLock(token);
3330
+ }
3331
+ }
3332
+ async acquireLock(token) {
3333
+ const deadline = Date.now() + this.lockTimeoutMs;
3334
+ let delay2 = 5;
3335
+ for (; ; ) {
3336
+ try {
3337
+ const handle = await fs.open(this.lockPath, "wx");
3338
+ try {
3339
+ await handle.writeFile(token, "utf-8");
3340
+ } finally {
3341
+ await handle.close();
3342
+ }
3343
+ return;
3344
+ } catch (err) {
3345
+ if (err.code !== "EEXIST") {
3346
+ throw new Error(
3347
+ `[FileSpendStore] Failed to create lock file ${this.lockPath}: ${err.message}`
3348
+ );
3349
+ }
3350
+ }
3351
+ await this.breakIfStale();
3352
+ if (Date.now() >= deadline) {
3353
+ throw new Error(
3354
+ `[FileSpendStore] Timed out after ${this.lockTimeoutMs}ms waiting for ${this.lockPath}. Another process is holding the spend file lock; if none is running, delete the lock file.`
3355
+ );
3356
+ }
3357
+ await sleep2(delay2 + Math.floor(Math.random() * delay2));
3358
+ delay2 = Math.min(delay2 * 2, 50);
3359
+ }
3360
+ }
3361
+ /** Remove the lock if its holder stopped refreshing it (crashed process). */
3362
+ async breakIfStale() {
3363
+ let before;
3364
+ try {
3365
+ before = await fs.stat(this.lockPath);
3366
+ } catch {
3367
+ return;
3368
+ }
3369
+ if (Date.now() - before.mtimeMs <= this.lockStaleMs) return;
3370
+ try {
3371
+ const after = await fs.stat(this.lockPath);
3372
+ if (after.ino !== before.ino || after.mtimeMs !== before.mtimeMs) return;
3373
+ await fs.unlink(this.lockPath);
3374
+ } catch {
3375
+ }
3376
+ }
3377
+ async releaseLock(token) {
3378
+ try {
3379
+ if (await fs.readFile(this.lockPath, "utf-8") === token) {
3380
+ await fs.unlink(this.lockPath);
3381
+ }
3382
+ } catch {
3383
+ }
3384
+ }
3385
+ };
2942
3386
 
2943
3387
  // src/store/SupabaseSeenTxStore.ts
2944
3388
  var SupabaseSeenTxStore = class {
@@ -2990,6 +3434,107 @@ var SupabaseSeenTxStore = class {
2990
3434
  return data;
2991
3435
  }
2992
3436
  };
3437
+ var FLAT_MODES = ["x402", "mpp-charge"];
3438
+ var SESSION_MODES = ["mpp-session", "mpp-session-ws"];
3439
+ function sameAmount(adapter, label, optionValue, manifestValue) {
3440
+ const parse = (where, value) => {
3441
+ try {
3442
+ return usdcToStroops(value);
3443
+ } catch {
3444
+ throw new RouteDockManifestError(
3445
+ `${adapter}: ${label} has an unparseable ${where} amount "${value}"`
3446
+ );
3447
+ }
3448
+ };
3449
+ return parse("option", optionValue) === parse("manifest", manifestValue);
3450
+ }
3451
+ function assertProviderConfig(opts, adapterName) {
3452
+ const { manifest } = opts;
3453
+ if (manifest === null || typeof manifest !== "object") {
3454
+ throw new RouteDockManifestError(`${adapterName}: opts.manifest is required and must be an object`);
3455
+ }
3456
+ try {
3457
+ validateManifestSchema(manifest);
3458
+ assertManifestValid(manifest);
3459
+ } catch (err) {
3460
+ if (err instanceof RouteDockManifestError) {
3461
+ throw new RouteDockManifestError(`${adapterName}: ${err.message}`, { cause: err });
3462
+ }
3463
+ throw err;
3464
+ }
3465
+ const served = new Set(opts.modes.filter((mode) => Boolean(opts.pricing[mode])));
3466
+ const advertised = new Set(manifest.modes);
3467
+ const notAdvertised = [...served].filter((mode) => !advertised.has(mode));
3468
+ const notServed = [...advertised].filter((mode) => !served.has(mode));
3469
+ if (notAdvertised.length > 0 || notServed.length > 0) {
3470
+ const parts = [];
3471
+ if (notAdvertised.length > 0) parts.push(`served but not in manifest.modes: ${notAdvertised.join(", ")}`);
3472
+ if (notServed.length > 0) parts.push(`in manifest.modes but not served (missing from modes or pricing): ${notServed.join(", ")}`);
3473
+ throw new RouteDockManifestError(`${adapterName}: modes do not match manifest.modes (${parts.join("; ")})`);
3474
+ }
3475
+ for (const mode of FLAT_MODES) {
3476
+ if (!served.has(mode)) continue;
3477
+ const optionValue = opts.pricing[mode];
3478
+ const manifestValue = manifest.pricing[mode]?.amount;
3479
+ if (manifestValue === void 0) {
3480
+ throw new RouteDockManifestError(`${adapterName}: manifest.pricing.${mode}.amount is missing but ${mode} is served`);
3481
+ }
3482
+ if (!sameAmount(adapterName, `pricing.${mode}`, optionValue, manifestValue)) {
3483
+ throw new RouteDockManifestError(
3484
+ `${adapterName}: pricing.${mode} "${optionValue}" does not match manifest.pricing.${mode}.amount "${manifestValue}"`
3485
+ );
3486
+ }
3487
+ }
3488
+ for (const mode of SESSION_MODES) {
3489
+ if (!served.has(mode)) continue;
3490
+ const optionValue = opts.pricing[mode];
3491
+ const manifestValue = manifest.pricing[mode];
3492
+ if (!manifestValue) {
3493
+ throw new RouteDockManifestError(`${adapterName}: manifest.pricing.${mode} is missing but ${mode} is served`);
3494
+ }
3495
+ if (!sameAmount(adapterName, `pricing.${mode}.rate`, optionValue.rate, manifestValue.rate)) {
3496
+ throw new RouteDockManifestError(
3497
+ `${adapterName}: pricing.${mode}.rate "${optionValue.rate}" does not match manifest.pricing.${mode}.rate "${manifestValue.rate}"`
3498
+ );
3499
+ }
3500
+ if (optionValue.channelFactory !== manifestValue.channel_factory) {
3501
+ throw new RouteDockManifestError(
3502
+ `${adapterName}: pricing.${mode}.channelFactory "${optionValue.channelFactory}" does not match manifest.pricing.${mode}.channel_factory "${manifestValue.channel_factory}"`
3503
+ );
3504
+ }
3505
+ }
3506
+ if (opts.assetContract !== void 0 && opts.assetContract !== manifest.asset_contract) {
3507
+ throw new RouteDockManifestError(
3508
+ `${adapterName}: assetContract "${opts.assetContract}" does not match manifest.asset_contract "${manifest.asset_contract}"`
3509
+ );
3510
+ }
3511
+ if (opts.asset !== void 0 && opts.asset !== manifest.asset) {
3512
+ throw new RouteDockManifestError(
3513
+ `${adapterName}: asset "${opts.asset}" does not match manifest.asset "${manifest.asset}"`
3514
+ );
3515
+ }
3516
+ if (opts.network !== manifest.network) {
3517
+ throw new RouteDockManifestError(
3518
+ `${adapterName}: network "${opts.network}" does not match manifest.network "${manifest.network}"`
3519
+ );
3520
+ }
3521
+ let signerPublicKey;
3522
+ try {
3523
+ signerPublicKey = Keypair.fromSecret(opts.payeeSecretKey).publicKey();
3524
+ } catch (err) {
3525
+ throw new RouteDockManifestError(`${adapterName}: payeeSecretKey is not a valid Stellar secret key`, { cause: err });
3526
+ }
3527
+ if (signerPublicKey !== manifest.payee) {
3528
+ throw new RouteDockManifestError(
3529
+ `${adapterName}: payeeSecretKey belongs to ${signerPublicKey}, not manifest.payee ${manifest.payee}; clients would reject the signed manifest`
3530
+ );
3531
+ }
3532
+ if (opts.payee !== manifest.payee) {
3533
+ throw new RouteDockManifestError(
3534
+ `${adapterName}: payee "${opts.payee}" does not match manifest.payee "${manifest.payee}"`
3535
+ );
3536
+ }
3537
+ }
2993
3538
  function extractPayerAddress(key) {
2994
3539
  if (typeof key !== "string") return null;
2995
3540
  return StrKey.isValidEd25519PublicKey(key) || StrKey.isValidMed25519PublicKey(key) ? key : null;
@@ -2998,6 +3543,18 @@ function extractPayerAddress(key) {
2998
3543
  // src/provider/SeenTxStore.ts
2999
3544
  var SETTLEMENT_REPLAY_WINDOW_MS = 6e4;
3000
3545
  var MAX_SETTLEMENT_REPLAYS = 1;
3546
+ async function recordSettlement(store, key, record, ctx) {
3547
+ try {
3548
+ await store.set(key, record);
3549
+ } catch (err) {
3550
+ ctx.logger("error", `${ctx.label} seenTxStore.set failed after settlement`, { error: err });
3551
+ try {
3552
+ ctx.onCallbackError?.(err, "seenTxStore.set");
3553
+ } catch (cbErr) {
3554
+ ctx.logger("error", `${ctx.label} onCallbackError threw`, { error: cbErr });
3555
+ }
3556
+ }
3557
+ }
3001
3558
  async function checkSettlementReplay(store, key, now = Date.now()) {
3002
3559
  const record = await store.get(key);
3003
3560
  if (!record) return { kind: "miss" };
@@ -3052,7 +3609,7 @@ var InMemorySeenTxStore = class {
3052
3609
  return next;
3053
3610
  }
3054
3611
  };
3055
- async function sha256Hex2(input) {
3612
+ async function sha256Hex(input) {
3056
3613
  const buf = new TextEncoder().encode(input);
3057
3614
  const digest = await crypto.subtle.digest("SHA-256", buf);
3058
3615
  return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
@@ -3060,22 +3617,11 @@ async function sha256Hex2(input) {
3060
3617
  async function paymentIdempotencyKey(getHeader, scope) {
3061
3618
  const material = getHeader("payment-signature") ?? getHeader("x-payment") ?? getHeader("authorization");
3062
3619
  if (!material) return null;
3063
- return sha256Hex2(
3620
+ return sha256Hex(
3064
3621
  [scope.method, scope.path, scope.amount, scope.payTo, material].join("\n")
3065
3622
  );
3066
3623
  }
3067
3624
 
3068
- // src/provider/settleResult.ts
3069
- function readSettleResult(result) {
3070
- const settle = result;
3071
- const failed = (reason) => reason ? { ok: false, reason } : { ok: false };
3072
- if (!settle || settle.success !== true) return failed(settle?.errorReason);
3073
- if (typeof settle.transaction !== "string" || settle.transaction.length === 0) {
3074
- return failed(settle.errorReason);
3075
- }
3076
- return { ok: true, txHash: settle.transaction };
3077
- }
3078
-
3079
3625
  // src/provider/x402Handler.ts
3080
3626
  var CAIP22 = {
3081
3627
  testnet: "stellar:testnet",
@@ -3215,10 +3761,10 @@ function createX402Handler(opts) {
3215
3761
  requirements
3216
3762
  );
3217
3763
  if (!verifyResult.isValid) {
3218
- res.status(401).json({
3219
- error: "Payment verification failed",
3220
- reason: verifyResult.invalidReason
3221
- });
3764
+ await respondPaymentRequired(
3765
+ "Payment verification failed",
3766
+ verifyResult.invalidReason
3767
+ );
3222
3768
  return;
3223
3769
  }
3224
3770
  const settleResult = await localFacilitator.settle(
@@ -3244,7 +3790,12 @@ function createX402Handler(opts) {
3244
3790
  if (typeof paymentResponse === "string") {
3245
3791
  headers["X-Payment-Response"] = paymentResponse;
3246
3792
  }
3247
- await seenTxStore.set(idempotencyKey, { txHash, headers, createdAt: Date.now() });
3793
+ await recordSettlement(
3794
+ seenTxStore,
3795
+ idempotencyKey,
3796
+ { txHash, headers, createdAt: Date.now() },
3797
+ { logger, label: "[x402]", onCallbackError: opts.onCallbackError }
3798
+ );
3248
3799
  }
3249
3800
  if (txHash && opts.onSettled) {
3250
3801
  Promise.resolve().then(() => opts.onSettled(txHash, opts.amount, "x402", payerAddress)).catch((err) => {
@@ -3274,21 +3825,21 @@ function createMppChargeHandler(opts) {
3274
3825
  const logger = resolveLogger(opts.logger);
3275
3826
  const seenTxStore = opts.seenTxStore ?? new InMemorySeenTxStore({ logger });
3276
3827
  const mppxInstances = /* @__PURE__ */ new Map();
3277
- function getMppx(contract) {
3278
- let instance = mppxInstances.get(contract);
3828
+ function getMppx(contract2) {
3829
+ let instance = mppxInstances.get(contract2);
3279
3830
  if (!instance) {
3280
3831
  instance = Mppx$1.create({
3281
3832
  secretKey: opts.payeeSecretKey,
3282
3833
  methods: [
3283
3834
  stellar$3.charge({
3284
3835
  recipient,
3285
- currency: contract,
3836
+ currency: contract2,
3286
3837
  network: networkId,
3287
3838
  feePayer: { envelopeSigner: opts.payeeSecretKey }
3288
3839
  })
3289
3840
  ]
3290
3841
  });
3291
- mppxInstances.set(contract, instance);
3842
+ mppxInstances.set(contract2, instance);
3292
3843
  }
3293
3844
  return instance;
3294
3845
  }
@@ -3309,7 +3860,7 @@ function createMppChargeHandler(opts) {
3309
3860
  const credPart = authHeader.replace(/^Payment\s+/, "").split(",").find((p) => p.trim().startsWith("credential="));
3310
3861
  if (credPart) {
3311
3862
  const b64 = credPart.split("=").slice(1).join("=").replace(/^"|"$/g, "");
3312
- const credJson = Buffer.from(b64, "base64").toString("utf8");
3863
+ const credJson = base64ToUtf8(b64);
3313
3864
  const cred = JSON.parse(credJson);
3314
3865
  const key = cred.sender ?? cred.payload?.sender ?? cred.payload?.from;
3315
3866
  payerAddress = extractPayerAddress(key);
@@ -3371,18 +3922,19 @@ function createMppChargeHandler(opts) {
3371
3922
  if (receiptHeader) {
3372
3923
  try {
3373
3924
  const parsed = JSON.parse(
3374
- Buffer.from(receiptHeader, "base64").toString("utf8")
3925
+ base64ToUtf8(receiptHeader)
3375
3926
  );
3376
3927
  reference = parsed.reference;
3377
3928
  } catch {
3378
3929
  }
3379
3930
  }
3380
3931
  if (idempotencyKey) {
3381
- await seenTxStore.set(idempotencyKey, {
3382
- txHash: reference ?? null,
3383
- headers: receiptHeaders,
3384
- createdAt: Date.now()
3385
- });
3932
+ await recordSettlement(
3933
+ seenTxStore,
3934
+ idempotencyKey,
3935
+ { txHash: reference ?? null, headers: receiptHeaders, createdAt: Date.now() },
3936
+ { logger, label: "[mpp-charge]", onCallbackError: opts.onCallbackError }
3937
+ );
3386
3938
  }
3387
3939
  if (reference && opts.onSettled) {
3388
3940
  Promise.resolve().then(() => opts.onSettled(reference, opts.amount, "mpp-charge", payerAddress)).catch((err) => {
@@ -3396,6 +3948,14 @@ function createMppChargeHandler(opts) {
3396
3948
  }
3397
3949
  };
3398
3950
  }
3951
+
3952
+ // src/provider/internal/vaultRpc.ts
3953
+ async function createSorobanRpcServer(rpcUrl) {
3954
+ const { Server } = await import('@stellar/stellar-sdk/rpc');
3955
+ return new Server(rpcUrl);
3956
+ }
3957
+
3958
+ // src/provider/internal/vaultSettlement.ts
3399
3959
  function resolveVaultSettlementAddresses(payer, payee) {
3400
3960
  if (!payer) return null;
3401
3961
  return { payer, payee };
@@ -3415,6 +3975,111 @@ function buildSessionSettlementTransaction(source, vaultContractId, channelId, a
3415
3975
  networkPassphrase
3416
3976
  }).addOperation(op).setTimeout(30).build();
3417
3977
  }
3978
+ var DEFAULT_RPC_URLS = {
3979
+ testnet: "https://soroban-testnet.stellar.org",
3980
+ mainnet: "https://mainnet.sorobanrpc.com"
3981
+ };
3982
+ var CONFIRM_TIMEOUT_MS = 12e4;
3983
+ var CONFIRM_POLL_MS = 1e3;
3984
+ function delay(ms) {
3985
+ return new Promise((resolve) => setTimeout(resolve, ms));
3986
+ }
3987
+ function describeSendError(sent) {
3988
+ const errorResult = sent.errorResult;
3989
+ try {
3990
+ const name = errorResult?.result?.().switch?.().name;
3991
+ if (name) return `sendTransaction returned ERROR: ${name}`;
3992
+ } catch {
3993
+ }
3994
+ return `sendTransaction returned status ${sent.status}`;
3995
+ }
3996
+ async function waitForConfirmation(server, txHash) {
3997
+ const deadline = Date.now() + CONFIRM_TIMEOUT_MS;
3998
+ for (; ; ) {
3999
+ const tx = await server.getTransaction(txHash);
4000
+ if (tx.status === "SUCCESS") return "success";
4001
+ if (tx.status === "FAILED") return "failed";
4002
+ if (Date.now() >= deadline) return "timeout";
4003
+ await delay(CONFIRM_POLL_MS);
4004
+ }
4005
+ }
4006
+ async function recordSessionSettlement(request, { logger, onCallbackError }) {
4007
+ const reportFailure = (fields) => {
4008
+ logger("error", "[vault] failed to record session_settled", {
4009
+ channelId: request.channelId,
4010
+ ...fields.txHash ? { txHash: fields.txHash } : {},
4011
+ reason: fields.reason,
4012
+ ...fields.error !== void 0 ? { error: fields.error } : {}
4013
+ });
4014
+ if (onCallbackError) {
4015
+ const err = fields.error ?? new Error(`session_settled vault record failed: ${fields.reason}`);
4016
+ onCallbackError(err, "vaultSettlement");
4017
+ }
4018
+ return { ok: false, reason: fields.reason, ...fields.txHash ? { txHash: fields.txHash } : {} };
4019
+ };
4020
+ try {
4021
+ const payeeKeypair = Keypair.fromSecret(request.payeeSecretKey);
4022
+ const addresses = resolveVaultSettlementAddresses(request.payer, payeeKeypair.publicKey());
4023
+ if (!addresses) {
4024
+ logger("warn", "[vault] skipped session_settled record: payer address unavailable", {
4025
+ channelId: request.channelId
4026
+ });
4027
+ return { ok: false, reason: "payer address unavailable" };
4028
+ }
4029
+ const networkPassphrase = request.network === "mainnet" ? Networks.PUBLIC : Networks.TESTNET;
4030
+ const rpcUrl = request.config.rpcUrl ?? DEFAULT_RPC_URLS[request.network];
4031
+ const server = await createSorobanRpcServer(rpcUrl);
4032
+ const sourceAccount = await server.getAccount(payeeKeypair.publicKey());
4033
+ const tx = buildSessionSettlementTransaction(
4034
+ sourceAccount,
4035
+ request.config.contract,
4036
+ request.channelId,
4037
+ addresses,
4038
+ request.amount,
4039
+ request.voucherCount,
4040
+ networkPassphrase
4041
+ );
4042
+ const preparedTx = await server.prepareTransaction(tx);
4043
+ preparedTx.sign(payeeKeypair);
4044
+ const sent = await server.sendTransaction(preparedTx);
4045
+ if (sent.status === "ERROR") {
4046
+ return reportFailure({
4047
+ txHash: sent.hash || preparedTx.hash().toString("hex"),
4048
+ reason: describeSendError(sent)
4049
+ });
4050
+ }
4051
+ const txHash = sent.hash || preparedTx.hash().toString("hex");
4052
+ const confirmation = await waitForConfirmation(server, txHash);
4053
+ if (confirmation === "success") {
4054
+ logger("debug", "[vault] recorded session_settled", {
4055
+ channelId: request.channelId,
4056
+ txHash
4057
+ });
4058
+ return { ok: true, txHash };
4059
+ }
4060
+ return reportFailure({
4061
+ txHash,
4062
+ reason: confirmation === "failed" ? "transaction failed on-chain" : `confirmation timed out after ${CONFIRM_TIMEOUT_MS}ms`
4063
+ });
4064
+ } catch (err) {
4065
+ const reason = err instanceof Error ? err.message : String(err);
4066
+ return reportFailure({ reason, error: err });
4067
+ }
4068
+ }
4069
+
4070
+ // src/internal/vaultConfig.ts
4071
+ function resolveVaultSettlementConfig(option, logger) {
4072
+ if (option) return option;
4073
+ if (typeof process === "undefined") return void 0;
4074
+ const fromEnv = process.env.AGENT_VAULT_CONTRACT ?? process.env.AGENT_VAULT_CONTRACT_ID;
4075
+ if (!fromEnv) return void 0;
4076
+ logger(
4077
+ "warn",
4078
+ "[vault] configuring the vault contract via the AGENT_VAULT_CONTRACT/AGENT_VAULT_CONTRACT_ID environment variables is deprecated and will be removed in the next minor release \u2014 pass the vaultSettlement option instead",
4079
+ {}
4080
+ );
4081
+ return { contract: fromEnv };
4082
+ }
3418
4083
  function channelAuthorizer(envelopeSigner) {
3419
4084
  return { envelopeSigner };
3420
4085
  }
@@ -3493,6 +4158,7 @@ function createMppSessionHandler(opts) {
3493
4158
  let record = null;
3494
4159
  let voucherCount = 0;
3495
4160
  let sessionOpened = false;
4161
+ let orphanFlagged = false;
3496
4162
  let idleTimer = null;
3497
4163
  let settledCleanly = false;
3498
4164
  let closeListenerArmed = false;
@@ -3518,6 +4184,14 @@ function createMppSessionHandler(opts) {
3518
4184
  return null;
3519
4185
  }
3520
4186
  record = { amount: BigInt(stored.amount), signature: stored.signature, payer: stored.payer ?? null };
4187
+ if (typeof stored.voucherCount === "number") {
4188
+ voucherCount = stored.voucherCount;
4189
+ }
4190
+ if (typeof stored.sessionOpened === "boolean") {
4191
+ sessionOpened = stored.sessionOpened;
4192
+ } else if (record !== null) {
4193
+ sessionOpened = true;
4194
+ }
3521
4195
  } catch {
3522
4196
  return null;
3523
4197
  }
@@ -3525,8 +4199,8 @@ function createMppSessionHandler(opts) {
3525
4199
  }
3526
4200
  async function flagOrphan(reason) {
3527
4201
  await loadPersistedRecord();
3528
- if (!sessionOpened || settledCleanly) return;
3529
- sessionOpened = false;
4202
+ if (!sessionOpened || settledCleanly || orphanFlagged) return;
4203
+ orphanFlagged = true;
3530
4204
  closeListenerArmed = false;
3531
4205
  clearIdleTimer();
3532
4206
  const cumulativeAmount = record ? (Number(record.amount) / 1e7).toFixed(7) : "0.0000000";
@@ -3574,16 +4248,20 @@ function createMppSessionHandler(opts) {
3574
4248
  if (record && amount < record.amount) return;
3575
4249
  const payer = record?.payer ?? extractPayerAddress(credential.source);
3576
4250
  record = { amount, signature: payload.signature, payer };
4251
+ voucherCount++;
4252
+ settledCleanly = false;
4253
+ orphanFlagged = false;
4254
+ armIdleTimer();
4255
+ const wasOpened = sessionOpened;
4256
+ sessionOpened = true;
3577
4257
  await innerStore.put(voucherRecordKey, {
3578
4258
  amount: record.amount.toString(),
3579
4259
  signature: record.signature,
3580
- payer: record.payer
4260
+ payer: record.payer,
4261
+ voucherCount,
4262
+ sessionOpened: true
3581
4263
  });
3582
- voucherCount++;
3583
- settledCleanly = false;
3584
- armIdleTimer();
3585
- if (!sessionOpened) {
3586
- sessionOpened = true;
4264
+ if (!wasOpened) {
3587
4265
  if (opts.onSessionOpen) {
3588
4266
  Promise.resolve().then(() => opts.onSessionOpen(opts.channelFactory, record.payer)).catch((err) => {
3589
4267
  logger("error", "[mpp-session] onSessionOpen callback error", { error: err });
@@ -3635,10 +4313,11 @@ function createMppSessionHandler(opts) {
3635
4313
  }
3636
4314
  if (closeAmount > 0n && closeSig) {
3637
4315
  const closePayer = record?.payer ?? null;
4316
+ const vouchersClosed = voucherCount;
3638
4317
  const closeTxHash = await close({
3639
4318
  channel: opts.channelFactory,
3640
4319
  amount: closeAmount,
3641
- signature: Buffer.from(closeSig, "hex"),
4320
+ signature: hexToBytes(closeSig),
3642
4321
  feePayer: channelAuthorizer(payeeKeypair),
3643
4322
  network: networkId
3644
4323
  });
@@ -3651,46 +4330,28 @@ function createMppSessionHandler(opts) {
3651
4330
  opts.onCallbackError?.(err, "onSettled");
3652
4331
  });
3653
4332
  }
3654
- const vaultContract = process.env.AGENT_VAULT_CONTRACT;
3655
- if (vaultContract) {
3656
- const settlementAddresses = resolveVaultSettlementAddresses(
3657
- closePayer,
3658
- payeeKeypair.publicKey()
4333
+ res.json({ closeTxHash });
4334
+ const vaultConfig = resolveVaultSettlementConfig(opts.vaultSettlement, logger);
4335
+ if (vaultConfig) {
4336
+ await recordSessionSettlement(
4337
+ {
4338
+ config: vaultConfig,
4339
+ network: opts.network,
4340
+ payeeSecretKey: opts.payeeSecretKey,
4341
+ channelId: opts.channelFactory,
4342
+ payer: closePayer,
4343
+ amount: closeAmount,
4344
+ voucherCount: vouchersClosed
4345
+ },
4346
+ { logger, onCallbackError: opts.onCallbackError }
3659
4347
  );
3660
- if (!settlementAddresses) {
3661
- logger("warn", "[mpp-session] skipped session_settled vault record: payer address unavailable");
3662
- } else {
3663
- try {
3664
- const { Networks: Networks2 } = await import('@stellar/stellar-sdk');
3665
- const { Server } = await import('@stellar/stellar-sdk/rpc');
3666
- const networkPassphrase = opts.network === "mainnet" ? Networks2.PUBLIC : Networks2.TESTNET;
3667
- const rpcUrl = opts.network === "mainnet" ? "https://mainnet.sorobanrpc.com" : "https://soroban-testnet.stellar.org";
3668
- const server = new Server(rpcUrl);
3669
- const payeePublicKey = payeeKeypair.publicKey();
3670
- const sourceAccount = await server.getAccount(payeePublicKey);
3671
- const tx = buildSessionSettlementTransaction(
3672
- sourceAccount,
3673
- vaultContract,
3674
- opts.channelFactory,
3675
- settlementAddresses,
3676
- closeAmount,
3677
- voucherCount,
3678
- networkPassphrase
3679
- );
3680
- const preparedTx = await server.prepareTransaction(tx);
3681
- preparedTx.sign(payeeKeypair);
3682
- await server.sendTransaction(preparedTx);
3683
- } catch (recordErr) {
3684
- logger("error", "[mpp-session] failed to record session_settled on vault", { error: recordErr });
3685
- }
3686
- }
3687
4348
  }
3688
- res.json({ closeTxHash });
3689
4349
  } else {
3690
4350
  res.json({ closeTxHash: null, message: "no vouchers received" });
3691
4351
  }
3692
4352
  sessionOpened = false;
3693
4353
  voucherCount = 0;
4354
+ orphanFlagged = false;
3694
4355
  record = null;
3695
4356
  await innerStore.delete(voucherRecordKey);
3696
4357
  closeListenerArmed = false;
@@ -3727,6 +4388,7 @@ function createMppSessionHandler(opts) {
3727
4388
  var MPP_MODES = ["mpp-session", "mpp-charge"];
3728
4389
  function routedock(opts) {
3729
4390
  const startTime = Date.now();
4391
+ assertProviderConfig(opts, "routedock");
3730
4392
  const signedManifest = signManifest(opts.manifest, opts.payeeSecretKey);
3731
4393
  const handlerMap = /* @__PURE__ */ new Map();
3732
4394
  if (opts.modes.includes("x402")) {
@@ -3790,7 +4452,8 @@ function routedock(opts) {
3790
4452
  ...opts.onCallbackError ? { onCallbackError: opts.onCallbackError } : {},
3791
4453
  ...opts.onOrphaned ? { onOrphaned: opts.onOrphaned } : {},
3792
4454
  ...opts.idleTimeoutMs != null ? { idleTimeoutMs: opts.idleTimeoutMs } : {},
3793
- ...opts.logger ? { logger: opts.logger } : {}
4455
+ ...opts.logger ? { logger: opts.logger } : {},
4456
+ ...opts.vaultSettlement ? { vaultSettlement: opts.vaultSettlement } : {}
3794
4457
  })
3795
4458
  );
3796
4459
  }
@@ -3880,6 +4543,8 @@ var MPP_NETWORK3 = {
3880
4543
  testnet: "stellar:testnet",
3881
4544
  mainnet: "stellar:pubnet"
3882
4545
  };
4546
+ var DEFAULT_MAX_RECONCILE_ATTEMPTS = 5;
4547
+ var ALREADY_CLOSED_PATTERN = /channel has been closed|channel is closed|already closed|finalized/i;
3883
4548
  function decimalToStroops(amountStr) {
3884
4549
  return usdcToStroops(amountStr);
3885
4550
  }
@@ -3889,12 +4554,15 @@ async function reconcileAbandonedSessions(opts) {
3889
4554
  recoveredCount: 0,
3890
4555
  skippedCount: 0,
3891
4556
  failedCount: 0,
4557
+ alreadyClosedCount: 0,
4558
+ terminalCount: 0,
3892
4559
  errors: []
3893
4560
  };
4561
+ const maxAttempts = opts.maxReconcileAttempts ?? DEFAULT_MAX_RECONCILE_ATTEMPTS;
3894
4562
  const payeeKeypair = Keypair.fromSecret(opts.payeeSecretKey);
3895
4563
  const networkId = MPP_NETWORK3[opts.network];
3896
4564
  try {
3897
- 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);
4565
+ const { data: closingSessions, error } = await opts.supabase.from("sessions").select("channel_id, channel_contract, cumulative_amount, last_signature, settlement_tx_hash, reconcile_attempts").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);
3898
4566
  if (error) {
3899
4567
  throw new Error(`Failed to query sessions: ${error.message}`);
3900
4568
  }
@@ -3916,7 +4584,7 @@ async function reconcileAbandonedSessions(opts) {
3916
4584
  continue;
3917
4585
  }
3918
4586
  const closeAmount = decimalToStroops(cumulative_amount);
3919
- const closeSig = Buffer.from(last_signature, "hex");
4587
+ const closeSig = hexToBytes(last_signature);
3920
4588
  const closeFn = opts.channelClose ?? (await import('@stellar/mpp/channel/server')).close;
3921
4589
  const closeTxHash = await closeFn({
3922
4590
  channel: channelId,
@@ -3939,11 +4607,36 @@ async function reconcileAbandonedSessions(opts) {
3939
4607
  await opts.onRecovered(channelId, closeTxHash, totalPaid);
3940
4608
  }
3941
4609
  } catch (err) {
4610
+ const reason = formatMppError(err);
4611
+ if (ALREADY_CLOSED_PATTERN.test(reason)) {
4612
+ const { error: closeError } = await opts.supabase.from("sessions").update({
4613
+ status: "closed",
4614
+ last_reconcile_error: `channel already closed on-chain: ${reason}`,
4615
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
4616
+ }).eq("channel_id", session.channel_id);
4617
+ if (!closeError) {
4618
+ stats.alreadyClosedCount++;
4619
+ continue;
4620
+ }
4621
+ }
3942
4622
  stats.failedCount++;
3943
- stats.errors.push({
3944
- channelId: session.channel_id,
3945
- reason: formatMppError(err)
3946
- });
4623
+ stats.errors.push({ channelId: session.channel_id, reason });
4624
+ const attempts = (Number(session.reconcile_attempts) || 0) + 1;
4625
+ const terminal = attempts >= maxAttempts;
4626
+ const { error: bookkeepingError } = await opts.supabase.from("sessions").update({
4627
+ reconcile_attempts: attempts,
4628
+ last_reconcile_error: reason,
4629
+ updated_at: (/* @__PURE__ */ new Date()).toISOString(),
4630
+ ...terminal ? { status: "failed" } : {}
4631
+ }).eq("channel_id", session.channel_id);
4632
+ if (bookkeepingError) {
4633
+ stats.errors.push({
4634
+ channelId: session.channel_id,
4635
+ reason: `Failed to record reconcile failure: ${bookkeepingError.message}`
4636
+ });
4637
+ } else if (terminal) {
4638
+ stats.terminalCount++;
4639
+ }
3947
4640
  }
3948
4641
  }
3949
4642
  return stats;
@@ -3964,7 +4657,7 @@ async function runStartupReconciliation(opts, logger) {
3964
4657
  }
3965
4658
  log(
3966
4659
  "info",
3967
- `[SessionReconciler] Found ${stats.orphanedCount} orphaned session(s). Recovered: ${stats.recoveredCount}, Skipped: ${stats.skippedCount}, Failed: ${stats.failedCount}`
4660
+ `[SessionReconciler] Found ${stats.orphanedCount} orphaned session(s). Recovered: ${stats.recoveredCount}, Skipped: ${stats.skippedCount}, Failed: ${stats.failedCount}, Already closed: ${stats.alreadyClosedCount}, Moved to failed: ${stats.terminalCount}`
3968
4661
  );
3969
4662
  if (stats.errors.length > 0) {
3970
4663
  log("warn", "[SessionReconciler] Reconciliation errors:");
@@ -3981,7 +4674,7 @@ function tryDecodeBase64(value) {
3981
4674
  return value;
3982
4675
  }
3983
4676
  try {
3984
- const decoded = typeof Buffer !== "undefined" ? Buffer.from(value, "base64").toString("utf-8") : atob(value);
4677
+ const decoded = base64ToUtf8(value);
3985
4678
  if (decoded.length > 0 && decoded !== value) return decoded;
3986
4679
  } catch {
3987
4680
  }
@@ -4118,4 +4811,4 @@ var ProviderRegistry = class {
4118
4811
  }
4119
4812
  };
4120
4813
 
4121
- export { DEFAULT_MAX_SESSION_DURATION_MS, DEFAULT_RETRY_POLICY, FileSpendStore, InMemorySpendStore, MppChargeClient, NulthPolicyError, OnChainRegistry, ProviderRegistry, RouteDockChannelStateError, RouteDockClient, RouteDockClientVersionError, RouteDockDisputeError, RouteDockError, RouteDockFacilitatorError, RouteDockManifestError, RouteDockManifestSunsetError, RouteDockManifestTimeoutError, RouteDockNetworkError, RouteDockNoSupportedModeError, RouteDockPolicyRejectError, RouteDockPolicyRejectedError, RouteDockRefundWindowError, RouteDockSessionError, RouteDockSignatureError, RouteDockTrustlineError, RouteDockVoucherMonotonicityError, SupabaseSeenTxStore, SupabaseSessionStore, X402Client, assertEndpointActive, assertManifestValid, assertNulthVaultManifest, backoffDelayMs, configureManifestCache, consoleLogger, createConsoleLogger, createNulthSigner, createPolicyState, decimalToStroops, decodeAuthSignature, fetchLedgerSequence, fetchManifest, getEligibleAssets, httpStatusToError, invalidateManifest, isAssetEligible, manifestDigest, noopLogger, normalizeManifestAssets, parseRetryAfterMs, paymentContextFromManifest, prepareNulthSigner, rankProvidersByLatency, reconcileAbandonedSessions, registerProvider, resolveAssetContract, resolveLogger, routedock, runStartupReconciliation, selectAsset, selectMode, setNulthPaymentContext, signManifest, usdcToMicros, usdcToUnits, verifyManifestSignature, withRetry, wrapFetchError, wrapMppError };
4814
+ export { DEFAULT_MAX_SESSION_DURATION_MS, DEFAULT_RETRY_POLICY, FileSpendStore, InMemorySpendStore, MppChargeClient, OnChainRegistry, ProviderRegistry, RouteDockChannelStateError, RouteDockClient, RouteDockClientVersionError, RouteDockDisputeError, RouteDockError, RouteDockFacilitatorError, RouteDockManifestError, RouteDockManifestSunsetError, RouteDockManifestTimeoutError, RouteDockNetworkError, RouteDockNoSupportedModeError, RouteDockPaymentRejectedError, RouteDockPolicyRejectError, RouteDockPolicyRejectedError, RouteDockRefundWindowError, RouteDockSessionError, RouteDockSignatureError, RouteDockTrustlineError, RouteDockVoucherMonotonicityError, SupabaseSeenTxStore, SupabaseSessionStore, X402Client, assertEndpointActive, assertManifestValid, assertNulthVaultManifest, backoffDelayMs, configureManifestCache, consoleLogger, createConsoleLogger, createNulthSigner, decimalToStroops, fetchLedgerSequence, fetchManifest, getEligibleAssets, httpStatusToError, invalidateManifest, isAssetEligible, manifestDigest, noopLogger, normalizeManifestAssets, parseRetryAfterMs, paymentRejectedError, prepareNulthSigner, rankProvidersByLatency, reconcileAbandonedSessions, registerProvider, resolveAssetContract, resolveLogger, routedock, runStartupReconciliation, selectAsset, selectMode, signManifest, usdcToMicros, usdcToUnits, verifyManifestSignature, withRetry, wrapFetchError, wrapMppError };