@agentcash/router 1.4.0 → 1.5.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.
package/README.md CHANGED
@@ -46,7 +46,51 @@ export const env = createEnv({
46
46
  });
47
47
  ```
48
48
 
49
- Without these keys, x402 routes will fail to initialize (empty 402 responses, no payment header).
49
+ Without these keys, x402 routes that use the default EVM facilitator will fail to initialize.
50
+ Run `validateRouterConfig(config)` during startup if you want missing facilitator, MPP, or store
51
+ configuration to throw immediately in every environment.
52
+
53
+ ### Recommended strict setup
54
+
55
+ ```typescript
56
+ import {
57
+ createRouter,
58
+ mppFromEnv,
59
+ validateRouterConfig,
60
+ x402AcceptsFromEnv,
61
+ type ProtocolType,
62
+ } from '@agentcash/router';
63
+
64
+ const accepts = x402AcceptsFromEnv(process.env);
65
+ const protocols: ProtocolType[] = process.env.MPP_SECRET_KEY ? ['x402', 'mpp'] : ['x402'];
66
+
67
+ const config = {
68
+ payeeAddress: process.env.X402_WALLET_ADDRESS!,
69
+ baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
70
+ strictRoutes: true,
71
+ protocols,
72
+ x402: { accepts },
73
+ mpp: mppFromEnv(process.env, {
74
+ recipient: process.env.X402_WALLET_ADDRESS,
75
+ useDefaultStore: true,
76
+ }),
77
+ discovery: {
78
+ title: 'My API',
79
+ version: '1.0.0',
80
+ },
81
+ };
82
+
83
+ validateRouterConfig(config);
84
+ export const router = createRouter(config);
85
+ ```
86
+
87
+ `x402AcceptsFromEnv()` always adds Base (`BASE_NETWORK`) and also adds Solana
88
+ mainnet (`SOLANA_MAINNET_NETWORK`) when `SOLANA_PAYEE_ADDRESS` is set. Solana
89
+ addresses are case-sensitive and are preserved as-is.
90
+
91
+ `mppFromEnv()` returns `undefined` when no MPP env vars are present. If any MPP
92
+ env var is present, the full trio is required: `MPP_SECRET_KEY`, `MPP_CURRENCY`,
93
+ and `TEMPO_RPC_URL`.
50
94
 
51
95
  ## Quick Start
52
96
 
@@ -143,9 +187,36 @@ Creates a `ServiceRouter` instance.
143
187
  | `plugin` | `RouterPlugin` | `undefined` | Observability plugin |
144
188
  | `prices` | `Record<string, string>` | `undefined` | Central pricing map (auto-applied) |
145
189
  | `siwx.nonceStore` | `NonceStore` | `MemoryNonceStore` | Custom nonce store |
146
- | `mpp` | `{ secretKey, currency, recipient? }` | `undefined` | MPP config |
190
+ | `mpp` | `{ secretKey, currency, recipient?, rpcUrl?, useDefaultStore? }` | `undefined` | MPP config |
191
+ | `protocols` | `('x402' \| 'mpp')[]` | `['x402']` | Default protocols for auto-priced routes |
147
192
  | `strictRoutes` | `boolean` | `false` | Enforce `route({ path })` and prevent key/path divergence |
148
193
 
194
+ ### Config validation helpers
195
+
196
+ ```typescript
197
+ import {
198
+ BASE_NETWORK,
199
+ SOLANA_MAINNET_NETWORK,
200
+ TEMPO_USDC_CURRENCY,
201
+ getRouterConfigIssues,
202
+ mppFromEnv,
203
+ paidOptionsForProtocols,
204
+ validateRouterConfig,
205
+ x402AcceptsFromEnv,
206
+ } from '@agentcash/router';
207
+ ```
208
+
209
+ - `validateRouterConfig(config)` throws `RouterConfigError` with structured
210
+ issues. Use it when you want invalid env/config to fail at startup.
211
+ - `getRouterConfigIssues(config)` returns the same structured issues without
212
+ throwing.
213
+ - `x402AcceptsFromEnv(env)` builds Base and optional Solana x402 accepts from
214
+ `X402_WALLET_ADDRESS` and `SOLANA_PAYEE_ADDRESS`.
215
+ - `mppFromEnv(env)` builds MPP config only when MPP env is present, and rejects
216
+ partial MPP env.
217
+ - `paidOptionsForProtocols(protocols)` copies a protocol array into a
218
+ route-level `PaidOptions` object.
219
+
149
220
  ### Path-First Routing
150
221
 
151
222
  Use path-first route definitions to keep runtime, OpenAPI, and discovery aligned:
@@ -259,12 +330,19 @@ interface HandlerContext<TBody, TQuery> {
259
330
  query: TQuery; // Parsed + validated
260
331
  request: NextRequest; // Raw request
261
332
  wallet: string | null; // Verified wallet address
333
+ payment: HandlerPaymentContext | null; // Payment metadata for this request
262
334
  account: unknown; // From .apiKey() resolver
263
335
  alert: AlertFn; // Fire observability alerts
264
336
  setVerifiedWallet: (addr: string) => void;
265
337
  }
266
338
  ```
267
339
 
340
+ `payment` is `null` for unprotected, API-key-only, and SIWX-only requests. For
341
+ paid requests it includes `protocol`, `status`, `payer`, `amount`, `network`,
342
+ and best-effort recipient/transaction/receipt metadata when the protocol
343
+ provides it. x402 handlers currently see `status: 'verified'` because settlement
344
+ happens after a successful handler response.
345
+
268
346
  ### RouterPlugin
269
347
 
270
348
  Pluggable observability. All hooks are optional and fire-and-forget.
package/dist/index.cjs CHANGED
@@ -188,6 +188,18 @@ var init_x402_facilitators = __esm({
188
188
  }
189
189
  });
190
190
 
191
+ // src/constants.ts
192
+ var BASE_NETWORK, SOLANA_MAINNET_NETWORK, TEMPO_USDC_CURRENCY, ZERO_EVM_ADDRESS;
193
+ var init_constants = __esm({
194
+ "src/constants.ts"() {
195
+ "use strict";
196
+ BASE_NETWORK = "eip155:8453";
197
+ SOLANA_MAINNET_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
198
+ TEMPO_USDC_CURRENCY = "0x20c000000000000000000000b9537d11c60e8b50";
199
+ ZERO_EVM_ADDRESS = "0x0000000000000000000000000000000000000000";
200
+ }
201
+ });
202
+
191
203
  // src/x402-config.ts
192
204
  async function resolvePayToValue(payTo, request, fallback, body) {
193
205
  if (!payTo) return fallback;
@@ -201,7 +213,7 @@ function getConfiguredX402Accepts(config) {
201
213
  return [
202
214
  {
203
215
  scheme: "exact",
204
- network: config.network ?? "eip155:8453",
216
+ network: config.network ?? BASE_NETWORK,
205
217
  payTo: config.payeeAddress
206
218
  }
207
219
  ];
@@ -230,6 +242,7 @@ async function resolveX402Accepts(request, routeEntry, accepts, fallbackPayTo, b
230
242
  var init_x402_config = __esm({
231
243
  "src/x402-config.ts"() {
232
244
  "use strict";
245
+ init_constants();
233
246
  }
234
247
  });
235
248
 
@@ -376,17 +389,28 @@ var init_upstash_rest = __esm({
376
389
  // src/index.ts
377
390
  var index_exports = {};
378
391
  __export(index_exports, {
392
+ BASE_NETWORK: () => BASE_NETWORK,
379
393
  HttpError: () => HttpError,
380
394
  MemoryEntitlementStore: () => MemoryEntitlementStore,
381
395
  MemoryNonceStore: () => MemoryNonceStore,
382
396
  RouteBuilder: () => RouteBuilder,
383
397
  RouteRegistry: () => RouteRegistry,
398
+ RouterConfigError: () => RouterConfigError,
384
399
  SIWX_CHALLENGE_EXPIRY_MS: () => SIWX_CHALLENGE_EXPIRY_MS,
385
400
  SIWX_ERROR_MESSAGES: () => SIWX_ERROR_MESSAGES,
401
+ SOLANA_MAINNET_NETWORK: () => SOLANA_MAINNET_NETWORK,
402
+ TEMPO_USDC_CURRENCY: () => TEMPO_USDC_CURRENCY,
403
+ ZERO_EVM_ADDRESS: () => ZERO_EVM_ADDRESS,
386
404
  consolePlugin: () => consolePlugin,
387
405
  createRedisEntitlementStore: () => createRedisEntitlementStore,
388
406
  createRedisNonceStore: () => createRedisNonceStore,
389
- createRouter: () => createRouter
407
+ createRouter: () => createRouter,
408
+ formatRouterConfigIssues: () => formatRouterConfigIssues,
409
+ getRouterConfigIssues: () => getRouterConfigIssues,
410
+ mppFromEnv: () => mppFromEnv,
411
+ paidOptionsForProtocols: () => paidOptionsForProtocols,
412
+ validateRouterConfig: () => validateRouterConfig,
413
+ x402AcceptsFromEnv: () => x402AcceptsFromEnv
390
414
  });
391
415
  module.exports = __toCommonJS(index_exports);
392
416
 
@@ -1029,6 +1053,10 @@ function getRequirementNetwork(requirements, fallback) {
1029
1053
  const network = requirements?.network;
1030
1054
  return typeof network === "string" ? network : fallback;
1031
1055
  }
1056
+ function getRequirementRecipient(requirements) {
1057
+ const payTo = requirements?.payTo;
1058
+ return typeof payTo === "string" ? payTo : void 0;
1059
+ }
1032
1060
  function siwxSignatureType(network) {
1033
1061
  return network.startsWith("solana:") ? "ed25519" : "eip191";
1034
1062
  }
@@ -1047,7 +1075,7 @@ function getSupportedChains(x402Accepts, fallbackNetwork) {
1047
1075
  return chains;
1048
1076
  }
1049
1077
  function createRequestHandler(routeEntry, handler, deps) {
1050
- async function invoke(request, meta, pluginCtx, wallet, account, parsedBody) {
1078
+ async function invoke(request, meta, pluginCtx, wallet, account, parsedBody, payment) {
1051
1079
  const ctx = {
1052
1080
  body: parsedBody,
1053
1081
  query: parseQuery(request, routeEntry),
@@ -1055,6 +1083,7 @@ function createRequestHandler(routeEntry, handler, deps) {
1055
1083
  requestId: meta.requestId,
1056
1084
  route: routeEntry.key,
1057
1085
  wallet,
1086
+ payment,
1058
1087
  account,
1059
1088
  alert(level, message, alertMeta) {
1060
1089
  firePluginHook(deps.plugin, "onAlert", pluginCtx, {
@@ -1107,7 +1136,8 @@ function createRequestHandler(routeEntry, handler, deps) {
1107
1136
  pluginCtx,
1108
1137
  wallet,
1109
1138
  account2,
1110
- body2.data
1139
+ body2.data,
1140
+ null
1111
1141
  );
1112
1142
  finalize(response, rawResult, meta, pluginCtx, body2.data);
1113
1143
  return response;
@@ -1153,6 +1183,9 @@ function createRequestHandler(routeEntry, handler, deps) {
1153
1183
  return fail(status, message, meta, pluginCtx, earlyBodyData);
1154
1184
  }
1155
1185
  }
1186
+ } else {
1187
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
1188
+ return earlyBodyResult.response;
1156
1189
  }
1157
1190
  }
1158
1191
  if (routeEntry.authMode === "siwx" || routeEntry.siwxEnabled) {
@@ -1381,7 +1414,16 @@ function createRequestHandler(routeEntry, handler, deps) {
1381
1414
  return await build402(request, routeEntry, deps, meta, pluginCtx, body.data);
1382
1415
  const { payload: verifyPayload, requirements: verifyRequirements } = verify;
1383
1416
  const matchedNetwork = getRequirementNetwork(verifyRequirements, deps.network);
1417
+ const matchedRecipient = getRequirementRecipient(verifyRequirements);
1384
1418
  const wallet = normalizeWalletAddress(verify.payer);
1419
+ const payment = {
1420
+ protocol: "x402",
1421
+ status: "verified",
1422
+ payer: wallet,
1423
+ amount: price,
1424
+ network: matchedNetwork,
1425
+ ...matchedRecipient ? { recipient: matchedRecipient } : {}
1426
+ };
1385
1427
  pluginCtx.setVerifiedWallet(wallet);
1386
1428
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
1387
1429
  protocol: "x402",
@@ -1395,7 +1437,8 @@ function createRequestHandler(routeEntry, handler, deps) {
1395
1437
  pluginCtx,
1396
1438
  wallet,
1397
1439
  account,
1398
- body.data
1440
+ body.data,
1441
+ payment
1399
1442
  );
1400
1443
  if (response.status < 400) {
1401
1444
  try {
@@ -1494,7 +1537,15 @@ function createRequestHandler(routeEntry, handler, deps) {
1494
1537
  pluginCtx,
1495
1538
  wallet,
1496
1539
  account,
1497
- body.data
1540
+ body.data,
1541
+ {
1542
+ protocol: "mpp",
1543
+ status: "verified",
1544
+ payer: wallet,
1545
+ amount: price,
1546
+ network: "tempo:4217",
1547
+ recipient: deps.payeeAddress
1548
+ }
1498
1549
  );
1499
1550
  if (response2.status < 400) {
1500
1551
  let mppResult2;
@@ -1627,7 +1678,17 @@ function createRequestHandler(routeEntry, handler, deps) {
1627
1678
  pluginCtx,
1628
1679
  wallet,
1629
1680
  account,
1630
- body.data
1681
+ body.data,
1682
+ {
1683
+ protocol: "mpp",
1684
+ status: "settled",
1685
+ payer: wallet,
1686
+ amount: price,
1687
+ network: "tempo:4217",
1688
+ recipient: deps.payeeAddress,
1689
+ ...txHash ? { transaction: txHash } : {},
1690
+ ...receiptHeader ? { receipt: receiptHeader } : {}
1691
+ }
1631
1692
  );
1632
1693
  if (response.status < 400) {
1633
1694
  if (routeEntry.siwxEnabled) {
@@ -2587,61 +2648,282 @@ function createLlmsTxtHandler(discovery) {
2587
2648
 
2588
2649
  // src/index.ts
2589
2650
  init_x402_config();
2651
+ init_constants();
2652
+
2653
+ // src/config.ts
2654
+ init_constants();
2590
2655
  init_evm();
2591
2656
  init_solana();
2657
+ init_x402_config();
2658
+ var RouterConfigError = class extends Error {
2659
+ issues;
2660
+ constructor(issues) {
2661
+ super(formatRouterConfigIssues(issues));
2662
+ this.name = "RouterConfigError";
2663
+ this.issues = issues;
2664
+ }
2665
+ };
2666
+ function validateRouterConfig(config, options = {}) {
2667
+ const issues = getRouterConfigIssues(config, options);
2668
+ if (issues.length > 0) throw new RouterConfigError(issues);
2669
+ }
2670
+ function getRouterConfigIssues(config, options = {}) {
2671
+ const env = options.env ?? process.env;
2672
+ const issues = [];
2673
+ const protocols = config.protocols ?? ["x402"];
2674
+ if (!config.baseUrl) {
2675
+ issues.push({
2676
+ code: "missing_base_url",
2677
+ message: '[router] baseUrl is required in RouterConfig. Set it to your production domain (e.g., "https://api.example.com"). The realm is used for payment matching and must be correct.'
2678
+ });
2679
+ }
2680
+ if (config.protocols && config.protocols.length === 0) {
2681
+ issues.push({
2682
+ code: "empty_protocols",
2683
+ message: "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
2684
+ });
2685
+ }
2686
+ if (protocols.includes("x402")) {
2687
+ issues.push(...validateX402Config(config, env, options));
2688
+ }
2689
+ if (protocols.includes("mpp")) {
2690
+ issues.push(...validateMppConfig(config, env));
2691
+ }
2692
+ return issues;
2693
+ }
2694
+ function formatRouterConfigIssues(issues) {
2695
+ return issues.map((issue) => issue.message).join("\n");
2696
+ }
2697
+ function mppFromEnv(env, options = {}) {
2698
+ const secretKey = env.MPP_SECRET_KEY;
2699
+ const currency = env.MPP_CURRENCY;
2700
+ const rpcUrl = env.TEMPO_RPC_URL;
2701
+ const hasAnyMppEnv = Boolean(secretKey || currency || rpcUrl || options.require);
2702
+ if (!hasAnyMppEnv) return void 0;
2703
+ const missing = [
2704
+ secretKey ? null : "MPP_SECRET_KEY",
2705
+ currency ? null : "MPP_CURRENCY",
2706
+ rpcUrl ? null : "TEMPO_RPC_URL"
2707
+ ].filter(Boolean);
2708
+ if (missing.length > 0) {
2709
+ throw new Error(`MPP env is incomplete. Missing: ${missing.join(", ")}`);
2710
+ }
2711
+ return {
2712
+ secretKey,
2713
+ currency,
2714
+ rpcUrl,
2715
+ ...options.recipient ? { recipient: options.recipient } : {},
2716
+ ...options.feePayerKey ? { feePayerKey: options.feePayerKey } : {},
2717
+ ...options.useDefaultStore !== void 0 ? { useDefaultStore: options.useDefaultStore } : {}
2718
+ };
2719
+ }
2720
+ function x402AcceptsFromEnv(env, options = {}) {
2721
+ const payeeEnv = options.payeeEnv ?? "X402_WALLET_ADDRESS";
2722
+ const solanaPayeeEnv = options.solanaPayeeEnv ?? "SOLANA_PAYEE_ADDRESS";
2723
+ const payeeAddress = options.payeeAddress ?? env[payeeEnv];
2724
+ if (!payeeAddress) {
2725
+ throw new Error(`${payeeEnv} is required to build x402 accepts`);
2726
+ }
2727
+ const accepts = [
2728
+ {
2729
+ scheme: "exact",
2730
+ network: options.network ?? BASE_NETWORK,
2731
+ payTo: payeeAddress
2732
+ }
2733
+ ];
2734
+ const solanaPayeeAddress = options.solanaPayeeAddress ?? env[solanaPayeeEnv];
2735
+ if (solanaPayeeAddress) {
2736
+ accepts.push({
2737
+ scheme: "exact",
2738
+ network: SOLANA_MAINNET_NETWORK,
2739
+ payTo: solanaPayeeAddress
2740
+ });
2741
+ }
2742
+ return accepts;
2743
+ }
2744
+ function paidOptionsForProtocols(protocols) {
2745
+ return { protocols: [...protocols] };
2746
+ }
2747
+ function validateX402Config(config, env, options) {
2748
+ const issues = [];
2749
+ const accepts = getConfiguredX402Accepts(config);
2750
+ if (accepts.length === 0) {
2751
+ issues.push({
2752
+ code: "missing_x402_accepts",
2753
+ protocol: "x402",
2754
+ message: "x402 requires at least one accept configuration."
2755
+ });
2756
+ return issues;
2757
+ }
2758
+ const acceptWithoutNetwork = accepts.find((accept) => !accept.network);
2759
+ if (acceptWithoutNetwork) {
2760
+ issues.push({
2761
+ code: "missing_x402_network",
2762
+ protocol: "x402",
2763
+ message: "x402 accepts require a network."
2764
+ });
2765
+ }
2766
+ const unsupported = accepts.find(
2767
+ (accept) => accept.network && !isSupportedX402Network(accept.network)
2768
+ );
2769
+ if (unsupported) {
2770
+ issues.push({
2771
+ code: "unsupported_x402_network",
2772
+ protocol: "x402",
2773
+ message: `unsupported x402 network '${unsupported.network}'. Use eip155:* or solana:*.`
2774
+ });
2775
+ }
2776
+ const missingAsset = accepts.find(
2777
+ (accept) => (accept.scheme ?? "exact") !== "exact" && !accept.asset
2778
+ );
2779
+ if (missingAsset) {
2780
+ issues.push({
2781
+ code: "missing_x402_asset",
2782
+ protocol: "x402",
2783
+ message: "non-exact x402 accepts require an asset."
2784
+ });
2785
+ }
2786
+ const invalidDecimals = accepts.find(
2787
+ (accept) => accept.decimals !== void 0 && (!Number.isInteger(accept.decimals) || accept.decimals < 0)
2788
+ );
2789
+ if (invalidDecimals) {
2790
+ issues.push({
2791
+ code: "invalid_x402_decimals",
2792
+ protocol: "x402",
2793
+ message: "x402 accept decimals must be a non-negative integer."
2794
+ });
2795
+ }
2796
+ if (accepts.some((accept) => !accept.payTo) && !config.payeeAddress) {
2797
+ issues.push({
2798
+ code: "missing_x402_payee",
2799
+ protocol: "x402",
2800
+ message: "x402 requires payeeAddress in router config or payTo on every x402 accept."
2801
+ });
2802
+ }
2803
+ const placeholder = findPlaceholderPayee([
2804
+ config.payeeAddress,
2805
+ ...accepts.map((accept) => typeof accept.payTo === "string" ? accept.payTo : void 0)
2806
+ ]);
2807
+ if (placeholder) {
2808
+ issues.push({
2809
+ code: "placeholder_payee",
2810
+ protocol: "x402",
2811
+ message: `x402 payee '${placeholder}' is a placeholder address and cannot receive payments.`
2812
+ });
2813
+ }
2814
+ if (options.requireCdpKeys !== false && usesDefaultEvmFacilitator(config)) {
2815
+ const missing = [
2816
+ env.CDP_API_KEY_ID ? null : "CDP_API_KEY_ID",
2817
+ env.CDP_API_KEY_SECRET ? null : "CDP_API_KEY_SECRET"
2818
+ ].filter(Boolean);
2819
+ if (missing.length > 0) {
2820
+ issues.push({
2821
+ code: "missing_cdp_keys",
2822
+ protocol: "x402",
2823
+ message: `default EVM x402 facilitator requires ${missing.join(" and ")}.`
2824
+ });
2825
+ }
2826
+ }
2827
+ return issues;
2828
+ }
2829
+ function validateMppConfig(config, env) {
2830
+ const issues = [];
2831
+ const mpp = config.mpp;
2832
+ if (!mpp) {
2833
+ return [
2834
+ {
2835
+ code: "missing_mpp_config",
2836
+ protocol: "mpp",
2837
+ message: 'protocols includes "mpp" but mpp config is missing. Add mpp: { secretKey, currency, recipient } to your router config.'
2838
+ }
2839
+ ];
2840
+ }
2841
+ if (!mpp.secretKey) {
2842
+ issues.push({
2843
+ code: "missing_mpp_secret_key",
2844
+ protocol: "mpp",
2845
+ message: "MPP requires secretKey. Set MPP_SECRET_KEY or pass mpp.secretKey."
2846
+ });
2847
+ }
2848
+ if (!mpp.currency) {
2849
+ issues.push({
2850
+ code: "missing_mpp_currency",
2851
+ protocol: "mpp",
2852
+ message: "MPP requires currency. Set MPP_CURRENCY or pass mpp.currency."
2853
+ });
2854
+ }
2855
+ if (!mpp.recipient && !config.payeeAddress) {
2856
+ issues.push({
2857
+ code: "missing_mpp_recipient",
2858
+ protocol: "mpp",
2859
+ message: "MPP requires a recipient address. Set mpp.recipient or payeeAddress in your router config."
2860
+ });
2861
+ }
2862
+ const placeholder = findPlaceholderPayee([mpp.recipient, config.payeeAddress]);
2863
+ if (placeholder) {
2864
+ issues.push({
2865
+ code: "placeholder_payee",
2866
+ protocol: "mpp",
2867
+ message: `MPP recipient '${placeholder}' is a placeholder address and cannot receive payments.`
2868
+ });
2869
+ }
2870
+ if (!(mpp.rpcUrl ?? env.TEMPO_RPC_URL)) {
2871
+ issues.push({
2872
+ code: "missing_mpp_rpc_url",
2873
+ protocol: "mpp",
2874
+ message: "MPP requires an authenticated Tempo RPC URL. Set TEMPO_RPC_URL env var or pass rpcUrl in the mpp config object."
2875
+ });
2876
+ }
2877
+ if (mpp.useDefaultStore && !mpp.store && (!env.KV_REST_API_URL || !env.KV_REST_API_TOKEN)) {
2878
+ issues.push({
2879
+ code: "missing_mpp_default_store_env",
2880
+ protocol: "mpp",
2881
+ message: "mpp.useDefaultStore requires KV_REST_API_URL and KV_REST_API_TOKEN environment variables. These are automatically set by Vercel KV."
2882
+ });
2883
+ }
2884
+ return issues;
2885
+ }
2886
+ function usesDefaultEvmFacilitator(config) {
2887
+ return getConfiguredX402Networks(config).some(
2888
+ (network) => typeof network === "string" && isEvmNetwork(network)
2889
+ ) && config.x402?.facilitators?.evm === void 0;
2890
+ }
2891
+ function isSupportedX402Network(network) {
2892
+ return isEvmNetwork(network) || isSolanaNetwork(network);
2893
+ }
2894
+ function findPlaceholderPayee(values) {
2895
+ return values.find((value) => value?.toLowerCase() === ZERO_EVM_ADDRESS) ?? null;
2896
+ }
2897
+
2898
+ // src/index.ts
2899
+ init_constants();
2592
2900
  function createRouter(config) {
2593
2901
  const registry = new RouteRegistry();
2594
2902
  const nonceStore = config.siwx?.nonceStore ?? new MemoryNonceStore();
2595
2903
  const entitlementStore = config.siwx?.entitlementStore ?? new MemoryEntitlementStore();
2596
- const network = config.network ?? "eip155:8453";
2904
+ const network = config.network ?? BASE_NETWORK;
2597
2905
  const x402Accepts = getConfiguredX402Accepts(config);
2598
- if (!config.baseUrl) {
2599
- throw new Error(
2600
- '[router] baseUrl is required in RouterConfig. Set it to your production domain (e.g., "https://api.example.com"). The realm is used for payment matching and must be correct.'
2601
- );
2602
- }
2603
- if (config.protocols && config.protocols.length === 0) {
2604
- throw new Error(
2605
- "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
2606
- );
2607
- }
2608
- const resolvedBaseUrl = config.baseUrl.replace(/\/+$/, "");
2609
- let x402ConfigError;
2610
- let mppConfigError;
2611
- if (!config.protocols || config.protocols.includes("x402")) {
2612
- if (x402Accepts.length === 0) {
2613
- x402ConfigError = "x402 requires at least one accept configuration.";
2614
- } else if (x402Accepts.some((accept) => !accept.network)) {
2615
- x402ConfigError = "x402 accepts require a network.";
2616
- } else if (x402Accepts.some((accept) => !isSupportedX402Network(accept.network))) {
2617
- const unsupported = x402Accepts.find((accept) => !isSupportedX402Network(accept.network));
2618
- x402ConfigError = `unsupported x402 network '${unsupported?.network}'. Use eip155:* or solana:*.`;
2619
- } else if (x402Accepts.some((accept) => (accept.scheme ?? "exact") !== "exact" && !accept.asset)) {
2620
- x402ConfigError = "non-exact x402 accepts require an asset.";
2621
- } else if (x402Accepts.some(
2622
- (accept) => accept.decimals !== void 0 && (!Number.isInteger(accept.decimals) || accept.decimals < 0)
2623
- )) {
2624
- x402ConfigError = "x402 accept decimals must be a non-negative integer.";
2625
- } else if (x402Accepts.some((accept) => !accept.payTo) && !config.payeeAddress) {
2626
- x402ConfigError = "x402 requires payeeAddress in router config or payTo on every x402 accept.";
2627
- }
2628
- }
2629
- if (config.protocols?.includes("mpp")) {
2630
- if (!config.mpp) {
2631
- mppConfigError = 'protocols includes "mpp" but mpp config is missing. Add mpp: { secretKey, currency, recipient } to your router config.';
2632
- } else if (!config.mpp.recipient && !config.payeeAddress) {
2633
- mppConfigError = "MPP requires a recipient address. Set mpp.recipient or payeeAddress in your router config.";
2634
- } else if (!(config.mpp.rpcUrl ?? process.env.TEMPO_RPC_URL)) {
2635
- mppConfigError = "MPP requires an authenticated Tempo RPC URL. Set TEMPO_RPC_URL env var or pass rpcUrl in the mpp config object.";
2636
- }
2637
- }
2638
- const allConfigErrors = [x402ConfigError, mppConfigError].filter(Boolean);
2639
- if (allConfigErrors.length > 0) {
2640
- for (const err of allConfigErrors) console.error(`[router] ${err}`);
2906
+ const configIssues = getRouterConfigIssues(config, {
2907
+ requireCdpKeys: process.env.NODE_ENV === "production"
2908
+ });
2909
+ const baseUrlIssue = configIssues.find((issue) => issue.code === "missing_base_url");
2910
+ if (baseUrlIssue) throw new RouterConfigError([baseUrlIssue]);
2911
+ const emptyProtocolsIssue = configIssues.find((issue) => issue.code === "empty_protocols");
2912
+ if (emptyProtocolsIssue) throw new RouterConfigError([emptyProtocolsIssue]);
2913
+ const protocolConfigIssues = configIssues.filter(
2914
+ (issue) => issue.code !== "missing_base_url" && issue.code !== "empty_protocols"
2915
+ );
2916
+ const x402ConfigIssues = protocolConfigIssues.filter((issue) => issue.protocol === "x402");
2917
+ const mppConfigIssues = protocolConfigIssues.filter((issue) => issue.protocol === "mpp");
2918
+ const x402ConfigError = x402ConfigIssues.length > 0 ? formatRouterConfigIssues(x402ConfigIssues) : void 0;
2919
+ const mppConfigError = mppConfigIssues.length > 0 ? formatRouterConfigIssues(mppConfigIssues) : void 0;
2920
+ if (protocolConfigIssues.length > 0) {
2921
+ for (const issue of protocolConfigIssues) console.error(`[router] ${issue.message}`);
2641
2922
  if (process.env.NODE_ENV === "production") {
2642
- throw new Error(allConfigErrors.join("\n"));
2923
+ throw new RouterConfigError(protocolConfigIssues);
2643
2924
  }
2644
2925
  }
2926
+ const resolvedBaseUrl = config.baseUrl.replace(/\/+$/, "");
2645
2927
  if (config.plugin?.init) {
2646
2928
  try {
2647
2929
  const result = config.plugin.init({ origin: resolvedBaseUrl });
@@ -2786,9 +3068,6 @@ function createRouter(config) {
2786
3068
  registry
2787
3069
  };
2788
3070
  }
2789
- function isSupportedX402Network(network) {
2790
- return isEvmNetwork(network) || isSolanaNetwork(network);
2791
- }
2792
3071
  function normalizePath(path) {
2793
3072
  let normalized = path.trim();
2794
3073
  normalized = normalized.replace(/^\/+/, "");
@@ -2797,15 +3076,26 @@ function normalizePath(path) {
2797
3076
  }
2798
3077
  // Annotate the CommonJS export names for ESM import in node:
2799
3078
  0 && (module.exports = {
3079
+ BASE_NETWORK,
2800
3080
  HttpError,
2801
3081
  MemoryEntitlementStore,
2802
3082
  MemoryNonceStore,
2803
3083
  RouteBuilder,
2804
3084
  RouteRegistry,
3085
+ RouterConfigError,
2805
3086
  SIWX_CHALLENGE_EXPIRY_MS,
2806
3087
  SIWX_ERROR_MESSAGES,
3088
+ SOLANA_MAINNET_NETWORK,
3089
+ TEMPO_USDC_CURRENCY,
3090
+ ZERO_EVM_ADDRESS,
2807
3091
  consolePlugin,
2808
3092
  createRedisEntitlementStore,
2809
3093
  createRedisNonceStore,
2810
- createRouter
3094
+ createRouter,
3095
+ formatRouterConfigIssues,
3096
+ getRouterConfigIssues,
3097
+ mppFromEnv,
3098
+ paidOptionsForProtocols,
3099
+ validateRouterConfig,
3100
+ x402AcceptsFromEnv
2811
3101
  });
package/dist/index.d.cts CHANGED
@@ -260,6 +260,17 @@ interface PaidOptions {
260
260
  /** Override MPP protocol metadata in x-payment-info discovery. */
261
261
  mpp?: MppProtocolInfo;
262
262
  }
263
+ type PaymentStatus = 'verified' | 'settled';
264
+ interface HandlerPaymentContext {
265
+ protocol: ProtocolType;
266
+ status: PaymentStatus;
267
+ payer: string;
268
+ amount: string;
269
+ network: string;
270
+ recipient?: string;
271
+ transaction?: string;
272
+ receipt?: string;
273
+ }
263
274
  interface HandlerContext<TBody = undefined, TQuery = undefined> {
264
275
  body: TBody;
265
276
  query: TQuery;
@@ -267,6 +278,7 @@ interface HandlerContext<TBody = undefined, TQuery = undefined> {
267
278
  requestId: string;
268
279
  route: string;
269
280
  wallet: string | null;
281
+ payment: HandlerPaymentContext | null;
270
282
  account: unknown;
271
283
  alert: AlertFn;
272
284
  setVerifiedWallet: (addr: string) => void;
@@ -640,6 +652,44 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, TOutput = unde
640
652
  handler(fn: HandlerArg<TBody, TQuery, HasAuth, NeedsBody, HasBody, NeedsInputExample, NeedsOutputExample>): (request: NextRequest) => Promise<Response>;
641
653
  }
642
654
 
655
+ declare const BASE_NETWORK = "eip155:8453";
656
+ declare const SOLANA_MAINNET_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
657
+ declare const TEMPO_USDC_CURRENCY = "0x20c000000000000000000000b9537d11c60e8b50";
658
+ declare const ZERO_EVM_ADDRESS = "0x0000000000000000000000000000000000000000";
659
+
660
+ type RouterEnv = Record<string, string | undefined>;
661
+ type RouterConfigIssueCode = 'missing_base_url' | 'empty_protocols' | 'missing_x402_accepts' | 'missing_x402_network' | 'unsupported_x402_network' | 'missing_x402_asset' | 'invalid_x402_decimals' | 'missing_x402_payee' | 'missing_cdp_keys' | 'placeholder_payee' | 'missing_mpp_config' | 'missing_mpp_secret_key' | 'missing_mpp_currency' | 'missing_mpp_recipient' | 'missing_mpp_rpc_url' | 'missing_mpp_default_store_env';
662
+ interface RouterConfigIssue {
663
+ code: RouterConfigIssueCode;
664
+ message: string;
665
+ protocol?: ProtocolType;
666
+ }
667
+ interface RouterConfigValidationOptions {
668
+ env?: RouterEnv;
669
+ requireCdpKeys?: boolean;
670
+ }
671
+ declare class RouterConfigError extends Error {
672
+ readonly issues: RouterConfigIssue[];
673
+ constructor(issues: RouterConfigIssue[]);
674
+ }
675
+ declare function validateRouterConfig(config: RouterConfig, options?: RouterConfigValidationOptions): void;
676
+ declare function getRouterConfigIssues(config: RouterConfig, options?: RouterConfigValidationOptions): RouterConfigIssue[];
677
+ declare function formatRouterConfigIssues(issues: readonly RouterConfigIssue[]): string;
678
+ declare function mppFromEnv(env: RouterEnv, options?: {
679
+ recipient?: string;
680
+ require?: boolean;
681
+ useDefaultStore?: boolean;
682
+ feePayerKey?: string;
683
+ }): RouterConfig['mpp'] | undefined;
684
+ declare function x402AcceptsFromEnv(env: RouterEnv, options?: {
685
+ payeeAddress?: string;
686
+ payeeEnv?: string;
687
+ network?: string;
688
+ solanaPayeeAddress?: string;
689
+ solanaPayeeEnv?: string;
690
+ }): X402AcceptConfig[];
691
+ declare function paidOptionsForProtocols(protocols: readonly ProtocolType[]): PaidOptions;
692
+
643
693
  interface MonitorEntry {
644
694
  provider: string;
645
695
  route: string;
@@ -660,4 +710,4 @@ declare function createRouter<const P extends Record<string, string> = Record<ne
660
710
  prices?: P;
661
711
  }): ServiceRouter<Extract<keyof P, string>>;
662
712
 
663
- export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, type DiscoveryConfig, type EntitlementStore, type ErrorEvent, type HandlerContext, HttpError, MemoryEntitlementStore, MemoryNonceStore, type MonitorEntry, type MppProtocolInfo, type NonceStore, type OveragePolicy, type PaidOptions, type PayToConfig, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisEntitlementStoreOptions, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, type ServiceRouter, type SettlementEvent, type TierConfig, type X402AcceptConfig, type X402FacilitatorTarget, type X402FacilitatorsConfig, type X402ResolvedAccept, type X402RouterFacilitatorConfig, type X402Server, consolePlugin, createRedisEntitlementStore, createRedisNonceStore, createRouter };
713
+ export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, BASE_NETWORK, type DiscoveryConfig, type EntitlementStore, type ErrorEvent, type HandlerContext, type HandlerPaymentContext, HttpError, MemoryEntitlementStore, MemoryNonceStore, type MonitorEntry, type MppProtocolInfo, type NonceStore, type OveragePolicy, type PaidOptions, type PayToConfig, type PaymentEvent, type PaymentStatus, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisEntitlementStoreOptions, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, RouterConfigError, type RouterConfigIssue, type RouterConfigIssueCode, type RouterConfigValidationOptions, type RouterEnv, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, SOLANA_MAINNET_NETWORK, type ServiceRouter, type SettlementEvent, TEMPO_USDC_CURRENCY, type TierConfig, type X402AcceptConfig, type X402FacilitatorTarget, type X402FacilitatorsConfig, type X402ResolvedAccept, type X402RouterFacilitatorConfig, type X402Server, ZERO_EVM_ADDRESS, consolePlugin, createRedisEntitlementStore, createRedisNonceStore, createRouter, formatRouterConfigIssues, getRouterConfigIssues, mppFromEnv, paidOptionsForProtocols, validateRouterConfig, x402AcceptsFromEnv };
package/dist/index.d.ts CHANGED
@@ -260,6 +260,17 @@ interface PaidOptions {
260
260
  /** Override MPP protocol metadata in x-payment-info discovery. */
261
261
  mpp?: MppProtocolInfo;
262
262
  }
263
+ type PaymentStatus = 'verified' | 'settled';
264
+ interface HandlerPaymentContext {
265
+ protocol: ProtocolType;
266
+ status: PaymentStatus;
267
+ payer: string;
268
+ amount: string;
269
+ network: string;
270
+ recipient?: string;
271
+ transaction?: string;
272
+ receipt?: string;
273
+ }
263
274
  interface HandlerContext<TBody = undefined, TQuery = undefined> {
264
275
  body: TBody;
265
276
  query: TQuery;
@@ -267,6 +278,7 @@ interface HandlerContext<TBody = undefined, TQuery = undefined> {
267
278
  requestId: string;
268
279
  route: string;
269
280
  wallet: string | null;
281
+ payment: HandlerPaymentContext | null;
270
282
  account: unknown;
271
283
  alert: AlertFn;
272
284
  setVerifiedWallet: (addr: string) => void;
@@ -640,6 +652,44 @@ declare class RouteBuilder<TBody = undefined, TQuery = undefined, TOutput = unde
640
652
  handler(fn: HandlerArg<TBody, TQuery, HasAuth, NeedsBody, HasBody, NeedsInputExample, NeedsOutputExample>): (request: NextRequest) => Promise<Response>;
641
653
  }
642
654
 
655
+ declare const BASE_NETWORK = "eip155:8453";
656
+ declare const SOLANA_MAINNET_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
657
+ declare const TEMPO_USDC_CURRENCY = "0x20c000000000000000000000b9537d11c60e8b50";
658
+ declare const ZERO_EVM_ADDRESS = "0x0000000000000000000000000000000000000000";
659
+
660
+ type RouterEnv = Record<string, string | undefined>;
661
+ type RouterConfigIssueCode = 'missing_base_url' | 'empty_protocols' | 'missing_x402_accepts' | 'missing_x402_network' | 'unsupported_x402_network' | 'missing_x402_asset' | 'invalid_x402_decimals' | 'missing_x402_payee' | 'missing_cdp_keys' | 'placeholder_payee' | 'missing_mpp_config' | 'missing_mpp_secret_key' | 'missing_mpp_currency' | 'missing_mpp_recipient' | 'missing_mpp_rpc_url' | 'missing_mpp_default_store_env';
662
+ interface RouterConfigIssue {
663
+ code: RouterConfigIssueCode;
664
+ message: string;
665
+ protocol?: ProtocolType;
666
+ }
667
+ interface RouterConfigValidationOptions {
668
+ env?: RouterEnv;
669
+ requireCdpKeys?: boolean;
670
+ }
671
+ declare class RouterConfigError extends Error {
672
+ readonly issues: RouterConfigIssue[];
673
+ constructor(issues: RouterConfigIssue[]);
674
+ }
675
+ declare function validateRouterConfig(config: RouterConfig, options?: RouterConfigValidationOptions): void;
676
+ declare function getRouterConfigIssues(config: RouterConfig, options?: RouterConfigValidationOptions): RouterConfigIssue[];
677
+ declare function formatRouterConfigIssues(issues: readonly RouterConfigIssue[]): string;
678
+ declare function mppFromEnv(env: RouterEnv, options?: {
679
+ recipient?: string;
680
+ require?: boolean;
681
+ useDefaultStore?: boolean;
682
+ feePayerKey?: string;
683
+ }): RouterConfig['mpp'] | undefined;
684
+ declare function x402AcceptsFromEnv(env: RouterEnv, options?: {
685
+ payeeAddress?: string;
686
+ payeeEnv?: string;
687
+ network?: string;
688
+ solanaPayeeAddress?: string;
689
+ solanaPayeeEnv?: string;
690
+ }): X402AcceptConfig[];
691
+ declare function paidOptionsForProtocols(protocols: readonly ProtocolType[]): PaidOptions;
692
+
643
693
  interface MonitorEntry {
644
694
  provider: string;
645
695
  route: string;
@@ -660,4 +710,4 @@ declare function createRouter<const P extends Record<string, string> = Record<ne
660
710
  prices?: P;
661
711
  }): ServiceRouter<Extract<keyof P, string>>;
662
712
 
663
- export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, type DiscoveryConfig, type EntitlementStore, type ErrorEvent, type HandlerContext, HttpError, MemoryEntitlementStore, MemoryNonceStore, type MonitorEntry, type MppProtocolInfo, type NonceStore, type OveragePolicy, type PaidOptions, type PayToConfig, type PaymentEvent, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisEntitlementStoreOptions, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, type ServiceRouter, type SettlementEvent, type TierConfig, type X402AcceptConfig, type X402FacilitatorTarget, type X402FacilitatorsConfig, type X402ResolvedAccept, type X402RouterFacilitatorConfig, type X402Server, consolePlugin, createRedisEntitlementStore, createRedisNonceStore, createRouter };
713
+ export { type AlertEvent, type AlertFn, type AlertLevel, type AuthEvent, type AuthMode, BASE_NETWORK, type DiscoveryConfig, type EntitlementStore, type ErrorEvent, type HandlerContext, type HandlerPaymentContext, HttpError, MemoryEntitlementStore, MemoryNonceStore, type MonitorEntry, type MppProtocolInfo, type NonceStore, type OveragePolicy, type PaidOptions, type PayToConfig, type PaymentEvent, type PaymentStatus, type PluginContext, type PricingConfig, type ProtocolType, type ProviderConfig, type ProviderQuotaEvent, type QuotaInfo, type QuotaLevel, type RedisEntitlementStoreOptions, type RedisNonceStoreOptions, type RequestMeta, type ResponseMeta, RouteBuilder, type RouteEntry, RouteRegistry, type RouterConfig, RouterConfigError, type RouterConfigIssue, type RouterConfigIssueCode, type RouterConfigValidationOptions, type RouterEnv, type RouterPlugin, SIWX_CHALLENGE_EXPIRY_MS, SOLANA_MAINNET_NETWORK, type ServiceRouter, type SettlementEvent, TEMPO_USDC_CURRENCY, type TierConfig, type X402AcceptConfig, type X402FacilitatorTarget, type X402FacilitatorsConfig, type X402ResolvedAccept, type X402RouterFacilitatorConfig, type X402Server, ZERO_EVM_ADDRESS, consolePlugin, createRedisEntitlementStore, createRedisNonceStore, createRouter, formatRouterConfigIssues, getRouterConfigIssues, mppFromEnv, paidOptionsForProtocols, validateRouterConfig, x402AcceptsFromEnv };
package/dist/index.js CHANGED
@@ -166,6 +166,18 @@ var init_x402_facilitators = __esm({
166
166
  }
167
167
  });
168
168
 
169
+ // src/constants.ts
170
+ var BASE_NETWORK, SOLANA_MAINNET_NETWORK, TEMPO_USDC_CURRENCY, ZERO_EVM_ADDRESS;
171
+ var init_constants = __esm({
172
+ "src/constants.ts"() {
173
+ "use strict";
174
+ BASE_NETWORK = "eip155:8453";
175
+ SOLANA_MAINNET_NETWORK = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
176
+ TEMPO_USDC_CURRENCY = "0x20c000000000000000000000b9537d11c60e8b50";
177
+ ZERO_EVM_ADDRESS = "0x0000000000000000000000000000000000000000";
178
+ }
179
+ });
180
+
169
181
  // src/x402-config.ts
170
182
  async function resolvePayToValue(payTo, request, fallback, body) {
171
183
  if (!payTo) return fallback;
@@ -179,7 +191,7 @@ function getConfiguredX402Accepts(config) {
179
191
  return [
180
192
  {
181
193
  scheme: "exact",
182
- network: config.network ?? "eip155:8453",
194
+ network: config.network ?? BASE_NETWORK,
183
195
  payTo: config.payeeAddress
184
196
  }
185
197
  ];
@@ -208,6 +220,7 @@ async function resolveX402Accepts(request, routeEntry, accepts, fallbackPayTo, b
208
220
  var init_x402_config = __esm({
209
221
  "src/x402-config.ts"() {
210
222
  "use strict";
223
+ init_constants();
211
224
  }
212
225
  });
213
226
 
@@ -990,6 +1003,10 @@ function getRequirementNetwork(requirements, fallback) {
990
1003
  const network = requirements?.network;
991
1004
  return typeof network === "string" ? network : fallback;
992
1005
  }
1006
+ function getRequirementRecipient(requirements) {
1007
+ const payTo = requirements?.payTo;
1008
+ return typeof payTo === "string" ? payTo : void 0;
1009
+ }
993
1010
  function siwxSignatureType(network) {
994
1011
  return network.startsWith("solana:") ? "ed25519" : "eip191";
995
1012
  }
@@ -1008,7 +1025,7 @@ function getSupportedChains(x402Accepts, fallbackNetwork) {
1008
1025
  return chains;
1009
1026
  }
1010
1027
  function createRequestHandler(routeEntry, handler, deps) {
1011
- async function invoke(request, meta, pluginCtx, wallet, account, parsedBody) {
1028
+ async function invoke(request, meta, pluginCtx, wallet, account, parsedBody, payment) {
1012
1029
  const ctx = {
1013
1030
  body: parsedBody,
1014
1031
  query: parseQuery(request, routeEntry),
@@ -1016,6 +1033,7 @@ function createRequestHandler(routeEntry, handler, deps) {
1016
1033
  requestId: meta.requestId,
1017
1034
  route: routeEntry.key,
1018
1035
  wallet,
1036
+ payment,
1019
1037
  account,
1020
1038
  alert(level, message, alertMeta) {
1021
1039
  firePluginHook(deps.plugin, "onAlert", pluginCtx, {
@@ -1068,7 +1086,8 @@ function createRequestHandler(routeEntry, handler, deps) {
1068
1086
  pluginCtx,
1069
1087
  wallet,
1070
1088
  account2,
1071
- body2.data
1089
+ body2.data,
1090
+ null
1072
1091
  );
1073
1092
  finalize(response, rawResult, meta, pluginCtx, body2.data);
1074
1093
  return response;
@@ -1114,6 +1133,9 @@ function createRequestHandler(routeEntry, handler, deps) {
1114
1133
  return fail(status, message, meta, pluginCtx, earlyBodyData);
1115
1134
  }
1116
1135
  }
1136
+ } else {
1137
+ firePluginResponse(deps, pluginCtx, meta, earlyBodyResult.response);
1138
+ return earlyBodyResult.response;
1117
1139
  }
1118
1140
  }
1119
1141
  if (routeEntry.authMode === "siwx" || routeEntry.siwxEnabled) {
@@ -1342,7 +1364,16 @@ function createRequestHandler(routeEntry, handler, deps) {
1342
1364
  return await build402(request, routeEntry, deps, meta, pluginCtx, body.data);
1343
1365
  const { payload: verifyPayload, requirements: verifyRequirements } = verify;
1344
1366
  const matchedNetwork = getRequirementNetwork(verifyRequirements, deps.network);
1367
+ const matchedRecipient = getRequirementRecipient(verifyRequirements);
1345
1368
  const wallet = normalizeWalletAddress(verify.payer);
1369
+ const payment = {
1370
+ protocol: "x402",
1371
+ status: "verified",
1372
+ payer: wallet,
1373
+ amount: price,
1374
+ network: matchedNetwork,
1375
+ ...matchedRecipient ? { recipient: matchedRecipient } : {}
1376
+ };
1346
1377
  pluginCtx.setVerifiedWallet(wallet);
1347
1378
  firePluginHook(deps.plugin, "onPaymentVerified", pluginCtx, {
1348
1379
  protocol: "x402",
@@ -1356,7 +1387,8 @@ function createRequestHandler(routeEntry, handler, deps) {
1356
1387
  pluginCtx,
1357
1388
  wallet,
1358
1389
  account,
1359
- body.data
1390
+ body.data,
1391
+ payment
1360
1392
  );
1361
1393
  if (response.status < 400) {
1362
1394
  try {
@@ -1455,7 +1487,15 @@ function createRequestHandler(routeEntry, handler, deps) {
1455
1487
  pluginCtx,
1456
1488
  wallet,
1457
1489
  account,
1458
- body.data
1490
+ body.data,
1491
+ {
1492
+ protocol: "mpp",
1493
+ status: "verified",
1494
+ payer: wallet,
1495
+ amount: price,
1496
+ network: "tempo:4217",
1497
+ recipient: deps.payeeAddress
1498
+ }
1459
1499
  );
1460
1500
  if (response2.status < 400) {
1461
1501
  let mppResult2;
@@ -1588,7 +1628,17 @@ function createRequestHandler(routeEntry, handler, deps) {
1588
1628
  pluginCtx,
1589
1629
  wallet,
1590
1630
  account,
1591
- body.data
1631
+ body.data,
1632
+ {
1633
+ protocol: "mpp",
1634
+ status: "settled",
1635
+ payer: wallet,
1636
+ amount: price,
1637
+ network: "tempo:4217",
1638
+ recipient: deps.payeeAddress,
1639
+ ...txHash ? { transaction: txHash } : {},
1640
+ ...receiptHeader ? { receipt: receiptHeader } : {}
1641
+ }
1592
1642
  );
1593
1643
  if (response.status < 400) {
1594
1644
  if (routeEntry.siwxEnabled) {
@@ -2548,61 +2598,282 @@ function createLlmsTxtHandler(discovery) {
2548
2598
 
2549
2599
  // src/index.ts
2550
2600
  init_x402_config();
2601
+ init_constants();
2602
+
2603
+ // src/config.ts
2604
+ init_constants();
2551
2605
  init_evm();
2552
2606
  init_solana();
2607
+ init_x402_config();
2608
+ var RouterConfigError = class extends Error {
2609
+ issues;
2610
+ constructor(issues) {
2611
+ super(formatRouterConfigIssues(issues));
2612
+ this.name = "RouterConfigError";
2613
+ this.issues = issues;
2614
+ }
2615
+ };
2616
+ function validateRouterConfig(config, options = {}) {
2617
+ const issues = getRouterConfigIssues(config, options);
2618
+ if (issues.length > 0) throw new RouterConfigError(issues);
2619
+ }
2620
+ function getRouterConfigIssues(config, options = {}) {
2621
+ const env = options.env ?? process.env;
2622
+ const issues = [];
2623
+ const protocols = config.protocols ?? ["x402"];
2624
+ if (!config.baseUrl) {
2625
+ issues.push({
2626
+ code: "missing_base_url",
2627
+ message: '[router] baseUrl is required in RouterConfig. Set it to your production domain (e.g., "https://api.example.com"). The realm is used for payment matching and must be correct.'
2628
+ });
2629
+ }
2630
+ if (config.protocols && config.protocols.length === 0) {
2631
+ issues.push({
2632
+ code: "empty_protocols",
2633
+ message: "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
2634
+ });
2635
+ }
2636
+ if (protocols.includes("x402")) {
2637
+ issues.push(...validateX402Config(config, env, options));
2638
+ }
2639
+ if (protocols.includes("mpp")) {
2640
+ issues.push(...validateMppConfig(config, env));
2641
+ }
2642
+ return issues;
2643
+ }
2644
+ function formatRouterConfigIssues(issues) {
2645
+ return issues.map((issue) => issue.message).join("\n");
2646
+ }
2647
+ function mppFromEnv(env, options = {}) {
2648
+ const secretKey = env.MPP_SECRET_KEY;
2649
+ const currency = env.MPP_CURRENCY;
2650
+ const rpcUrl = env.TEMPO_RPC_URL;
2651
+ const hasAnyMppEnv = Boolean(secretKey || currency || rpcUrl || options.require);
2652
+ if (!hasAnyMppEnv) return void 0;
2653
+ const missing = [
2654
+ secretKey ? null : "MPP_SECRET_KEY",
2655
+ currency ? null : "MPP_CURRENCY",
2656
+ rpcUrl ? null : "TEMPO_RPC_URL"
2657
+ ].filter(Boolean);
2658
+ if (missing.length > 0) {
2659
+ throw new Error(`MPP env is incomplete. Missing: ${missing.join(", ")}`);
2660
+ }
2661
+ return {
2662
+ secretKey,
2663
+ currency,
2664
+ rpcUrl,
2665
+ ...options.recipient ? { recipient: options.recipient } : {},
2666
+ ...options.feePayerKey ? { feePayerKey: options.feePayerKey } : {},
2667
+ ...options.useDefaultStore !== void 0 ? { useDefaultStore: options.useDefaultStore } : {}
2668
+ };
2669
+ }
2670
+ function x402AcceptsFromEnv(env, options = {}) {
2671
+ const payeeEnv = options.payeeEnv ?? "X402_WALLET_ADDRESS";
2672
+ const solanaPayeeEnv = options.solanaPayeeEnv ?? "SOLANA_PAYEE_ADDRESS";
2673
+ const payeeAddress = options.payeeAddress ?? env[payeeEnv];
2674
+ if (!payeeAddress) {
2675
+ throw new Error(`${payeeEnv} is required to build x402 accepts`);
2676
+ }
2677
+ const accepts = [
2678
+ {
2679
+ scheme: "exact",
2680
+ network: options.network ?? BASE_NETWORK,
2681
+ payTo: payeeAddress
2682
+ }
2683
+ ];
2684
+ const solanaPayeeAddress = options.solanaPayeeAddress ?? env[solanaPayeeEnv];
2685
+ if (solanaPayeeAddress) {
2686
+ accepts.push({
2687
+ scheme: "exact",
2688
+ network: SOLANA_MAINNET_NETWORK,
2689
+ payTo: solanaPayeeAddress
2690
+ });
2691
+ }
2692
+ return accepts;
2693
+ }
2694
+ function paidOptionsForProtocols(protocols) {
2695
+ return { protocols: [...protocols] };
2696
+ }
2697
+ function validateX402Config(config, env, options) {
2698
+ const issues = [];
2699
+ const accepts = getConfiguredX402Accepts(config);
2700
+ if (accepts.length === 0) {
2701
+ issues.push({
2702
+ code: "missing_x402_accepts",
2703
+ protocol: "x402",
2704
+ message: "x402 requires at least one accept configuration."
2705
+ });
2706
+ return issues;
2707
+ }
2708
+ const acceptWithoutNetwork = accepts.find((accept) => !accept.network);
2709
+ if (acceptWithoutNetwork) {
2710
+ issues.push({
2711
+ code: "missing_x402_network",
2712
+ protocol: "x402",
2713
+ message: "x402 accepts require a network."
2714
+ });
2715
+ }
2716
+ const unsupported = accepts.find(
2717
+ (accept) => accept.network && !isSupportedX402Network(accept.network)
2718
+ );
2719
+ if (unsupported) {
2720
+ issues.push({
2721
+ code: "unsupported_x402_network",
2722
+ protocol: "x402",
2723
+ message: `unsupported x402 network '${unsupported.network}'. Use eip155:* or solana:*.`
2724
+ });
2725
+ }
2726
+ const missingAsset = accepts.find(
2727
+ (accept) => (accept.scheme ?? "exact") !== "exact" && !accept.asset
2728
+ );
2729
+ if (missingAsset) {
2730
+ issues.push({
2731
+ code: "missing_x402_asset",
2732
+ protocol: "x402",
2733
+ message: "non-exact x402 accepts require an asset."
2734
+ });
2735
+ }
2736
+ const invalidDecimals = accepts.find(
2737
+ (accept) => accept.decimals !== void 0 && (!Number.isInteger(accept.decimals) || accept.decimals < 0)
2738
+ );
2739
+ if (invalidDecimals) {
2740
+ issues.push({
2741
+ code: "invalid_x402_decimals",
2742
+ protocol: "x402",
2743
+ message: "x402 accept decimals must be a non-negative integer."
2744
+ });
2745
+ }
2746
+ if (accepts.some((accept) => !accept.payTo) && !config.payeeAddress) {
2747
+ issues.push({
2748
+ code: "missing_x402_payee",
2749
+ protocol: "x402",
2750
+ message: "x402 requires payeeAddress in router config or payTo on every x402 accept."
2751
+ });
2752
+ }
2753
+ const placeholder = findPlaceholderPayee([
2754
+ config.payeeAddress,
2755
+ ...accepts.map((accept) => typeof accept.payTo === "string" ? accept.payTo : void 0)
2756
+ ]);
2757
+ if (placeholder) {
2758
+ issues.push({
2759
+ code: "placeholder_payee",
2760
+ protocol: "x402",
2761
+ message: `x402 payee '${placeholder}' is a placeholder address and cannot receive payments.`
2762
+ });
2763
+ }
2764
+ if (options.requireCdpKeys !== false && usesDefaultEvmFacilitator(config)) {
2765
+ const missing = [
2766
+ env.CDP_API_KEY_ID ? null : "CDP_API_KEY_ID",
2767
+ env.CDP_API_KEY_SECRET ? null : "CDP_API_KEY_SECRET"
2768
+ ].filter(Boolean);
2769
+ if (missing.length > 0) {
2770
+ issues.push({
2771
+ code: "missing_cdp_keys",
2772
+ protocol: "x402",
2773
+ message: `default EVM x402 facilitator requires ${missing.join(" and ")}.`
2774
+ });
2775
+ }
2776
+ }
2777
+ return issues;
2778
+ }
2779
+ function validateMppConfig(config, env) {
2780
+ const issues = [];
2781
+ const mpp = config.mpp;
2782
+ if (!mpp) {
2783
+ return [
2784
+ {
2785
+ code: "missing_mpp_config",
2786
+ protocol: "mpp",
2787
+ message: 'protocols includes "mpp" but mpp config is missing. Add mpp: { secretKey, currency, recipient } to your router config.'
2788
+ }
2789
+ ];
2790
+ }
2791
+ if (!mpp.secretKey) {
2792
+ issues.push({
2793
+ code: "missing_mpp_secret_key",
2794
+ protocol: "mpp",
2795
+ message: "MPP requires secretKey. Set MPP_SECRET_KEY or pass mpp.secretKey."
2796
+ });
2797
+ }
2798
+ if (!mpp.currency) {
2799
+ issues.push({
2800
+ code: "missing_mpp_currency",
2801
+ protocol: "mpp",
2802
+ message: "MPP requires currency. Set MPP_CURRENCY or pass mpp.currency."
2803
+ });
2804
+ }
2805
+ if (!mpp.recipient && !config.payeeAddress) {
2806
+ issues.push({
2807
+ code: "missing_mpp_recipient",
2808
+ protocol: "mpp",
2809
+ message: "MPP requires a recipient address. Set mpp.recipient or payeeAddress in your router config."
2810
+ });
2811
+ }
2812
+ const placeholder = findPlaceholderPayee([mpp.recipient, config.payeeAddress]);
2813
+ if (placeholder) {
2814
+ issues.push({
2815
+ code: "placeholder_payee",
2816
+ protocol: "mpp",
2817
+ message: `MPP recipient '${placeholder}' is a placeholder address and cannot receive payments.`
2818
+ });
2819
+ }
2820
+ if (!(mpp.rpcUrl ?? env.TEMPO_RPC_URL)) {
2821
+ issues.push({
2822
+ code: "missing_mpp_rpc_url",
2823
+ protocol: "mpp",
2824
+ message: "MPP requires an authenticated Tempo RPC URL. Set TEMPO_RPC_URL env var or pass rpcUrl in the mpp config object."
2825
+ });
2826
+ }
2827
+ if (mpp.useDefaultStore && !mpp.store && (!env.KV_REST_API_URL || !env.KV_REST_API_TOKEN)) {
2828
+ issues.push({
2829
+ code: "missing_mpp_default_store_env",
2830
+ protocol: "mpp",
2831
+ message: "mpp.useDefaultStore requires KV_REST_API_URL and KV_REST_API_TOKEN environment variables. These are automatically set by Vercel KV."
2832
+ });
2833
+ }
2834
+ return issues;
2835
+ }
2836
+ function usesDefaultEvmFacilitator(config) {
2837
+ return getConfiguredX402Networks(config).some(
2838
+ (network) => typeof network === "string" && isEvmNetwork(network)
2839
+ ) && config.x402?.facilitators?.evm === void 0;
2840
+ }
2841
+ function isSupportedX402Network(network) {
2842
+ return isEvmNetwork(network) || isSolanaNetwork(network);
2843
+ }
2844
+ function findPlaceholderPayee(values) {
2845
+ return values.find((value) => value?.toLowerCase() === ZERO_EVM_ADDRESS) ?? null;
2846
+ }
2847
+
2848
+ // src/index.ts
2849
+ init_constants();
2553
2850
  function createRouter(config) {
2554
2851
  const registry = new RouteRegistry();
2555
2852
  const nonceStore = config.siwx?.nonceStore ?? new MemoryNonceStore();
2556
2853
  const entitlementStore = config.siwx?.entitlementStore ?? new MemoryEntitlementStore();
2557
- const network = config.network ?? "eip155:8453";
2854
+ const network = config.network ?? BASE_NETWORK;
2558
2855
  const x402Accepts = getConfiguredX402Accepts(config);
2559
- if (!config.baseUrl) {
2560
- throw new Error(
2561
- '[router] baseUrl is required in RouterConfig. Set it to your production domain (e.g., "https://api.example.com"). The realm is used for payment matching and must be correct.'
2562
- );
2563
- }
2564
- if (config.protocols && config.protocols.length === 0) {
2565
- throw new Error(
2566
- "RouterConfig.protocols cannot be empty. Omit the field to use default ['x402'] or specify protocols explicitly."
2567
- );
2568
- }
2569
- const resolvedBaseUrl = config.baseUrl.replace(/\/+$/, "");
2570
- let x402ConfigError;
2571
- let mppConfigError;
2572
- if (!config.protocols || config.protocols.includes("x402")) {
2573
- if (x402Accepts.length === 0) {
2574
- x402ConfigError = "x402 requires at least one accept configuration.";
2575
- } else if (x402Accepts.some((accept) => !accept.network)) {
2576
- x402ConfigError = "x402 accepts require a network.";
2577
- } else if (x402Accepts.some((accept) => !isSupportedX402Network(accept.network))) {
2578
- const unsupported = x402Accepts.find((accept) => !isSupportedX402Network(accept.network));
2579
- x402ConfigError = `unsupported x402 network '${unsupported?.network}'. Use eip155:* or solana:*.`;
2580
- } else if (x402Accepts.some((accept) => (accept.scheme ?? "exact") !== "exact" && !accept.asset)) {
2581
- x402ConfigError = "non-exact x402 accepts require an asset.";
2582
- } else if (x402Accepts.some(
2583
- (accept) => accept.decimals !== void 0 && (!Number.isInteger(accept.decimals) || accept.decimals < 0)
2584
- )) {
2585
- x402ConfigError = "x402 accept decimals must be a non-negative integer.";
2586
- } else if (x402Accepts.some((accept) => !accept.payTo) && !config.payeeAddress) {
2587
- x402ConfigError = "x402 requires payeeAddress in router config or payTo on every x402 accept.";
2588
- }
2589
- }
2590
- if (config.protocols?.includes("mpp")) {
2591
- if (!config.mpp) {
2592
- mppConfigError = 'protocols includes "mpp" but mpp config is missing. Add mpp: { secretKey, currency, recipient } to your router config.';
2593
- } else if (!config.mpp.recipient && !config.payeeAddress) {
2594
- mppConfigError = "MPP requires a recipient address. Set mpp.recipient or payeeAddress in your router config.";
2595
- } else if (!(config.mpp.rpcUrl ?? process.env.TEMPO_RPC_URL)) {
2596
- mppConfigError = "MPP requires an authenticated Tempo RPC URL. Set TEMPO_RPC_URL env var or pass rpcUrl in the mpp config object.";
2597
- }
2598
- }
2599
- const allConfigErrors = [x402ConfigError, mppConfigError].filter(Boolean);
2600
- if (allConfigErrors.length > 0) {
2601
- for (const err of allConfigErrors) console.error(`[router] ${err}`);
2856
+ const configIssues = getRouterConfigIssues(config, {
2857
+ requireCdpKeys: process.env.NODE_ENV === "production"
2858
+ });
2859
+ const baseUrlIssue = configIssues.find((issue) => issue.code === "missing_base_url");
2860
+ if (baseUrlIssue) throw new RouterConfigError([baseUrlIssue]);
2861
+ const emptyProtocolsIssue = configIssues.find((issue) => issue.code === "empty_protocols");
2862
+ if (emptyProtocolsIssue) throw new RouterConfigError([emptyProtocolsIssue]);
2863
+ const protocolConfigIssues = configIssues.filter(
2864
+ (issue) => issue.code !== "missing_base_url" && issue.code !== "empty_protocols"
2865
+ );
2866
+ const x402ConfigIssues = protocolConfigIssues.filter((issue) => issue.protocol === "x402");
2867
+ const mppConfigIssues = protocolConfigIssues.filter((issue) => issue.protocol === "mpp");
2868
+ const x402ConfigError = x402ConfigIssues.length > 0 ? formatRouterConfigIssues(x402ConfigIssues) : void 0;
2869
+ const mppConfigError = mppConfigIssues.length > 0 ? formatRouterConfigIssues(mppConfigIssues) : void 0;
2870
+ if (protocolConfigIssues.length > 0) {
2871
+ for (const issue of protocolConfigIssues) console.error(`[router] ${issue.message}`);
2602
2872
  if (process.env.NODE_ENV === "production") {
2603
- throw new Error(allConfigErrors.join("\n"));
2873
+ throw new RouterConfigError(protocolConfigIssues);
2604
2874
  }
2605
2875
  }
2876
+ const resolvedBaseUrl = config.baseUrl.replace(/\/+$/, "");
2606
2877
  if (config.plugin?.init) {
2607
2878
  try {
2608
2879
  const result = config.plugin.init({ origin: resolvedBaseUrl });
@@ -2747,9 +3018,6 @@ function createRouter(config) {
2747
3018
  registry
2748
3019
  };
2749
3020
  }
2750
- function isSupportedX402Network(network) {
2751
- return isEvmNetwork(network) || isSolanaNetwork(network);
2752
- }
2753
3021
  function normalizePath(path) {
2754
3022
  let normalized = path.trim();
2755
3023
  normalized = normalized.replace(/^\/+/, "");
@@ -2757,15 +3025,26 @@ function normalizePath(path) {
2757
3025
  return normalized.replace(/\/+$/, "");
2758
3026
  }
2759
3027
  export {
3028
+ BASE_NETWORK,
2760
3029
  HttpError,
2761
3030
  MemoryEntitlementStore,
2762
3031
  MemoryNonceStore,
2763
3032
  RouteBuilder,
2764
3033
  RouteRegistry,
3034
+ RouterConfigError,
2765
3035
  SIWX_CHALLENGE_EXPIRY_MS,
2766
3036
  SIWX_ERROR_MESSAGES,
3037
+ SOLANA_MAINNET_NETWORK,
3038
+ TEMPO_USDC_CURRENCY,
3039
+ ZERO_EVM_ADDRESS,
2767
3040
  consolePlugin,
2768
3041
  createRedisEntitlementStore,
2769
3042
  createRedisNonceStore,
2770
- createRouter
3043
+ createRouter,
3044
+ formatRouterConfigIssues,
3045
+ getRouterConfigIssues,
3046
+ mppFromEnv,
3047
+ paidOptionsForProtocols,
3048
+ validateRouterConfig,
3049
+ x402AcceptsFromEnv
2771
3050
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentcash/router",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Unified route builder for Next.js App Router APIs with x402, MPP, SIWX, and API key auth",
5
5
  "type": "module",
6
6
  "exports": {