verify402-mcp 1.0.4 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -6
- package/README.md +54 -7
- package/index.mjs +163 -54
- package/package.json +2 -2
- 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:** `verify402-mcp@1.0.6` is published to npm and
|
|
14
|
+
> defaults paid calls to Base Sepolia testnet (`eip155:84532`) for the public
|
|
15
|
+
> demo. Base mainnet (`eip155:8453`) requires an explicit `VERIFY402_NETWORKS`
|
|
16
|
+
> opt-in in a separate deployment and is not enabled by this release. The
|
|
17
|
+
> previously published `1.0.5` package is immutable; `1.0.4` is also immutable
|
|
18
|
+
> and used Base mainnet by default. Do not republish or overwrite either
|
|
19
|
+
> version.
|
|
10
20
|
|
|
11
21
|
## Tools
|
|
12
22
|
|
|
@@ -24,6 +34,13 @@ transaction or x402 payment request. If `sign_allowed` is false, **do not
|
|
|
24
34
|
sign**. If `requires_ack` is true, surface the reasons to the operator. The
|
|
25
35
|
receipt expires (default 90s); call again instead of reusing it.
|
|
26
36
|
|
|
37
|
+
For payment intents, `policy.allowedResourceHosts` binds the host in
|
|
38
|
+
`payment.resource`; a matching host can PASS and a foreign host is BLOCK.
|
|
39
|
+
Without `allowedResourceHosts` or `expectedResource`, an otherwise valid
|
|
40
|
+
resource is CAUTION `resource_identity_unverified` and `resource identity` is
|
|
41
|
+
listed in `notChecked`. The bridge forwards this policy to the server; the
|
|
42
|
+
server enforces it.
|
|
43
|
+
|
|
27
44
|
The gate is advisory at this layer: the bridge does not hold the agent's
|
|
28
45
|
transaction key and cannot stop an agent that ignores the answer. Enforcement
|
|
29
46
|
belongs in the executor that signs — verify the receipt, then sign. See
|
|
@@ -85,11 +102,12 @@ To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_saf
|
|
|
85
102
|
1. **Create a Dedicated Hot Wallet**:
|
|
86
103
|
- Create a brand new wallet in MetaMask, Rabby, or via `cast wallet new`.
|
|
87
104
|
- ⚠️ **SECURITY WARNING**: Never use your primary personal wallet, cold storage, or vault.
|
|
88
|
-
2. **Fund the Wallet on Base (EIP-155:
|
|
89
|
-
- **USDC**:
|
|
90
|
-
- **Gas (ETH on Base)**:
|
|
105
|
+
2. **Fund the Wallet on Base Sepolia testnet (EIP-155:84532)**:
|
|
106
|
+
- **USDC**: Get Base Sepolia testnet USDC from a faucet (the bridge pays per call: $0.03 to $0.10 in testnet USDC).
|
|
107
|
+
- **Gas (ETH on Base Sepolia)**: Get a small amount of Base Sepolia testnet ETH for gas if your client needs it. No mainnet funds are required for the public demo.
|
|
91
108
|
3. **Configure Environment Variables in Your MCP Client**:
|
|
92
109
|
- Set `EVM_PRIVATE_KEY` to the 64-character private key of the hot wallet.
|
|
110
|
+
- Set `VERIFY402_NETWORKS` to `eip155:84532` (Base Sepolia testnet).
|
|
93
111
|
- Set safety caps `MAX_PAYMENT_USD` (e.g., `"0.25"`) and `MAX_DAILY_USD` (e.g., `"1.00"`).
|
|
94
112
|
|
|
95
113
|
**Paid Configuration Block (Claude Desktop, Cursor, Cline, Windsurf)**:
|
|
@@ -104,7 +122,7 @@ To enable the 5 deep on-chain truth checks (`verify_arbitrage`, `check_token_saf
|
|
|
104
122
|
"EVM_PRIVATE_KEY": "0xYOUR_HOT_WALLET_PRIVATE_KEY",
|
|
105
123
|
"MAX_PAYMENT_USD": "0.25",
|
|
106
124
|
"MAX_DAILY_USD": "1.00",
|
|
107
|
-
"VERIFY402_NETWORKS": "eip155:
|
|
125
|
+
"VERIFY402_NETWORKS": "eip155:84532",
|
|
108
126
|
"VERIFY402_PAY_TO": "0xAeEF086DD4e436c69d6d0F684b4c9F43392df9cb"
|
|
109
127
|
}
|
|
110
128
|
}
|
|
@@ -125,6 +143,7 @@ In `opencode.jsonc` (or `~/.config/opencode/opencode.jsonc`):
|
|
|
125
143
|
"environment": {
|
|
126
144
|
"VERIFY402_URL": "https://verify402.onrender.com",
|
|
127
145
|
"EVM_PRIVATE_KEY": "{env:EVM_PRIVATE_KEY}",
|
|
146
|
+
"VERIFY402_NETWORKS": "eip155:84532",
|
|
128
147
|
"MAX_PAYMENT_USD": "0.25",
|
|
129
148
|
"MAX_DAILY_USD": "1.00"
|
|
130
149
|
}
|
|
@@ -163,9 +182,20 @@ Once configured, your agent automatically sees the tools in its context. You can
|
|
|
163
182
|
server and a known paid route, on an allowlisted EVM network, to the
|
|
164
183
|
verify402 payee. It accepts only recognized default assets (USDC), caps the
|
|
165
184
|
amount at `MAX_PAYMENT_USD`, and refuses redirects.
|
|
185
|
+
- The whole 402 challenge is checked, not just the option the client picks:
|
|
186
|
+
every offered requirement must use an allowlisted network, the configured
|
|
187
|
+
payee, the canonical USDC contract **and** the canonical EIP-712 domain for
|
|
188
|
+
that network, an `extra.assetTransferMethod` of `eip3009`, a
|
|
189
|
+
`maxTimeoutSeconds` that is a positive integer no greater than `300`, and an
|
|
190
|
+
amount inside the cap. A challenge that also offers Base mainnet is refused
|
|
191
|
+
as a whole rather than filtered down to the testnet option. The client also
|
|
192
|
+
proves that the requirement it signs is the exact object the server sent.
|
|
166
193
|
- The allowlist can be narrowed or pointed at another deployment with
|
|
167
194
|
`VERIFY402_NETWORKS`, `VERIFY402_PAY_TO`, and `VERIFY402_RESOURCE_PREFIX`.
|
|
168
|
-
|
|
195
|
+
The bridge defaults are Base Sepolia testnet (`eip155:84532`) and the
|
|
196
|
+
public verify402 payee. `VERIFY402_NETWORKS` is constrained to the supported
|
|
197
|
+
map (`eip155:84532`, `eip155:8453`); an explicitly empty value fails closed
|
|
198
|
+
instead of falling back to a default.
|
|
169
199
|
- `MAX_PAYMENT_USD` is enforced inside the x402 client, before a signature
|
|
170
200
|
exists; removing it is a security regression. The caps limit what the bridge
|
|
171
201
|
can authorize; they do **not** protect a compromised private key.
|
|
@@ -177,6 +207,23 @@ Once configured, your agent automatically sees the tools in its context. You can
|
|
|
177
207
|
- Use the [reference executor](https://github.com/a32116150-ctrl/verify402/blob/master/examples/preflight-guard.mjs)
|
|
178
208
|
when you want enforcement rather than advice.
|
|
179
209
|
|
|
210
|
+
## Explicit mainnet configuration (not the public demo)
|
|
211
|
+
|
|
212
|
+
Mainnet settlement is a separate deployment choice, not a bridge or demo
|
|
213
|
+
default. Do not point this client at mainnet until the service-side mainnet
|
|
214
|
+
readiness checks, facilitator, always-on host, and wallet funding have been
|
|
215
|
+
verified independently.
|
|
216
|
+
|
|
217
|
+
```bash
|
|
218
|
+
# Only for a deliberate mainnet deployment, never against the public demo.
|
|
219
|
+
VERIFY402_NETWORKS=eip155:8453
|
|
220
|
+
VERIFY402_PAY_TO=<the payee named by the mainnet 402>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
The public demo offers `eip155:84532`; a mainnet-only policy will refuse that
|
|
224
|
+
challenge instead of silently spending mainnet funds. The reverse is also
|
|
225
|
+
enforced: the demo policy refuses a mainnet-only challenge.
|
|
226
|
+
|
|
180
227
|
## Development
|
|
181
228
|
|
|
182
229
|
```bash
|
package/index.mjs
CHANGED
|
@@ -31,13 +31,24 @@ import { config } from "dotenv";
|
|
|
31
31
|
import { z } from "zod";
|
|
32
32
|
import { installConsoleRedaction, logInfo, logError } from "./log.mjs";
|
|
33
33
|
import { paidPost } from "./paid-call.mjs";
|
|
34
|
+
import { fileURLToPath } from "node:url";
|
|
35
|
+
import { realpathSync } from "node:fs";
|
|
36
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
34
37
|
|
|
35
38
|
installConsoleRedaction();
|
|
36
|
-
config();
|
|
37
39
|
|
|
38
|
-
|
|
40
|
+
// Only the process entry point loads the operator's `.env`. Importing this
|
|
41
|
+
// module (tests, tooling) must never read the wallet file.
|
|
42
|
+
const IS_MAIN = isMainModule();
|
|
43
|
+
if (IS_MAIN) config();
|
|
44
|
+
|
|
45
|
+
const VERSION = "1.0.6";
|
|
39
46
|
const EVM_PRIVATE_KEY = process.env.EVM_PRIVATE_KEY;
|
|
40
|
-
|
|
47
|
+
// Normalise once so the axios base URL, the payment policy prefix, and the
|
|
48
|
+
// per-tool expected resource are all the same string (no trailing slash).
|
|
49
|
+
const SERVER_URL = (process.env.VERIFY402_URL || "https://verify402.onrender.com")
|
|
50
|
+
.trim()
|
|
51
|
+
.replace(/\/+$/, "");
|
|
41
52
|
// Tell the server which client made the call. This is a label, not an identity:
|
|
42
53
|
// it helps separate MCP use from generic HTTP probes without collecting keys or
|
|
43
54
|
// wallet addresses.
|
|
@@ -59,6 +70,37 @@ const MAX_PAYMENT_USD = Number(process.env.MAX_PAYMENT_USD || 0.25);
|
|
|
59
70
|
// registered only when a wallet is configured.
|
|
60
71
|
const PAID_ENABLED = Boolean(EVM_PRIVATE_KEY);
|
|
61
72
|
|
|
73
|
+
// Each paid MCP tool is bound to exactly one paid API route. This map is local,
|
|
74
|
+
// trusted configuration: a 402 challenge can never choose which resource a tool
|
|
75
|
+
// is allowed to pay for. The pre-sign guard compares the challenge's resource
|
|
76
|
+
// against the route of the tool that is currently running.
|
|
77
|
+
export const PAID_TOOL_ROUTES = Object.freeze({
|
|
78
|
+
verify_arbitrage: "/verify",
|
|
79
|
+
check_token_safety: "/safety",
|
|
80
|
+
get_best_route: "/route",
|
|
81
|
+
verify_quote_fairness: "/quotecheck",
|
|
82
|
+
get_trade_depth: "/depth",
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Carries the current tool's expected resource from the tool handler to the
|
|
86
|
+
// pre-sign hook. AsyncLocalStorage keeps concurrent tool calls isolated and is
|
|
87
|
+
// the only channel the guard trusts for the expected resource.
|
|
88
|
+
const paymentContext = new AsyncLocalStorage();
|
|
89
|
+
|
|
90
|
+
function paidCallContext(tool) {
|
|
91
|
+
const route = PAID_TOOL_ROUTES[tool];
|
|
92
|
+
if (!route) throw new Error(`unknown paid tool: ${String(tool)}`);
|
|
93
|
+
return { tool, route, expectedResource: `${SERVER_URL}${route}` };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run `run` as the given paid tool. Exported so tests can drive the real
|
|
98
|
+
* binding without starting the MCP transport.
|
|
99
|
+
*/
|
|
100
|
+
export function runWithPaidTool(tool, run) {
|
|
101
|
+
return paymentContext.run(paidCallContext(tool), run);
|
|
102
|
+
}
|
|
103
|
+
|
|
62
104
|
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
63
105
|
console.log(`verify402-mcp — MCP server for verify402
|
|
64
106
|
|
|
@@ -73,7 +115,7 @@ Environment:
|
|
|
73
115
|
MAX_PAYMENT_USD cap on one x402 payment (default 0.25)
|
|
74
116
|
MAX_DAILY_USD optional cap for the UTC day, persisted in a small ledger
|
|
75
117
|
SPEND_LEDGER_PATH optional ledger path (default .spend-ledger.json)
|
|
76
|
-
VERIFY402_NETWORKS comma-separated EVM networks for paid calls (default eip155:8453)
|
|
118
|
+
VERIFY402_NETWORKS comma-separated EVM networks for paid calls (default eip155:84532, Base Sepolia testnet; eip155:8453 only by explicit opt-in)
|
|
77
119
|
VERIFY402_PAY_TO EVM payee the 402 must name (default the verify402 payee)
|
|
78
120
|
VERIFY402_RESOURCE_PREFIX resource origin/prefix the 402 must match (default VERIFY402_URL)
|
|
79
121
|
|
|
@@ -89,14 +131,12 @@ if (process.argv.includes("--version") || process.argv.includes("-v")) {
|
|
|
89
131
|
}
|
|
90
132
|
|
|
91
133
|
// ---------------------------------------------------------------- payment ---
|
|
92
|
-
|
|
134
|
+
export function createBridgeClientFromEnv() {
|
|
93
135
|
if (!Number.isFinite(MAX_PAYMENT_USD) || MAX_PAYMENT_USD <= 0) {
|
|
94
136
|
throw new Error("MAX_PAYMENT_USD must be a positive number");
|
|
95
137
|
}
|
|
96
|
-
const policy =
|
|
97
|
-
|
|
98
|
-
payTo: process.env.VERIFY402_PAY_TO || DEFAULT_PAY_TO,
|
|
99
|
-
resourcePrefix: process.env.VERIFY402_RESOURCE_PREFIX || SERVER_URL,
|
|
138
|
+
const policy = createPaymentPolicyFromEnv({
|
|
139
|
+
resourcePrefix: SERVER_URL,
|
|
100
140
|
paidRoutes: Object.keys(DEFAULT_PRICES),
|
|
101
141
|
});
|
|
102
142
|
const account = privateKeyToAccount(EVM_PRIVATE_KEY);
|
|
@@ -108,12 +148,29 @@ async function createPaidClient() {
|
|
|
108
148
|
for (const network of policy.allowedNetworks) {
|
|
109
149
|
client.register(network, new ExactEvmScheme(account));
|
|
110
150
|
}
|
|
111
|
-
|
|
151
|
+
// Full-challenge guard (version, every EVM offer, canonical asset/domain,
|
|
152
|
+
// payee, amount cap, timeout, EIP-3009) plus the resource/route layer. It
|
|
153
|
+
// runs after the SDK has selected a requirement and before the signer.
|
|
154
|
+
// The expected resource is resolved per invocation from the tool handler
|
|
155
|
+
// context, never from the 402 challenge.
|
|
156
|
+
client.onBeforePaymentCreation(
|
|
157
|
+
createPaymentGuard(policy, {
|
|
158
|
+
maxAmountUsd: MAX_PAYMENT_USD,
|
|
159
|
+
expectedResource: () => paymentContext.getStore()?.expectedResource,
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
112
162
|
logInfo(
|
|
113
163
|
`paid policy: networks=${policy.allowedNetworks.join(",")} ` +
|
|
114
164
|
`payee=${policy.allowedPayTo} resources=${policy.resourcePrefix} ` +
|
|
115
165
|
`{${policy.paidRoutes.join(",")}}`,
|
|
116
166
|
);
|
|
167
|
+
return client;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function createBridgePaidClient(client = createBridgeClientFromEnv()) {
|
|
171
|
+
if (!Number.isFinite(MAX_PAYMENT_USD) || MAX_PAYMENT_USD <= 0) {
|
|
172
|
+
throw new Error("MAX_PAYMENT_USD must be a positive number");
|
|
173
|
+
}
|
|
117
174
|
const axiosInstance = axios.create({ baseURL: SERVER_URL, timeout: 120_000, maxRedirects: 0, headers: CLIENT_HEADERS });
|
|
118
175
|
return wrapAxiosWithPayment(axiosInstance, client);
|
|
119
176
|
}
|
|
@@ -123,22 +180,38 @@ async function createPaidClient() {
|
|
|
123
180
|
// (optional) caps the whole UTC day across restarts, persisted in a tiny
|
|
124
181
|
// ledger file. Attempted charges are counted conservatively (before payment).
|
|
125
182
|
import { createDailyBudget, DEFAULT_PRICES } from "./budget.mjs";
|
|
126
|
-
import {
|
|
183
|
+
import { createPaymentGuard, createPaymentPolicyFromEnv } from "./payment-policy.mjs";
|
|
127
184
|
const dailyBudget = createDailyBudget({
|
|
128
185
|
maxDailyUsd: process.env.MAX_DAILY_USD,
|
|
129
186
|
});
|
|
130
187
|
|
|
131
|
-
async function guardedPaidCall(
|
|
132
|
-
|
|
133
|
-
|
|
188
|
+
async function guardedPaidCall(tool, run) {
|
|
189
|
+
const context = paidCallContext(tool);
|
|
190
|
+
if (dailyBudget.enabled) dailyBudget.assert(context.route);
|
|
191
|
+
const res = await paymentContext.run(context, run);
|
|
134
192
|
if (res?.data?.recovery?.replayed) {
|
|
135
193
|
// Visible to the operator without exposing the bearer key. The same flag
|
|
136
194
|
// is already in the JSON returned to the MCP client.
|
|
137
|
-
logInfo(`${route}: recovery.replayed=true (settled verdict returned without a second charge)`);
|
|
195
|
+
logInfo(`${context.route}: recovery.replayed=true (settled verdict returned without a second charge)`);
|
|
138
196
|
}
|
|
139
197
|
return res;
|
|
140
198
|
}
|
|
141
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Run one paid MCP tool. The route is resolved from local tool configuration
|
|
202
|
+
* (PAID_TOOL_ROUTES), recorded in the async context, and used for both the HTTP
|
|
203
|
+
* request and the pre-sign expected-resource check, so the 402 challenge can
|
|
204
|
+
* never redirect a tool to a different paid resource.
|
|
205
|
+
*/
|
|
206
|
+
export async function runPaidTool({ tool, client, body }) {
|
|
207
|
+
if (!Object.hasOwn(PAID_TOOL_ROUTES, tool)) {
|
|
208
|
+
throw new Error(`unknown paid tool: ${String(tool)}`);
|
|
209
|
+
}
|
|
210
|
+
if (!client) throw new Error(`paid client is not configured for tool ${tool}`);
|
|
211
|
+
const route = PAID_TOOL_ROUTES[tool];
|
|
212
|
+
return guardedPaidCall(tool, () => paidPost(client, route, body));
|
|
213
|
+
}
|
|
214
|
+
|
|
142
215
|
// ------------------------------------------------------------------ tools ---
|
|
143
216
|
const DESCRIPTIONS = {
|
|
144
217
|
preflight_gate:
|
|
@@ -177,7 +250,7 @@ const DESCRIPTIONS = {
|
|
|
177
250
|
};
|
|
178
251
|
|
|
179
252
|
async function main() {
|
|
180
|
-
const paidApi = PAID_ENABLED ? await
|
|
253
|
+
const paidApi = PAID_ENABLED ? await createBridgePaidClient() : null;
|
|
181
254
|
|
|
182
255
|
const server = new McpServer({ name: "verify402", version: VERSION });
|
|
183
256
|
|
|
@@ -190,7 +263,7 @@ async function main() {
|
|
|
190
263
|
from: z.string().optional().describe("The wallet that will sign. Strongly recommended: enables simulation and balance checks."),
|
|
191
264
|
payment: z.object({
|
|
192
265
|
scheme: z.string().optional().describe('x402 scheme, normally "exact"'),
|
|
193
|
-
network: z.string().describe('CAIP-2 network, e.g. "eip155:
|
|
266
|
+
network: z.string().describe('CAIP-2 network, e.g. "eip155:84532" for the public demo or "solana:..."'),
|
|
194
267
|
asset: z.string().describe("Token address or mint, e.g. canonical USDC on that network"),
|
|
195
268
|
amount: z.string().describe("RAW amount in the asset's smallest unit, as a string"),
|
|
196
269
|
payTo: z.string().describe("Recipient address from the 402 challenge"),
|
|
@@ -267,12 +340,16 @@ async function main() {
|
|
|
267
340
|
size: z.number().optional().describe("Trade size in whole base units, e.g. 100000 (default 100000)"),
|
|
268
341
|
},
|
|
269
342
|
async (args) => {
|
|
270
|
-
const res = await
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
343
|
+
const res = await runPaidTool({
|
|
344
|
+
tool: "verify_arbitrage",
|
|
345
|
+
client: paidApi,
|
|
346
|
+
body: {
|
|
347
|
+
chainId: args.chainId,
|
|
348
|
+
target: String(args.target ?? "WETH").toUpperCase(),
|
|
349
|
+
base: String(args.base ?? "USDC").toUpperCase(),
|
|
350
|
+
size: args.size ?? 100000,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
276
353
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
277
354
|
},
|
|
278
355
|
);
|
|
@@ -287,12 +364,16 @@ async function main() {
|
|
|
287
364
|
offeredPriceUsd: z.number().optional().describe("Optional price per token someone offered you"),
|
|
288
365
|
},
|
|
289
366
|
async (args) => {
|
|
290
|
-
const res = await
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
367
|
+
const res = await runPaidTool({
|
|
368
|
+
tool: "check_token_safety",
|
|
369
|
+
client: paidApi,
|
|
370
|
+
body: {
|
|
371
|
+
chainId: args.chainId,
|
|
372
|
+
tokenAddress: String(args.tokenAddress ?? "").trim(),
|
|
373
|
+
intendedUsd: args.intendedUsd ?? 500,
|
|
374
|
+
offeredPriceUsd: args.offeredPriceUsd ?? null,
|
|
375
|
+
},
|
|
376
|
+
});
|
|
296
377
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
297
378
|
},
|
|
298
379
|
);
|
|
@@ -307,12 +388,16 @@ async function main() {
|
|
|
307
388
|
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
308
389
|
},
|
|
309
390
|
async (args) => {
|
|
310
|
-
const res = await
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
391
|
+
const res = await runPaidTool({
|
|
392
|
+
tool: "get_best_route",
|
|
393
|
+
client: paidApi,
|
|
394
|
+
body: {
|
|
395
|
+
chainId: args.chainId,
|
|
396
|
+
tokenIn: args.tokenIn,
|
|
397
|
+
tokenOut: args.tokenOut,
|
|
398
|
+
amountIn: args.amountIn,
|
|
399
|
+
},
|
|
400
|
+
});
|
|
316
401
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
317
402
|
},
|
|
318
403
|
);
|
|
@@ -329,14 +414,18 @@ async function main() {
|
|
|
329
414
|
chainId: z.number().int().describe("REQUIRED. 1 Ethereum, 42161 Arbitrum, 8453 Base. There is no default; omitting it returns 400 missing_chain."),
|
|
330
415
|
},
|
|
331
416
|
async (args) => {
|
|
332
|
-
const res = await
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
417
|
+
const res = await runPaidTool({
|
|
418
|
+
tool: "verify_quote_fairness",
|
|
419
|
+
client: paidApi,
|
|
420
|
+
body: {
|
|
421
|
+
chainId: args.chainId,
|
|
422
|
+
tokenIn: args.tokenIn,
|
|
423
|
+
tokenOut: args.tokenOut,
|
|
424
|
+
amountIn: args.amountIn,
|
|
425
|
+
quotedOut: args.quotedOut,
|
|
426
|
+
venueClaimed: args.venueClaimed ?? null,
|
|
427
|
+
},
|
|
428
|
+
});
|
|
340
429
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
341
430
|
},
|
|
342
431
|
);
|
|
@@ -352,13 +441,17 @@ async function main() {
|
|
|
352
441
|
maxRoundTripPct: z.number().optional().describe("Your round-trip cost tolerance, default 1%"),
|
|
353
442
|
},
|
|
354
443
|
async (args) => {
|
|
355
|
-
const res = await
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
444
|
+
const res = await runPaidTool({
|
|
445
|
+
tool: "get_trade_depth",
|
|
446
|
+
client: paidApi,
|
|
447
|
+
body: {
|
|
448
|
+
chainId: args.chainId,
|
|
449
|
+
tokenAddress: args.tokenAddress,
|
|
450
|
+
baseSymbol: args.baseSymbol ?? "USDC",
|
|
451
|
+
sizesUsd: args.sizesUsd ?? null,
|
|
452
|
+
maxRoundTripPct: args.maxRoundTripPct ?? 1.0,
|
|
453
|
+
},
|
|
454
|
+
});
|
|
362
455
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
|
363
456
|
},
|
|
364
457
|
);
|
|
@@ -373,7 +466,23 @@ async function main() {
|
|
|
373
466
|
);
|
|
374
467
|
}
|
|
375
468
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
469
|
+
// True only when this file is the process entry point. The published package
|
|
470
|
+
// is installed through a `bin` symlink, so both sides are compared by real
|
|
471
|
+
// path: Node resolves import.meta.url through the symlink but keeps the
|
|
472
|
+
// symlink path in process.argv[1].
|
|
473
|
+
function isMainModule() {
|
|
474
|
+
const entry = process.argv[1];
|
|
475
|
+
if (!entry) return false;
|
|
476
|
+
try {
|
|
477
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
478
|
+
} catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (IS_MAIN) {
|
|
484
|
+
main().catch((e) => {
|
|
485
|
+
logError("bridge failed", e);
|
|
486
|
+
process.exit(1);
|
|
487
|
+
});
|
|
488
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verify402-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"mcpName": "io.github.a32116150-ctrl/verify402-mcp",
|
|
5
5
|
"description": "MCP server for verify402: a free, fail-closed preflight gate before signing, plus five paid on-chain checks paid per call via x402.",
|
|
6
6
|
"type": "module",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
23
|
"start": "node index.mjs",
|
|
24
|
-
"test": "node --test test/payment-policy.test.mjs"
|
|
24
|
+
"test": "node --test test/payment-policy.test.mjs test/first-payment.test.mjs test/paid-call.test.mjs test/bridge-client.test.mjs"
|
|
25
25
|
},
|
|
26
26
|
"engines": {
|
|
27
27
|
"node": ">=20"
|
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
|
+
}
|