verify402-mcp 1.0.3 → 1.0.5

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:** npm `verify402-mcp@1.0.4` is immutable and its
14
+ > published README predates this source-tree testnet default. Its built-in
15
+ > runtime default is Base mainnet (`eip155:8453`). The explicit
16
+ > `VERIFY402_NETWORKS=eip155:84532` block below is required with that package
17
+ > for the public demo; a future patch release is needed to update the npm page
18
+ > and change the built-in default. This source tree is `1.0.5` and already
19
+ > defaults to Base Sepolia testnet, but `1.0.5` is not published yet.
10
20
 
11
21
  ## Tools
12
22
 
@@ -62,7 +72,7 @@ node index.mjs --help
62
72
  ## AI Agent Integration (Step-by-Step)
63
73
 
64
74
  ### Step 1: Free Mode (Zero Config, No Wallet)
65
- To give your AI agent the free **`preflight_gate`** (checks transaction calldata, payment challenges, payee/amount, approvals, and block freshness before signing), paste this into your client configuration. Honeypot/sellability evidence is the paid `check_token_safety` check, not a free-gate guarantee.
75
+ To give your AI agent the free **`preflight_gate`** (checks transaction calldata, payment challenges, payee/amount, approvals, and block freshness before signing), paste this into your client configuration. The paid `check_token_safety` check adds a quote-based sellability check; it does not execute a transfer and is not a honeypot guarantee.
66
76
 
67
77
  **Claude Desktop** (`claude_desktop_config.json`) or **Cursor** (`.cursor/mcp.json`):
68
78
  ```json
@@ -85,11 +95,12 @@ To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_saf
85
95
  1. **Create a Dedicated Hot Wallet**:
86
96
  - Create a brand new wallet in MetaMask, Rabby, or via `cast wallet new`.
87
97
  - ⚠️ **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.
98
+ 2. **Fund the Wallet on Base Sepolia testnet (EIP-155:84532)**:
99
+ - **USDC**: Get Base Sepolia testnet USDC from a faucet (the bridge pays per call: $0.03 to $0.10 in testnet USDC).
100
+ - **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
101
  3. **Configure Environment Variables in Your MCP Client**:
92
102
  - Set `EVM_PRIVATE_KEY` to the 64-character private key of the hot wallet.
103
+ - Set `VERIFY402_NETWORKS` to `eip155:84532` (Base Sepolia testnet).
93
104
  - Set safety caps `MAX_PAYMENT_USD` (e.g., `"0.25"`) and `MAX_DAILY_USD` (e.g., `"1.00"`).
94
105
 
95
106
  **Paid Configuration Block (Claude Desktop, Cursor, Cline, Windsurf)**:
@@ -104,7 +115,7 @@ To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_saf
104
115
  "EVM_PRIVATE_KEY": "0xYOUR_HOT_WALLET_PRIVATE_KEY",
105
116
  "MAX_PAYMENT_USD": "0.25",
106
117
  "MAX_DAILY_USD": "1.00",
107
- "VERIFY402_NETWORKS": "eip155:8453",
118
+ "VERIFY402_NETWORKS": "eip155:84532",
108
119
  "VERIFY402_PAY_TO": "0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb"
109
120
  }
110
121
  }
@@ -125,6 +136,7 @@ In `opencode.jsonc` (or `~/.config/opencode/opencode.jsonc`):
125
136
  "environment": {
126
137
  "VERIFY402_URL": "https://verify402.onrender.com",
127
138
  "EVM_PRIVATE_KEY": "{env:EVM_PRIVATE_KEY}",
139
+ "VERIFY402_NETWORKS": "eip155:84532",
128
140
  "MAX_PAYMENT_USD": "0.25",
129
141
  "MAX_DAILY_USD": "1.00"
130
142
  }
@@ -142,8 +154,8 @@ Once configured, your agent automatically sees the tools in its context. You can
142
154
 
143
155
  - **Preflight Gate (Free)**:
144
156
  > *"Before signing this transaction, call `preflight_gate` on the payment request / calldata. If it returns BLOCK, abort and explain why."*
145
- - **Token Safety / Sellability Check ($0.10, not a code audit)**:
146
- > *"Analyze token `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` using `check_token_safety`. Is it sellable at a realistic size, what is the round-trip cost, and is the result TRUST / CAUTION / AVOID?"*
157
+ - **Token Quote-Based Sellability Check ($0.10, not a code audit)**:
158
+ > *"Analyze token `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` using `check_token_safety`. Can it be sold at a realistic size on the configured venues, what is the round-trip cost, and is the result TRUST / CAUTION / AVOID? Read notChecked for transfer restrictions."*
147
159
  - **Arbitrage Verification ($0.05)**:
148
160
  > *"Use `verify_arbitrage` to verify if there is an arbitrage opportunity between Uniswap V3 and Sushiswap for WETH/USDC on chain 1 at the current block."*
149
161
  - **Quote Fairness Audit ($0.05)**:
@@ -154,13 +166,29 @@ Once configured, your agent automatically sees the tools in its context. You can
154
166
  ## Safety
155
167
 
156
168
  - The bridge signs **x402 payments only**. It never signs or broadcasts a trade.
169
+ - Each paid tool call mints one `crypto.randomUUID()` idempotency key and sends it
170
+ as `x-idempotency-key`. A timeout or lost response is retried with the **same**
171
+ key, so the server replays the settled verdict (`recovery.replayed: true`)
172
+ instead of charging twice. A 402 or 409 is never auto-retried; the error is
173
+ surfaced to the MCP client. The key is a secret and is never logged or returned.
157
174
  - Before signing, the paid client requires the 402 to come from the configured
158
175
  server and a known paid route, on an allowlisted EVM network, to the
159
176
  verify402 payee. It accepts only recognized default assets (USDC), caps the
160
177
  amount at `MAX_PAYMENT_USD`, and refuses redirects.
178
+ - The whole 402 challenge is checked, not just the option the client picks:
179
+ every offered requirement must use an allowlisted network, the configured
180
+ payee, the canonical USDC contract **and** the canonical EIP-712 domain for
181
+ that network, an `extra.assetTransferMethod` of `eip3009`, a
182
+ `maxTimeoutSeconds` that is a positive integer no greater than `300`, and an
183
+ amount inside the cap. A challenge that also offers Base mainnet is refused
184
+ as a whole rather than filtered down to the testnet option. The client also
185
+ proves that the requirement it signs is the exact object the server sent.
161
186
  - The allowlist can be narrowed or pointed at another deployment with
162
187
  `VERIFY402_NETWORKS`, `VERIFY402_PAY_TO`, and `VERIFY402_RESOURCE_PREFIX`.
163
- Defaults are Base mainnet and the public verify402 payee.
188
+ The source-tree defaults are Base Sepolia testnet (`eip155:84532`) and the
189
+ public verify402 payee. `VERIFY402_NETWORKS` is constrained to the supported
190
+ map (`eip155:84532`, `eip155:8453`); an explicitly empty value fails closed
191
+ instead of falling back to a default.
164
192
  - `MAX_PAYMENT_USD` is enforced inside the x402 client, before a signature
165
193
  exists; removing it is a security regression. The caps limit what the bridge
166
194
  can authorize; they do **not** protect a compromised private key.
@@ -172,6 +200,23 @@ Once configured, your agent automatically sees the tools in its context. You can
172
200
  - Use the [reference executor](https://github.com/a32116150-ctrl/verify402/blob/master/examples/preflight-guard.mjs)
173
201
  when you want enforcement rather than advice.
174
202
 
203
+ ## Explicit mainnet configuration (not the public demo)
204
+
205
+ Mainnet settlement is a separate deployment choice, not a bridge or demo
206
+ default. Do not point this client at mainnet until the service-side mainnet
207
+ readiness checks, facilitator, always-on host, and wallet funding have been
208
+ verified independently.
209
+
210
+ ```bash
211
+ # Only for a deliberate mainnet deployment, never against the public demo.
212
+ VERIFY402_NETWORKS=eip155:8453
213
+ VERIFY402_PAY_TO=<the payee named by the mainnet 402>
214
+ ```
215
+
216
+ The public demo offers `eip155:84532`; a mainnet-only policy will refuse that
217
+ challenge instead of silently spending mainnet funds. The reverse is also
218
+ enforced: the demo policy refuses a mainnet-only challenge.
219
+
175
220
  ## Development
176
221
 
177
222
  ```bash
package/index.mjs CHANGED
@@ -29,12 +29,26 @@ import { ExactEvmScheme } from "@x402/evm/exact/client";
29
29
  import { privateKeyToAccount } from "viem/accounts";
30
30
  import { config } from "dotenv";
31
31
  import { z } from "zod";
32
+ import { installConsoleRedaction, logInfo, logError } from "./log.mjs";
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";
32
37
 
33
- config();
38
+ installConsoleRedaction();
34
39
 
35
- const VERSION = "1.0.3";
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.5";
36
46
  const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY;
37
- 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(/\/+$/, "");
38
52
  // Tell the server which client made the call. This is a label, not an identity:
39
53
  // it helps separate MCP use from generic HTTP probes without collecting keys or
40
54
  // wallet addresses.
@@ -56,6 +70,37 @@ const MAX_PAYMENT_USD = Number(process.env.MAX_PAYMENT_USD || 0.25);
56
70
  // registered only when a wallet is configured.
57
71
  const PAID_ENABLED = Boolean(EVM_PRIVATE_KEY);
58
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
+
59
104
  if (process.argv.includes("--help") || process.argv.includes("-h")) {
60
105
  console.log(`verify402-mcp — MCP server for verify402
61
106
 
@@ -70,7 +115,7 @@ Environment:
70
115
  MAX_PAYMENT_USD cap on one x402 payment (default 0.25)
71
116
  MAX_DAILY_USD optional cap for the UTC day, persisted in a small ledger
72
117
  SPEND_LEDGER_PATH optional ledger path (default .spend-ledger.json)
73
- 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)
74
119
  VERIFY402_PAY_TO EVM payee the 402 must name (default the verify402 payee)
75
120
  VERIFY402_RESOURCE_PREFIX resource origin/prefix the 402 must match (default VERIFY402_URL)
76
121
 
@@ -86,14 +131,12 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
86
131
  }
87
132
 
88
133
  // ---------------------------------------------------------------- payment ---
89
- async function createPaidClient() {
134
+ export function createBridgeClientFromEnv() {
90
135
  if (!Number.isFinite(MAX_PAYMENT_USD) || MAX_PAYMENT_USD <= 0) {
91
136
  throw new Error("MAX_PAYMENT_USD must be a positive number");
92
137
  }
93
- const policy = createPaymentPolicy({
94
- networks: process.env.VERIFY402_NETWORKS || DEFAULT_NETWORKS,
95
- payTo: process.env.VERIFY402_PAY_TO || DEFAULT_PAY_TO,
96
- resourcePrefix: process.env.VERIFY402_RESOURCE_PREFIX || SERVER_URL,
138
+ const policy = createPaymentPolicyFromEnv({
139
+ resourcePrefix: SERVER_URL,
97
140
  paidRoutes: Object.keys(DEFAULT_PRICES),
98
141
  });
99
142
  const account = privateKeyToAccount(EVM_PRIVATE_KEY);
@@ -105,12 +148,29 @@ async function createPaidClient() {
105
148
  for (const network of policy.allowedNetworks) {
106
149
  client.register(network, new ExactEvmScheme(account));
107
150
  }
108
- client.onBeforePaymentCreation((context) => policy.checkBeforeSign(context));
109
- console.error(
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
+ );
162
+ logInfo(
110
163
  `paid policy: networks=${policy.allowedNetworks.join(",")} ` +
111
164
  `payee=${policy.allowedPayTo} resources=${policy.resourcePrefix} ` +
112
165
  `{${policy.paidRoutes.join(",")}}`,
113
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
+ }
114
174
  const axiosInstance = axios.create({ baseURL: SERVER_URL, timeout: 120_000, maxRedirects: 0, headers: CLIENT_HEADERS });
115
175
  return wrapAxiosWithPayment(axiosInstance, client);
116
176
  }
@@ -120,14 +180,36 @@ async function createPaidClient() {
120
180
  // (optional) caps the whole UTC day across restarts, persisted in a tiny
121
181
  // ledger file. Attempted charges are counted conservatively (before payment).
122
182
  import { createDailyBudget, DEFAULT_PRICES } from "./budget.mjs";
123
- import { createPaymentPolicy, DEFAULT_NETWORKS, DEFAULT_PAY_TO } from "./payment-policy.mjs";
183
+ import { createPaymentGuard, createPaymentPolicyFromEnv } from "./payment-policy.mjs";
124
184
  const dailyBudget = createDailyBudget({
125
185
  maxDailyUsd: process.env.MAX_DAILY_USD,
126
186
  });
127
187
 
128
- async function guardedPaidCall(route, run) {
129
- if (dailyBudget.enabled) dailyBudget.assert(route);
130
- return 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);
192
+ if (res?.data?.recovery?.replayed) {
193
+ // Visible to the operator without exposing the bearer key. The same flag
194
+ // is already in the JSON returned to the MCP client.
195
+ logInfo(`${context.route}: recovery.replayed=true (settled verdict returned without a second charge)`);
196
+ }
197
+ return res;
198
+ }
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));
131
213
  }
132
214
 
133
215
  // ------------------------------------------------------------------ tools ---
@@ -138,32 +220,37 @@ const DESCRIPTIONS = {
138
220
  "the checks that did not, and a short-lived receipt bound to the exact intent + block. " +
139
221
  "If sign_allowed is false, DO NOT SIGN. If requires_ack is true, surface the reasons to the " +
140
222
  "operator before signing. The receipt expires (default 90s): call again rather than reuse it. " +
141
- "Free. Pass `from` (the wallet that will sign) to enable simulation and balance checks.",
223
+ "Free. Pass `from` (the wallet that will sign) to enable simulation and balance checks. For " +
224
+ "kind=transaction, chainId is REQUIRED (400 missing_chain without it). For payment intents " +
225
+ "with no `from` or no proof, the response is PASS with structural_only:true and names what was not checked.",
142
226
  verify_arbitrage:
143
227
  "Ask verify402 whether a cross-venue arbitrage opportunity is REAL, right now. " +
144
228
  "Quotes come from Uniswap's canonical QuoterV2 and each DEX router at one pinned block. " +
145
229
  "The honest answer is usually 'no_edge' - that IS the product. Costs $0.05 per call.",
146
230
  check_token_safety:
147
- "Ask verify402 whether a token is safe to buy BEFORE the agent risks capital. " +
231
+ "Ask verify402 for a quote-based sellability check on a token BEFORE the agent risks capital. " +
148
232
  "Returns TRUST/CAUTION/AVOID with evidence: live contract facts, market liquidity, " +
149
- "a USD-sized sell probe against every venue (honeypot test), round-trip cost at your " +
150
- "intended size, and phantom-price detection. Costs $0.10 per call.",
233
+ "a USD-sized sell QUOTED through the configured venues' own routers (direct and WETH-routed), " +
234
+ "round-trip cost at your intended size, and phantom-price detection. It does NOT execute or " +
235
+ "simulate a transfer: transfer tax, blacklist, pause, and max-tx restrictions are listed in " +
236
+ "notChecked, not tested. Unconfigured venues are also named in notChecked. Costs $0.10 per call.",
151
237
  get_best_route:
152
- "Ask verify402 what the chain would ACTUALLY pay for a swap: the full execution book " +
153
- "(every venue, direct and WETH-routed) at one pinned block, best first. Costs $0.03 per call.",
238
+ "Ask verify402 what the chain would ACTUALLY pay for a swap: the full book from every " +
239
+ "configured venue (direct and WETH-routed) at one pinned block, best first. Aggregators and " +
240
+ "unconfigured DEXs are not included. Costs $0.03 per call.",
154
241
  verify_quote_fairness:
155
242
  "Audit a swap quote someone else gave you (aggregator, broker, bot) BEFORE signing. " +
156
- "Returns fair / overpaying / too_good_to_be_true with the full chain book as evidence. " +
243
+ "Returns fair / overpaying / too_good_to_be_true with the configured-venue book as evidence. " +
157
244
  "Ask this before accepting ANY number you did not produce yourself. Costs $0.05 per call.",
158
245
  get_trade_depth:
159
246
  "Position sizing: measure how much of a token the chain ACTUALLY lets you trade - " +
160
- "entry and exit across every venue at a ladder of USD sizes, per-size impact and " +
247
+ "entry and exit across every configured venue at a ladder of USD sizes, per-size impact and " +
161
248
  "round-trip cost, plus the largest size under your cost tolerance. Ask BEFORE choosing " +
162
249
  "a position size. Costs $0.05 per call.",
163
250
  };
164
251
 
165
252
  async function main() {
166
- const paidApi = PAID_ENABLED ? await createPaidClient() : null;
253
+ const paidApi = PAID_ENABLED ? await createBridgePaidClient() : null;
167
254
 
168
255
  const server = new McpServer({ name: "verify402", version: VERSION });
169
256
 
@@ -176,7 +263,7 @@ async function main() {
176
263
  from: z.string().optional().describe("The wallet that will sign. Strongly recommended: enables simulation and balance checks."),
177
264
  payment: z.object({
178
265
  scheme: z.string().optional().describe('x402 scheme, normally "exact"'),
179
- 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:..."'),
180
267
  asset: z.string().describe("Token address or mint, e.g. canonical USDC on that network"),
181
268
  amount: z.string().describe("RAW amount in the asset's smallest unit, as a string"),
182
269
  payTo: z.string().describe("Recipient address from the 402 challenge"),
@@ -248,17 +335,21 @@ async function main() {
248
335
  DESCRIPTIONS.verify_arbitrage,
249
336
  {
250
337
  target: z.string().describe('Token to buy and sell back, e.g. "WETH"'),
251
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
338
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
252
339
  base: z.string().optional().describe('Token to trade from, e.g. "USDC" (default USDC)'),
253
340
  size: z.number().optional().describe("Trade size in whole base units, e.g. 100000 (default 100000)"),
254
341
  },
255
342
  async (args) => {
256
- const res = await guardedPaidCall("/verify", () => paidApi.post("/verify", {
257
- chainId: args.chainId ?? 1,
258
- target: String(args.target ?? "WETH").toUpperCase(),
259
- base: String(args.base ?? "USDC").toUpperCase(),
260
- size: args.size ?? 100000,
261
- }));
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
+ });
262
353
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
263
354
  },
264
355
  );
@@ -268,17 +359,21 @@ async function main() {
268
359
  DESCRIPTIONS.check_token_safety,
269
360
  {
270
361
  tokenAddress: z.string().describe("ERC-20 contract address, or a known symbol like WETH"),
271
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
362
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
272
363
  intendedUsd: z.number().optional().describe("Size the agent plans to buy, in USD (default 500)"),
273
364
  offeredPriceUsd: z.number().optional().describe("Optional price per token someone offered you"),
274
365
  },
275
366
  async (args) => {
276
- const res = await guardedPaidCall("/safety", () => paidApi.post("/safety", {
277
- chainId: args.chainId ?? 1,
278
- tokenAddress: String(args.tokenAddress ?? "").trim(),
279
- intendedUsd: args.intendedUsd ?? 500,
280
- offeredPriceUsd: args.offeredPriceUsd ?? null,
281
- }));
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
+ });
282
377
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
283
378
  },
284
379
  );
@@ -290,15 +385,19 @@ async function main() {
290
385
  tokenIn: z.string().describe("Token symbol (e.g. USDC) or 0x address"),
291
386
  tokenOut: z.string().describe("Token symbol (e.g. WETH) or 0x address"),
292
387
  amountIn: z.string().describe("RAW base units of tokenIn, wei-scale, as a string"),
293
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
388
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
294
389
  },
295
390
  async (args) => {
296
- const res = await guardedPaidCall("/route", () => paidApi.post("/route", {
297
- chainId: args.chainId ?? 1,
298
- tokenIn: args.tokenIn,
299
- tokenOut: args.tokenOut,
300
- amountIn: args.amountIn,
301
- }));
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
+ });
302
401
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
303
402
  },
304
403
  );
@@ -312,17 +411,21 @@ async function main() {
312
411
  amountIn: z.string().describe("RAW units of tokenIn, as in the offer"),
313
412
  quotedOut: z.string().describe("RAW units of tokenOut you were OFFERED"),
314
413
  venueClaimed: z.string().optional().describe("Optional: who gave you the offer"),
315
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
414
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
316
415
  },
317
416
  async (args) => {
318
- const res = await guardedPaidCall("/quotecheck", () => paidApi.post("/quotecheck", {
319
- chainId: args.chainId ?? 1,
320
- tokenIn: args.tokenIn,
321
- tokenOut: args.tokenOut,
322
- amountIn: args.amountIn,
323
- quotedOut: args.quotedOut,
324
- venueClaimed: args.venueClaimed ?? null,
325
- }));
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
+ });
326
429
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
327
430
  },
328
431
  );
@@ -332,19 +435,23 @@ async function main() {
332
435
  DESCRIPTIONS.get_trade_depth,
333
436
  {
334
437
  tokenAddress: z.string().describe("Token you plan to buy (symbol or 0x address)"),
335
- chainId: z.number().optional().describe("1 Ethereum, 42161 Arbitrum, 8453 Base (default 1)"),
438
+ chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
336
439
  baseSymbol: z.string().optional().describe("What you pay with (default USDC)"),
337
440
  sizesUsd: z.array(z.number()).optional().describe("Ladder of sizes in USD, max 6 (default 100, 1000, 10000, 100000)"),
338
441
  maxRoundTripPct: z.number().optional().describe("Your round-trip cost tolerance, default 1%"),
339
442
  },
340
443
  async (args) => {
341
- const res = await guardedPaidCall("/depth", () => paidApi.post("/depth", {
342
- chainId: args.chainId ?? 1,
343
- tokenAddress: args.tokenAddress,
344
- baseSymbol: args.baseSymbol ?? "USDC",
345
- sizesUsd: args.sizesUsd ?? null,
346
- maxRoundTripPct: args.maxRoundTripPct ?? 1.0,
347
- }));
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
+ });
348
455
  return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
349
456
  },
350
457
  );
@@ -352,14 +459,30 @@ async function main() {
352
459
 
353
460
  const transport = new StdioServerTransport();
354
461
  await server.connect(transport);
355
- console.error(
462
+ logInfo(
356
463
  paidApi
357
464
  ? `verify402 MCP bridge connected -> ${SERVER_URL} (paid tools on, max payment $${MAX_PAYMENT_USD})`
358
465
  : `verify402 MCP bridge connected -> ${SERVER_URL} (free preflight_gate only; set EVM_PRIVATE_KEY for paid tools)`,
359
466
  );
360
467
  }
361
468
 
362
- main().catch((e) => {
363
- console.error("bridge failed:", e.message);
364
- process.exit(1);
365
- });
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/log.mjs ADDED
@@ -0,0 +1,198 @@
1
+ // Central logging and redaction (MCP bridge copy).
2
+ // Keep in sync with ../src/log.mjs: the MCP bridge is published as a standalone
3
+ // package and cannot import files outside its own package root.
4
+ //
5
+ // Every runtime log line goes through this module. The goal is that an RPC URL
6
+ // path, query string, basic-auth credential, header value, API key, or
7
+ // idempotency key cannot reach stdout/stderr even when the source is a raw
8
+ // viem/HttpRequestError, a stack trace, or request metadata.
9
+ //
10
+ // The public error boundary already keeps these values out of HTTP responses.
11
+ // This module closes the other half: the log sink.
12
+
13
+ import { createHash } from "node:crypto";
14
+
15
+ const ORIGINAL = {
16
+ log: console.log.bind(console),
17
+ info: console.info.bind(console),
18
+ warn: console.warn.bind(console),
19
+ error: console.error.bind(console),
20
+ debug: console.debug.bind(console),
21
+ };
22
+
23
+ const SENSITIVE_KEY_RE =
24
+ /^(?:x-payment|payment-signature|authorization|proxy-authorization|x-api-key|api[-_]?key|apikey|secret|password|passwd|token|access[-_]?token|refresh[-_]?token|credential|private[-_]?key|idempotency[-_]?key|x-idempotency-key)$/i;
25
+ const IDEMPOTENCY_KEY_RE = /^(?:x-)?idempotency[-_]?key$/i;
26
+ const HEADER_OR_PARAM_RE =
27
+ /(x-payment|payment-signature|authorization|proxy-authorization|x-api-key|api[-_]?key|apikey|secret|password|passwd|access[-_]?token|refresh[-_]?token|private[-_]?key)(\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r,;]+)/gi;
28
+ const IDEMPOTENCY_HEADER_RE =
29
+ /(x-idempotency-key|idempotency-key)(\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;]+)/gi;
30
+
31
+ let installed = false;
32
+
33
+ function stripQuotes(value) {
34
+ return String(value ?? "").replace(/^["']|["']$/g, "");
35
+ }
36
+
37
+ /** Stable 8-character hash prefix for a caller-supplied idempotency key. */
38
+ export function idempotencyPrefix(value) {
39
+ if (value == null || String(value) === "") return null;
40
+ return `idem:${createHash("sha256").update(String(value)).digest("hex").slice(0, 8)}`;
41
+ }
42
+
43
+ function sanitizeUrl(raw) {
44
+ try {
45
+ const u = new URL(raw);
46
+ if (!/^(?:https?|wss?):$/i.test(u.protocol)) return raw;
47
+ return `${u.protocol}//${u.host}/***`;
48
+ } catch {
49
+ // Best effort for malformed URLs: drop userinfo and everything after the
50
+ // authority, but never echo the original path/query.
51
+ return String(raw)
52
+ .replace(/\/\/[^/@\s]+@/, "//")
53
+ .replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#\s]+).*$/i, "$1/***");
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Redact a string. `idempotencyKey` is optional context: when the caller's key
59
+ * is known, any literal occurrence is replaced with its 8-character hash
60
+ * prefix instead of a generic marker.
61
+ */
62
+ export function redactText(value, { idempotencyKey = null } = {}) {
63
+ let s = String(value ?? "");
64
+ if (idempotencyKey) {
65
+ const key = String(idempotencyKey);
66
+ s = s.split(key).join(idempotencyPrefix(key));
67
+ }
68
+
69
+ // Full URLs: keep scheme + host (including port), replace the rest.
70
+ s = s.replace(/\b(?:https?|wss?):\/\/[^\s"'<>]+/gi, (match) => {
71
+ const trailing = match.match(/[),.;:!?]+$/)?.[0] ?? "";
72
+ const core = trailing ? match.slice(0, -trailing.length) : match;
73
+ return sanitizeUrl(core) + trailing;
74
+ });
75
+
76
+ // Idempotency keys get a stable 8-character hash prefix, not a generic
77
+ // marker, so a replay can still be correlated without exposing the key.
78
+ s = s.replace(IDEMPOTENCY_HEADER_RE, (match, key, sep, rawValue) => {
79
+ const value = stripQuotes(rawValue);
80
+ if (/^idem:[0-9a-f]{8}$/.test(value)) return match;
81
+ return `${key}${sep}${idempotencyPrefix(value)}`;
82
+ });
83
+
84
+ // Other headers and key=value query parameters: redact the whole value.
85
+ // This intentionally over-redacts an inline header line rather than risk
86
+ // leaving a fragment of "Bearer <token>" or a multi-word credential behind.
87
+ s = s.replace(HEADER_OR_PARAM_RE, (match, key, sep) => {
88
+ return `${key}${sep}[REDACTED]`;
89
+ });
90
+
91
+ return s;
92
+ }
93
+
94
+ function safeStringify(value, depth = 0, seen = new WeakSet()) {
95
+ if (value == null) return String(value);
96
+ if (typeof value === "bigint") return value.toString();
97
+ if (typeof value === "function") return "[Function]";
98
+ if (typeof value !== "object") return JSON.stringify(value);
99
+ if (value instanceof Error) return formatError(value);
100
+ if (depth > 5) return "[MaxDepth]";
101
+ if (seen.has(value)) return "[Circular]";
102
+ seen.add(value);
103
+ if (Array.isArray(value)) {
104
+ const out = value.slice(0, 50).map((v) => safeStringify(v, depth + 1, seen));
105
+ if (value.length > 50) out.push(`[+${value.length - 50} more]`);
106
+ return `[${out.join(",")}]`;
107
+ }
108
+ const pairs = [];
109
+ for (const [key, v] of Object.entries(value)) {
110
+ if (SENSITIVE_KEY_RE.test(key)) {
111
+ pairs.push(`${JSON.stringify(key)}:${JSON.stringify(IDEMPOTENCY_KEY_RE.test(key) ? idempotencyPrefix(v) : "[REDACTED]")}`);
112
+ } else {
113
+ pairs.push(`${JSON.stringify(key)}:${safeStringify(v, depth + 1, seen)}`);
114
+ }
115
+ }
116
+ return `{${pairs.join(",")}}`;
117
+ }
118
+
119
+ export function redactValue(value, { idempotencyKey = null } = {}) {
120
+ if (value instanceof Error) return formatError(value, { idempotencyKey });
121
+ if (typeof value === "string") return redactText(value, { idempotencyKey });
122
+ if (value == null || typeof value !== "object") return value;
123
+ if (Array.isArray(value)) return value.map((v) => redactValue(v, { idempotencyKey }));
124
+ const out = {};
125
+ for (const [key, v] of Object.entries(value)) {
126
+ if (SENSITIVE_KEY_RE.test(key)) {
127
+ out[key] = IDEMPOTENCY_KEY_RE.test(key)
128
+ ? idempotencyPrefix(v)
129
+ : "[REDACTED]";
130
+ } else {
131
+ out[key] = redactValue(v, { idempotencyKey });
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /** Format an Error without ever dumping its raw enumerable properties. */
138
+ export function formatError(error, { idempotencyKey = null } = {}) {
139
+ if (error == null) return "";
140
+ if (typeof error === "string") return redactText(error, { idempotencyKey });
141
+ if (!(error instanceof Error)) {
142
+ return redactText(safeStringify(error), { idempotencyKey });
143
+ }
144
+
145
+ const head = redactText(`${error.name || "Error"}: ${error.message || ""}`, { idempotencyKey });
146
+ const cause = error.cause
147
+ ? `\ncause: ${formatError(error.cause, { idempotencyKey })}`
148
+ : "";
149
+ const stack = error.stack
150
+ ? `\n${redactText(error.stack, { idempotencyKey })}`
151
+ : "";
152
+ return `${head}${cause}${stack}`;
153
+ }
154
+
155
+ function emit(level, message, error = null, context = null) {
156
+ const parts = [redactText(message)];
157
+ if (context && typeof context === "object" && Object.keys(context).length) {
158
+ parts.push(redactText(safeStringify(context)));
159
+ }
160
+ const detail = error == null ? "" : formatError(error);
161
+ const line = `${parts.join(" ")}${detail ? `\n${detail}` : ""}`;
162
+ // Use the current console method so tests and embedders can still capture
163
+ // structured events. installConsoleRedaction() makes this idempotent.
164
+ (console[level] || ORIGINAL[level])(line);
165
+ }
166
+
167
+ export function logInfo(message, error = null, context = null) {
168
+ emit("log", message, error, context);
169
+ }
170
+
171
+ export function logWarn(message, error = null, context = null) {
172
+ emit("warn", message, error, context);
173
+ }
174
+
175
+ export function logError(message, error = null, context = null) {
176
+ emit("error", message, error, context);
177
+ }
178
+
179
+ /**
180
+ * Defence in depth: wrap console.* so a future direct console call cannot dump
181
+ * a raw Error or an unredacted URL. Safe to call more than once.
182
+ */
183
+ export function installConsoleRedaction() {
184
+ if (installed) return;
185
+ installed = true;
186
+ for (const level of ["log", "info", "warn", "error", "debug"]) {
187
+ console[level] = (...args) => {
188
+ const safe = args.map((arg) =>
189
+ arg instanceof Error
190
+ ? formatError(arg)
191
+ : typeof arg === "string"
192
+ ? redactText(arg)
193
+ : arg,
194
+ );
195
+ ORIGINAL[level](...safe);
196
+ };
197
+ }
198
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verify402-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
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",
@@ -12,6 +12,8 @@
12
12
  "index.mjs",
13
13
  "budget.mjs",
14
14
  "payment-policy.mjs",
15
+ "paid-call.mjs",
16
+ "log.mjs",
15
17
  "README.md",
16
18
  ".env.example",
17
19
  "LICENSE",
@@ -19,7 +21,7 @@
19
21
  ],
20
22
  "scripts": {
21
23
  "start": "node index.mjs",
22
- "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"
23
25
  },
24
26
  "engines": {
25
27
  "node": ">=20"
@@ -52,8 +54,8 @@
52
54
  },
53
55
  "dependencies": {
54
56
  "@modelcontextprotocol/sdk": "^1.12.1",
55
- "@x402/axios": "^2.28.0",
56
- "@x402/evm": "^2.28.0",
57
+ "@x402/axios": "2.28.0",
58
+ "@x402/evm": "2.28.0",
57
59
  "axios": "^1.13.2",
58
60
  "dotenv": "^16.4.5",
59
61
  "viem": "^2.39.0",
package/paid-call.mjs ADDED
@@ -0,0 +1,73 @@
1
+ // One paid operation = one idempotency key, reused on every retry.
2
+ //
3
+ // The key is a bearer credential (see README/recovery docs), so it is never
4
+ // logged or returned to the MCP client. The bridge only needs it to survive a
5
+ // lost response: the server returns the settled verdict with
6
+ // recovery.replayed=true instead of charging a second time.
7
+ //
8
+ // Retry rules:
9
+ // * network/timeout/5xx-gateway failures: retry with the SAME key
10
+ // * 402: never retry here (the x402 wrapper owns the payment handshake)
11
+ // * 409: never retry (a conflicting body will not become valid)
12
+
13
+ import { randomUUID } from "node:crypto";
14
+
15
+ export const DEFAULT_RETRIES = 2;
16
+
17
+ const RETRYABLE_CODES = new Set([
18
+ "ECONNABORTED",
19
+ "ECONNRESET",
20
+ "EHOSTUNREACH",
21
+ "ENETUNREACH",
22
+ "EPIPE",
23
+ "ETIMEDOUT",
24
+ "EAI_AGAIN",
25
+ "ERR_NETWORK",
26
+ "UND_ERR_CONNECT_TIMEOUT",
27
+ "UND_ERR_HEADERS_TIMEOUT",
28
+ "UND_ERR_BODY_TIMEOUT",
29
+ "UND_ERR_SOCKET",
30
+ ]);
31
+
32
+ const RETRYABLE_STATUS = new Set([502, 503, 504]);
33
+
34
+ export function isRetryable(error) {
35
+ const status = error?.response?.status;
36
+ if (status != null) return RETRYABLE_STATUS.has(status);
37
+ if (RETRYABLE_CODES.has(error?.code)) return true;
38
+ // Axios tags transport failures as isAxiosError with no response. Anything
39
+ // else (bad config, programmer error) is not retried.
40
+ return error?.isAxiosError === true;
41
+ }
42
+
43
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
44
+
45
+ /**
46
+ * POST a paid operation with one generated idempotency key.
47
+ *
48
+ * @param {{post: Function}} paidApi - x402-wrapped axios instance (or a fake)
49
+ * @param {string} route
50
+ * @param {object} body
51
+ * @param {{idempotencyKey?: string, retries?: number, sleep?: Function}} options
52
+ * @returns {Promise<object>} the final axios response
53
+ */
54
+ export async function paidPost(paidApi, route, body, {
55
+ idempotencyKey = randomUUID(),
56
+ retries = DEFAULT_RETRIES,
57
+ sleep = delay,
58
+ } = {}) {
59
+ let lastError;
60
+ for (let attempt = 0; attempt <= retries; attempt++) {
61
+ try {
62
+ return await paidApi.post(route, body, {
63
+ headers: { "x-idempotency-key": idempotencyKey },
64
+ });
65
+ } catch (error) {
66
+ lastError = error;
67
+ if (!isRetryable(error) || attempt === retries) throw error;
68
+ // Keep the same key: a retry after a lost response must replay, not pay.
69
+ await sleep(250 * (attempt + 1));
70
+ }
71
+ }
72
+ throw lastError;
73
+ }
@@ -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
+ }