From 1859cbc139dcb2235f382cc30373a525539f75e1 Mon Sep 17 00:00:00 2001 From: Fsocietyhhh <1211904451@qq.com> Date: Sat, 23 May 2026 19:01:55 -0700 Subject: [PATCH 1/2] feat(surf): SurfMarket / SurfChain / SurfSocial function-call tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generic BlockRun primitive made the agent construct free-form Surf paths and hand-handle x402. Weak routed models failed two ways: guessing non-existent endpoints (market/token-ranking → 404 → circuit breaker), and after discovery, trying to hand-roll SDK payment in Bash until the loop guard tripped (~$0.58 wasted). Add three VideoGen-style function-call tools, split by the existing skill categories (surf-market / surf-chain / surf-social): - `endpoint` is an enum of valid paths — the model picks, never guesses. - Required params per endpoint are validated with a clear error. - x402 is signed internally (same pattern as videogen.ts) — the model never touches paths or payment, just fills params. - Short-name tolerance: a model that drops the category prefix ("fear-greed" vs "market/fear-greed") is resolved when unambiguous; ambiguous names ("ranking") return a "did you mean" list. The generic BlockRun primitive stays for long-tail paths and future partners; these three cover the high-traffic Surf surface with guardrails. Verified: - Direct execute(): market/fear-greed + market/ranking return data with auto $0.001 payment; missing-param and unknown-endpoint give clean errors; short names resolve. - End-to-end: "market pulse" prompt drives SurfMarket across fear-greed + ranking, returns real data (F&G 26, HYPE +9.6%), no fallback, no loop. Known limitation: the endpoint tables are hand-maintained from the gateway's SURF_ENDPOINTS registry. A follow-up should generate them so the gateway stays the single source of truth (no drift). --- src/tools/index.ts | 4 +- src/tools/surf.ts | 341 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/tools/surf.ts diff --git a/src/tools/index.ts b/src/tools/index.ts index caec225..e0d48dc 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -43,6 +43,7 @@ import { import { predictionMarketCapability } from './prediction.js'; import { modalCapabilities } from './modal.js'; import { blockrunCapability } from './blockrun.js'; +import { surfCapabilities } from './surf.js'; import { listPhoneNumbersCapability, buyPhoneNumberCapability, @@ -195,7 +196,8 @@ export const allCapabilities: CapabilityHandler[] = [ defiLlamaYieldsCapability, defiLlamaPriceCapability, predictionMarketCapability, // Polymarket / Kalshi / matching / smart money via Predexon - blockrunCapability, // Generic x402-paid gateway primitive — Surf, future partners (see /surf-* skills) + blockrunCapability, // Generic x402-paid gateway primitive — future partners + long-tail Surf paths + ...surfCapabilities, // SurfMarket / SurfChain / SurfSocial — endpoint-enum function tools (no path guessing, auto x402) // Phone & Voice — typed surface so the agent pattern-matches on the user // intent ("buy a number", "make a call") without needing to consult the // BlockRun primitive or the .well-known/x402 manifest. All wrap the same diff --git a/src/tools/surf.ts b/src/tools/surf.ts new file mode 100644 index 0000000..386abe3 --- /dev/null +++ b/src/tools/surf.ts @@ -0,0 +1,341 @@ +/** + * Surf — function-call tools for BlockRun's crypto data API. + * + * Three category tools (SurfMarket / SurfChain / SurfSocial) that mirror the + * /surf-market, /surf-chain, /surf-social skills. Unlike the generic BlockRun + * primitive (free-form `path` string), these expose the valid endpoints as an + * `endpoint` enum so the model picks instead of guessing, and they sign the + * x402 payment internally — the model never touches paths or payment, same UX + * as VideoGen / ImageGen. + * + * The endpoint tables below are derived from the gateway's SURF_ENDPOINTS + * registry (blockrun/src/lib/surf.ts). They are hand-maintained for now; a + * follow-up will generate them so the gateway stays the single source of truth. + * + * x402 signing mirrors src/tools/blockrun.ts (kept as copy-paste per the same + * rationale documented there — refactoring into a shared module is out of scope). + */ + +import { + getOrCreateWallet, + getOrCreateSolanaWallet, + createPaymentPayload, + createSolanaPaymentPayload, + parsePaymentRequired, + extractPaymentDetails, + solanaKeyToBytes, + SOLANA_NETWORK, +} from '@blockrun/llm'; +import type { CapabilityHandler, CapabilityResult, ExecutionScope } from '../agent/types.js'; +import { loadChain, API_URLS, USER_AGENT } from '../config.js'; +import { recordUsage } from '../stats/tracker.js'; +import { logger } from '../logger.js'; + +const TIMEOUT_MS = 30_000; + +interface SurfEndpoint { + /** Path under /v1/surf/, e.g. "market/ranking". */ + path: string; + method: 'GET' | 'POST'; + /** Query params (GET) the endpoint needs — surfaced in the tool description. */ + required: string[]; + desc: string; +} + +// ── Endpoint tables (derived from gateway SURF_ENDPOINTS) ─────────────────── + +const MARKET_ENDPOINTS: SurfEndpoint[] = [ + { path: 'market/ranking', method: 'GET', required: [], desc: 'Token rankings (market cap, volume, 24h change).' }, + { path: 'market/fear-greed', method: 'GET', required: [], desc: 'Fear & Greed index history.' }, + { path: 'market/futures', method: 'GET', required: [], desc: 'Futures market overview.' }, + { path: 'market/price', method: 'GET', required: ['symbol'], desc: 'Token price history.' }, + { path: 'market/etf', method: 'GET', required: ['symbol'], desc: 'Spot ETF flow history (BTC/ETH).' }, + { path: 'market/options', method: 'GET', required: ['symbol'], desc: 'Options skew / IV / volume.' }, + { path: 'market/liquidation/exchange-list', method: 'GET', required: [], desc: 'Liquidations by exchange.' }, + { path: 'market/liquidation/order', method: 'GET', required: [], desc: 'Large (whale) liquidation orders.' }, + { path: 'market/liquidation/chart', method: 'GET', required: ['symbol'], desc: 'Liquidation chart over time.' }, + { path: 'market/onchain-indicator', method: 'GET', required: ['symbol', 'metric'], desc: 'On-chain indicators (NUPL/SOPR/MVRV/Puell/NVT).' }, + { path: 'market/price-indicator', method: 'GET', required: ['indicator', 'symbol'], desc: 'Technical indicators (RSI/MACD/BBANDS/EMA).' }, + { path: 'exchange/markets', method: 'GET', required: [], desc: 'CEX trading pairs catalog.' }, + { path: 'exchange/price', method: 'GET', required: ['pair'], desc: 'CEX ticker price for a pair.' }, + { path: 'exchange/perp', method: 'GET', required: ['pair'], desc: 'Perpetual contract snapshot.' }, + { path: 'exchange/depth', method: 'GET', required: ['pair'], desc: 'Order book depth.' }, + { path: 'exchange/klines', method: 'GET', required: ['pair'], desc: 'OHLCV candles.' }, + { path: 'exchange/funding-history', method: 'GET', required: ['pair'], desc: 'Funding rate history.' }, + { path: 'exchange/long-short-ratio', method: 'GET', required: ['pair'], desc: 'Long/short account ratio.' }, + { path: 'fund/detail', method: 'GET', required: [], desc: 'VC fund profile detail.' }, + { path: 'fund/portfolio', method: 'GET', required: [], desc: 'VC fund portfolio holdings.' }, + { path: 'fund/ranking', method: 'GET', required: ['metric'], desc: 'Top VC funds ranking.' }, + { path: 'news/feed', method: 'GET', required: [], desc: 'AI-curated crypto news feed.' }, + { path: 'news/detail', method: 'GET', required: ['id'], desc: 'Full article detail by id.' }, + { path: 'project/detail', method: 'GET', required: [], desc: 'Project profile.' }, + { path: 'project/defi/metrics', method: 'GET', required: ['metric'], desc: 'DeFi protocol metrics.' }, + { path: 'project/defi/ranking', method: 'GET', required: ['metric'], desc: 'DeFi protocol ranking.' }, +]; + +const CHAIN_ENDPOINTS: SurfEndpoint[] = [ + { path: 'onchain/bridge/ranking', method: 'GET', required: [], desc: 'Bridge protocol ranking by volume.' }, + { path: 'onchain/yield/ranking', method: 'GET', required: [], desc: 'Yield pool ranking (lending/LP/staking).' }, + { path: 'onchain/gas-price', method: 'GET', required: ['chain'], desc: 'Current gas price for a chain.' }, + { path: 'onchain/tx', method: 'GET', required: ['hash', 'chain'], desc: 'Transaction details by hash.' }, + { path: 'onchain/schema', method: 'GET', required: [], desc: 'Schema introspection for the SQL tables.' }, + { path: 'onchain/query', method: 'POST', required: [], desc: 'Structured chain query (POST body).' }, + { path: 'onchain/sql', method: 'POST', required: [], desc: 'Raw SQL against 80+ indexed chain tables (POST body, Tier-3 $0.02).' }, + { path: 'token/tokenomics', method: 'GET', required: [], desc: 'Token supply / unlock / distribution.' }, + { path: 'token/dex-trades', method: 'GET', required: ['address'], desc: 'Recent DEX trades for a token.' }, + { path: 'token/holders', method: 'GET', required: ['address', 'chain'], desc: 'Top holders / concentration.' }, + { path: 'token/transfers', method: 'GET', required: ['address', 'chain'], desc: 'Token transfer history.' }, + { path: 'wallet/detail', method: 'GET', required: ['address'], desc: 'Wallet overview.' }, + { path: 'wallet/history', method: 'GET', required: ['address'], desc: 'Wallet activity history.' }, + { path: 'wallet/net-worth', method: 'GET', required: ['address'], desc: 'Wallet net worth.' }, + { path: 'wallet/transfers', method: 'GET', required: ['address'], desc: 'Wallet transfers.' }, + { path: 'wallet/protocols', method: 'GET', required: ['address'], desc: 'Protocols the wallet interacts with.' }, + { path: 'wallet/labels/batch', method: 'GET', required: ['addresses'], desc: 'Batch wallet labels (CEX/Whale/Bridge/MEV).' }, +]; + +const SOCIAL_ENDPOINTS: SurfEndpoint[] = [ + { path: 'social/detail', method: 'GET', required: [], desc: 'Social signal detail.' }, + { path: 'social/ranking', method: 'GET', required: [], desc: 'KOL / account influence ranking.' }, + { path: 'social/smart-followers/history', method: 'GET', required: [], desc: 'Smart-follower growth history.' }, + { path: 'social/mindshare', method: 'GET', required: ['q', 'interval'], desc: 'Topic/token mindshare over an interval.' }, + { path: 'social/tweets', method: 'GET', required: ['ids'], desc: 'Tweets by ids.' }, + { path: 'social/tweet/replies', method: 'GET', required: ['tweet_id'], desc: 'Replies to a tweet.' }, + { path: 'social/user', method: 'GET', required: ['handle'], desc: 'User profile.' }, + { path: 'social/user/followers', method: 'GET', required: ['handle'], desc: 'User followers.' }, + { path: 'social/user/following', method: 'GET', required: ['handle'], desc: 'User followings.' }, + { path: 'social/user/posts', method: 'GET', required: ['handle'], desc: 'User posts.' }, + { path: 'social/user/replies', method: 'GET', required: ['handle'], desc: 'User replies.' }, +]; + +// ── x402 signing (mirrors blockrun.ts) ────────────────────────────────────── + +async function extractPaymentReq(response: Response): Promise { + let header = response.headers.get('payment-required'); + if (!header) { + try { + const body = (await response.clone().json()) as Record; + if (body.x402 || body.accepts) header = btoa(JSON.stringify(body)); + } catch { /* not JSON */ } + } + return header; +} + +async function signPayment( + response: Response, + chain: 'base' | 'solana', + endpoint: string, + resourceDescription: string, +): Promise<{ headers: Record; amountUsd: number } | null> { + try { + const paymentHeader = await extractPaymentReq(response); + if (!paymentHeader) return null; + const paymentRequired = parsePaymentRequired(paymentHeader); + if (chain === 'solana') { + const wallet = await getOrCreateSolanaWallet(); + const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK); + const secretBytes = await solanaKeyToBytes(wallet.privateKey); + const feePayer = details.extra?.feePayer || details.recipient; + const payload = await createSolanaPaymentPayload( + secretBytes, wallet.address, details.recipient, details.amount, feePayer as string, + { + resourceUrl: details.resource?.url || endpoint, + resourceDescription: details.resource?.description || resourceDescription, + maxTimeoutSeconds: details.maxTimeoutSeconds || 300, + extra: details.extra as Record | undefined, + }, + ); + return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 }; + } + const wallet = getOrCreateWallet(); + const details = extractPaymentDetails(paymentRequired); + const payload = await createPaymentPayload( + wallet.privateKey as `0x${string}`, wallet.address, details.recipient, details.amount, + details.network || 'eip155:8453', + { + resourceUrl: details.resource?.url || endpoint, + resourceDescription: details.resource?.description || resourceDescription, + maxTimeoutSeconds: details.maxTimeoutSeconds || 300, + extra: details.extra as Record | undefined, + }, + ); + return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 }; + } catch (err) { + logger.warn(`[franklin] Surf payment error: ${(err as Error).message}`); + return null; + } +} + +// ── Shared call: resolve endpoint → sign x402 → return data ────────────────── + +async function callSurf( + toolName: string, + table: SurfEndpoint[], + input: Record, + ctx: ExecutionScope, +): Promise { + const endpoint = typeof input.endpoint === 'string' ? input.endpoint.trim().replace(/^\/+|\/+$/g, '') : ''; + let entry = table.find((e) => e.path === endpoint); + if (!entry) { + // Tolerate a weak model dropping the category prefix ("fear-greed" instead + // of "market/fear-greed") — accept a suffix match when it's unambiguous. + const matches = table.filter((e) => e.path === endpoint || e.path.endsWith(`/${endpoint}`)); + if (matches.length === 1) { + entry = matches[0]; + } else if (matches.length > 1) { + return { output: `Ambiguous ${toolName} endpoint "${endpoint}". Did you mean: ${matches.map((m) => m.path).join(', ')}?`, isError: true }; + } else { + return { output: `Unknown ${toolName} endpoint: "${endpoint}". Valid: ${table.map((e) => e.path).join(', ')}`, isError: true }; + } + } + + // Collect query params: the named fields the caller provided (everything + // except `endpoint`/`body`), plus an explicit `params` object if given. + const query: Record = {}; + for (const [k, v] of Object.entries(input)) { + if (k === 'endpoint' || k === 'body' || k === 'params') continue; + if (v !== undefined && v !== null && v !== '') query[k] = v; + } + if (input.params && typeof input.params === 'object') Object.assign(query, input.params); + + const missing = entry.required.filter((p) => query[p] === undefined); + if (missing.length > 0) { + return { + output: `${toolName} ${endpoint} needs: ${entry.required.join(', ')}. Missing: ${missing.join(', ')}.`, + isError: true, + }; + } + + const chain = loadChain(); + const base = API_URLS[chain]; // ends in /api + let url = `${base}/v1/surf/${entry.path}`; + const body = entry.method === 'POST' + ? (input.body && typeof input.body === 'object' ? input.body as Record : query) + : undefined; + if (entry.method === 'GET' && Object.keys(query).length > 0) { + const usp = new URLSearchParams(); + for (const [k, v] of Object.entries(query)) { + if (Array.isArray(v)) for (const x of v) usp.append(k, String(x)); + else usp.append(k, String(v)); + } + url += `?${usp.toString()}`; + } + + const start = Date.now(); + const ctrl = new AbortController(); + const onAbort = () => ctrl.abort(); + ctx.abortSignal.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS); + const headers: Record = { Accept: 'application/json', 'User-Agent': USER_AGENT }; + if (entry.method === 'POST') headers['Content-Type'] = 'application/json'; + const payload = body !== undefined ? JSON.stringify(body) : undefined; + const resourceDescription = `Surf ${entry.method} /v1/surf/${entry.path}`; + + try { + let response = await fetch(url, { method: entry.method, signal: ctrl.signal, headers, body: payload }); + let paidUsd = 0; + if (response.status === 402) { + const signed = await signPayment(response, chain, url, resourceDescription); + if (!signed) return { output: `${toolName} ${endpoint}: payment signing failed`, isError: true }; + paidUsd = signed.amountUsd; + response = await fetch(url, { + method: entry.method, signal: ctrl.signal, + headers: { ...headers, ...signed.headers }, body: payload, + }); + } + if (!response.ok) paidUsd = 0; + const raw = await response.text().catch(() => ''); + try { recordUsage(`${toolName}:${entry.path}`, 0, 0, paidUsd, Date.now() - start); } catch { /* best-effort */ } + + if (!response.ok) { + return { + output: `${toolName} ${endpoint} failed (status ${response.status}). No charge if 4xx pre-payment.\n${raw.slice(0, 800)}`, + isError: true, + }; + } + const head = `Surf /v1/surf/${entry.path} → $${paidUsd.toFixed(4)} · ${Date.now() - start}ms`; + return { output: `${head}\n\n\`\`\`json\n${raw}\n\`\`\`` }; + } catch (err) { + return { output: `${toolName} ${endpoint} error: ${(err as Error).message}`, isError: true }; + } finally { + clearTimeout(timer); + ctx.abortSignal.removeEventListener('abort', onAbort); + } +} + +// ── Tool specs ─────────────────────────────────────────────────────────────── + +function makeSurfTool( + name: string, + blurb: string, + table: SurfEndpoint[], + extraParams: Record, +): CapabilityHandler { + const endpointList = table.map((e) => `\`${e.path}\`${e.required.length ? ` (needs ${e.required.join('+')})` : ''} — ${e.desc}`).join('\n'); + return { + spec: { + name, + description: + `${blurb} Picks an endpoint from a fixed list and signs the x402 USDC payment from the wallet automatically — ` + + `you do not build paths or handle payment. Tier-1 $0.001, Tier-2 $0.005, Tier-3 $0.02.\n\nEndpoints:\n${endpointList}`, + input_schema: { + type: 'object', + properties: { + endpoint: { + type: 'string', + enum: table.map((e) => e.path), + description: 'Which Surf endpoint to call (see list in the tool description).', + }, + ...extraParams, + body: { type: 'object', description: 'Request body for POST endpoints (onchain/query, onchain/sql).' }, + }, + required: ['endpoint'], + }, + }, + concurrent: true, + execute: (input: Record, ctx: ExecutionScope) => callSurf(name, table, input, ctx), + }; +} + +export const surfMarketCapability = makeSurfTool( + 'SurfMarket', + 'Crypto market data: token rankings, fear/greed, futures, ETF flows, options, liquidations, technical & on-chain indicators, CEX pairs, VC funds, news, DeFi projects.', + MARKET_ENDPOINTS, + { + symbol: { type: 'string', description: 'Token symbol, e.g. "BTC". Required by price/etf/options/liquidation-chart/indicators.' }, + pair: { type: 'string', description: 'Exchange pair, e.g. "BTC-USDT". Required by exchange/* endpoints.' }, + metric: { type: 'string', description: 'Metric name (e.g. "NUPL" for onchain-indicator, ranking metric for fund/project).' }, + indicator: { type: 'string', description: 'Technical indicator, e.g. "RSI", "MACD", "BBANDS".' }, + id: { type: 'string', description: 'Article id for news/detail.' }, + }, +); + +export const surfChainCapability = makeSurfTool( + 'SurfChain', + 'On-chain data: bridge/yield rankings, gas, transactions, token analytics (holders, transfers, DEX trades), wallet intelligence, and raw SQL over 80+ indexed chain tables.', + CHAIN_ENDPOINTS, + { + chain: { type: 'string', description: 'Chain name, e.g. "ethereum", "base". Required by gas-price/tx/holders/transfers.' }, + hash: { type: 'string', description: 'Transaction hash for onchain/tx.' }, + address: { type: 'string', description: 'Token or wallet address.' }, + addresses: { type: 'string', description: 'Comma-separated addresses for wallet/labels/batch.' }, + }, +); + +export const surfSocialCapability = makeSurfTool( + 'SurfSocial', + 'Crypto-Twitter / KOL signal: influence rankings, mindshare, smart-follower history, tweets, and user profiles. The canonical source for CT sentiment.', + SOCIAL_ENDPOINTS, + { + q: { type: 'string', description: 'Query/topic for mindshare.' }, + interval: { type: 'string', description: 'Time interval for mindshare, e.g. "24h", "7d".' }, + handle: { type: 'string', description: 'Twitter/X handle for social/user* endpoints.' }, + ids: { type: 'string', description: 'Comma-separated tweet ids for social/tweets.' }, + tweet_id: { type: 'string', description: 'Tweet id for social/tweet/replies.' }, + }, +); + +export const surfCapabilities: CapabilityHandler[] = [ + surfMarketCapability, + surfChainCapability, + surfSocialCapability, +]; From 51f1208a402737ef717b2d2410bb52e11f6d9838 Mon Sep 17 00:00:00 2001 From: Fsocietyhhh <1211904451@qq.com> Date: Sat, 23 May 2026 19:17:40 -0700 Subject: [PATCH 2/2] feat(surf): promote SurfMarket to the core tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SurfMarket worked when invoked but was activation-gated, so on a natural prompt ("what's the crypto mood / which coins are pumping?") the agent never reached for it — it fell back to TradingMarket prices and *guessed* the Fear & Greed index, getting flagged twice for ungrounded claims. PredictionMarket is already in CORE_TOOL_NAMES for exactly this reason ("what are the odds of X"). SurfMarket is the same shape of capability for crypto market data, so it belongs in the hero surface too. SurfChain / SurfSocial stay activation-gated (lower-frequency, long-tail). Verified: same no-"surf"-mention prompt now drives SurfMarket on its own — real Fear & Greed (26) + real token rankings, no hallucination flags, no TradingMarket-only fallback. --- src/tools/tool-categories.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tools/tool-categories.ts b/src/tools/tool-categories.ts index db87519..8ada1b5 100644 --- a/src/tools/tool-categories.ts +++ b/src/tools/tool-categories.ts @@ -50,6 +50,13 @@ export const CORE_TOOL_NAMES: ReadonlySet = new Set([ // category. Cross-platform pair lookup is unique to the gateway and // is the kind of data a non-wallet agent fundamentally cannot reach. 'PredictionMarket', + // Crypto market data — fear/greed, token rankings, ETF flows, options, + // liquidations, technical & on-chain indicators. The "what's the crypto + // mood / which coins are pumping / BTC's RSI" category. Core so the agent + // reaches for it on natural crypto questions instead of falling back to + // TradingMarket prices + guessing the Fear & Greed index. SurfChain / + // SurfSocial stay activation-gated (lower-frequency, long-tail surface). + 'SurfMarket', // Research — synthesized answers with real citations, semantic web // search, and clean URL fetching. Any factual current-events question // ("why did X drop?") should route here rather than the model's prior.