verify402-mcp 1.0.4 → 1.0.6

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/.env.example CHANGED
@@ -3,9 +3,9 @@
3
3
  # The bridge runs without any of these. With no EVM_PRIVATE_KEY it exposes only
4
4
  # the free preflight_gate. Set a key only when you want the five paid checks.
5
5
  #
6
- # EVM_PRIVATE_KEY is a dedicated hot wallet that pays for calls in USDC. Use a
7
- # wallet with only the money you are willing to spend. NEVER commit the real
8
- # .env and never use a main wallet.
6
+ # EVM_PRIVATE_KEY is a dedicated hot wallet that pays for calls in testnet
7
+ # USDC on the public demo. Use a wallet with only the money you are willing to
8
+ # spend. NEVER commit the real .env and never use a main wallet.
9
9
  EVM_PRIVATE_KEY= # optional; omit for the free gate only
10
10
  VERIFY402_URL=https://verify402.onrender.com
11
11
  # spend controls (paid tools only)
@@ -13,8 +13,11 @@ MAX_PAYMENT_USD=0.25 # cap on ONE payment, enforced at signature time
13
13
  MAX_DAILY_USD=0.50 # cap on the whole UTC day, survives restarts; omit to disable
14
14
  SPEND_LEDGER_PATH= # optional override for the ledger file (default .spend-ledger.json)
15
15
 
16
- # payment allowlist (paid tools only; these are the defaults)
17
- VERIFY402_NETWORKS=eip155:8453
16
+ # payment allowlist (paid tools only; these are the public-demo defaults)
17
+ # Base Sepolia testnet: the live demo's EVM payment rail.
18
+ VERIFY402_NETWORKS=eip155:84532
18
19
  VERIFY402_PAY_TO=0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb
19
20
  # VERIFY402_RESOURCE_PREFIX=https://verify402.onrender.com
20
- # For local testnet, set VERIFY402_NETWORKS=eip155:84532 and the payee the local server names.
21
+ # Explicit mainnet is a separate deployment, not a demo setting. Set this only
22
+ # after separate readiness verification, with a mainnet facilitator and host:
23
+ # VERIFY402_NETWORKS=eip155:8453
package/README.md CHANGED
@@ -5,8 +5,18 @@ agent signs a payment or a chain transaction, plus five paid on-chain checks
5
5
  paid per call over x402.
6
6
 
7
7
  Works with Claude Desktop, Cursor, OpenCode, and any MCP client that speaks
8
- stdio. The free gate needs **no wallet**. The paid tools use a dedicated hot
9
- wallet that pays in USDC; the bridge never signs or sends a trade.
8
+ stdio. The free gate needs **no wallet**. The public demo's paid tools use a
9
+ dedicated hot wallet funded with **Base Sepolia testnet USDC** (`eip155:84532`).
10
+ The analysis reads mainnet chain data, but the demo never settles mainnet
11
+ payments. The bridge never signs or sends a trade.
12
+
13
+ > **Published package note:** `verify402-mcp@1.0.6` is published to npm and
14
+ > defaults paid calls to Base Sepolia testnet (`eip155:84532`) for the public
15
+ > demo. Base mainnet (`eip155:8453`) requires an explicit `VERIFY402_NETWORKS`
16
+ > opt-in in a separate deployment and is not enabled by this release. The
17
+ > previously published `1.0.5` package is immutable; `1.0.4` is also immutable
18
+ > and used Base mainnet by default. Do not republish or overwrite either
19
+ > version.
10
20
 
11
21
  ## Tools
12
22
 
@@ -24,6 +34,13 @@ transaction or x402 payment request. If `sign_allowed` is false, **do not
24
34
  sign**. If `requires_ack` is true, surface the reasons to the operator. The
25
35
  receipt expires (default 90s); call again instead of reusing it.
26
36
 
37
+ For payment intents, `policy.allowedResourceHosts` binds the host in
38
+ `payment.resource`; a matching host can PASS and a foreign host is BLOCK.
39
+ Without `allowedResourceHosts` or `expectedResource`, an otherwise valid
40
+ resource is CAUTION `resource_identity_unverified` and `resource identity` is
41
+ listed in `notChecked`. The bridge forwards this policy to the server; the
42
+ server enforces it.
43
+
27
44
  The gate is advisory at this layer: the bridge does not hold the agent's
28
45
  transaction key and cannot stop an agent that ignores the answer. Enforcement
29
46
  belongs in the executor that signs — verify the receipt, then sign. See
@@ -85,11 +102,12 @@ To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_saf
85
102
  1. **Create a Dedicated Hot Wallet**:
86
103
  - Create a brand new wallet in MetaMask, Rabby, or via `cast wallet new`.
87
104
  - ⚠️ **SECURITY WARNING**: Never use your primary personal wallet, cold storage, or vault.
88
- 2. **Fund the Wallet on Base (EIP-155:8453)**:
89
- - **USDC**: Transfer $2 to $10 of USDC on Base (the bridge pays per call: $0.03 to $0.10).
90
- - **Gas (ETH on Base)**: Transfer ~$0.50 worth of ETH on Base for minimal gas allowances if needed.
105
+ 2. **Fund the Wallet on Base Sepolia testnet (EIP-155:84532)**:
106
+ - **USDC**: Get Base Sepolia testnet USDC from a faucet (the bridge pays per call: $0.03 to $0.10 in testnet USDC).
107
+ - **Gas (ETH on Base Sepolia)**: Get a small amount of Base Sepolia testnet ETH for gas if your client needs it. No mainnet funds are required for the public demo.
91
108
  3. **Configure Environment Variables in Your MCP Client**:
92
109
  - Set `EVM_PRIVATE_KEY` to the 64-character private key of the hot wallet.
110
+ - Set `VERIFY402_NETWORKS` to `eip155:84532` (Base Sepolia testnet).
93
111
  - Set safety caps `MAX_PAYMENT_USD` (e.g., `"0.25"`) and `MAX_DAILY_USD` (e.g., `"1.00"`).
94
112
 
95
113
  **Paid Configuration Block (Claude Desktop, Cursor, Cline, Windsurf)**:
@@ -104,7 +122,7 @@ To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_saf
104
122
  "EVM_PRIVATE_KEY": "0xYOUR_HOT_WALLET_PRIVATE_KEY",
105
123
  "MAX_PAYMENT_USD": "0.25",
106
124
  "MAX_DAILY_USD": "1.00",
107
- "VERIFY402_NETWORKS": "eip155:8453",
125
+ "VERIFY402_NETWORKS": "eip155:84532",
108
126
  "VERIFY402_PAY_TO": "0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb"
109
127
  }
110
128
  }
@@ -125,6 +143,7 @@ In `opencode.jsonc` (or `~/.config/opencode/opencode.jsonc`):
125
143
  "environment": {
126
144
  "VERIFY402_URL": "https://verify402.onrender.com",
127
145
  "EVM_PRIVATE_KEY": "{env:EVM_PRIVATE_KEY}",
146
+ "VERIFY402_NETWORKS": "eip155:84532",
128
147
  "MAX_PAYMENT_USD": "0.25",
129
148
  "MAX_DAILY_USD": "1.00"
130
149
  }
@@ -163,9 +182,20 @@ Once configured, your agent automatically sees the tools in its context. You can
163
182
  server and a known paid route, on an allowlisted EVM network, to the
164
183
  verify402 payee. It accepts only recognized default assets (USDC), caps the
165
184
  amount at `MAX_PAYMENT_USD`, and refuses redirects.
185
+ - The whole 402 challenge is checked, not just the option the client picks:
186
+ every offered requirement must use an allowlisted network, the configured
187
+ payee, the canonical USDC contract **and** the canonical EIP-712 domain for
188
+ that network, an `extra.assetTransferMethod` of `eip3009`, a
189
+ `maxTimeoutSeconds` that is a positive integer no greater than `300`, and an
190
+ amount inside the cap. A challenge that also offers Base mainnet is refused
191
+ as a whole rather than filtered down to the testnet option. The client also
192
+ proves that the requirement it signs is the exact object the server sent.
166
193
  - The allowlist can be narrowed or pointed at another deployment with
167
194
  `VERIFY402_NETWORKS`, `VERIFY402_PAY_TO`, and `VERIFY402_RESOURCE_PREFIX`.
168
- Defaults are Base mainnet and the public verify402 payee.
195
+ The bridge defaults are Base Sepolia testnet (`eip155:84532`) and the
196
+ public verify402 payee. `VERIFY402_NETWORKS` is constrained to the supported
197
+ map (`eip155:84532`, `eip155:8453`); an explicitly empty value fails closed
198
+ instead of falling back to a default.
169
199
  - `MAX_PAYMENT_USD` is enforced inside the x402 client, before a signature
170
200
  exists; removing it is a security regression. The caps limit what the bridge
171
201
  can authorize; they do **not** protect a compromised private key.
@@ -177,6 +207,23 @@ Once configured, your agent automatically sees the tools in its context. You can
177
207
  - Use the [reference executor](https://github.com/a32116150-ctrl/verify402/blob/master/examples/preflight-guard.mjs)
178
208
  when you want enforcement rather than advice.
179
209
 
210
+ ## Explicit mainnet configuration (not the public demo)
211
+
212
+ Mainnet settlement is a separate deployment choice, not a bridge or demo
213
+ default. Do not point this client at mainnet until the service-side mainnet
214
+ readiness checks, facilitator, always-on host, and wallet funding have been
215
+ verified independently.
216
+
217
+ ```bash
218
+ # Only for a deliberate mainnet deployment, never against the public demo.
219
+ VERIFY402_NETWORKS=eip155:8453
220
+ VERIFY402_PAY_TO=<the payee named by the mainnet 402>
221
+ ```
222
+
223
+ The public demo offers `eip155:84532`; a mainnet-only policy will refuse that
224
+ challenge instead of silently spending mainnet funds. The reverse is also
225
+ enforced: the demo policy refuses a mainnet-only challenge.
226
+
180
227
  ## Development
181
228
 
182
229
  ```bash
package/index.mjs CHANGED
@@ -31,13 +31,24 @@ import { config } from "dotenv";
31
31
  import { z } from "zod";
32
32
  import { installConsoleRedaction, logInfo, logError } from "./log.mjs";
33
33
  import { paidPost } from "./paid-call.mjs";
34
+ import { fileURLToPath } from "node:url";
35
+ import { realpathSync } from "node:fs";
36
+ import { AsyncLocalStorage } from "node:async_hooks";
34
37
 
35
38
  installConsoleRedaction();
36
- config();
37
39
 
38
- const VERSION = "1.0.4";
40
+ // Only the process entry point loads the operator's `.env`. Importing this
41
+ // module (tests, tooling) must never read the wallet file.
42
+ const IS_MAIN = isMainModule();
43
+ if (IS_MAIN) config();
44
+
45
+ const VERSION = "1.0.6";
39
46
  const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY;
40
- const SERVER_URL = process.env.VERIFY402_URL || "https://verify402.onrender.com";
47
+ // Normalise once so the axios base URL, the payment policy prefix, and the
48
+ // per-tool expected resource are all the same string (no trailing slash).
49
+ const SERVER_URL = (process.env.VERIFY402_URL || "https://verify402.onrender.com")
50
+ .trim()
51
+ .replace(/\/+$/, "");
41
52
  // Tell the server which client made the call. This is a label, not an identity:
42
53
  // it helps separate MCP use from generic HTTP probes without collecting keys or
43
54
  // wallet addresses.
@@ -59,6 +70,37 @@ const MAX_PAYMENT_USD = Number(process.env.MAX_PAYMENT_USD || 0.25);
59
70
  // registered only when a wallet is configured.
60
71
  const PAID_ENABLED = Boolean(EVM_PRIVATE_KEY);
61
72
 
73
+ // Each paid MCP tool is bound to exactly one paid API route. This map is local,
74
+ // trusted configuration: a 402 challenge can never choose which resource a tool
75
+ // is allowed to pay for. The pre-sign guard compares the challenge's resource
76
+ // against the route of the tool that is currently running.
77
+ export const PAID_TOOL_ROUTES = Object.freeze({
78
+ verify_arbitrage: "/verify",
79
+ check_token_safety: "/safety",
80
+ get_best_route: "/route",
81
+ verify_quote_fairness: "/quotecheck",
82
+ get_trade_depth: "/depth",
83
+ });
84
+
85
+ // Carries the current tool's expected resource from the tool handler to the
86
+ // pre-sign hook. AsyncLocalStorage keeps concurrent tool calls isolated and is
87
+ // the only channel the guard trusts for the expected resource.
88
+ const paymentContext = new AsyncLocalStorage();
89
+
90
+ function paidCallContext(tool) {
91
+ const route = PAID_TOOL_ROUTES[tool];
92
+ if (!route) throw new Error(`unknown paid tool: ${String(tool)}`);
93
+ return { tool, route, expectedResource: `${SERVER_URL}${route}` };
94
+ }
95
+
96
+ /**
97
+ * Run `run` as the given paid tool. Exported so tests can drive the real
98
+ * binding without starting the MCP transport.
99
+ */
100
+ export function runWithPaidTool(tool, run) {
101
+ return paymentContext.run(paidCallContext(tool), run);
102
+ }
103
+
62
104
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
63
105
  console.log(`verify402-mcp — MCP server for verify402
64
106
 
@@ -73,7 +115,7 @@ Environment:
73
115
  MAX_PAYMENT_USD cap on one x402 payment (default 0.25)
74
116
  MAX_DAILY_USD optional cap for the UTC day, persisted in a small ledger
75
117
  SPEND_LEDGER_PATH optional ledger path (default .spend-ledger.json)
76
- VERIFY402_NETWORKS comma-separated EVM networks for paid calls (default eip155:8453)
118
+ VERIFY402_NETWORKS comma-separated EVM networks for paid calls (default eip155:84532, Base Sepolia testnet; eip155:8453 only by explicit opt-in)
77
119
  VERIFY402_PAY_TO EVM payee the 402 must name (default the verify402 payee)
78
120
  VERIFY402_RESOURCE_PREFIX resource origin/prefix the 402 must match (default VERIFY402_URL)
79
121
 
@@ -89,14 +131,12 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
89
131
  }
90
132
 
91
133
  // ---------------------------------------------------------------- payment ---
92
- async function createPaidClient() {
134
+ export function createBridgeClientFromEnv() {
93
135
  if (!Number.isFinite(MAX_PAYMENT_USD) || MAX_PAYMENT_USD <= 0) {
94
136
  throw new Error("MAX_PAYMENT_USD must be a positive number");
95
137
  }
96
- const policy = createPaymentPolicy({
97
- networks: process.env.VERIFY402_NETWORKS || DEFAULT_NETWORKS,
98
- payTo: process.env.VERIFY402_PAY_TO || DEFAULT_PAY_TO,
99
- resourcePrefix: process.env.VERIFY402_RESOURCE_PREFIX || SERVER_URL,
138
+ const policy = createPaymentPolicyFromEnv({
139
+ resourcePrefix: SERVER_URL,
100
140
  paidRoutes: Object.keys(DEFAULT_PRICES),
101
141
  });
102
142
  const account = privateKeyToAccount(EVM_PRIVATE_KEY);
@@ -108,12 +148,29 @@ async function createPaidClient() {
108
148
  for (const network of policy.allowedNetworks) {
109
149
  client.register(network, new ExactEvmScheme(account));
110
150
  }
111
- client.onBeforePaymentCreation((context) => policy.checkBeforeSign(context));
151
+ // Full-challenge guard (version, every EVM offer, canonical asset/domain,
152
+ // payee, amount cap, timeout, EIP-3009) plus the resource/route layer. It
153
+ // runs after the SDK has selected a requirement and before the signer.
154
+ // The expected resource is resolved per invocation from the tool handler
155
+ // context, never from the 402 challenge.
156
+ client.onBeforePaymentCreation(
157
+ createPaymentGuard(policy, {
158
+ maxAmountUsd: MAX_PAYMENT_USD,
159
+ expectedResource: () => paymentContext.getStore()?.expectedResource,
160
+ }),
161
+ );
112
162
  logInfo(
113
163
  `paid policy: networks=${policy.allowedNetworks.join(",")} ` +
114
164
  `payee=${policy.allowedPayTo} resources=${policy.resourcePrefix} ` +
115
165
  `{${policy.paidRoutes.join(",")}}`,
116
166
  );
167
+ return client;
168
+ }
169
+
170
+ export async function createBridgePaidClient(client = createBridgeClientFromEnv()) {
171
+ if (!Number.isFinite(MAX_PAYMENT_USD) || MAX_PAYMENT_USD <= 0) {
172
+ throw new Error("MAX_PAYMENT_USD must be a positive number");
173
+ }
117
174
  const axiosInstance = axios.create({ baseURL: SERVER_URL, timeout: 120_000, maxRedirects: 0, headers: CLIENT_HEADERS });
118
175
  return wrapAxiosWithPayment(axiosInstance, client);
119
176
  }
@@ -123,22 +180,38 @@ async function createPaidClient() {
123
180
  // (optional) caps the whole UTC day across restarts, persisted in a tiny
124
181
  // ledger file. Attempted charges are counted conservatively (before payment).
125
182
  import { createDailyBudget, DEFAULT_PRICES } from "./budget.mjs";
126
- import { createPaymentPolicy, DEFAULT_NETWORKS, DEFAULT_PAY_TO } from "./payment-policy.mjs";
183
+ import { createPaymentGuard, createPaymentPolicyFromEnv } from "./payment-policy.mjs";
127
184
  const dailyBudget = createDailyBudget({
128
185
  maxDailyUsd: process.env.MAX_DAILY_USD,
129
186
  });
130
187
 
131
- async function guardedPaidCall(route, run) {
132
- if (dailyBudget.enabled) dailyBudget.assert(route);
133
- const res = await run();
188
+ async function guardedPaidCall(tool, run) {
189
+ const context = paidCallContext(tool);
190
+ if (dailyBudget.enabled) dailyBudget.assert(context.route);
191
+ const res = await paymentContext.run(context, run);
134
192
  if (res?.data?.recovery?.replayed) {
135
193
  // Visible to the operator without exposing the bearer key. The same flag
136
194
  // is already in the JSON returned to the MCP client.
137
- logInfo(`${route}: recovery.replayed=true (settled verdict returned without a second charge)`);
195
+ logInfo(`${context.route}: recovery.replayed=true (settled verdict returned without a second charge)`);
138
196
  }
139
197
  return res;
140
198
  }
141
199
 
200
+ /**
201
+ * Run one paid MCP tool. The route is resolved from local tool configuration
202
+ * (PAID_TOOL_ROUTES), recorded in the async context, and used for both the HTTP
203
+ * request and the pre-sign expected-resource check, so the 402 challenge can
204
+ * never redirect a tool to a different paid resource.
205
+ */
206
+ export async function runPaidTool({ tool, client, body }) {
207
+ if (!Object.hasOwn(PAID_TOOL_ROUTES, tool)) {
208
+ throw new Error(`unknown paid tool: ${String(tool)}`);
209
+ }
210
+ if (!client) throw new Error(`paid client is not configured for tool ${tool}`);
211
+ const route = PAID_TOOL_ROUTES[tool];
212
+ return guardedPaidCall(tool, () => paidPost(client, route, body));
213
+ }
214
+
142
215
  // ------------------------------------------------------------------ tools ---
143
216
  const DESCRIPTIONS = {
144
217
  preflight_gate:
@@ -177,7 +250,7 @@ const DESCRIPTIONS = {
177
250
  };
178
251
 
179
252
  async function main() {
180
- const paidApi = PAID_ENABLED ? await createPaidClient() : null;
253
+ const paidApi = PAID_ENABLED ? await createBridgePaidClient() : null;
181
254
 
182
255
  const server = new McpServer({ name: "verify402", version: VERSION });
183
256
 
@@ -190,7 +263,7 @@ async function main() {
190
263
  from: z.string().optional().describe("The wallet that will sign. Strongly recommended: enables simulation and balance checks."),
191
264
  payment: z.object({
192
265
  scheme: z.string().optional().describe('x402 scheme, normally "exact"'),
193
- network: z.string().describe('CAIP-2 network, e.g. "eip155:8453" or "solana:..."'),
266
+ network: z.string().describe('CAIP-2 network, e.g. "eip155:84532" for the public demo or "solana:..."'),
194
267
  asset: z.string().describe("Token address or mint, e.g. canonical USDC on that network"),
195
268
  amount: z.string().describe("RAW amount in the asset's smallest unit, as a string"),
196
269
  payTo: z.string().describe("Recipient address from the 402 challenge"),
@@ -267,12 +340,16 @@ async function main() {
267
340
  size: z.number().optional().describe("Trade size in whole base units, e.g. 100000 (default 100000)"),
268
341
  },
269
342
  async (args) => {
270
- const res = await guardedPaidCall("/verify", () => paidPost(paidApi, "/verify", {
271
- chainId: args.chainId,
272
- target: String(args.target ?? "WETH").toUpperCase(),
273
- base: String(args.base ?? "USDC").toUpperCase(),
274
- size: args.size ?? 100000,
275
- }));
343
+ const res = await runPaidTool({
344
+ tool: "verify_arbitrage",
345
+ client: paidApi,
346
+ body: {
347
+ chainId: args.chainId,
348
+ target: String(args.target ?? "WETH").toUpperCase(),
349
+ base: String(args.base ?? "USDC").toUpperCase(),
350
+ size: args.size ?? 100000,
351
+ },
352
+ });
276
353
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
277
354
  },
278
355
  );
@@ -287,12 +364,16 @@ async function main() {
287
364
  offeredPriceUsd: z.number().optional().describe("Optional price per token someone offered you"),
288
365
  },
289
366
  async (args) => {
290
- const res = await guardedPaidCall("/safety", () => paidPost(paidApi, "/safety", {
291
- chainId: args.chainId,
292
- tokenAddress: String(args.tokenAddress ?? "").trim(),
293
- intendedUsd: args.intendedUsd ?? 500,
294
- offeredPriceUsd: args.offeredPriceUsd ?? null,
295
- }));
367
+ const res = await runPaidTool({
368
+ tool: "check_token_safety",
369
+ client: paidApi,
370
+ body: {
371
+ chainId: args.chainId,
372
+ tokenAddress: String(args.tokenAddress ?? "").trim(),
373
+ intendedUsd: args.intendedUsd ?? 500,
374
+ offeredPriceUsd: args.offeredPriceUsd ?? null,
375
+ },
376
+ });
296
377
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
297
378
  },
298
379
  );
@@ -307,12 +388,16 @@ async function main() {
307
388
  chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
308
389
  },
309
390
  async (args) => {
310
- const res = await guardedPaidCall("/route", () => paidPost(paidApi, "/route", {
311
- chainId: args.chainId,
312
- tokenIn: args.tokenIn,
313
- tokenOut: args.tokenOut,
314
- amountIn: args.amountIn,
315
- }));
391
+ const res = await runPaidTool({
392
+ tool: "get_best_route",
393
+ client: paidApi,
394
+ body: {
395
+ chainId: args.chainId,
396
+ tokenIn: args.tokenIn,
397
+ tokenOut: args.tokenOut,
398
+ amountIn: args.amountIn,
399
+ },
400
+ });
316
401
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
317
402
  },
318
403
  );
@@ -329,14 +414,18 @@ async function main() {
329
414
  chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
330
415
  },
331
416
  async (args) => {
332
- const res = await guardedPaidCall("/quotecheck", () => paidPost(paidApi, "/quotecheck", {
333
- chainId: args.chainId,
334
- tokenIn: args.tokenIn,
335
- tokenOut: args.tokenOut,
336
- amountIn: args.amountIn,
337
- quotedOut: args.quotedOut,
338
- venueClaimed: args.venueClaimed ?? null,
339
- }));
417
+ const res = await runPaidTool({
418
+ tool: "verify_quote_fairness",
419
+ client: paidApi,
420
+ body: {
421
+ chainId: args.chainId,
422
+ tokenIn: args.tokenIn,
423
+ tokenOut: args.tokenOut,
424
+ amountIn: args.amountIn,
425
+ quotedOut: args.quotedOut,
426
+ venueClaimed: args.venueClaimed ?? null,
427
+ },
428
+ });
340
429
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
341
430
  },
342
431
  );
@@ -352,13 +441,17 @@ async function main() {
352
441
  maxRoundTripPct: z.number().optional().describe("Your round-trip cost tolerance, default 1%"),
353
442
  },
354
443
  async (args) => {
355
- const res = await guardedPaidCall("/depth", () => paidPost(paidApi, "/depth", {
356
- chainId: args.chainId,
357
- tokenAddress: args.tokenAddress,
358
- baseSymbol: args.baseSymbol ?? "USDC",
359
- sizesUsd: args.sizesUsd ?? null,
360
- maxRoundTripPct: args.maxRoundTripPct ?? 1.0,
361
- }));
444
+ const res = await runPaidTool({
445
+ tool: "get_trade_depth",
446
+ client: paidApi,
447
+ body: {
448
+ chainId: args.chainId,
449
+ tokenAddress: args.tokenAddress,
450
+ baseSymbol: args.baseSymbol ?? "USDC",
451
+ sizesUsd: args.sizesUsd ?? null,
452
+ maxRoundTripPct: args.maxRoundTripPct ?? 1.0,
453
+ },
454
+ });
362
455
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
363
456
  },
364
457
  );
@@ -373,7 +466,23 @@ async function main() {
373
466
  );
374
467
  }
375
468
 
376
- main().catch((e) => {
377
- logError("bridge failed", e);
378
- process.exit(1);
379
- });
469
+ // True only when this file is the process entry point. The published package
470
+ // is installed through a `bin` symlink, so both sides are compared by real
471
+ // path: Node resolves import.meta.url through the symlink but keeps the
472
+ // symlink path in process.argv[1].
473
+ function isMainModule() {
474
+ const entry = process.argv[1];
475
+ if (!entry) return false;
476
+ try {
477
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
478
+ } catch {
479
+ return false;
480
+ }
481
+ }
482
+
483
+ if (IS_MAIN) {
484
+ main().catch((e) => {
485
+ logError("bridge failed", e);
486
+ process.exit(1);
487
+ });
488
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verify402-mcp",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "mcpName": "io.github.a32116150-ctrl/verify402-mcp",
5
5
  "description": "MCP server for verify402: a free, fail-closed preflight gate before signing, plus five paid on-chain checks paid per call via x402.",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "start": "node index.mjs",
24
- "test": "node --test test/payment-policy.test.mjs"
24
+ "test": "node --test test/payment-policy.test.mjs test/first-payment.test.mjs test/paid-call.test.mjs test/bridge-client.test.mjs"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=20"
@@ -3,20 +3,284 @@
3
3
  // The bridge is a payer, not a blank cheque for any remote 402. Before a
4
4
  // signature exists, the policy requires:
5
5
  //
6
- // - the network to be explicitly allowlisted (default Base mainnet);
6
+ // - the network to be explicitly allowlisted (the public demo default is
7
+ // Base Sepolia testnet, eip155:84532);
7
8
  // - payTo to be the verify402 payee (default the EVM payTo);
8
9
  // - the 402 resource to be on the configured server and a known paid route.
9
10
  //
10
- // The amount cap and the default-asset (USDC) allowlist are enforced by
11
- // x402Client's spendControls, which run before this policy. Redirects are
12
- // refused by the axios client in index.mjs.
11
+ // The amount cap and the canonical-USDC allowlist are enforced both by
12
+ // x402Client's spendControls and by validatePaymentChallenge(), which runs
13
+ // before the signing hook in first-payment.mjs and index.mjs. Redirects are
14
+ // refused by the axios clients in index.mjs and first-payment.mjs.
15
+ //
16
+ // Mainnet is available only through an explicit VERIFY402_NETWORKS value. The
17
+ // demo default must never silently select Base mainnet.
13
18
  //
14
19
  // Free-only mode never imports or constructs this policy.
15
20
 
16
- export const DEFAULT_NETWORKS = ["eip155:8453"];
21
+ export const DEMO_NETWORK = "eip155:84532";
22
+ export const MAINNET_NETWORK = "eip155:8453";
23
+ export const DEFAULT_NETWORKS = [DEMO_NETWORK];
17
24
  export const DEFAULT_PAY_TO = "0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb";
18
25
  export const DEFAULT_PAID_ROUTES = ["/verify", "/safety", "/route", "/quotecheck", "/depth"];
19
26
 
27
+ // Hard upper bound for a single EIP-3009 authorization. The SDK signs
28
+ // `validBefore = now + maxTimeoutSeconds`; an unbounded value hands the payee a
29
+ // long-lived transferable authorization. 300 seconds is the documented cap and
30
+ // matches the live verify402 challenge.
31
+ export const MAX_TIMEOUT_SECONDS = 300;
32
+
33
+ // The canonical USDC contract for each network this bridge knows how to pay.
34
+ // A 402 that names a different asset on one of these networks is a different
35
+ // payment, not a cheaper one, so the guard refuses it.
36
+ export const DEMO_USDC = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
37
+ export const MAINNET_USDC = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
38
+ export const NETWORK_USDC = {
39
+ [DEMO_NETWORK]: DEMO_USDC,
40
+ [MAINNET_NETWORK]: MAINNET_USDC,
41
+ };
42
+
43
+ // EIP-712 domain parameters for the canonical USDC contract on each supported
44
+ // network. These are signing-relevant: a server that supplies a different
45
+ // `extra.name`/`extra.version` asks the wallet to sign a different token
46
+ // domain. Values mirror the installed @x402/evm default-asset table.
47
+ export const NETWORK_USDC_DOMAIN = {
48
+ [DEMO_NETWORK]: { name: "USDC", version: "2" },
49
+ [MAINNET_NETWORK]: { name: "USD Coin", version: "2" },
50
+ };
51
+
52
+ // The only networks the bridge will ever register a signer for. VERIFY402_NETWORKS
53
+ // is constrained to this map; an arbitrary eip155:<chainId> is refused rather
54
+ // than inferred from an asset the SDK happens to recognise.
55
+ export const SUPPORTED_PAYMENT_NETWORKS = new Set(Object.keys(NETWORK_USDC));
56
+
57
+ const EVM_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
58
+ const CAIP2_RE = /^[a-z0-9][a-z0-9-]*:[A-Za-z0-9._:-]+$/;
59
+ const SUPPORTED_NETWORK_NAMESPACES = new Set(["eip155", "solana"]);
60
+
61
+ // `extra` fields that do not change how a payload is signed. Everything else on
62
+ // an EVM requirement is a signing-mode extension and is refused until it is
63
+ // explicitly validated here.
64
+ const EVM_EXTRA_ALLOWED_KEYS = new Set(["name", "version", "assetTransferMethod", "paymentFlow"]);
65
+
66
+ /**
67
+ * Convert a USD amount to six-decimal atomic units. Returns null when the
68
+ * value is not a positive finite number, so callers fail closed instead of
69
+ * rounding a bad cap into zero or Infinity.
70
+ */
71
+ export function usdToAtomic(value) {
72
+ const parsed = typeof value === "string" ? Number(value.replace(/^\$/, "").trim()) : Number(value);
73
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
74
+ return BigInt(Math.round(parsed * 1e6));
75
+ }
76
+
77
+ function normalizeUrl(value) {
78
+ try {
79
+ const url = new URL(String(value));
80
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
81
+ return url;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ // Exact resource identity: origin + path are compared with no trailing-slash
88
+ // normalisation, so `/route/` is not `/route`.
89
+ function resourceKey(url) {
90
+ return `${url.origin}${url.pathname}`;
91
+ }
92
+
93
+ function isPlainObject(value) {
94
+ return typeof value === "object" && value !== null && !Array.isArray(value);
95
+ }
96
+
97
+ /**
98
+ * Validate `maxTimeoutSeconds` as a positive integer no greater than the
99
+ * documented cap. Strings, floats, NaN, infinities, zero and negatives are all
100
+ * refused before any arithmetic happens.
101
+ */
102
+ function validateTimeoutSeconds(value) {
103
+ if (typeof value !== "number") return "maxTimeoutSeconds must be a number";
104
+ if (!Number.isInteger(value)) return "maxTimeoutSeconds must be an integer";
105
+ if (value <= 0) return "maxTimeoutSeconds must be positive";
106
+ if (value > MAX_TIMEOUT_SECONDS) {
107
+ return `maxTimeoutSeconds ${value} exceeds the ${MAX_TIMEOUT_SECONDS}s maximum`;
108
+ }
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * Validate the EIP-712 domain and transfer mode of an EVM requirement. The
114
+ * installed SDK selects EIP-3009 unless `extra.assetTransferMethod === "permit2"`,
115
+ * so permit2 (and any unvalidated extension key) is refused here.
116
+ */
117
+ function validateEvmExtra(requirement, index, expectedDomainByNetwork) {
118
+ const prefix = `402 accepts[${index}]`;
119
+ const extra = requirement.extra;
120
+ if (!isPlainObject(extra)) return `${prefix} has no valid extra`;
121
+ if (extra.assetTransferMethod !== undefined && extra.assetTransferMethod !== "eip3009") {
122
+ return `${prefix} transfer method ${String(extra.assetTransferMethod)} is not supported`;
123
+ }
124
+ if (extra.paymentFlow !== undefined && extra.paymentFlow !== "authorization") {
125
+ return `${prefix} paymentFlow ${String(extra.paymentFlow)} is not supported`;
126
+ }
127
+ for (const key of Object.keys(extra)) {
128
+ if (!EVM_EXTRA_ALLOWED_KEYS.has(key)) {
129
+ return `${prefix} extra.${key} is not a supported signing extension`;
130
+ }
131
+ }
132
+ if (typeof extra.name !== "string" || !extra.name.trim()) {
133
+ return `${prefix} EIP-712 domain name is missing`;
134
+ }
135
+ if (typeof extra.version !== "string" || !extra.version.trim()) {
136
+ return `${prefix} EIP-712 domain version is missing`;
137
+ }
138
+ const expectedDomain = expectedDomainByNetwork?.[requirement.network];
139
+ if (expectedDomain) {
140
+ if (extra.name !== expectedDomain.name || extra.version !== expectedDomain.version) {
141
+ return `${prefix} EIP-712 domain ${extra.name}/${extra.version} is not the canonical USDC domain for ${requirement.network}`;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Validate a complete x402 v2 challenge before a payer is allowed to sign.
149
+ *
150
+ * This is deliberately stricter than "pick an acceptable requirement": every
151
+ * offered requirement is checked, and every EVM option must be on an
152
+ * allowlisted network, name the configured payee, use the canonical USDC asset
153
+ * and EIP-712 domain for that network, pin EIP-3009, and stay under the
154
+ * per-payment cap. A mixed challenge that also offers Base mainnet is refused
155
+ * as a whole, not silently filtered down to the testnet option.
156
+ *
157
+ * Returns `{ ok: true, requirement }` for the selected EVM requirement, or
158
+ * `{ ok: false, reason }`. Missing or malformed fields are failures.
159
+ */
160
+ export function validatePaymentChallenge(challenge, {
161
+ allowedNetworks = DEFAULT_NETWORKS,
162
+ allowedPayTo = DEFAULT_PAY_TO,
163
+ expectedResource,
164
+ maxAmountUsd,
165
+ expectedAssetByNetwork = NETWORK_USDC,
166
+ expectedDomainByNetwork = NETWORK_USDC_DOMAIN,
167
+ } = {}) {
168
+ const fail = (reason) => ({ ok: false, reason });
169
+
170
+ if (!isPlainObject(challenge)) {
171
+ return fail("402 challenge is not an object");
172
+ }
173
+ if (challenge.x402Version !== 2) {
174
+ return fail("402 challenge is not x402 v2");
175
+ }
176
+ if (!Array.isArray(challenge.accepts) || challenge.accepts.length === 0) {
177
+ return fail("402 challenge has no accepts[]");
178
+ }
179
+
180
+ const resource = normalizeUrl(challenge.resource?.url ?? challenge.resource);
181
+ if (!resource) {
182
+ return fail("402 challenge has no valid resource URL");
183
+ }
184
+ if (resource.search || resource.hash) {
185
+ return fail("402 resource must not contain a query or fragment");
186
+ }
187
+ if (expectedResource !== undefined) {
188
+ const expected = normalizeUrl(expectedResource);
189
+ if (!expected) return fail("expected resource is not a valid http(s) URL");
190
+ if (resourceKey(resource) !== resourceKey(expected)) {
191
+ return fail(`402 resource ${resource.href} is not the expected ${expected.href}`);
192
+ }
193
+ }
194
+
195
+ const networks = new Set(allowedNetworks);
196
+ const expectedPayTo = String(allowedPayTo ?? "").trim().toLowerCase();
197
+ if (!EVM_ADDRESS_RE.test(expectedPayTo)) {
198
+ return fail("no valid expected payee is configured");
199
+ }
200
+
201
+ const maxAtomic = maxAmountUsd === undefined ? null : usdToAtomic(maxAmountUsd);
202
+ if (maxAmountUsd !== undefined && maxAtomic === null) {
203
+ return fail("configured maximum payment amount is not a positive USD value");
204
+ }
205
+
206
+ let selected = null;
207
+ for (const [index, requirement] of challenge.accepts.entries()) {
208
+ const prefix = `402 accepts[${index}]`;
209
+
210
+ if (!isPlainObject(requirement)) {
211
+ return fail(`${prefix} is not an object`);
212
+ }
213
+
214
+ const network = requirement.network;
215
+ if (typeof network !== "string" || !CAIP2_RE.test(network)) {
216
+ return fail(`${prefix} has no valid network`);
217
+ }
218
+ const namespace = network.split(":")[0];
219
+ if (!SUPPORTED_NETWORK_NAMESPACES.has(namespace)) {
220
+ return fail(`${prefix} network ${network} is unsupported`);
221
+ }
222
+
223
+ const scheme = requirement.scheme;
224
+ if (scheme !== "exact") {
225
+ return fail(`${prefix} scheme ${String(scheme)} is not exact`);
226
+ }
227
+
228
+ const timeoutError = validateTimeoutSeconds(requirement.maxTimeoutSeconds);
229
+ if (timeoutError) {
230
+ return fail(`${prefix} ${timeoutError}`);
231
+ }
232
+
233
+ const amount = requirement.amount;
234
+ if (typeof amount !== "string" || !/^\d+$/.test(amount) || BigInt(amount) <= 0n) {
235
+ return fail(`${prefix} has no valid amount`);
236
+ }
237
+
238
+ const asset = requirement.asset;
239
+ if (typeof asset !== "string" || !asset.trim()) {
240
+ return fail(`${prefix} has no asset`);
241
+ }
242
+
243
+ if (namespace !== "eip155") continue;
244
+
245
+ if (!networks.has(network)) {
246
+ return fail(`${prefix} network ${network} is not an allowed EVM network`);
247
+ }
248
+ if (!EVM_ADDRESS_RE.test(asset)) {
249
+ return fail(`${prefix} asset is not an EVM token address`);
250
+ }
251
+
252
+ const expectedAsset = expectedAssetByNetwork?.[network];
253
+ if (!expectedAsset) {
254
+ return fail(`no canonical USDC asset is configured for ${network}`);
255
+ }
256
+ if (asset.toLowerCase() !== expectedAsset.toLowerCase()) {
257
+ return fail(`${prefix} asset ${asset} is not the canonical USDC for ${network}`);
258
+ }
259
+
260
+ const payTo = String(requirement.payTo ?? "").trim();
261
+ if (!EVM_ADDRESS_RE.test(payTo)) {
262
+ return fail(`${prefix} has no valid payee`);
263
+ }
264
+ if (payTo.toLowerCase() !== expectedPayTo) {
265
+ return fail(`${prefix} payee ${payTo} is not the configured payee`);
266
+ }
267
+
268
+ const extraError = validateEvmExtra(requirement, index, expectedDomainByNetwork);
269
+ if (extraError) return fail(extraError);
270
+
271
+ if (maxAtomic !== null && BigInt(amount) > maxAtomic) {
272
+ return fail(`${prefix} amount ${amount} exceeds the ${maxAmountUsd} USD cap`);
273
+ }
274
+
275
+ if (!selected) selected = requirement;
276
+ }
277
+
278
+ if (!selected) {
279
+ return fail("402 challenge offers no allowed EVM payment requirement");
280
+ }
281
+ return { ok: true, requirement: selected };
282
+ }
283
+
20
284
  function parseNetworks(raw) {
21
285
  const values = Array.isArray(raw) ? raw : String(raw ?? "").split(",");
22
286
  const networks = values.map((value) => String(value).trim()).filter(Boolean);
@@ -27,6 +291,12 @@ function parseNetworks(raw) {
27
291
  if (!/^eip155:\d+$/.test(network)) {
28
292
  throw new Error(`VERIFY402_NETWORKS: ${network} is not an eip155:<chainId> network`);
29
293
  }
294
+ if (!SUPPORTED_PAYMENT_NETWORKS.has(network)) {
295
+ throw new Error(
296
+ `VERIFY402_NETWORKS: ${network} is not a supported payment network ` +
297
+ `(supported: ${[...SUPPORTED_PAYMENT_NETWORKS].join(", ")})`,
298
+ );
299
+ }
30
300
  }
31
301
  return [...new Set(networks)];
32
302
  }
@@ -102,6 +372,10 @@ export function createPaymentPolicy({
102
372
  /**
103
373
  * x402Client onBeforePaymentCreation hook: runs after selection and before
104
374
  * the scheme creates a signature. Returning `{ abort: true }` fails closed.
375
+ *
376
+ * This is the resource/route layer of the policy. The full challenge and
377
+ * the selected requirement are validated by createPaymentGuard(), which
378
+ * wraps this method for both payers.
105
379
  */
106
380
  checkBeforeSign(context) {
107
381
  const rawResource = context?.paymentRequired?.resource;
@@ -117,6 +391,10 @@ export function createPaymentPolicy({
117
391
  return { abort: true, reason: `402 resource is not a URL: ${String(resourceUrl)}` };
118
392
  }
119
393
 
394
+ if (url.search || url.hash) {
395
+ return { abort: true, reason: "402 resource must not contain a query or fragment" };
396
+ }
397
+
120
398
  if (url.origin !== prefix.origin) {
121
399
  return {
122
400
  abort: true,
@@ -143,7 +421,7 @@ export function createPaymentPolicy({
143
421
  }
144
422
 
145
423
  const requirement = context?.selectedRequirements;
146
- if (!requirement) {
424
+ if (!requirement || typeof requirement !== "object") {
147
425
  return { abort: true, reason: "402 selected no payment requirement" };
148
426
  }
149
427
  if (
@@ -160,3 +438,116 @@ export function createPaymentPolicy({
160
438
  },
161
439
  };
162
440
  }
441
+
442
+ /**
443
+ * Build the pre-signature guard shared by every payer. It validates the whole
444
+ * 402 challenge, proves the SDK's selected requirement is the exact object that
445
+ * was validated, re-validates that object, and then applies the resource/route
446
+ * layer in policy.checkBeforeSign().
447
+ *
448
+ * `expectedResource` may be a fixed string (the standalone payer and the smoke
449
+ * preflight, which know their one resource) or a resolver returning the
450
+ * resource bound to the current invocation (the MCP bridge, where each paid
451
+ * tool names its own route). A resolver that returns nothing aborts: the guard
452
+ * never signs a challenge that is not tied to locally known tool configuration.
453
+ *
454
+ * Returns a hook `(context) => { abort: true, reason } | undefined` suitable
455
+ * for x402Client.onBeforePaymentCreation().
456
+ */
457
+ export function createPaymentGuard(policy, {
458
+ expectedResource,
459
+ maxAmountUsd,
460
+ expectedAssetByNetwork = NETWORK_USDC,
461
+ expectedDomainByNetwork = NETWORK_USDC_DOMAIN,
462
+ } = {}) {
463
+ if (!policy || !Array.isArray(policy.allowedNetworks)) {
464
+ throw new Error("createPaymentGuard requires a payment policy");
465
+ }
466
+ if (
467
+ expectedResource !== undefined &&
468
+ typeof expectedResource !== "string" &&
469
+ typeof expectedResource !== "function"
470
+ ) {
471
+ throw new Error("expectedResource must be a string or a resolver function");
472
+ }
473
+
474
+ const isResolver = typeof expectedResource === "function";
475
+ const resolveExpectedResource = (context) => {
476
+ const value = isResolver ? expectedResource(context) : expectedResource;
477
+ if (typeof value === "string" && value !== "") return value;
478
+ return null;
479
+ };
480
+
481
+ const baseOptions = {
482
+ allowedNetworks: policy.allowedNetworks,
483
+ allowedPayTo: policy.allowedPayTo,
484
+ maxAmountUsd,
485
+ expectedAssetByNetwork,
486
+ expectedDomainByNetwork,
487
+ };
488
+
489
+ return function guard(context) {
490
+ const challenge = context?.paymentRequired;
491
+ const resolvedExpectedResource = resolveExpectedResource(context);
492
+ if (isResolver && resolvedExpectedResource === null) {
493
+ return { abort: true, reason: "no expected payment resource is bound to this call" };
494
+ }
495
+ if (!isResolver && expectedResource !== undefined && resolvedExpectedResource === null) {
496
+ return { abort: true, reason: "no expected payment resource for this payment" };
497
+ }
498
+ const options = resolvedExpectedResource === null
499
+ ? baseOptions
500
+ : { ...baseOptions, expectedResource: resolvedExpectedResource };
501
+
502
+ const validation = validatePaymentChallenge(challenge, options);
503
+ if (!validation.ok) return { abort: true, reason: validation.reason };
504
+
505
+ const selected = context?.selectedRequirements;
506
+ if (!selected || typeof selected !== "object") {
507
+ return { abort: true, reason: "402 selected no payment requirement" };
508
+ }
509
+
510
+ const selectedValidation = validatePaymentChallenge(
511
+ { ...challenge, accepts: [selected] },
512
+ options,
513
+ );
514
+ if (!selectedValidation.ok) return { abort: true, reason: selectedValidation.reason };
515
+ if (selectedValidation.requirement !== selected) {
516
+ return { abort: true, reason: "selected payment requirement changed after validation" };
517
+ }
518
+
519
+ // Reference identity: the SDK selector returns an element of
520
+ // paymentRequired.accepts, so a clone/substitution is refused here.
521
+ if (!Array.isArray(challenge.accepts) || !challenge.accepts.includes(selected)) {
522
+ return { abort: true, reason: "selected payment requirement is not from the 402 challenge" };
523
+ }
524
+
525
+ if (typeof policy.checkBeforeSign === "function") {
526
+ const resourceCheck = policy.checkBeforeSign(context);
527
+ if (resourceCheck?.abort) return resourceCheck;
528
+ }
529
+
530
+ return;
531
+ };
532
+ }
533
+
534
+ /**
535
+ * Build the paid-call policy from the bridge's documented environment.
536
+ *
537
+ * The default is deliberately Base Sepolia testnet. Base mainnet is selected
538
+ * only when VERIFY402_NETWORKS explicitly names eip155:8453. An explicitly
539
+ * empty network list or payee throws, so a typo cannot widen the policy.
540
+ */
541
+ export function createPaymentPolicyFromEnv({
542
+ resourcePrefix,
543
+ paidRoutes = DEFAULT_PAID_ROUTES,
544
+ env = process.env,
545
+ } = {}) {
546
+ return createPaymentPolicy({
547
+ networks: env.VERIFY402_NETWORKS === undefined ? DEFAULT_NETWORKS : env.VERIFY402_NETWORKS,
548
+ payTo: env.VERIFY402_PAY_TO === undefined ? DEFAULT_PAY_TO : env.VERIFY402_PAY_TO,
549
+ resourcePrefix:
550
+ env.VERIFY402_RESOURCE_PREFIX === undefined ? resourcePrefix : env.VERIFY402_RESOURCE_PREFIX,
551
+ paidRoutes,
552
+ });
553
+ }