diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ec733a5..81efa1a 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,7 +19,7 @@ Smoke-test the built server via the MCP stdio handshake:
(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n'; sleep 2) | node dist/index.js 2>/dev/null
```
-Should return 20 tools including `blockrun_surf` and any new one you add.
+Should return 21 tools including `blockrun_surf` and any new one you add.
To test locally with Claude Code, point it at your dev build:
diff --git a/README.md b/README.md
index 78b2a75..e2ee2b4 100644
--- a/README.md
+++ b/README.md
@@ -6,12 +6,12 @@
Agents can't sign up for accounts. Agents can't enter credit cards.
Agents can only sign transactions.
-BlockRun MCP gives your agent 20 tools — markets, research, web search, images, video, on-chain data, and live Polymarket trading — paid per call in USDC. No accounts. No API keys. No dashboards.
+BlockRun MCP gives your agent 21 tools — markets, research, web search, images, video, on-chain data, and live Polymarket trading — paid per call in USDC. No accounts. No API keys. No dashboards.
Read the odds and place the bet, from one self-custody wallet.
-
+
@@ -42,7 +42,7 @@ claude mcp add blockrun -s user -- npx -y @blockrun/mcp@latest
---
-> **BlockRun MCP** is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude — and any MCP-compatible agent — 20 tools for real-time data and real actions: 70 LLMs, image & video generation, prediction-market data, live web/X search, on-chain queries across 40 chains, and **the ability to place real, USDC-settled bets on Polymarket**. Authentication is a wallet signature (no API keys); you pay per call in USDC via the [x402](https://x402.org) protocol (no credit cards, no subscriptions). One self-custody wallet on Base or Solana. MIT licensed.
+> **BlockRun MCP** is an open-source [Model Context Protocol](https://modelcontextprotocol.io) server that gives Claude — and any MCP-compatible agent — 21 tools for real-time data and real actions: 70 LLMs, image & video generation, prediction-market data, live web/X search, on-chain queries across 40 chains, and **the ability to place real, USDC-settled bets on Polymarket**. Authentication is a wallet signature (no API keys); you pay per call in USDC via the [x402](https://x402.org) protocol (no credit cards, no subscriptions). One self-custody wallet on Base or Solana. MIT licensed.
## 🏆 First of its kind — the signal → trade loop in Claude Code
@@ -56,7 +56,7 @@ Every other data integration was built for **human developers** — create an ac
**Agents can't do any of that.** BlockRun MCP is built for the agent-first world:
-- **One wallet, every source** — 20 tools behind a single self-custody wallet. No per-vendor signups.
+- **One wallet, every source** — 21 tools behind a single self-custody wallet. No per-vendor signups.
- **No API keys** — your wallet signature *is* authentication.
- **No credit cards** — pay per request in USDC via [x402](https://x402.org), fractions of a cent each.
- **Starts free** — the free tier (`blockrun_chat mode:"free"`, `blockrun_dex`, crypto `blockrun_price`, `blockrun_models`) costs $0.
@@ -71,7 +71,7 @@ Every other data integration was built for **human developers** — create an ac
| ------------------- | -------------------------------- | ------------------------- | ----------------------------------------- |
| **Setup** | Account + API key *per vendor* | Account/key for 1 vendor | **Wallet auto-created, no signup** |
| **Payment** | Credit card, monthly minimums | Credit card / vendor plan | **USDC per-call via x402** |
-| **Data sources** | One per integration | One vendor | **20 tools — LLMs, media, markets, chain**|
+| **Data sources** | One per integration | One vendor | **21 tools — LLMs, media, markets, chain**|
| **Place real bets** | Build it yourself | Rare | **Yes — Polymarket CLOB, confirm-gated** |
| **Pay-chain** | — | — | **Base + Solana** |
| **Agent budgets** | Manual | — | **Built-in per-agent delegation** |
@@ -147,7 +147,7 @@ Expose a trimmed tool set so the client loads fewer schemas into context. Pass `
| Profile | Tools |
|---------|-------|
-| `full` *(default)* | everything (20 tools) |
+| `full` *(default)* | everything (21 tools) |
| `media` | `wallet` `models` `image` `video` `realface` `music` `speech` |
| `trading` | `wallet` `price` `dex` `markets` `surf` `defi` `rpc` `polymarket_read` `polymarket` |
| `research` | `wallet` `models` `chat` `search` `exa` `surf` |
@@ -215,6 +215,7 @@ Claude reads the odds with `blockrun_markets` and — with your confirmation —
| `blockrun_polymarket` | **Trade on Polymarket** (CLOB V2): place/cancel real bets, positions, redeem winnings — signed locally, settled in pUSD from a gasless deposit wallet. Confirm-gated, $25/order default cap. [Details ↓](#-polymarket-trading) | free tool; bets are your funds |
| `blockrun_surf` | Surf (asksurf.ai) — 83 endpoints: CEX data, on-chain SQL (13 chains, 80+ tables), 100M+ labeled wallets, Polymarket + Kalshi, social mindshare, news, Surf-1.5 chat with citations | $0.0095/call |
| `blockrun_exa` | Neural web search (Exa) — research, competitors, papers, URL content | $0.01/query |
+| `blockrun_scrapecheck` | ScrapeCheck — verify a held web value (price, title, availability) against its live source page; pass/fail/unverifiable verdict carrying an ed25519 signature you can check against ScrapeCheck's published key | $0.012 full / $0.0040 presence |
| `blockrun_search` | Grok Live Search — web + X/Twitter + news with citations | $0.025 × max_results |
| `blockrun_dex` | Live DEX prices via DexScreener | free |
| `blockrun_rpc` | Raw JSON-RPC on 40 chains (Ethereum, Base, Solana, Bitcoin, Sui, NEAR, …) via Tatum | $0.002/call |
@@ -295,7 +296,7 @@ blockrun_wallet action:"setup" # shows the Solana address + fun
Then send USDC (SPL) on the **Solana** network — from Coinbase (pick "Solana"), Phantom, Solflare, or Backpack. Switch back with `blockrun_wallet action:"chain" chain:"base"`. The server keeps both wallets; switching just changes which one pays.
-**Base-only** — these fall back to Base regardless of active chain: `blockrun_music`, `blockrun_speech`, `blockrun_video`, paid `blockrun_realface`, paid stock `blockrun_price`, and native Anthropic (`claude-*`) passthrough. In Solana mode they return a "switch to Base" message instead of charging. `blockrun_image` pays on either chain.
+**Base-only** — these fall back to Base regardless of active chain: `blockrun_music`, `blockrun_speech`, `blockrun_video`, `blockrun_scrapecheck`, paid `blockrun_realface`, paid stock `blockrun_price`, and native Anthropic (`claude-*`) passthrough. In Solana mode they return a "switch to Base" message instead of charging. `blockrun_image` pays on either chain.
---
@@ -391,7 +392,7 @@ The server runs a non-blocking npm registry check at startup and prints an `Upda
## FAQ
**What is BlockRun MCP?**
-An open-source MCP server that gives Claude and other agents 20 tools for real-time data and real actions (trading, media, on-chain), paid per call in USDC. No accounts, no API keys.
+An open-source MCP server that gives Claude and other agents 21 tools for real-time data and real actions (trading, media, on-chain), paid per call in USDC. No accounts, no API keys.
**Do I need API keys or accounts?**
No. A wallet is auto-created locally on first run; you fund it with USDC. No signups, no dashboards, no key rotation.
diff --git a/brand-numbers.json b/brand-numbers.json
index c74dbdd..13cf537 100644
--- a/brand-numbers.json
+++ b/brand-numbers.json
@@ -21,7 +21,7 @@
"aliases": 229
},
"mcp": {
- "tools": 20
+ "tools": 21
},
"chains": {
"rpc": 40
diff --git a/scripts/verify-prices.ts b/scripts/verify-prices.ts
index add76a0..5c34039 100644
--- a/scripts/verify-prices.ts
+++ b/scripts/verify-prices.ts
@@ -27,6 +27,7 @@ import { estimateSurfCost, SURF_PRICE_USD } from "../src/tools/surf.js";
import { estimateSearchCost } from "../src/tools/search.js";
import { estimateCost as estimateImageCost } from "../src/tools/image.js";
import { estimateExaCost } from "../src/tools/exa.js";
+import { estimateScrapecheckCost } from "../src/tools/scrapecheck.js";
import { estimateChatCost, promptCharSize } from "../src/tools/chat.js";
import { estimateVideoCost } from "../src/tools/video.js";
import { MARKETS_PRICE_USD } from "../src/tools/markets.js";
@@ -111,6 +112,12 @@ const PROBES: Probe[] = [
// Routes with no exported estimator: pin the documented figure instead, so a
// gateway reprice still trips this gate rather than only the skill docs.
{ label: "exa/search", path: "exa/search?query=t", expected: withTxFee(0.01) },
+ // ScrapeCheck is a pass-through partner: the BASE price is set by ScrapeCheck,
+ // not by our own catalogue, so it can move without a BlockRun deploy — the
+ // exact drift this sweep exists to catch. Base only (settles to ScrapeCheck's
+ // Base treasury), so `[sol: not served]` on these two rows is correct, not a gap.
+ { label: "scrapecheck/verify", path: "scrapecheck/verify", body: { url: "https://example.com", claim: { price: "$1" }, asked: "what is the price?" }, expected: estimateScrapecheckCost("verify") },
+ { label: "scrapecheck/presence", path: "scrapecheck/verify-presence", body: { url: "https://example.com", claim: { price: "$1" }, asked: "what is the price?" }, expected: estimateScrapecheckCost("presence") },
{ label: "defillama/protocols", path: "defillama/protocols", expected: withTxFee(0.005) },
{ label: "rpc/ethereum (single)", path: "rpc/ethereum", body: { jsonrpc: "2.0", id: 1, method: "eth_blockNumber", params: [] }, expected: withTxFee(0.002) },
diff --git a/src/mcp-handler.ts b/src/mcp-handler.ts
index a9d890d..96a9c2f 100644
--- a/src/mcp-handler.ts
+++ b/src/mcp-handler.ts
@@ -24,6 +24,7 @@ import { registerSurfTool } from "./tools/surf.js";
import { registerRpcTool } from "./tools/rpc.js";
import { registerDefiTool } from "./tools/defi.js";
import { registerPolymarketReadTool, registerPolymarketTool } from "./tools/polymarket.js";
+import { registerScrapecheckTool } from "./tools/scrapecheck.js";
import { resolveTools, type ToolName } from "./profiles.js";
/**
@@ -75,6 +76,7 @@ export function initializeMcpServer(
defi: () => registerDefiTool(server, budget),
polymarket_read: () => registerPolymarketReadTool(server),
polymarket: () => registerPolymarketTool(server),
+ scrapecheck: () => registerScrapecheckTool(server, budget),
};
for (const [name, register] of Object.entries(registrars) as [ToolName, () => void][]) {
diff --git a/src/profiles.ts b/src/profiles.ts
index 34ec67c..94a4f21 100644
--- a/src/profiles.ts
+++ b/src/profiles.ts
@@ -28,7 +28,8 @@ export type ToolName =
| "rpc"
| "defi"
| "polymarket_read"
- | "polymarket";
+ | "polymarket"
+ | "scrapecheck";
// `as const satisfies` keeps the literal tuple type (so the exhaustiveness
// guard below can see the actual entries) AND rejects any entry that isn't a
@@ -36,7 +37,7 @@ export type ToolName =
export const ALL_TOOLS = [
"wallet", "chat", "models", "image", "music", "speech", "video", "realface",
"search", "exa", "markets", "price", "dex", "modal", "phone", "surf", "rpc", "defi",
- "polymarket_read", "polymarket",
+ "polymarket_read", "polymarket", "scrapecheck",
] as const satisfies readonly ToolName[];
// Compile-time guard: if a new ToolName is added to the union but not to
@@ -60,7 +61,7 @@ export const PROFILES: Record = {
trading: ["wallet", "price", "dex", "markets", "surf", "defi", "rpc", "polymarket_read", "polymarket"],
// Web research & analysis: live search, neural search, Surf's news/SQL,
// and chat for synthesis, plus wallet and the model catalogue.
- research: ["wallet", "models", "chat", "search", "exa", "surf"],
+ research: ["wallet", "models", "chat", "search", "exa", "surf", "scrapecheck"],
// Minimal LLM gateway: just chat + model discovery + wallet.
chat: ["wallet", "models", "chat"],
};
diff --git a/src/tools/scrapecheck.ts b/src/tools/scrapecheck.ts
new file mode 100644
index 0000000..520b1d7
--- /dev/null
+++ b/src/tools/scrapecheck.ts
@@ -0,0 +1,137 @@
+// src/tools/scrapecheck.ts
+//
+// ScrapeCheck (scrapecheck.fly.dev) — independent verification of a held web
+// value against its live source page. Typed structured tool (not a skill):
+// two endpoints, a fixed input contract the LLM should respect, one price
+// each. The agent holds a value it got from somewhere else (a search result,
+// a scraper, another tool) and needs to know it is on the source page right
+// now before acting on it.
+//
+// Settlement: per call to ScrapeCheck's Base treasury via the gateway, the
+// same pass-through shape as Surf (see the gateway's x402-partner.ts). Base
+// only — see the getChain() guard below.
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { TOOL_ANNOTATIONS } from "../tool-annotations.js";
+import { z } from "zod";
+import { reserveBudget, recordSpending } from "../utils/budget.js";
+import { withTxFee } from "../utils/tx-fee.js";
+import { asStructuredContent } from "../utils/body.js";
+import { getClient, getChain } from "../utils/wallet.js";
+import { isBlockedFetchHostResolved } from "../utils/ssrf.js";
+import { formatError, extractErrorMessage } from "../utils/errors.js";
+import type { BudgetState } from "../types.js";
+
+type RawClient = {
+ requestWithPaymentRaw: (endpoint: string, body: unknown) => Promise;
+};
+
+// Base prices published in ScrapeCheck's OpenAPI and charged by its 402
+// challenge (verified against the live challenge header, x402 v2):
+// /verify $0.01, /verify-presence $0.002. The gateway adds its flat tx fee.
+//
+// PROVISIONAL until the gateway routes exist: what the caller is actually
+// charged is whatever /api/v1/scrapecheck/* quotes in its `payment-required`
+// header, which is the gateway's published price, not ScrapeCheck's base.
+// `npm run verify:prices` probes both routes — if this estimator ever reserves
+// LESS than the live quote, the budget gate under-reserves on every call and
+// the ledger under-counts permanently. Fix this constant, not the script.
+export function estimateScrapecheckCost(tier: string): number {
+ return withTxFee(tier === "presence" ? 0.002 : 0.01);
+}
+
+export function registerScrapecheckTool(server: McpServer, budget: BudgetState): void {
+ server.registerTool(
+ "blockrun_scrapecheck",
+ {
+ description: `Verify a web value you already hold against its live source page (ScrapeCheck). Not retrieval: it checks a value, it does not find data.
+
+Call when a price, title, availability, or any page value came from a search result, a scraper, or another tool, and you are about to act on it. ScrapeCheck independently re-fetches the page and returns a pass/fail/unverifiable verdict — a claim is never certified unless the re-fetched page contains it, and anything unconfirmed is unverifiable, never pass. Verdicts carry an ed25519 signature you can check against ScrapeCheck's published key; this tool returns the verdict as-is and does NOT verify that signature for you.
+
+Tiers:
+- verify — full check: is the value on the page AND does it answer what was asked ($${withTxFee(0.01).toFixed(3)}/check charged)
+- presence — cheap screen: does the value appear on the page at all; never returns pass ($${withTxFee(0.002).toFixed(4)}/check charged)
+
+Scope: server-rendered pages; JS-only content returns unverifiable rather than a guess. Settles on Base only. Public http(s) URLs only.`,
+ annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld,
+ inputSchema: {
+ url: z.string().url().describe("Source page the value came from (public http/https)"),
+ claim: z
+ .record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
+ .describe('The value(s) to check, as {field: value}, e.g. {"price": "$45"}'),
+ asked: z.string().describe("The question the value is supposed to answer, plain text"),
+ tier: z
+ .enum(["verify", "presence"])
+ .optional()
+ .describe('"verify" (default, full check) or "presence" (cheap screen, never returns pass)'),
+ agent_id: z.string().optional().describe("Agent identifier for budget tracking and enforcement."),
+ },
+ },
+ async ({ url, claim, asked, tier, agent_id }) => {
+ try {
+ // Settlement goes to ScrapeCheck's Base treasury; sol.blockrun.ai does
+ // not carry these routes. Fail closed with the same actionable message
+ // the other Base-only paid tools use (price.ts, realface.ts) rather
+ // than letting the call 404 against the wrong gateway.
+ if (getChain() !== "base") {
+ return {
+ content: [{ type: "text", text: formatError("blockrun_scrapecheck settles on Base only. Switch BlockRun to Base (run blockrun_wallet with action:chain chain:base) and fund the Base wallet with USDC.") }],
+ isError: true,
+ };
+ }
+
+ // SSRF guard on the caller-supplied URL, mirroring blockrun_video and
+ // blockrun_image. This process never fetches the URL — the GATEWAY and
+ // then ScrapeCheck do — so this is defense-in-depth plus a saved round
+ // trip: a URL pointing at localhost / the metadata endpoint / the
+ // private network would otherwise be forwarded, quoted, and PAID for
+ // before failing (or worse, succeeding) server-side. Resolved, not
+ // literal: wildcard-DNS names like 127.0.0.1.nip.io are public strings
+ // that map to private addresses. Runs BEFORE reserveBudget so a
+ // rejected URL never touches the ledger.
+ let parsed: URL;
+ try {
+ parsed = new URL(url);
+ } catch {
+ return { content: [{ type: "text", text: formatError(`url is not a valid URL: ${url}`) }], isError: true };
+ }
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return {
+ content: [{ type: "text", text: formatError(`url must be an http(s) URL — got scheme "${parsed.protocol}"`) }],
+ isError: true,
+ };
+ }
+ if (await isBlockedFetchHostResolved(parsed.hostname)) {
+ return {
+ content: [{ type: "text", text: formatError(`url resolves to a private/loopback/link-local address (${parsed.hostname}) — refusing to forward it to the gateway.`) }],
+ isError: true,
+ };
+ }
+
+ const chosenTier = tier === "presence" ? "presence" : "verify";
+ const estimatedCost = estimateScrapecheckCost(chosenTier);
+ const gate = reserveBudget(budget, agent_id, estimatedCost);
+ if (!gate.allowed) {
+ return {
+ content: [{ type: "text", text: `${gate.reason}. Use blockrun_wallet action:"report" to see usage or action:"delegate" to increase agent budget.` }],
+ isError: true,
+ };
+ }
+ try {
+ const client = getClient() as unknown as RawClient;
+ const endpoint = chosenTier === "presence" ? "/v1/scrapecheck/verify-presence" : "/v1/scrapecheck/verify";
+ const result = await client.requestWithPaymentRaw(endpoint, { url, claim, asked });
+ recordSpending(budget, estimatedCost, agent_id);
+ return {
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
+ structuredContent: asStructuredContent(result),
+ };
+ } finally {
+ gate.release();
+ }
+ } catch (err) {
+ return { content: [{ type: "text", text: formatError(extractErrorMessage(err)) }], isError: true };
+ }
+ }
+ );
+}
diff --git a/test/profiles.test.ts b/test/profiles.test.ts
index 9a06de8..6409d41 100644
--- a/test/profiles.test.ts
+++ b/test/profiles.test.ts
@@ -4,16 +4,16 @@ import assert from "node:assert/strict";
import { ALL_TOOLS, PROFILES, resolveProfileName, resolveTools } from "../src/profiles.js";
const EXPECTED_COUNTS: Record = {
- full: 20,
+ full: 21,
media: 7,
trading: 9,
- research: 6,
+ research: 7,
chat: 3,
};
-test("ALL_TOOLS has the full 20-tool set", () => {
- assert.equal(ALL_TOOLS.length, 20);
- assert.equal(new Set(ALL_TOOLS).size, 20, "no duplicates");
+test("ALL_TOOLS has the full 21-tool set", () => {
+ assert.equal(ALL_TOOLS.length, 21);
+ assert.equal(new Set(ALL_TOOLS).size, 21, "no duplicates");
});
test("resolveProfileName precedence: --profile flag > env > default", () => {
@@ -46,16 +46,16 @@ test("every profile includes wallet (needed to pay)", () => {
}
});
-test("unknown profile name falls back to full (20 tools)", () => {
+test("unknown profile name falls back to full (21 tools)", () => {
const { profile, tools } = resolveTools(["--profile", "nonsense"], {});
assert.equal(profile, "full");
- assert.equal(tools.size, 20);
+ assert.equal(tools.size, 21);
});
test("no args → full", () => {
const { profile, tools } = resolveTools([], {});
assert.equal(profile, "full");
- assert.equal(tools.size, 20);
+ assert.equal(tools.size, 21);
});
test("Object.prototype key names fall back to full instead of crashing", () => {
@@ -64,7 +64,7 @@ test("Object.prototype key names fall back to full instead of crashing", () => {
for (const name of ["constructor", "__proto__", "toString", "hasOwnProperty"]) {
const { profile, tools } = resolveTools(["--profile", name], {});
assert.equal(profile, "full", `${name} should fall back to full`);
- assert.equal(tools.size, 20, `${name} should expose all 20 tools`);
+ assert.equal(tools.size, 21, `${name} should expose all 21 tools`);
}
});
diff --git a/test/scrapecheck.test.ts b/test/scrapecheck.test.ts
new file mode 100644
index 0000000..46aae98
--- /dev/null
+++ b/test/scrapecheck.test.ts
@@ -0,0 +1,150 @@
+// Run with: npm test (tsx --experimental-test-module-mocks --test)
+//
+// blockrun_scrapecheck is a pass-through partner route: the caller hands us a
+// URL and we pay a THIRD PARTY to fetch it. That makes two guards load-bearing,
+// and both have to fire before a single micro-dollar is reserved:
+//
+// 1. SSRF. This process never fetches the URL — the gateway does, then
+// ScrapeCheck does. Without the guard a URL pointing at localhost / the
+// metadata endpoint / the private network is forwarded, quoted and PAID
+// for before it fails (or worse, succeeds) server-side. Same hole
+// blockrun_video closed; same fix.
+// 2. Chain. Settlement goes to ScrapeCheck's BASE treasury and sol.blockrun.ai
+// does not carry the routes, so a Solana session must fail closed with an
+// actionable message rather than 404 against the wrong gateway.
+//
+// Plus the cost table, because the base price here is set by ScrapeCheck rather
+// than by our own catalogue and can move without a BlockRun deploy.
+import { test, mock } from "node:test";
+import assert from "node:assert/strict";
+import type { BudgetState } from "../src/types.js";
+
+let activeChain: "base" | "solana" = "base";
+let rawCalls = 0;
+let lastEndpoint = "";
+let lastBody: unknown = null;
+
+mock.module("../src/utils/wallet.js", {
+ namedExports: {
+ getChain: () => activeChain,
+ getClient: () => ({
+ requestWithPaymentRaw: async (endpoint: string, body: unknown) => {
+ rawCalls++;
+ lastEndpoint = endpoint;
+ lastBody = body;
+ return { verdict: "pass", signature: "ed25519:deadbeef" };
+ },
+ }),
+ },
+});
+// Hostname-keyed, no DNS: the real resolver is covered by ssrf.test.ts; here we
+// only need "this hostname is private" to be decidable offline.
+mock.module("../src/utils/ssrf.js", {
+ namedExports: {
+ isBlockedFetchHostResolved: async (hostname: string) =>
+ hostname === "169.254.169.254" || hostname === "127.0.0.1.nip.io" || hostname === "localhost",
+ isBlockedFetchHost: () => false,
+ },
+});
+
+const { registerScrapecheckTool, estimateScrapecheckCost } = await import("../src/tools/scrapecheck.js");
+
+function makeHarness(limit: number | null = null) {
+ let handler: ((args: Record) => Promise) | undefined;
+ const server = {
+ registerTool: (_n: string, _c: unknown, h: any) => { handler = h; },
+ server: { getClientCapabilities: () => ({}) },
+ } as any;
+ const budget: BudgetState = { limit, spent: 0, calls: 0, agents: new Map() };
+ registerScrapecheckTool(server, budget);
+ return { call: (args: Record) => handler!(args), budget };
+}
+
+const OK = { claim: { price: "$45" }, asked: "what is the price?" };
+const text = (res: any) => res.content.map((c: any) => c.text).join("\n");
+
+test("reserves the CHARGED price, not ScrapeCheck's base", () => {
+ // base + the gateway's flat tx fee (src/utils/tx-fee.ts). Reserving the base
+ // would leave the gate short on every call.
+ assert.equal(estimateScrapecheckCost("verify"), 0.012);
+ assert.equal(estimateScrapecheckCost("presence"), 0.004);
+});
+
+test("an unknown tier reserves the EXPENSIVE tier, never the cheap one", () => {
+ // Fail closed: the handler normalises to "verify", so the estimator must
+ // agree. Reserving $0.004 for a $0.012 call is how a cap becomes a lie.
+ for (const t of ["", "VERIFY", "full", "nonsense", "Presence"]) {
+ assert.equal(estimateScrapecheckCost(t), 0.012, `tier "${t}" must not reserve the presence price`);
+ }
+});
+
+test("SSRF: private, loopback, metadata and non-http(s) URLs are refused before ANY call or reservation", async () => {
+ for (const url of [
+ "file:///etc/passwd",
+ "ftp://example.com/x",
+ "http://169.254.169.254/latest/meta-data/",
+ "https://127.0.0.1.nip.io/page",
+ "http://localhost:8080/admin",
+ ]) {
+ rawCalls = 0;
+ const { call, budget } = makeHarness();
+ const res = await call({ url, ...OK });
+ assert.equal(res.isError, true, text(res));
+ assert.match(text(res), /http\(s\) URL|private\/loopback\/link-local|not a valid URL/);
+ assert.equal(rawCalls, 0, `paid call was made for ${url}`);
+ assert.equal(budget.spent, 0, `budget was touched for ${url}`);
+ }
+});
+
+test("a public https page still goes through and books the spend once", async () => {
+ rawCalls = 0;
+ const { call, budget } = makeHarness();
+ const res = await call({ url: "https://shop.example.com/item/1", ...OK });
+ assert.notEqual(res.isError, true, text(res));
+ assert.equal(rawCalls, 1);
+ assert.equal(lastEndpoint, "/v1/scrapecheck/verify");
+ assert.deepEqual(lastBody, { url: "https://shop.example.com/item/1", ...OK });
+ assert.equal(budget.spent, 0.012);
+});
+
+test("tier:presence hits the cheap route and books the cheap price", async () => {
+ rawCalls = 0;
+ const { call, budget } = makeHarness();
+ const res = await call({ url: "https://shop.example.com/item/1", ...OK, tier: "presence" });
+ assert.notEqual(res.isError, true, text(res));
+ assert.equal(lastEndpoint, "/v1/scrapecheck/verify-presence");
+ assert.equal(budget.spent, 0.004);
+});
+
+test("Solana sessions fail closed with the switch-chain message — no call, no spend", async () => {
+ activeChain = "solana";
+ rawCalls = 0;
+ const { call, budget } = makeHarness();
+ const res = await call({ url: "https://shop.example.com/item/1", ...OK });
+ activeChain = "base";
+ assert.equal(res.isError, true, text(res));
+ assert.match(text(res), /Base only/);
+ assert.match(text(res), /action:chain chain:base/);
+ assert.equal(rawCalls, 0, "a Solana session must not reach the Base-only route");
+ assert.equal(budget.spent, 0);
+});
+
+test("the chain guard runs before the SSRF guard's DNS work and before the ledger", async () => {
+ // Ordering matters for the error the user sees: on Solana with a bad URL the
+ // actionable answer is "switch chains", not "that host is private".
+ activeChain = "solana";
+ const { call } = makeHarness();
+ const res = await call({ url: "http://169.254.169.254/", ...OK });
+ activeChain = "base";
+ assert.match(text(res), /Base only/);
+});
+
+test("the budget gate rejects before paying, and a rejected call books nothing", async () => {
+ rawCalls = 0;
+ const { call, budget } = makeHarness(0.005); // under the $0.012 verify reserve
+ const res = await call({ url: "https://shop.example.com/item/1", ...OK });
+ assert.equal(res.isError, true, text(res));
+ assert.match(text(res), /blockrun_wallet/);
+ assert.equal(rawCalls, 0, "the gate must stop the call, not just record it");
+ assert.equal(budget.spent, 0);
+});