diff --git a/CHANGELOG.md b/CHANGELOG.md index 526ce1a..a11547b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,47 @@ All notable changes to BlockRun MCP will be documented in this file. +## 0.33.1 + +Live-probes the three pre-payment rules 0.33.0 shipped on inference rather than +evidence. Two were wrong, and both cost users real money. + +- **`fix(markets)` — `markets/listings` is blocked again; it is retired + upstream.** Verified against the live gateway: the route settles a payment and + THEN returns **410 Gone**. 0.33.0 unblocked it because blockrun's own registry + still lists, prices, and advertises it — but the gateway only proxies, and + Predexon has retired it. Registry presence is not evidence that a route serves. + #81 had this right and the 0.33.0 change was the regression. +- **`fix(markets)` — `window` is no longer treated as a smart-money cohort + filter.** Verified: no params 400s, `{ window: "7d" }` alone **also** 400s, and + `{ min_trades: "100" }` alone succeeds (window then defaults to `all_time`). + `window` scopes the time range; it does not define the cohort. 0.33.0 counted + it as a filter and passed a guaranteed paid 400 straight through. The check now + requires a smart-wallet criterion and says why when only `window` is present. +- **`fix(markets)` — the candlestick interval whitelist is gone.** Verified on + one market: omitting `interval` succeeds, `1440` succeeds, `1h` 422s, and + `60` returns a paid **400**. Which integer intervals a market can serve is + data-dependent, so a client-side numeric whitelist both blocks valid calls and + still lets paid failures through. Only the shape is checkable client-side: + non-numeric is rejected, integers pass. + +- **`perf` — the process-global paid-call queue is removed.** 0.33.0 serialized + every paid data call on the reasoning that "concurrent authorizations from one + wallet can race at the settlement layer". Measured, that protects nothing: + Base mints a fresh random 32-byte nonce per payment (`x402.ts:235`), so two + concurrent authorizations can never collide; the real Solana collision is + handled inside `@blockrun/llm` by making each payment distinct — the + dependency floor is raised to `^3.8.4` here, because `^3.6.1` admitted seven + published versions with no distinctness at all and the lockfile pinned the + oldest of them. Its check-and-add is synchronous on the common path (the + exhausted-nonce branch does await, which is unreachable below 65 identical + payments per blockhash), so it is concurrency-safe without a caller queue. The tools that *did* have a concurrency bug (`chat`'s settled-cost + delta) were never in the queue — they use a fresh non-cached client instead. + Cost of keeping it, measured on 4 concurrent `markets/search` calls: **+4025ms + (2.68x)**, with the unserialized arm returning 4/4 clean. Tracked as #89. + +Tool description and the `prediction-markets` skill corrected to match. + ## 0.33.0 A 20th tool, MCP safety annotations that describe effect instead of price, and diff --git a/docs/stanford-trading-demo.md b/docs/stanford-trading-demo.md index 0cdff65..afd65d1 100644 --- a/docs/stanford-trading-demo.md +++ b/docs/stanford-trading-demo.md @@ -160,7 +160,7 @@ estimated fill and cannot sign or submit an order. - $5 per-order and $5 per-session demo caps; - $0.15 API budget; - pre-payment parameter validation; -- serialized x402 calls from one wallet; +- per-call payment authorization signed locally; - explicit confirmation gate plus regional eligibility enforcement. ## Tested fallbacks diff --git a/package-lock.json b/package-lock.json index e12e30e..2d34205 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@blockrun/mcp", - "version": "0.32.9", + "version": "0.33.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@blockrun/mcp", - "version": "0.32.9", + "version": "0.33.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", - "@blockrun/llm": "^3.6.1", + "@blockrun/llm": "^3.8.4", "@modelcontextprotocol/sdk": "^1.0.0", "@polymarket/builder-relayer-client": "^0.0.10", "@polymarket/builder-signing-sdk": "0.0.8", @@ -89,9 +89,9 @@ } }, "node_modules/@blockrun/llm": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@blockrun/llm/-/llm-3.6.1.tgz", - "integrity": "sha512-x9zSLlYe2PjXrTcxnEqQA8E//5GOm7j4P6AmwFIrsWY7Hs/cQakXoUVQxrVQUAyHIqeJhXLhACcr0rjJ8Ykb2Q==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@blockrun/llm/-/llm-3.9.0.tgz", + "integrity": "sha512-d72COH2mIn4pKuUTv/JOcF1Ut01vcpDwGsMYst0NhGnES132goJ+KtrzrVnwzyECZx3287tw6xWWcJNFQLYeww==", "license": "MIT", "dependencies": { "bs58": "^6.0.0", diff --git a/package.json b/package.json index 6d689cb..86d2afb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@blockrun/mcp", - "version": "0.33.0", + "version": "0.33.1", "mcpName": "io.github.BlockRunAI/blockrun-mcp", "description": "BlockRun MCP Server - Give your AI agent web search, deep research, prediction markets, and crypto data. Paid via x402 micropayments.", "type": "module", @@ -53,7 +53,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", - "@blockrun/llm": "^3.6.1", + "@blockrun/llm": "^3.8.4", "@modelcontextprotocol/sdk": "^1.0.0", "@polymarket/builder-relayer-client": "^0.0.10", "@polymarket/builder-signing-sdk": "0.0.8", diff --git a/skills/prediction-markets/SKILL.md b/skills/prediction-markets/SKILL.md index 4e7c764..e4faa24 100644 --- a/skills/prediction-markets/SKILL.md +++ b/skills/prediction-markets/SKILL.md @@ -64,7 +64,7 @@ One tool, three params. Method auto-routes: POST when `body` is set, GET otherwi ```ts blockrun_markets({ path: "polymarket/events", params: { limit: "10" } }) -blockrun_markets({ path: "polymarket/candlesticks/0xCONDITION_ID", params: { interval: "60" } }) +blockrun_markets({ path: "polymarket/candlesticks/0xCONDITION_ID", params: { interval: "1440" } }) blockrun_markets({ path: "polymarket/wallet/identities", body: { addresses: ["0xabc...", "0xdef..."] @@ -73,8 +73,10 @@ blockrun_markets({ path: "polymarket/wallet/identities", body: { Paths are relative — no `/api/v1/pm/` prefix. Use `agent_id` to bill a child agent's budget. -Make paid calls sequentially when one wallet is paying. The MCP serializes them -as a second guard against concurrent x402 payment races. +Paid calls can run in parallel **on Base**: each EIP-3009 authorization carries +its own random nonce, so concurrent calls from one wallet cannot collide. +The Solana payload has no nonce field — distinctness comes from the SDK +(`@blockrun/llm` >= 3.8.4), so keep that floor if you fan out on Solana. Current parameter contracts that prevent paid 4xx responses: @@ -84,14 +86,18 @@ Current parameter contracts that prevent paid 4xx responses: `q`. Use `status:"open"` rather than Gamma's `active`/`closed`, and `sort` rather than `order`/`ascending`. `end_after`/`end_before` are supported (Unix seconds). -- Candlestick `interval` is integer minutes: `0`, `1`, `5`, `15`, `60`, or - `1440`. Optional `start_time`/`end_time` are Unix seconds. +- Candlestick `interval` is integer minutes (`1440`, not `1h`) and is + **optional** — the server has a default. Which intervals a market can serve + varies: `1440` may work where `60` returns a paid 400. Optional + `start_time`/`end_time` are Unix seconds. - `polymarket/orderbooks` requires `token_id`, `start_time`, and `end_time`; the times are Unix milliseconds. -- Smart-money calls need at least one cohort filter — `window`, `min_trades`, - `min_volume`, `min_roi`, `min_realized_pnl`, `min_total_pnl`, `min_win_rate`, - or `min_profit_factor`. For general analysis use - `{ window: "30d", min_trades: "100" }`; narrower cohorts are fine too. +- Smart-money needs a smart-wallet **criterion**: `min_trades`, `min_volume`, + `min_roi`, `min_realized_pnl`, `min_total_pnl`, `min_win_rate`, or + `min_profit_factor`. `window` only scopes the time range and is **not** + sufficient alone (verified: window-only returns a paid 400). Use + `{ window: "30d", min_trades: "100" }`; narrower cohorts are fine. +- `markets/listings` is retired upstream (410 Gone) — the MCP blocks it before payment. ## Two Pricing Tiers @@ -107,7 +113,6 @@ Pass-through pricing, 0% BlockRun margin — settles straight to Predexon's Base |---|---|---| | **Same question across venues** | `markets` | 1 | | **Search every venue at once** | `markets/search` | 2 | -| Venue-native tradable listings | `markets/listings` | 1 | | Resolve a canonical outcome ID | `outcomes/{predexon_id}` | 1 | | **Equivalent markets (arbitrage)** | `matching-markets` | 2 | | Active matched pairs | `matching-markets/pairs` | 2 | @@ -177,7 +182,7 @@ blockrun_markets({ path: "outcomes/PXM-12345" }) // → venue listings + price ```ts blockrun_markets({ path: "polymarket/candlesticks/0xCONDITION_ID", params: { - interval: "60", start_time: "", end_time: "" + interval: "1440", start_time: "", end_time: "" } }) blockrun_markets({ path: "polymarket/volume-chart/0xCONDITION_ID" }) blockrun_markets({ path: "polymarket/markets/0xCONDITION_ID/open_interest" }) @@ -284,7 +289,7 @@ Inside the MCP, use `blockrun_markets` above. For standalone scripts: from blockrun_llm import setup_agent_wallet # setup_agent_solana_wallet() on Solana client = setup_agent_wallet() -client.pm("polymarket/candlesticks/0xCONDITION_ID", interval="60") +client.pm("polymarket/candlesticks/0xCONDITION_ID", interval="1440") client.pm_query("polymarket/wallet/identities", {"addresses": ["0xabc"]}) ``` diff --git a/skills/signal-to-trade-demo/SKILL.md b/skills/signal-to-trade-demo/SKILL.md index 97e6753..3a312aa 100644 --- a/skills/signal-to-trade-demo/SKILL.md +++ b/skills/signal-to-trade-demo/SKILL.md @@ -28,8 +28,8 @@ or preparing a fallback. 4. Choose the smallest whole-dollar preview from $1–$5 that satisfies the live `min_order_size` and book depth. Never present a smaller, non-executable preview as valid. Do not split orders to bypass caps. -5. Make paid market-data calls sequentially. Do not launch them in parallel - against one payment wallet. +5. Paid market-data calls may run in parallel on Base. On Solana keep + `@blockrun/llm` >= 3.8.4, which is what makes concurrent payments distinct. ## 1. Private operator preflight @@ -80,12 +80,13 @@ Use four independent lenses where the market supports them: ```text blockrun_markets { path: "polymarket/candlesticks/token/", - params: { interval: "60", start_time: "", end_time: "" } + params: { interval: "1440", start_time: "", end_time: "" } } ``` - `interval` is integer minutes (`60`, not `1h`); `start_time` and `end_time` - are Unix seconds. + `interval` is integer minutes (`1440`, not `1h`) and is optional. `60` was + observed returning a paid 400 where `1440` worked; `start_time` and + `end_time` are Unix seconds. 3. **Smart money:** use a meaningful cohort: ```text diff --git a/src/tools/defi.ts b/src/tools/defi.ts index 7f6e2d0..d92b8af 100644 --- a/src/tools/defi.ts +++ b/src/tools/defi.ts @@ -7,7 +7,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; import { withTxFee } from "../utils/tx-fee.js"; @@ -69,7 +68,7 @@ Use blockrun_price (free) for plain spot quotes, blockrun_dex (free) for DEX pai } try { const client = getClient() as unknown as RawClient; - const result = await serializePaidRequest(() => client.getWithPaymentRaw(`/v1/defillama/${cleanPath}`)); + const result = await client.getWithPaymentRaw(`/v1/defillama/${cleanPath}`); recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/exa.ts b/src/tools/exa.ts index dfa2ce6..5ecda9b 100644 --- a/src/tools/exa.ts +++ b/src/tools/exa.ts @@ -6,7 +6,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; import { withTxFee } from "../utils/tx-fee.js"; @@ -80,7 +79,7 @@ Full request/response shapes + worked research workflows in the \`exa-research\` try { const client = getClient() as unknown as RawClient; const endpoint = `/v1/exa/${cleanPath}`; - const result = await serializePaidRequest(() => client.requestWithPaymentRaw(endpoint, body ?? {})); + const result = await client.requestWithPaymentRaw(endpoint, body ?? {}); recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/markets.ts b/src/tools/markets.ts index a9f23da..799e9ca 100644 --- a/src/tools/markets.ts +++ b/src/tools/markets.ts @@ -8,7 +8,6 @@ import { extractErrorMessage, formatError } from "../utils/errors.js"; import { hasPathTraversal } from "../utils/path-safety.js"; import type { BudgetState } from "../types.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; import { validateMarketRequest } from "../utils/markets-validation.js"; // What x402 CHARGES, which is not the 402's JSON `price` field. That field is the @@ -36,7 +35,6 @@ export function registerMarketsTool(server: McpServer, budget: BudgetState): voi CANONICAL CROSS-VENUE (Tier 1) — Predexon v2 unified data layer: - markets — list canonical market/question containers with cross-venue Predexon IDs -- markets/listings — venue-native executable listings flattened across canonical markets - outcomes/:predexon_id — resolve a canonical outcome ID to its market context + venue listings Filter with ?venue=polymarket|kalshi|limitless|opinion|predictfun, ?status=, ?category=, ?league=, ?event_id=, ?pagination_key= @@ -85,10 +83,9 @@ CROSS-PLATFORM: REQUEST CONTRACTS: - Discover current markets with markets/search (its search term is "q"), then resolve the chosen Polymarket market with polymarket/markets/keyset and condition_id. - On polymarket/markets{,/keyset} the free-text filter is "search" (NOT "q"), and status:"open"/"closed" replaces Gamma's active/closed. "sort", "end_after", and "end_before" are supported; "order"/"ascending" are not. -- Candlesticks interval is integer minutes: 0|1|5|15|60|1440 (use "60", not "1h"); start_time/end_time are Unix seconds. +- Candlesticks interval is integer minutes ("1440", not "1h"); it is OPTIONAL (the server defaults). Which intervals a market serves varies — 1440 may work where 60 does not. start_time/end_time are Unix seconds. - polymarket/orderbooks requires token_id plus start_time/end_time in Unix milliseconds. -- Smart-money calls need at least one cohort filter (window, min_trades, min_volume, min_roi, min_*_pnl, min_win_rate, min_profit_factor); a good default is { window: "30d", min_trades: "100" }. -- Issue paid calls sequentially. The MCP also serializes them to protect one wallet from concurrent x402 payment races. +- Smart-money needs a smart-wallet CRITERION (min_trades, min_volume, min_roi, min_*_pnl, min_win_rate, min_profit_factor). "window" only scopes time and is NOT sufficient on its own. Default: { window: "30d", min_trades: "100" }. Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. polymarket/wallet/identities).`, annotations: TOOL_ANNOTATIONS.readOnlyOpenWorld, @@ -121,9 +118,9 @@ Pass query params via 'params' (GET). Use 'body' only for POST endpoints (e.g. p } try { const llm = getClient(); - const result = await serializePaidRequest(() => body !== undefined - ? llm.pmQuery(path, body) - : llm.pm(path, params)); + const result = body !== undefined + ? await llm.pmQuery(path, body) + : await llm.pm(path, params); recordSpending(budget, estimatedCost, agent_id); return { diff --git a/src/tools/price.ts b/src/tools/price.ts index 7b75275..df04cf3 100644 --- a/src/tools/price.ts +++ b/src/tools/price.ts @@ -20,7 +20,6 @@ import type { BudgetState } from "../types.js"; import { getChain, getPriceClient } from "../utils/wallet.js"; import { extractErrorMessage, formatError } from "../utils/errors.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; const CATEGORY = z.enum(["crypto", "fx", "commodity", "usstock", "stocks"]); const MARKET = z.enum([ @@ -105,7 +104,7 @@ Examples: market: market as StockMarket | undefined, session: session as MarketSession | undefined, }); - const result = paid ? await serializePaidRequest(task) : await task(); + const result = await task(); if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -123,7 +122,7 @@ Examples: from, to, }); - const result = paid ? await serializePaidRequest(task) : await task(); + const result = await task(); if (estimatedCost > 0) recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/rpc.ts b/src/tools/rpc.ts index 1feef61..ce0552a 100644 --- a/src/tools/rpc.ts +++ b/src/tools/rpc.ts @@ -9,7 +9,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; import { withTxFee } from "../utils/tx-fee.js"; @@ -92,7 +91,7 @@ Prefer blockrun_price (free quotes), blockrun_dex (free DEX data), or blockrun_s } try { const client = getClient() as unknown as RawClient; - const result = await serializePaidRequest(() => client.requestWithPaymentRaw(`/v1/rpc/${cleanNetwork}`, body)); + const result = await client.requestWithPaymentRaw(`/v1/rpc/${cleanNetwork}`, body); recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/search.ts b/src/tools/search.ts index 88571fb..a8c6aa7 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -7,7 +7,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; @@ -102,7 +101,7 @@ Full request shape + worked examples in the \`search\` skill (\`skills/search/SK try { const client = getClient() as unknown as RawClient; const endpoint = cleanPath ? `/v1/search/${cleanPath}` : "/v1/search"; - const result = await serializePaidRequest(() => client.requestWithPaymentRaw(endpoint, body ?? {})); + const result = await client.requestWithPaymentRaw(endpoint, body ?? {}); recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/tools/surf.ts b/src/tools/surf.ts index 6956159..e31a933 100644 --- a/src/tools/surf.ts +++ b/src/tools/surf.ts @@ -12,7 +12,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { TOOL_ANNOTATIONS } from "../tool-annotations.js"; -import { serializePaidRequest } from "../utils/payment-serialization.js"; import { z } from "zod"; import { reserveBudget, recordSpending } from "../utils/budget.js"; import { asStructuredContent, coerceBody } from "../utils/body.js"; @@ -101,9 +100,9 @@ Each Surf endpoint pre-validates required params before settling — you get a 4 try { const client = getClient() as unknown as SurfClient; const endpoint = `/v1/surf/${cleanPath}`; - const result = await serializePaidRequest(() => body !== undefined - ? client.requestWithPaymentRaw(endpoint, body) - : client.getWithPaymentRaw(endpoint, params)); + const result = body !== undefined + ? await client.requestWithPaymentRaw(endpoint, body) + : await client.getWithPaymentRaw(endpoint, params); recordSpending(budget, estimatedCost, agent_id); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], diff --git a/src/utils/markets-validation.ts b/src/utils/markets-validation.ts index 0a1a17a..667e4c3 100644 --- a/src/utils/markets-validation.ts +++ b/src/utils/markets-validation.ts @@ -1,11 +1,12 @@ -const CANDLE_INTERVALS = new Set(["0", "1", "5", "15", "60", "1440"]); -// Gamma-only filter names, i.e. params the Polymarket Gamma API accepts but -// Predexon v2 does not. Kept to exactly that set: `search`, `sort`, `end_after`, -// and `end_before` ARE spec-backed Predexon filters on polymarket/markets{,/keyset} -// (see blockrun/src/lib/predexon.ts POLYMARKET_MARKET_PARAMS), so rejecting them -// here would block valid queries before payment. -const SMART_MONEY_FILTERS = [ - "window", +import { normalizeClassifyPath } from "./path-safety.js"; + +/** + * Smart-wallet CRITERIA — the params that define which wallets count as "smart". + * `window` is deliberately absent: it scopes the time range, not the cohort, and + * live probing shows `{ window: "7d" }` alone still 400s while + * `{ min_trades: "100" }` alone succeeds (window then defaults to all_time). + */ +const SMART_MONEY_CRITERIA = [ "min_trades", "min_volume", "min_roi", @@ -14,6 +15,12 @@ const SMART_MONEY_FILTERS = [ "min_win_rate", "min_profit_factor", ] as const; +// Gamma-only filter names, i.e. params the Polymarket Gamma API accepts but +// Predexon v2 does not. Kept to exactly that set: `search`, `sort`, `end_after`, +// and `end_before` ARE spec-backed Predexon filters on polymarket/markets{,/keyset} +// (see blockrun/src/lib/predexon.ts POLYMARKET_MARKET_PARAMS), so rejecting them +// here would block valid queries before payment. + const GAMMA_ONLY_MARKET_PARAMS = new Set([ "active", "closed", @@ -21,6 +28,28 @@ const GAMMA_ONLY_MARKET_PARAMS = new Set([ "ascending", ]); +/** + * Reduce a caller-supplied path to the slug the gateway will actually route to, + * so a rule cannot be stepped around by decorating the path. + * + * Every rule below matched a bare, exactly-cased slug, which meant + * `markets/listings?venue=polymarket`, `Markets/Listings`, `markets//listings`, + * and a trailing tab all sailed past and settled a payment for the very failure + * the rule exists to prevent. `normalizeClassifyPath` already drops the query + * string / fragment, strips outer slashes, and lowercases — the same hazard its + * own doc comment describes for the price tables. Two more are needed here: + * control characters (the URL parser deletes tab/CR/LF, so `..` reaches + * the gateway as `..`, which is why `hasPathTraversal` strips them too) and + * interior slash runs, which the router collapses and the helper does not. + * + * NOTE: the price-classification path shares the un-collapsed helper, so a + * doubled interior slash can still mis-classify an expensive route as cheap + * there. Out of scope for this fix; worth its own change. + */ +function normalizeMarketPath(rawPath: string): string { + return normalizeClassifyPath(rawPath.replace(/[\t\n\r]/g, "")).replace(/\/{2,}/g, "/"); +} + function numberParam(params: Record, key: string): number | undefined { if (!(key in params)) return undefined; const value = Number(params[key]); @@ -38,9 +67,18 @@ export function validateMarketRequest( params: Record | undefined, body: unknown, ): string | null { - const path = rawPath.replace(/^\/+|\/+$/g, ""); + const path = normalizeMarketPath(rawPath); const query = params ?? {}; + // Verified live 2026-07-29: this route settles a payment and THEN returns + // 410 Gone. The gateway still registers, prices, and advertises it + // (blockrun/src/lib/predexon.ts), which is what talked me out of this block in + // 0.33.0 — but the gateway only proxies, and Predexon has retired it upstream. + // The registry is not evidence that a route still serves. + if (path === "markets/listings") { + return "Predexon has retired 'markets/listings' — it returns 410 Gone after settling payment. Use 'markets/search' to discover open venue markets, then resolve the selected Polymarket market with 'polymarket/markets/keyset'. No payment was made."; + } + if (path === "markets/search" && query.status === "active") { return "markets/search uses params.status:'open', not the Gamma-style value 'active'. No payment was made."; } @@ -58,11 +96,20 @@ export function validateMarketRequest( if (body !== undefined) { return "Polymarket candlesticks is a GET endpoint. Pass query values in params, not body. No payment was made."; } - // Only validate the VALUE, and only when one is supplied. "1h" is a known - // paid failure; whether the endpoint requires `interval` at all is not - // established, so omitting it must not be rejected client-side. - if (query.interval !== undefined && !CANDLE_INTERVALS.has(query.interval)) { - return `Polymarket candlesticks interval '${query.interval}' is not valid. Use integer minutes: '0', '1', '5', '15', '60', or '1440' (so '60', not '1h'). Optional start_time/end_time are Unix seconds. No payment was made.`; + // Reject only what is definitively malformed. Verified live: omitting + // `interval` succeeds (the server has a default), `1440` succeeds, `1h` + // 422s — but `60` returns a paid 400 on a market where `1440` works. Which + // integer intervals a given market can serve is data-dependent, so a + // client-side whitelist of numeric values would block valid calls on some + // markets and still let paid failures through on others. Only the shape is + // ours to check. + // + // Honest limit: ONE market was probed. "Data-dependent" is an inference, not + // a finding — "60 is simply unsupported" fits the same evidence. If that is + // the true rule, letting integers through under-blocks every hourly request. + // Re-probe a second market before treating this as settled. + if (query.interval !== undefined && !/^\d+$/.test(query.interval)) { + return `Polymarket candlesticks interval '${query.interval}' is not a number. It is integer minutes. The minute-equivalent of '1h' is '60', but '60' was observed returning a paid 400 on a market where '1440' (daily) worked — so '1440' is the safer choice if daily resolution is acceptable, and note it is coarser than hourly. Which intervals a market serves varies. Optional start_time/end_time are Unix seconds. No payment was made.`; } if ("start" in query || "end" in query) { return "Polymarket candlesticks uses params.start_time and params.end_time in Unix seconds, not start/end. No payment was made."; @@ -81,15 +128,23 @@ export function validateMarketRequest( } } - // The observed paid failure was an UNFILTERED smart-money call. Require some - // cohort filter, but don't invent magnitudes: thresholds like "min_trades >= - // 100" were never verified against the API and would reject legitimate - // narrower cohorts (a 20-trade window, a 7d lookback) with no way to override. + // Requires a smart-wallet CRITERION, not merely any param. Verified live: + // no params 400s, `{ window: "7d" }` alone ALSO 400s, `{ min_trades: "100" }` + // alone succeeds. Treating `window` as a cohort filter (0.33.0 did) let a + // guaranteed paid 400 straight through. if (/^polymarket\/market\/[^/]+\/smart-money$/.test(path)) { - const hasCohortFilter = SMART_MONEY_FILTERS.some((key) => key in query); - if (!hasCohortFilter) { - return "Polymarket smart-money needs at least one cohort filter — an unfiltered call is rejected upstream. A good general default is params { window: '30d', min_trades: '100' }; " + - `any of ${SMART_MONEY_FILTERS.map((key) => `'${key}'`).join(", ")} also works. No payment was made.`; + // Presence is not enough: `{ min_trades: "" }` is the shape a model emits + // when it knows the param name but not a value, and it 400s upstream after + // settling exactly like the unfiltered call. The orderbooks rule above + // already tests usability this way. + const hasCriterion = SMART_MONEY_CRITERIA.some((key) => (query[key] ?? "").trim() !== ""); + if (!hasCriterion) { + const why = "window" in query + ? "'window' only scopes the time range — it does not define the cohort, and window-only calls are rejected upstream. " + : "An unfiltered call is rejected upstream. "; + return `Polymarket smart-money needs a smart-wallet criterion. ${why}` + + "Verified working: params { min_trades: '100' } (window then defaults to all_time). " + + `Also accepted, by name symmetry rather than probing: ${SMART_MONEY_CRITERIA.slice(1).map((key) => `'${key}'`).join(", ")}. No payment was made.`; } } diff --git a/src/utils/payment-serialization.ts b/src/utils/payment-serialization.ts deleted file mode 100644 index b622c64..0000000 --- a/src/utils/payment-serialization.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Serialize x402 requests made by one MCP process. Concurrent authorizations - * from the same wallet can race at the settlement layer (especially on - * Solana), causing one otherwise-funded call to be rejected. The queue never - * swallows task errors and always releases the next waiter. - * - * SCOPE — this deliberately covers the fast paid DATA tools only: markets, - * surf, exa, search, rpc, defi, and paid price lookups. Sub-second calls, so - * queueing them costs nothing. - * - * The long-running paid tools are deliberately NOT serialized: video and music - * take 60–180s, image and modal tens of seconds, and chat streams. Putting a - * 5ms price lookup behind a 3-minute video render to dodge a settlement race - * trades a rare failure for a guaranteed one. Those tools stay concurrent, so - * the race is still reachable across a media call and a data call — the real - * fix for that lives at the wallet/nonce layer, not here. - * - * The queue is process-global and every waiter blocks on the one ahead of it, - * so a single task that never settles would wedge EVERY paid tool for the life - * of the process with no recovery short of a restart. `QUEUE_MAX_WAIT_MS` - * bounds that blast radius: a waiter gives up on its predecessor and proceeds. - * Losing serialization for one call is a recoverable annoyance; a permanently - * stuck server is not. - */ -// Read per call, not at module load, so a test (or an operator) can set it -// without caring about ESM import order. -function queueMaxWaitMs(): number { - return Number(process.env.BLOCKRUN_PAYMENT_QUEUE_MAX_WAIT_MS) || 120_000; -} - -let tail: Promise = Promise.resolve(); - -/** Resolves when `previous` settles, or after the cap — whichever comes first. */ -function waitForTurn(previous: Promise): Promise { - return new Promise((resolve) => { - const timer = setTimeout(resolve, queueMaxWaitMs()); - // `unref` keeps a pending watchdog from holding the process open; it is - // absent in non-Node runtimes, hence the guard. - (timer as { unref?: () => void }).unref?.(); - previous.then( - () => { clearTimeout(timer); resolve(); }, - () => { clearTimeout(timer); resolve(); }, - ); - }); -} - -export async function serializePaidRequest(task: () => Promise): Promise { - const previous = tail; - let release!: () => void; - tail = new Promise((resolve) => { - release = resolve; - }); - - await waitForTurn(previous); - try { - return await task(); - } finally { - release(); - } -} diff --git a/test/markets-validation.test.ts b/test/markets-validation.test.ts index 2202f13..911032d 100644 --- a/test/markets-validation.test.ts +++ b/test/markets-validation.test.ts @@ -2,11 +2,11 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { validateMarketRequest } from "../src/utils/markets-validation.js"; -test("markets/listings is a live Tier-1 route, not a pre-payment rejection", () => { - // The gateway still registers, prices, and advertises markets/listings in the - // x402 manifest (blockrun/src/lib/predexon.ts). Blocking it here would break a - // paid endpoint the user is being sold. - assert.equal(validateMarketRequest("markets/listings", { venue: "polymarket" }, undefined), null); +test("markets/listings is retired upstream and blocked before payment", () => { + // Verified live 2026-07-29: settles payment, THEN returns 410 Gone. The + // gateway still registers and prices it, but the gateway only proxies — + // registry presence is not evidence that a route still serves. + assert.match(validateMarketRequest("markets/listings", { venue: "polymarket" }, undefined) ?? "", /410 Gone/); }); test("Gamma-only market discovery params are rejected before payment", () => { @@ -35,9 +35,14 @@ test("Predexon's own filters on polymarket/markets are not mistaken for Gamma pa } }); -test("candlesticks rejects a bad interval value but does not invent a required one", () => { +test("candlesticks rejects a malformed interval but does not whitelist numbers", () => { const path = "polymarket/candlesticks/token/123"; - assert.match(validateMarketRequest(path, { interval: "1h" }, undefined) ?? "", /'60', not '1h'/i); + assert.match(validateMarketRequest(path, { interval: "1h" }, undefined) ?? "", /not a number/i); + assert.match(validateMarketRequest(path, { interval: "1d" }, undefined) ?? "", /not a number/i); + // Verified live: 1440 works where 60 returns a paid 400 on the SAME market, + // so which integers serve is data-dependent and not ours to guess. + assert.equal(validateMarketRequest(path, { interval: "1440" }, undefined), null); + assert.equal(validateMarketRequest(path, { interval: "60" }, undefined), null); assert.match(validateMarketRequest(path, { interval: "60", start: "1", end: "2" }, undefined) ?? "", /start_time/); assert.equal(validateMarketRequest(path, { interval: "60", start_time: "1", end_time: "2" }, undefined), null); @@ -57,20 +62,77 @@ test("historical orderbooks require a valid millisecond range", () => { }, undefined), null); }); -test("smart-money blocks the unfiltered call without inventing thresholds", () => { +test("smart-money requires a wallet criterion, and window alone is not one", () => { const path = "polymarket/market/0xabc/smart-money"; - // The observed paid failure: no filter at all. assert.match(validateMarketRequest(path, {}, undefined) ?? "", /min_trades: '100'/); - assert.equal(validateMarketRequest(path, { window: "30d", min_trades: "100" }, undefined), null); - // Magnitudes were never verified against the API, so a narrower but perfectly - // legitimate cohort must still go through. - assert.equal(validateMarketRequest(path, { window: "7d" }, undefined), null); + // Verified live: window-only 400s upstream. 0.33.0 counted `window` as a + // cohort filter and let that paid failure through. + const windowOnly = validateMarketRequest(path, { window: "7d" }, undefined) ?? ""; + assert.match(windowOnly, /only scopes the time range/); + + // Verified live: min_trades alone succeeds (window defaults to all_time). + assert.equal(validateMarketRequest(path, { min_trades: "100" }, undefined), null); + assert.equal(validateMarketRequest(path, { window: "30d", min_trades: "100" }, undefined), null); + // Magnitudes stay unvalidated — a narrower cohort is legitimate. assert.equal(validateMarketRequest(path, { min_trades: "20" }, undefined), null); assert.equal(validateMarketRequest(path, { min_roi: "0.05" }, undefined), null); - assert.equal(validateMarketRequest(path, { min_win_rate: "0.6" }, undefined), null); }); test("unknown paths remain forward compatible", () => { assert.equal(validateMarketRequest("future/provider/endpoint", {}, undefined), null); }); + +test("a decorated path cannot step around a rule", () => { + // Every rule matched a bare, exactly-cased slug, so these all sailed past and + // settled a payment for the exact failure the rule exists to prevent. The + // gateway router ignores query strings, fragments, casing, slash runs, and + // control characters when matching, so validation has to as well. + for (const path of [ + "markets/listings?venue=polymarket", + "Markets/Listings", + "markets//listings", + "markets/listings#x", + "markets/listings\t", + "/markets/listings/", + ]) { + assert.match( + validateMarketRequest(path, {}, undefined) ?? "", + /410 Gone/, + `${JSON.stringify(path)} must not bypass the retired-route block`, + ); + } + + assert.match( + validateMarketRequest("polymarket/market/0xabc/smart-money?window=7d", {}, undefined) ?? "", + /smart-wallet criterion/, + ); + assert.match( + validateMarketRequest("POLYMARKET/ORDERBOOKS", {}, undefined) ?? "", + /token_id/, + ); +}); + +test("every smart-money criterion is honoured, and an unusable value is not one", () => { + const path = "polymarket/market/0xabc/smart-money"; + // Without this loop, dropping 5 of the 7 entries kept the whole suite green. + for (const key of [ + "min_trades", "min_volume", "min_roi", "min_realized_pnl", + "min_total_pnl", "min_win_rate", "min_profit_factor", + ]) { + assert.equal( + validateMarketRequest(path, { [key]: "1" }, undefined), null, + `${key} should satisfy the criterion requirement`, + ); + } + // Presence is not usability — these 400 upstream exactly like no filter. + for (const q of [{ min_trades: "" }, { min_roi: " " }] as Array>) { + assert.match(validateMarketRequest(path, q, undefined) ?? "", /smart-wallet criterion/); + } +}); + +test("a non-Polymarket candlestick route keeps its own interval format", () => { + // Binance klines take "1h" natively; applying the digits-only rule there + // would be a pure over-block. + assert.equal(validateMarketRequest("binance/candles/BTCUSDT", { interval: "1h" }, undefined), null); +}); diff --git a/test/payment-serialization.test.ts b/test/payment-serialization.test.ts deleted file mode 100644 index a7f207a..0000000 --- a/test/payment-serialization.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { serializePaidRequest } from "../src/utils/payment-serialization.js"; - -test("paid requests execute serially and preserve result order", async () => { - let active = 0; - let maxActive = 0; - const started: number[] = []; - - const run = (id: number) => serializePaidRequest(async () => { - active += 1; - maxActive = Math.max(maxActive, active); - started.push(id); - await new Promise((resolve) => setTimeout(resolve, 5)); - active -= 1; - return id; - }); - - const results = await Promise.all([run(1), run(2), run(3)]); - assert.deepEqual(results, [1, 2, 3]); - assert.deepEqual(started, [1, 2, 3]); - assert.equal(maxActive, 1); -}); - -test("a stalled task does not wedge the queue past the wait cap", async () => { - // The queue is process-global: without a bounded wait, one hung x402 call - // would block every paid tool until the server is restarted. - process.env.BLOCKRUN_PAYMENT_QUEUE_MAX_WAIT_MS = "20"; - try { - const stalled = serializePaidRequest( - () => new Promise((resolve) => setTimeout(() => resolve("slow"), 500)), - ); - - const started = Date.now(); - assert.equal(await serializePaidRequest(async () => "not blocked"), "not blocked"); - assert.ok(Date.now() - started < 400, "waiter should give up on the stalled predecessor"); - - assert.equal(await stalled, "slow"); - } finally { - delete process.env.BLOCKRUN_PAYMENT_QUEUE_MAX_WAIT_MS; - } -}); - -test("a rejected paid request releases the next waiter", async () => { - const first = serializePaidRequest(async () => { - throw new Error("expected failure"); - }); - const second = serializePaidRequest(async () => "completed"); - - await assert.rejects(first, /expected failure/); - assert.equal(await second, "completed"); -});