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 +9 -6
- package/README.md +55 -10
- package/index.mjs +191 -68
- package/log.mjs +198 -0
- package/package.json +6 -4
- package/paid-call.mjs +73 -0
- package/payment-policy.mjs +397 -6
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
|
|
7
|
-
# wallet with only the money you are willing to
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
9
|
-
|
|
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.
|
|
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:
|
|
89
|
-
- **USDC**:
|
|
90
|
-
- **Gas (ETH on Base)**:
|
|
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:
|
|
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
|
|
146
|
-
> *"Analyze token `0x95aD61b0a150d79219dCF64E1E6Cc01f0B64C4cE` using `check_token_safety`.
|
|
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
|
-
|
|
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
|
-
|
|
38
|
+
installConsoleRedaction();
|
|
34
39
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
94
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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 {
|
|
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(
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
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
|
|
150
|
-
"intended size, and phantom-price detection.
|
|
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
|
|
153
|
-
"
|
|
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
|
|
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
|
|
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:
|
|
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().
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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().
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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().
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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().
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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().
|
|
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
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
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
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
+
"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": "
|
|
56
|
-
"@x402/evm": "
|
|
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
|
+
}
|
package/payment-policy.mjs
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
// x402Client's spendControls
|
|
12
|
-
//
|
|
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
|
|
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
|
+
}
|