@routedock/routedock 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +305 -0
  2. package/dist/MppSessionHandler-1EL8QBAn.d.cts +215 -0
  3. package/dist/MppSessionHandler-Bq4S-JTE.d.ts +215 -0
  4. package/dist/{ProviderRegistry-C50nyhaF.d.ts → ProviderRegistry-CA_TdREG.d.ts} +2 -2
  5. package/dist/{ProviderRegistry-BJ9oJLVz.d.cts → ProviderRegistry-D-0rqK0N.d.cts} +2 -2
  6. package/dist/{RouteDockClient-B1roCa8h.d.cts → RouteDockClient-Bh86bVly.d.ts} +23 -9
  7. package/dist/{RouteDockClient-BbkqjhMG.d.ts → RouteDockClient-go9aopfF.d.cts} +23 -9
  8. package/dist/{SessionReconciler-Dscw43F3.d.ts → SessionReconciler-Bw-PoBVl.d.ts} +14 -5
  9. package/dist/{SessionReconciler-bi0z2qtN.d.cts → SessionReconciler-Cz2nuOlJ.d.cts} +14 -5
  10. package/dist/{SessionStore-DvkZv8-E.d.ts → SessionStore-B4-Hi4H8.d.cts} +34 -2
  11. package/dist/{SessionStore-BpK8vv0L.d.cts → SessionStore-BaYiegP0.d.ts} +34 -2
  12. package/dist/client.cjs +429 -117
  13. package/dist/client.d.cts +9 -8
  14. package/dist/client.d.ts +9 -8
  15. package/dist/client.js +421 -119
  16. package/dist/index.cjs +652 -220
  17. package/dist/index.d.cts +8 -8
  18. package/dist/index.d.ts +8 -8
  19. package/dist/index.js +643 -221
  20. package/dist/{types-DmhmfLzP.d.cts → logger-D8_Q2ytp.d.cts} +58 -1
  21. package/dist/{types-DmhmfLzP.d.ts → logger-D8_Q2ytp.d.ts} +58 -1
  22. package/dist/provider/fastify.cjs +306 -89
  23. package/dist/provider/fastify.d.cts +9 -4
  24. package/dist/provider/fastify.d.ts +9 -4
  25. package/dist/provider/fastify.js +305 -88
  26. package/dist/provider/hono.cjs +286 -82
  27. package/dist/provider/hono.d.cts +9 -7
  28. package/dist/provider/hono.d.ts +9 -7
  29. package/dist/provider/hono.js +282 -78
  30. package/dist/provider.cjs +326 -98
  31. package/dist/provider.d.cts +14 -10
  32. package/dist/provider.d.ts +14 -10
  33. package/dist/provider.js +318 -98
  34. package/dist/react.cjs +407 -117
  35. package/dist/react.d.cts +2 -2
  36. package/dist/react.d.ts +2 -2
  37. package/dist/react.js +408 -118
  38. package/dist/{routedockMiddleware-BlDsHuPl.d.ts → routedockMiddleware-57gUaEMf.d.ts} +9 -4
  39. package/dist/{routedockMiddleware-D97A5ptQ.d.cts → routedockMiddleware-DlyLrxp7.d.cts} +9 -4
  40. package/dist/schema.cjs +40 -0
  41. package/dist/schema.js +40 -0
  42. package/dist/{sign-Dpj2Mp7k.d.ts → sign-AKH67sft.d.cts} +1 -1
  43. package/dist/{sign-CUf3rhTx.d.cts → sign-BEq-OsSq.d.ts} +1 -1
  44. package/dist/testing.cjs +6 -4
  45. package/dist/testing.d.cts +32 -13
  46. package/dist/testing.d.ts +32 -13
  47. package/dist/testing.js +6 -4
  48. package/package.json +2 -2
  49. package/dist/MppSessionHandler-B4TyD7Xg.d.cts +0 -121
  50. package/dist/MppSessionHandler-CLB5SzgN.d.ts +0 -121
package/dist/react.js CHANGED
@@ -2,7 +2,7 @@ import { createContext, useContext, useMemo, useEffect, useState, useRef, useCal
2
2
  import { jsx } from 'react/jsx-runtime';
3
3
  import { Keypair, Horizon, Networks, rpc, xdr, Address, scValToNative, hash } from '@stellar/stellar-sdk';
4
4
  import { Validator } from '@cfworker/json-schema';
5
- import { usdcToStroops } from '@routedock/nulth-sdk';
5
+ import { usdcToStroops, stroopsToUsdc } from '@routedock/nulth-sdk';
6
6
  import { createHash } from 'crypto';
7
7
  import { createEd25519Signer } from '@x402/stellar';
8
8
  import { ExactStellarScheme } from '@x402/stellar/exact/client';
@@ -161,6 +161,31 @@ function wrapFetchError(err, context) {
161
161
  if (err instanceof RouteDockError) return err;
162
162
  return new RouteDockNetworkError(`${context}: ${String(err)}`, { cause: err });
163
163
  }
164
+ function wrapMppError(err, context) {
165
+ if (err instanceof RouteDockError) return err;
166
+ if (err instanceof TypeError || err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) {
167
+ return new RouteDockNetworkError(`${context}: ${String(err)}`, { cause: err });
168
+ }
169
+ return new RouteDockSignatureError(`${context}: ${String(err)}`, { cause: err });
170
+ }
171
+
172
+ // src/internal/logger.ts
173
+ function createConsoleLogger() {
174
+ return (level, message, fields) => {
175
+ const values = fields ? Object.values(fields) : [];
176
+ if (values.length > 0) {
177
+ console[level](message, ...values);
178
+ } else {
179
+ console[level](message);
180
+ }
181
+ };
182
+ }
183
+ var consoleLogger = createConsoleLogger();
184
+ var noopLogger = () => {
185
+ };
186
+ function resolveLogger(logger, fallback = consoleLogger) {
187
+ return logger ?? fallback;
188
+ }
164
189
 
165
190
  // src/internal/retry.ts
166
191
  var DEFAULT_RETRY_POLICY = {
@@ -176,10 +201,11 @@ function sleep(ms) {
176
201
  return new Promise((resolve) => setTimeout(resolve, ms));
177
202
  }
178
203
  async function withRetry(fn, policy = {}) {
179
- const { maxAttempts, baseDelayMs, maxDelayMs, onRetry } = {
204
+ const { maxAttempts, baseDelayMs, maxDelayMs, onRetry, logger } = {
180
205
  ...DEFAULT_RETRY_POLICY,
181
206
  ...policy
182
207
  };
208
+ const log = resolveLogger(logger);
183
209
  let lastError;
184
210
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
185
211
  try {
@@ -197,7 +223,7 @@ async function withRetry(fn, policy = {}) {
197
223
  try {
198
224
  await onRetry(attempt, err, delay);
199
225
  } catch (cbErr) {
200
- console.error("[withRetry] onRetry callback threw:", cbErr);
226
+ log("error", "[withRetry] onRetry callback threw:", { error: cbErr });
201
227
  }
202
228
  }
203
229
  await sleep(delay);
@@ -289,6 +315,14 @@ var routedock_schema_default = {
289
315
  type: "string",
290
316
  description: "Stellar Asset Contract (SAC) address for the payment asset"
291
317
  },
318
+ assets: {
319
+ type: "array",
320
+ minItems: 1,
321
+ items: {
322
+ $ref: "#/definitions/AssetConfig"
323
+ },
324
+ description: "Payment assets accepted by this provider. Each asset can be scoped to specific modes and endpoints. When present, assets[0] must match the root-level asset and asset_contract."
325
+ },
292
326
  payee: {
293
327
  type: "string",
294
328
  description: "Stellar address (G...) that receives payments"
@@ -421,6 +455,38 @@ var routedock_schema_default = {
421
455
  enum: ["x402", "mpp-charge", "mpp-session", "mpp-session-ws"],
422
456
  description: "A supported RouteDock payment mode"
423
457
  },
458
+ AssetConfig: {
459
+ type: "object",
460
+ description: "Payment asset configuration with optional mode and endpoint scoping",
461
+ required: ["asset", "asset_contract"],
462
+ additionalProperties: false,
463
+ properties: {
464
+ asset: {
465
+ type: "string",
466
+ description: "Asset ticker symbol (e.g. 'USDC' or 'XLM')"
467
+ },
468
+ asset_contract: {
469
+ type: "string",
470
+ description: "Stellar Asset Contract (SAC) address for the payment asset"
471
+ },
472
+ modes: {
473
+ type: "array",
474
+ items: {
475
+ $ref: "#/definitions/PaymentMode"
476
+ },
477
+ uniqueItems: true,
478
+ description: "Optional: restrict this asset to specific payment modes. If omitted, available for all modes."
479
+ },
480
+ endpoints: {
481
+ type: "array",
482
+ items: {
483
+ type: "string"
484
+ },
485
+ uniqueItems: true,
486
+ description: "Optional: restrict this asset to specific endpoints (by name). If omitted, available for all endpoints."
487
+ }
488
+ }
489
+ },
424
490
  PricingConfig: {
425
491
  type: "object",
426
492
  description: "Per-request pricing for x402 or mpp-charge modes",
@@ -566,7 +632,7 @@ var routedock_schema_default = {
566
632
 
567
633
  // package.json
568
634
  var package_default = {
569
- version: "0.2.0"};
635
+ version: "0.3.0"};
570
636
  var MANIFEST_SIGNATURE_VERSION = "2";
571
637
  function canonicalize(value) {
572
638
  if (Array.isArray(value)) {
@@ -680,6 +746,7 @@ function assertEndpointActive(manifest, url, logger) {
680
746
  const [key, descriptor] = deprecated;
681
747
  const sunsetSuffix = descriptor.sunset_at ? `; sunset_at ${descriptor.sunset_at}` : "";
682
748
  logger(
749
+ "warn",
683
750
  `[RouteDock] WARNING: ${manifest.name} \u2192 ${pathname}; endpoint '${key}' is deprecated${sunsetSuffix}`
684
751
  );
685
752
  }
@@ -701,6 +768,27 @@ function assertManifestValid(manifest, baseUrl) {
701
768
  }
702
769
  }
703
770
  }
771
+ if (manifest.assets !== void 0) {
772
+ if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
773
+ throw new RouteDockManifestError(
774
+ `Invalid manifest${context}: assets must be a non-empty array when defined`
775
+ );
776
+ }
777
+ for (let i = 0; i < manifest.assets.length; i++) {
778
+ const a = manifest.assets[i];
779
+ if (!a || typeof a.asset !== "string" || typeof a.asset_contract !== "string") {
780
+ throw new RouteDockManifestError(
781
+ `Invalid manifest${context}: assets[${i}] must define asset and asset_contract strings`
782
+ );
783
+ }
784
+ }
785
+ const first = manifest.assets[0];
786
+ if (first.asset !== manifest.asset || first.asset_contract !== manifest.asset_contract) {
787
+ throw new RouteDockManifestError(
788
+ `Invalid manifest${context}: assets[0] (${first.asset}:${first.asset_contract}) must match root asset fields (${manifest.asset}:${manifest.asset_contract})`
789
+ );
790
+ }
791
+ }
704
792
  }
705
793
  var CACHE_TTL_MS = 6e4;
706
794
  var DEFAULT_MANIFEST_CACHE_MAX_SIZE = 512;
@@ -874,16 +962,16 @@ function logSelection(manifest, selection, log, deprecated) {
874
962
  const reason = selection.reason ? ` (${selection.reason})` : "";
875
963
  if (deprecated) {
876
964
  log(
965
+ "warn",
877
966
  `[RouteDock] WARNING: ${manifest.name} \u2192 ${selection.mode}${reason}; selected deprecated mode because no active supported mode is available`
878
967
  );
879
968
  return;
880
969
  }
881
- log(`[RouteDock] ${manifest.name} \u2192 ${selection.mode}${reason}`);
970
+ log("info", `[RouteDock] ${manifest.name} \u2192 ${selection.mode}${reason}`);
882
971
  }
883
972
  function selectMode(manifest, options = {}) {
884
973
  const modes = manifest.modes;
885
- const log = options.logger ?? (() => {
886
- });
974
+ const log = options.logger ?? noopLogger;
887
975
  const deprecatedSet = new Set(manifest.deprecated_modes ?? []);
888
976
  if (options.forceMode) {
889
977
  if (!modes.includes(options.forceMode)) {
@@ -893,10 +981,11 @@ function selectMode(manifest, options = {}) {
893
981
  }
894
982
  if (deprecatedSet.has(options.forceMode)) {
895
983
  log(
984
+ "warn",
896
985
  `[RouteDock] WARNING: ${manifest.name} \u2192 ${options.forceMode} (forced); selected mode is deprecated`
897
986
  );
898
987
  } else {
899
- log(`[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
988
+ log("info", `[RouteDock] ${manifest.name} \u2192 ${options.forceMode} (forced)`);
900
989
  }
901
990
  return options.forceMode;
902
991
  }
@@ -916,6 +1005,69 @@ function selectMode(manifest, options = {}) {
916
1005
  `No supported payment mode found in manifest (modes: ${modes.join(", ")})`
917
1006
  );
918
1007
  }
1008
+
1009
+ // src/internal/payee.ts
1010
+ function resolvePayee(manifest, mode) {
1011
+ const override = manifest.pricing[mode]?.payee;
1012
+ return override ?? manifest.payee;
1013
+ }
1014
+
1015
+ // src/client/challenge.ts
1016
+ function isRecord(value) {
1017
+ return typeof value === "object" && value !== null;
1018
+ }
1019
+ function isPositiveIntegerString(value) {
1020
+ return typeof value === "string" && /^[1-9]\d*$/.test(value);
1021
+ }
1022
+ function checkAmount(amount, maxAmount) {
1023
+ if (!isPositiveIntegerString(amount)) {
1024
+ return new RouteDockPolicyRejectError("challenge_amount_invalid");
1025
+ }
1026
+ let max;
1027
+ try {
1028
+ max = usdcToStroops(maxAmount);
1029
+ } catch {
1030
+ return new RouteDockPolicyRejectError("challenge_amount_invalid");
1031
+ }
1032
+ if (BigInt(amount) > max) {
1033
+ return new RouteDockPolicyRejectError("challenge_amount_exceeds_manifest");
1034
+ }
1035
+ return null;
1036
+ }
1037
+ function checkX402Accept(accept, manifest, network, maxAmount) {
1038
+ if (accept.network !== network) {
1039
+ return new RouteDockPolicyRejectError("challenge_network_mismatch");
1040
+ }
1041
+ if (accept.payTo !== resolvePayee(manifest, "x402")) {
1042
+ return new RouteDockPolicyRejectError("challenge_payee_mismatch");
1043
+ }
1044
+ if (accept.asset !== manifest.asset_contract) {
1045
+ return new RouteDockPolicyRejectError("challenge_asset_mismatch");
1046
+ }
1047
+ return checkAmount(accept.amount, maxAmount);
1048
+ }
1049
+ function filterX402Accepts(accepts, manifest, network, maxAmount) {
1050
+ return accepts.filter((accept) => checkX402Accept(accept, manifest, network, maxAmount) === null);
1051
+ }
1052
+ function checkChargeChallenge(request, manifest, network, maxAmount) {
1053
+ const methodDetails = request["methodDetails"];
1054
+ const rawNetwork = isRecord(methodDetails) ? methodDetails["network"] : void 0;
1055
+ const challengeNetwork = rawNetwork === void 0 ? "stellar:testnet" : rawNetwork;
1056
+ if (challengeNetwork !== network) {
1057
+ return new RouteDockPolicyRejectError("challenge_network_mismatch");
1058
+ }
1059
+ const recipient = request["recipient"];
1060
+ if (typeof recipient !== "string" || recipient !== resolvePayee(manifest, "mpp-charge")) {
1061
+ return new RouteDockPolicyRejectError("challenge_payee_mismatch");
1062
+ }
1063
+ const currency = request["currency"];
1064
+ if (typeof currency !== "string" || currency !== manifest.asset_contract) {
1065
+ return new RouteDockPolicyRejectError("challenge_asset_mismatch");
1066
+ }
1067
+ return checkAmount(request["amount"], maxAmount);
1068
+ }
1069
+
1070
+ // src/client/x402Client.ts
919
1071
  var CAIP2 = {
920
1072
  testnet: "stellar:testnet",
921
1073
  mainnet: "stellar:pubnet"
@@ -947,83 +1099,106 @@ var X402Client = class _X402Client {
947
1099
  if (!pricing.facilitator) {
948
1100
  throw new RouteDockManifestError("manifest.pricing.x402.facilitator missing");
949
1101
  }
950
- return withRetry(async () => {
951
- let init;
1102
+ const caip2 = CAIP2[this.network];
1103
+ const probe = await withRetry(async () => {
1104
+ let init2;
952
1105
  try {
953
- init = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
1106
+ init2 = await fetch(url, { headers: { "X-Preferred-Mode": "x402" } });
954
1107
  } catch (err) {
955
1108
  throw wrapFetchError(err, "x402 initial request");
956
1109
  }
957
- if (init.status !== 402) {
958
- if (!init.ok) {
1110
+ if (init2.status !== 402) {
1111
+ if (!init2.ok) {
959
1112
  throw httpStatusToError(
960
- `x402 initial request failed: HTTP ${init.status}`,
961
- init.status,
962
- init
1113
+ `x402 initial request failed: HTTP ${init2.status}`,
1114
+ init2.status,
1115
+ init2
963
1116
  );
964
1117
  }
965
1118
  let data2;
966
1119
  try {
967
- data2 = await init.json();
1120
+ data2 = await init2.json();
968
1121
  } catch {
969
- throw new RouteDockManifestError(`Failed to parse JSON from non-402 response (HTTP ${init.status})`);
1122
+ throw new RouteDockManifestError(
1123
+ `Failed to parse JSON from non-402 response (HTTP ${init2.status})`
1124
+ );
970
1125
  }
971
- return { data: data2, txHash: null, mode: "x402", amount: "0", timestamp: Date.now() };
972
- }
973
- const reqHeader = init.headers.get("X-Payment-Requirements");
974
- if (!reqHeader) {
975
- throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
1126
+ return { kind: "free", data: data2 };
976
1127
  }
977
- let paymentRequired;
978
- try {
979
- paymentRequired = decodePaymentRequiredHeader(reqHeader);
980
- } catch (err) {
981
- throw new RouteDockManifestError("402 X-Payment-Requirements header is malformed", {
982
- cause: err
983
- });
984
- }
985
- let paymentPayload;
986
- try {
987
- paymentPayload = await this.httpClient.createPaymentPayload(paymentRequired);
988
- } catch (err) {
989
- throw new RouteDockSignatureError(`x402 payment signing failed: ${String(err)}`, {
990
- cause: err
991
- });
992
- }
993
- const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
994
- let settled;
1128
+ return { kind: "payment-required", init: init2 };
1129
+ }, this.retryPolicy);
1130
+ if (probe.kind === "free") {
1131
+ return { data: probe.data, txHash: null, mode: "x402", amount: "0", timestamp: Date.now() };
1132
+ }
1133
+ const init = probe.init;
1134
+ const reqHeader = init.headers.get("X-Payment-Requirements");
1135
+ if (!reqHeader) {
1136
+ throw new RouteDockManifestError("402 response missing X-Payment-Requirements header");
1137
+ }
1138
+ let paymentRequired;
1139
+ try {
1140
+ paymentRequired = decodePaymentRequiredHeader(reqHeader);
1141
+ } catch (err) {
1142
+ throw new RouteDockManifestError("402 X-Payment-Requirements header is malformed", {
1143
+ cause: err
1144
+ });
1145
+ }
1146
+ const firstAccept = paymentRequired.accepts[0];
1147
+ if (!firstAccept) {
1148
+ throw new RouteDockManifestError("402 response carries no payment accepts");
1149
+ }
1150
+ const allowed = filterX402Accepts(paymentRequired.accepts, manifest, caip2, pricing.amount);
1151
+ if (allowed.length === 0) {
1152
+ throw checkX402Accept(firstAccept, manifest, caip2, pricing.amount);
1153
+ }
1154
+ let paymentPayload;
1155
+ try {
1156
+ paymentPayload = await this.httpClient.createPaymentPayload({
1157
+ ...paymentRequired,
1158
+ accepts: allowed
1159
+ });
1160
+ } catch (err) {
1161
+ throw new RouteDockSignatureError(`x402 payment signing failed: ${String(err)}`, {
1162
+ cause: err
1163
+ });
1164
+ }
1165
+ const paymentHeaders = this.httpClient.encodePaymentSignatureHeader(paymentPayload);
1166
+ const signedAmount = stroopsToUsdc(BigInt(paymentPayload.accepted.amount));
1167
+ const settled = await withRetry(async () => {
1168
+ let response;
995
1169
  try {
996
- settled = await fetch(url, { headers: paymentHeaders });
1170
+ response = await fetch(url, { headers: paymentHeaders });
997
1171
  } catch (err) {
998
1172
  throw wrapFetchError(err, "x402 settlement request");
999
1173
  }
1000
- if (!settled.ok) {
1001
- if (settled.status >= 500 || settled.status === 429 || settled.status === 503) {
1174
+ if (!response.ok) {
1175
+ if (response.status >= 500 || response.status === 429 || response.status === 503) {
1002
1176
  throw httpStatusToError(
1003
- `x402 payment failed: HTTP ${settled.status}`,
1004
- settled.status,
1005
- settled
1177
+ `x402 payment failed: HTTP ${response.status}`,
1178
+ response.status,
1179
+ response
1006
1180
  );
1007
1181
  }
1008
- throw new RouteDockManifestError(`x402 payment failed: HTTP ${settled.status}`);
1009
- }
1010
- let txHash = null;
1011
- const responseHeader = settled.headers.get("X-Payment-Response");
1012
- if (responseHeader) {
1013
- try {
1014
- const settleResponse = decodePaymentResponseHeader(responseHeader);
1015
- txHash = settleResponse.transaction ?? null;
1016
- } catch {
1017
- }
1182
+ throw new RouteDockManifestError(`x402 payment failed: HTTP ${response.status}`);
1018
1183
  }
1019
- let data;
1184
+ return response;
1185
+ }, this.retryPolicy);
1186
+ let txHash = null;
1187
+ const responseHeader = settled.headers.get("X-Payment-Response");
1188
+ if (responseHeader) {
1020
1189
  try {
1021
- data = await settled.json();
1190
+ const settleResponse = decodePaymentResponseHeader(responseHeader);
1191
+ txHash = settleResponse.transaction ?? null;
1022
1192
  } catch {
1023
- throw new RouteDockManifestError("Failed to parse JSON from settled response");
1024
1193
  }
1025
- return { data, txHash, mode: "x402", amount: pricing.amount, timestamp: Date.now() };
1026
- }, this.retryPolicy);
1194
+ }
1195
+ let data;
1196
+ try {
1197
+ data = await settled.json();
1198
+ } catch {
1199
+ throw new RouteDockManifestError("Failed to parse JSON from settled response");
1200
+ }
1201
+ return { data, txHash, mode: "x402", amount: signedAmount, timestamp: Date.now() };
1027
1202
  }
1028
1203
  };
1029
1204
  var MppChargeClient = class {
@@ -1040,27 +1215,42 @@ var MppChargeClient = class {
1040
1215
  if (!pricing) {
1041
1216
  throw new RouteDockManifestError("manifest.pricing.mpp-charge missing");
1042
1217
  }
1043
- return withRetry(async () => {
1044
- let txHash = null;
1045
- const mppx = Mppx.create({
1046
- polyfill: false,
1047
- methods: [
1048
- stellar.charge({
1049
- keypair: this.keypair,
1050
- mode: "pull",
1051
- onProgress(event) {
1052
- if (event.type === "paid") {
1053
- txHash = event.hash;
1054
- }
1218
+ const caip2 = this.network === "mainnet" ? "stellar:pubnet" : "stellar:testnet";
1219
+ let txHash = null;
1220
+ let signedAmount;
1221
+ let credential;
1222
+ const mppx = Mppx.create({
1223
+ polyfill: false,
1224
+ methods: [
1225
+ stellar.charge({
1226
+ keypair: this.keypair,
1227
+ mode: "pull",
1228
+ onProgress(event) {
1229
+ if (event.type === "paid") {
1230
+ txHash = event.hash;
1055
1231
  }
1056
- })
1057
- ]
1058
- });
1232
+ }
1233
+ })
1234
+ ],
1235
+ onChallenge: async (challenge, { createCredential }) => {
1236
+ const request = challenge.request;
1237
+ const rejection = checkChargeChallenge(request, manifest, caip2, pricing.amount);
1238
+ if (rejection) throw rejection;
1239
+ const amount = request["amount"];
1240
+ if (typeof amount !== "string") {
1241
+ throw new RouteDockPolicyRejectError("challenge_amount_invalid");
1242
+ }
1243
+ signedAmount = stroopsToUsdc(BigInt(amount));
1244
+ credential ??= await createCredential();
1245
+ return credential;
1246
+ }
1247
+ });
1248
+ return withRetry(async () => {
1059
1249
  let response;
1060
1250
  try {
1061
- response = await mppx.fetch(url);
1251
+ response = credential ? await mppx.rawFetch(url, { headers: { Authorization: credential } }) : await mppx.fetch(url);
1062
1252
  } catch (err) {
1063
- throw wrapFetchError(err, "MPP charge request");
1253
+ throw wrapMppError(err, "MPP charge request");
1064
1254
  }
1065
1255
  if (!response.ok) {
1066
1256
  if (response.status >= 500 || response.status === 429 || response.status === 503) {
@@ -1081,7 +1271,13 @@ var MppChargeClient = class {
1081
1271
  { cause }
1082
1272
  );
1083
1273
  }
1084
- return { data, txHash, mode: "mpp-charge", amount: pricing.amount, timestamp: Date.now() };
1274
+ return {
1275
+ data,
1276
+ txHash,
1277
+ mode: "mpp-charge",
1278
+ amount: signedAmount ?? pricing.amount,
1279
+ timestamp: Date.now()
1280
+ };
1085
1281
  }, this.retryPolicy);
1086
1282
  }
1087
1283
  };
@@ -1125,16 +1321,18 @@ function resolveStoreNetworkId(network) {
1125
1321
  return network === STELLAR_PUBNET ? STELLAR_PUBNET : STELLAR_TESTNET;
1126
1322
  }
1127
1323
  var MppSessionClient = class {
1128
- constructor(keypair, network, retryPolicy, webSocketFactory = defaultWebSocketFactory) {
1324
+ constructor(keypair, network, retryPolicy, webSocketFactory = defaultWebSocketFactory, logger = consoleLogger) {
1129
1325
  this.keypair = keypair;
1130
1326
  this.network = network;
1131
1327
  this.retryPolicy = retryPolicy;
1132
1328
  this.webSocketFactory = webSocketFactory;
1329
+ this.logger = logger;
1133
1330
  }
1134
1331
  keypair;
1135
1332
  network;
1136
1333
  retryPolicy;
1137
1334
  webSocketFactory;
1335
+ logger;
1138
1336
  async openSession(url, manifest, commitmentSecret, options, onSpend) {
1139
1337
  const mode = options?.mode ?? "mpp-session";
1140
1338
  const pricing = manifest.pricing[mode];
@@ -1359,7 +1557,7 @@ var MppSessionClient = class {
1359
1557
  if (abortController.signal.aborted) {
1360
1558
  throw err;
1361
1559
  }
1362
- throw wrapFetchError(err, "Voucher request");
1560
+ throw wrapMppError(err, "Voucher request");
1363
1561
  }
1364
1562
  if (!resp.ok) {
1365
1563
  if (resp.status >= 500 || resp.status === 429 || resp.status === 503) {
@@ -1652,9 +1850,10 @@ var MppSessionClient = class {
1652
1850
  emit("session:timeout", { maxDurationMs });
1653
1851
  void handle.close().catch((error) => {
1654
1852
  emit("session:close-failed", { maxDurationMs, error });
1655
- console.warn(
1853
+ this.logger(
1854
+ "warn",
1656
1855
  `RouteDock: maxDuration auto-close failed after ${maxDurationMs}ms \u2014 the channel may still hold collateral; retry close() or call requestRefund().`,
1657
- error
1856
+ { error }
1658
1857
  );
1659
1858
  });
1660
1859
  }, maxDurationMs);
@@ -1704,7 +1903,7 @@ var MppSessionClient = class {
1704
1903
  );
1705
1904
  onSigned?.();
1706
1905
  } catch (err) {
1707
- throw wrapFetchError(err, "Voucher credential");
1906
+ throw wrapMppError(err, "Voucher credential");
1708
1907
  }
1709
1908
  const wsUrl = url.replace(/^http/, "ws");
1710
1909
  const queue = [];
@@ -1901,7 +2100,10 @@ var NulthClient = class {
1901
2100
  `NulthClient cannot use the insecure mock prover on mainnet; a production prover is required (network must be 'testnet', got ${String(config.network)})`
1902
2101
  );
1903
2102
  }
1904
- console.warn("NulthClient is using the insecure mock prover; proofs are not cryptographically sound");
2103
+ resolveLogger(config.logger)(
2104
+ "warn",
2105
+ "NulthClient is using the insecure mock prover; proofs are not cryptographically sound"
2106
+ );
1905
2107
  }
1906
2108
  config;
1907
2109
  policy;
@@ -1953,6 +2155,12 @@ var NulthClient = class {
1953
2155
  return proof;
1954
2156
  }
1955
2157
  enforcePolicy(context) {
2158
+ if (context.amountStroops < 0n) {
2159
+ throw new RangeError(`Invalid amountStroops: ${context.amountStroops}`);
2160
+ }
2161
+ if (!Number.isSafeInteger(context.ledgerSequence) || context.ledgerSequence < 0) {
2162
+ throw new RangeError(`Invalid ledgerSequence: ${context.ledgerSequence}`);
2163
+ }
1956
2164
  if (this.policy.expiryLedger !== void 0 && context.ledgerSequence > this.policy.expiryLedger) {
1957
2165
  throw new NulthPolicyError("session_expired", "Nulth session expired");
1958
2166
  }
@@ -2056,7 +2264,8 @@ var InMemorySpendStore = class {
2056
2264
  state = null;
2057
2265
  constructor(options = {}) {
2058
2266
  if (options.warn !== false) {
2059
- console.warn(
2267
+ resolveLogger(options.logger)(
2268
+ "warn",
2060
2269
  "[RouteDock] Using in-memory SpendStore: the daily spend cap is NOT durable and resets on every process restart. Inject a persistent SpendStore via RouteDockClientConfig.spendStore for production safety."
2061
2270
  );
2062
2271
  }
@@ -2069,6 +2278,72 @@ var InMemorySpendStore = class {
2069
2278
  }
2070
2279
  };
2071
2280
 
2281
+ // src/internal/assetUtils.ts
2282
+ function normalizeManifestAssets(manifest) {
2283
+ if (manifest.assets && manifest.assets.length > 0) {
2284
+ return manifest.assets;
2285
+ }
2286
+ if (manifest.asset && manifest.asset_contract) {
2287
+ return [
2288
+ {
2289
+ asset: manifest.asset,
2290
+ asset_contract: manifest.asset_contract
2291
+ }
2292
+ ];
2293
+ }
2294
+ throw new RouteDockManifestError(
2295
+ "Manifest must define either assets[] or root asset/asset_contract fields"
2296
+ );
2297
+ }
2298
+ function getEligibleAssets(manifest, mode, endpoint) {
2299
+ const assets = normalizeManifestAssets(manifest);
2300
+ return assets.filter((asset) => {
2301
+ if (asset.modes && !asset.modes.includes(mode)) {
2302
+ return false;
2303
+ }
2304
+ if (endpoint && asset.endpoints && asset.endpoints.length > 0) {
2305
+ const trimmedEndpoint = endpoint.replace(/^\//, "");
2306
+ if (asset.endpoints.includes(endpoint) || asset.endpoints.includes(trimmedEndpoint)) {
2307
+ return true;
2308
+ }
2309
+ if (manifest.endpoints) {
2310
+ let pathname = endpoint;
2311
+ if (endpoint.startsWith("http://") || endpoint.startsWith("https://")) {
2312
+ try {
2313
+ pathname = new URL(endpoint).pathname;
2314
+ } catch {
2315
+ }
2316
+ }
2317
+ const trimmedPathname = pathname.replace(/^\//, "");
2318
+ const matchesEndpoint = Object.entries(manifest.endpoints).some(([key, desc]) => {
2319
+ const pathMatches = desc.path === pathname || desc.path === endpoint || desc.path.replace(/\/+$/, "") === pathname.replace(/\/+$/, "") || desc.path.replace(/^\//, "") === trimmedPathname;
2320
+ const keyMatches = key === endpoint || key === trimmedEndpoint;
2321
+ if (pathMatches || keyMatches) {
2322
+ return asset.endpoints.includes(key) || asset.endpoints.includes(desc.path) || asset.endpoints.includes(desc.path.replace(/^\//, ""));
2323
+ }
2324
+ return false;
2325
+ });
2326
+ if (!matchesEndpoint) {
2327
+ return false;
2328
+ }
2329
+ } else {
2330
+ return false;
2331
+ }
2332
+ }
2333
+ return true;
2334
+ });
2335
+ }
2336
+ function selectAsset(manifest, mode, endpoint) {
2337
+ const eligible = getEligibleAssets(manifest, mode, endpoint);
2338
+ if (eligible.length === 0) {
2339
+ const endpointMsg = endpoint ? ` for endpoint '${endpoint}'` : "";
2340
+ throw new RouteDockManifestError(
2341
+ `No eligible assets found for mode '${mode}'${endpointMsg}`
2342
+ );
2343
+ }
2344
+ return eligible[0];
2345
+ }
2346
+
2072
2347
  // src/client/RouteDockClient.ts
2073
2348
  var _secrets = /* @__PURE__ */ new WeakMap();
2074
2349
  function normalizeEndpointCaps(endpointCaps) {
@@ -2145,7 +2420,10 @@ var RouteDockClient = class _RouteDockClient {
2145
2420
  this.network = config.network;
2146
2421
  this.spendCap = config.spendCap ? normalizeSpendCap(config.spendCap) : void 0;
2147
2422
  this.retryPolicy = config.retryPolicy;
2148
- this.spendStore = config.spendStore ?? new InMemorySpendStore({ warn: !!config.spendCap });
2423
+ this.spendStore = config.spendStore ?? new InMemorySpendStore({
2424
+ warn: !!config.spendCap,
2425
+ ...config.logger && { logger: config.logger }
2426
+ });
2149
2427
  this.logger = config.logger;
2150
2428
  this.manifestTimeoutMs = config.manifestTimeoutMs;
2151
2429
  this.expectedPayee = config.expectedPayee;
@@ -2157,9 +2435,10 @@ var RouteDockClient = class _RouteDockClient {
2157
2435
  _secrets.set(this, config.commitmentSecret);
2158
2436
  }
2159
2437
  const secretKey = this.keypair.secret();
2160
- this.x402 = new X402Client(secretKey, this.network, this.retryPolicy);
2161
- this.charge = new MppChargeClient(this.keypair, this.network, this.retryPolicy);
2162
- this.session = new MppSessionClient(this.keypair, this.network, this.retryPolicy);
2438
+ const retryPolicy = this.logger ? { ...this.retryPolicy, logger: this.logger } : this.retryPolicy;
2439
+ this.x402 = new X402Client(secretKey, this.network, retryPolicy);
2440
+ this.charge = new MppChargeClient(this.keypair, this.network, retryPolicy);
2441
+ this.session = new MppSessionClient(this.keypair, this.network, retryPolicy, void 0, this.logger);
2163
2442
  }
2164
2443
  /** Fetch manifest and select mode — shared by pay() and estimateCost(). */
2165
2444
  async _resolveManifest(url, options) {
@@ -2188,12 +2467,13 @@ var RouteDockClient = class _RouteDockClient {
2188
2467
  * call before committing to a payment — for approval gates and manual
2189
2468
  * trustline remediation.
2190
2469
  */
2191
- async preflight(manifest) {
2470
+ async preflight(manifest, mode, endpoint) {
2192
2471
  assertManifestValid(manifest);
2193
- await this._checkTrustline(manifest);
2472
+ const selectedAsset = mode ? selectAsset(manifest, mode, endpoint).asset : manifest.asset;
2473
+ await this._checkTrustline(manifest, selectedAsset);
2194
2474
  return {
2195
2475
  hasTrustline: true,
2196
- asset: manifest.asset,
2476
+ asset: selectedAsset,
2197
2477
  modes: manifest.modes,
2198
2478
  ...manifest.regions && { regions: manifest.regions },
2199
2479
  ...manifest.latency_hints && { latency_hints: manifest.latency_hints },
@@ -2207,8 +2487,8 @@ var RouteDockClient = class _RouteDockClient {
2207
2487
  * trustline is missing. Results are cached per (account, asset) so the
2208
2488
  * per-payment overhead is one RPC call amortized to zero after the first.
2209
2489
  */
2210
- async _checkTrustline(manifest) {
2211
- const cacheKey = `${this.network}:${this.keypair.publicKey()}:${manifest.asset}`;
2490
+ async _checkTrustline(manifest, targetAsset = manifest.asset) {
2491
+ const cacheKey = `${this.network}:${this.keypair.publicKey()}:${targetAsset}`;
2212
2492
  const cached = _RouteDockClient._trustlineCache.get(cacheKey);
2213
2493
  if (cached && Date.now() < cached.expiresAt) return;
2214
2494
  const horizonUrl = this.network === "testnet" ? "https://horizon-testnet.stellar.org" : "https://horizon.stellar.org";
@@ -2216,13 +2496,13 @@ var RouteDockClient = class _RouteDockClient {
2216
2496
  try {
2217
2497
  const account = await server.loadAccount(this.keypair.publicKey());
2218
2498
  const balances = account.balances;
2219
- const expectedIssuer = getAssetIssuer(manifest.asset, this.network);
2499
+ const expectedIssuer = getAssetIssuer(targetAsset, this.network);
2220
2500
  const hasTrustline = balances.some(
2221
- (b) => typeof b === "object" && b !== null && "asset_code" in b && b.asset_code === manifest.asset && (!expectedIssuer || b.asset_issuer === expectedIssuer)
2501
+ (b) => typeof b === "object" && b !== null && (targetAsset === "XLM" && b.asset_type === "native" || "asset_code" in b && b.asset_code === targetAsset && (!expectedIssuer || b.asset_issuer === expectedIssuer))
2222
2502
  );
2223
2503
  if (!hasTrustline) {
2224
- const remediation = expectedIssuer ? `Run: stellar tx new --source ${this.keypair.publicKey()} --network ${this.network} change-trust --asset ${manifest.asset}:${expectedIssuer} --limit 100000` : `Establish a trustline for ${manifest.asset} with the appropriate issuer on ${this.network}`;
2225
- throw new RouteDockTrustlineError(manifest.asset, expectedIssuer || "unknown", remediation);
2504
+ const remediation = expectedIssuer ? `Run: stellar tx new --source ${this.keypair.publicKey()} --network ${this.network} change-trust --asset ${targetAsset}:${expectedIssuer} --limit 100000` : `Establish a trustline for ${targetAsset} with the appropriate issuer on ${this.network}`;
2505
+ throw new RouteDockTrustlineError(targetAsset, expectedIssuer || "unknown", remediation);
2226
2506
  }
2227
2507
  _RouteDockClient._trustlineCache.set(cacheKey, {
2228
2508
  exists: true,
@@ -2231,7 +2511,8 @@ var RouteDockClient = class _RouteDockClient {
2231
2511
  } catch (err) {
2232
2512
  if (err instanceof RouteDockTrustlineError) throw err;
2233
2513
  this.logger?.(
2234
- `[RouteDock] Trustline preflight: could not verify trustline for ${manifest.asset} \u2014 continuing`
2514
+ "warn",
2515
+ `[RouteDock] Trustline preflight: could not verify trustline for ${targetAsset} \u2014 continuing`
2235
2516
  );
2236
2517
  }
2237
2518
  }
@@ -2239,18 +2520,18 @@ var RouteDockClient = class _RouteDockClient {
2239
2520
  * Pay for one request at `url`. Fetches manifest, selects payment mode,
2240
2521
  * runs trustline preflight, reserves local spend cap BEFORE executing the
2241
2522
  * payment, then commits the spend record on success. Rolls back the
2242
- * reservation if the on-chain payment fails. Concurrent pay() calls are
2243
- * serialized through a per-instance mutex to prevent spend-cap overrun.
2523
+ * reservation if the on-chain payment fails, and also when the call
2524
+ * completes without paying anything (an x402 endpoint answering a plain
2525
+ * 200 with no challenge). Concurrent pay() calls are serialized through a
2526
+ * per-instance mutex to prevent spend-cap overrun.
2244
2527
  */
2245
2528
  async pay(url, options) {
2246
2529
  const baseUrl = new URL(url).origin;
2247
2530
  const manifest = await fetchManifest(baseUrl, this.retryPolicy, this.manifestTimeoutMs, this.expectedPayee);
2248
2531
  assertEndpointActive(manifest, url, this.logger);
2249
2532
  const mode = selectMode(manifest, { ...options, ...this.logger && { logger: this.logger } });
2250
- await this._checkTrustline(manifest);
2251
- if (this.vault?.mode === "nulth") {
2252
- return this._payWithNulthVault(url, manifest, mode);
2253
- }
2533
+ const selectedAsset = selectAsset(manifest, mode, url).asset;
2534
+ await this._checkTrustline(manifest, selectedAsset);
2254
2535
  let amount;
2255
2536
  switch (mode) {
2256
2537
  case "x402":
@@ -2270,22 +2551,30 @@ var RouteDockClient = class _RouteDockClient {
2270
2551
  const reserveId = await this._checkAndReserveSpend(amount, baseUrl);
2271
2552
  let result;
2272
2553
  try {
2273
- switch (mode) {
2274
- case "x402":
2275
- result = await this.x402.pay(url, manifest);
2276
- break;
2277
- case "mpp-charge":
2278
- result = await this.charge.pay(url, manifest);
2279
- break;
2280
- default:
2281
- throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2554
+ if (this.vault?.mode === "nulth") {
2555
+ result = await this._payWithNulthVault(url, manifest, mode);
2556
+ } else {
2557
+ switch (mode) {
2558
+ case "x402":
2559
+ result = await this.x402.pay(url, manifest);
2560
+ break;
2561
+ case "mpp-charge":
2562
+ result = await this.charge.pay(url, manifest);
2563
+ break;
2564
+ default:
2565
+ throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2566
+ }
2282
2567
  }
2283
2568
  } catch (err) {
2284
2569
  await this._rollbackSpend(reserveId).catch(() => {
2285
2570
  });
2286
2571
  throw err;
2287
2572
  }
2288
- await this._commitSpend(reserveId);
2573
+ if (result.txHash === null && result.amount === "0") {
2574
+ await this._rollbackSpend(reserveId);
2575
+ } else {
2576
+ await this._commitSpend(reserveId);
2577
+ }
2289
2578
  return result;
2290
2579
  }
2291
2580
  /** Nulth ZK vault path — proof built off-chain, attached as auth signature */
@@ -2335,9 +2624,10 @@ var RouteDockClient = class _RouteDockClient {
2335
2624
  default:
2336
2625
  throw new RouteDockManifestError(`Unknown payment mode: ${mode}`);
2337
2626
  }
2627
+ const selectedAsset = selectAsset(manifest, mode, url).asset;
2338
2628
  return {
2339
2629
  amount,
2340
- asset: manifest.asset,
2630
+ asset: selectedAsset,
2341
2631
  mode,
2342
2632
  manifest,
2343
2633
  ...manifest.regions && { regions: manifest.regions },