@blockrun/franklin 3.21.9 → 3.23.0

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.
@@ -0,0 +1,263 @@
1
+ /**
2
+ * RealFace — enroll a real person's face as a reusable video avatar.
3
+ *
4
+ * Wraps BlockRun's /v1/realface/* flow so the agent never hand-rolls paths or
5
+ * x402. Enrollment is a three-step, human-in-the-loop flow because the upstream
6
+ * provider (Token360 / BytePlus) requires a live liveness check on a phone:
7
+ *
8
+ * 1. action="init" (FREE) → creates a REAL_FACE group, returns an `h5_link`.
9
+ * Show it to the user as a QR / URL. They scan it
10
+ * on their phone and do a ~1-minute liveness check
11
+ * (nod + blink). The link expires in 120s; call
12
+ * init again with the same group_id to refresh.
13
+ * 2. action="status" (FREE) → poll the group until status === "active" (the
14
+ * person finished the phone liveness). Bounded
15
+ * poll (~24s) so a quick scan resolves in one call.
16
+ * 3. action="enroll" ($0.01)→ uploads a face photo (public https URL), waits
17
+ * for the biometric match, returns the `ta_xxx`
18
+ * asset id. Pre-flights group-active (425 if not);
19
+ * no charge if the upload/match fails.
20
+ * action="list" (FREE) → lists the wallet's enrolled RealFace assets.
21
+ *
22
+ * Use the returned `ta_xxx` as `real_face_asset_id` on a VideoGen call with a
23
+ * Seedance 2.0 model for cross-frame character consistency.
24
+ *
25
+ * x402 signing mirrors src/tools/videogen.ts / blockrun.ts (kept as copy-paste
26
+ * per the same rationale documented there — a shared module is out of scope).
27
+ */
28
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
29
+ import { loadChain, API_URLS, USER_AGENT } from '../config.js';
30
+ import { recordUsage } from '../stats/tracker.js';
31
+ import { logger } from '../logger.js';
32
+ const REQUEST_TIMEOUT_MS = 60_000;
33
+ // status poll budget — a phone liveness check takes ~1 min, but the upstream
34
+ // flip to `active` can lag the user saying "done" by a few seconds. Poll a
35
+ // short window so a just-finished scan resolves in one call without hanging
36
+ // the agent loop; if still pending, return and let the agent re-check.
37
+ const STATUS_POLL_ATTEMPTS = 6;
38
+ const STATUS_POLL_INTERVAL_MS = 4_000;
39
+ const GROUP_ID_RE = /^legacy_rf_\d+$/;
40
+ async function extractPaymentReq(response) {
41
+ let header = response.headers.get('payment-required');
42
+ if (!header) {
43
+ try {
44
+ const body = (await response.clone().json());
45
+ if (body.x402 || body.accepts)
46
+ header = btoa(JSON.stringify(body));
47
+ }
48
+ catch { /* not JSON */ }
49
+ }
50
+ return header;
51
+ }
52
+ async function signPayment(response, chain, endpoint, resourceDescription) {
53
+ try {
54
+ const paymentHeader = await extractPaymentReq(response);
55
+ if (!paymentHeader)
56
+ return null;
57
+ const paymentRequired = parsePaymentRequired(paymentHeader);
58
+ if (chain === 'solana') {
59
+ const wallet = await getOrCreateSolanaWallet();
60
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
61
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
62
+ const feePayer = details.extra?.feePayer || details.recipient;
63
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
64
+ resourceUrl: details.resource?.url || endpoint,
65
+ resourceDescription: details.resource?.description || resourceDescription,
66
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
67
+ extra: details.extra,
68
+ });
69
+ return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 };
70
+ }
71
+ const wallet = getOrCreateWallet();
72
+ const details = extractPaymentDetails(paymentRequired);
73
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
74
+ resourceUrl: details.resource?.url || endpoint,
75
+ resourceDescription: details.resource?.description || resourceDescription,
76
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
77
+ extra: details.extra,
78
+ });
79
+ return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 };
80
+ }
81
+ catch (err) {
82
+ logger.warn(`[franklin] RealFace payment error: ${err.message}`);
83
+ return null;
84
+ }
85
+ }
86
+ function walletAddress(chain) {
87
+ if (chain === 'solana')
88
+ return getOrCreateSolanaWallet().then((w) => w.address);
89
+ return Promise.resolve(getOrCreateWallet().address);
90
+ }
91
+ async function timedFetch(url, init, ctx) {
92
+ const ctrl = new AbortController();
93
+ const onAbort = () => ctrl.abort();
94
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
95
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
96
+ try {
97
+ return await fetch(url, { ...init, signal: ctrl.signal });
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ ctx.abortSignal.removeEventListener('abort', onAbort);
102
+ }
103
+ }
104
+ const fence = (raw) => `\n\n\`\`\`json\n${raw}\n\`\`\``;
105
+ async function actionInit(base, input, ctx) {
106
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
107
+ if (!name)
108
+ return { output: 'RealFace init needs a `name` (the real person\'s display name, 1–64 chars).', isError: true };
109
+ const groupId = typeof input.group_id === 'string' ? input.group_id.trim() : undefined;
110
+ if (groupId && !GROUP_ID_RE.test(groupId)) {
111
+ return { output: `RealFace init: group_id must look like "legacy_rf_<digits>". Got: ${groupId}`, isError: true };
112
+ }
113
+ const body = JSON.stringify(groupId ? { name, groupId } : { name });
114
+ const res = await timedFetch(`${base}/v1/realface/init`, {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT },
117
+ body,
118
+ }, ctx);
119
+ const raw = await res.text().catch(() => '');
120
+ if (!res.ok)
121
+ return { output: `RealFace init failed (status ${res.status}).\n${raw.slice(0, 600)}`, isError: true };
122
+ return {
123
+ output: 'RealFace group created — FREE. Show the `h5_link` to the user as a QR code or tappable URL. ' +
124
+ 'They scan it on their phone and complete a ~1-minute liveness check (nod + blink). The link ' +
125
+ 'expires in ~120s — re-run action="init" with the same `group_id` to refresh. Then poll ' +
126
+ 'action="status" with the `group_id` until status="active", and finish with action="enroll".' +
127
+ fence(raw),
128
+ };
129
+ }
130
+ async function actionStatus(base, input, ctx) {
131
+ const groupId = typeof input.group_id === 'string' ? input.group_id.trim() : '';
132
+ if (!GROUP_ID_RE.test(groupId)) {
133
+ return { output: 'RealFace status needs a valid `group_id` (format "legacy_rf_<digits>") from action="init".', isError: true };
134
+ }
135
+ let raw = '';
136
+ let lastStatus = '';
137
+ for (let attempt = 0; attempt < STATUS_POLL_ATTEMPTS; attempt++) {
138
+ if (ctx.abortSignal.aborted)
139
+ break;
140
+ const res = await timedFetch(`${base}/v1/realface/status?groupId=${encodeURIComponent(groupId)}`, {
141
+ method: 'GET',
142
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
143
+ }, ctx);
144
+ raw = await res.text().catch(() => '');
145
+ if (!res.ok)
146
+ return { output: `RealFace status failed (status ${res.status}).\n${raw.slice(0, 600)}`, isError: true };
147
+ try {
148
+ lastStatus = String(JSON.parse(raw).status ?? '');
149
+ }
150
+ catch { /* keep raw */ }
151
+ if (lastStatus === 'active') {
152
+ return { output: `RealFace group is ACTIVE — the person finished the phone liveness check. Proceed with action="enroll".${fence(raw)}` };
153
+ }
154
+ if (attempt < STATUS_POLL_ATTEMPTS - 1)
155
+ await new Promise((r) => setTimeout(r, STATUS_POLL_INTERVAL_MS));
156
+ }
157
+ return {
158
+ output: `RealFace group not yet active (status="${lastStatus || 'unknown'}"). The person hasn't finished the phone ` +
159
+ `liveness check, or the upstream is still processing. Ask them to scan the QR (action="init" to refresh an ` +
160
+ `expired link), then call action="status" again.${fence(raw)}`,
161
+ };
162
+ }
163
+ async function actionEnroll(base, chain, input, ctx) {
164
+ const name = typeof input.name === 'string' ? input.name.trim() : '';
165
+ const imageUrl = typeof input.image_url === 'string' ? input.image_url.trim() : '';
166
+ const groupId = typeof input.group_id === 'string' ? input.group_id.trim() : '';
167
+ if (!name)
168
+ return { output: 'RealFace enroll needs `name`.', isError: true };
169
+ if (!GROUP_ID_RE.test(groupId))
170
+ return { output: 'RealFace enroll needs a valid `group_id` (from action="init").', isError: true };
171
+ if (!/^https?:\/\//.test(imageUrl)) {
172
+ return { output: 'RealFace enroll needs `image_url` as a public http(s) URL to the face photo (JPG/PNG/WEBP, ≤10 MB). The gateway fetches it server-side — local paths and data: URIs are not accepted.', isError: true };
173
+ }
174
+ const url = `${base}/v1/realface/enroll`;
175
+ const body = JSON.stringify({ name, image_url: imageUrl, group_id: groupId });
176
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': USER_AGENT };
177
+ const start = Date.now();
178
+ let res = await timedFetch(url, { method: 'POST', headers, body }, ctx);
179
+ let paidUsd = 0;
180
+ if (res.status === 402) {
181
+ const signed = await signPayment(res, chain, url, `RealFace enrollment — "${name.slice(0, 32)}"`);
182
+ if (!signed)
183
+ return { output: 'RealFace enroll: payment signing failed. Check wallet balance with `franklin balance`.', isError: true };
184
+ paidUsd = signed.amountUsd;
185
+ res = await timedFetch(url, { method: 'POST', headers: { ...headers, ...signed.headers }, body }, ctx);
186
+ }
187
+ const raw = await res.text().catch(() => '');
188
+ if (!res.ok)
189
+ paidUsd = 0;
190
+ try {
191
+ recordUsage('RealFace:enroll', 0, 0, paidUsd, Date.now() - start);
192
+ }
193
+ catch { /* best-effort */ }
194
+ if (res.status === 425) {
195
+ return { output: `RealFace enroll: the group isn't active yet — the person must finish the phone liveness check first. No payment taken. Poll action="status" until active, then retry.\n${raw.slice(0, 600)}`, isError: true };
196
+ }
197
+ if (res.status === 422) {
198
+ return { output: `RealFace enroll: the uploaded photo didn't match the live face captured on the phone. No payment taken. Use a clearer front-facing photo of the same person and retry.\n${raw.slice(0, 600)}`, isError: true };
199
+ }
200
+ if (!res.ok) {
201
+ return { output: `RealFace enroll failed (status ${res.status}). No charge if 4xx pre-payment.\n${raw.slice(0, 600)}`, isError: true };
202
+ }
203
+ return {
204
+ output: `RealFace enrolled → $${paidUsd.toFixed(4)} · ${Date.now() - start}ms. ` +
205
+ `Use the returned \`asset_id\` (ta_xxx) as \`real_face_asset_id\` on a VideoGen call with ` +
206
+ `bytedance/seedance-2.0 or -fast for a real-person clip.${fence(raw)}`,
207
+ };
208
+ }
209
+ async function actionList(base, chain, ctx) {
210
+ const addr = await walletAddress(chain);
211
+ const res = await timedFetch(`${base}/v1/wallet/${addr}/realfaces`, {
212
+ method: 'GET',
213
+ headers: { Accept: 'application/json', 'User-Agent': USER_AGENT },
214
+ }, ctx);
215
+ const raw = await res.text().catch(() => '');
216
+ if (!res.ok)
217
+ return { output: `RealFace list failed (status ${res.status}).\n${raw.slice(0, 600)}`, isError: true };
218
+ return { output: `RealFace assets for ${addr}:${fence(raw)}` };
219
+ }
220
+ async function execute(input, ctx) {
221
+ const action = typeof input.action === 'string' ? input.action.trim() : '';
222
+ const chain = loadChain();
223
+ const base = API_URLS[chain]; // ends in /api
224
+ switch (action) {
225
+ case 'init': return actionInit(base, input, ctx);
226
+ case 'status': return actionStatus(base, input, ctx);
227
+ case 'enroll': return actionEnroll(base, chain, input, ctx);
228
+ case 'list': return actionList(base, chain, ctx);
229
+ default:
230
+ return { output: `RealFace: unknown action "${action}". Valid: init, status, enroll, list.`, isError: true };
231
+ }
232
+ }
233
+ export const realFaceCapability = {
234
+ spec: {
235
+ name: 'RealFace',
236
+ description: 'Enroll a real person\'s face as a reusable video avatar (ta_xxx asset), then use it in VideoGen ' +
237
+ 'for cross-frame character consistency on Seedance 2.0. Human-in-the-loop, four actions:\n' +
238
+ '• action="init" (FREE) — create a group, get an `h5_link`; show it to the user as a QR. They scan it ' +
239
+ 'on their phone and do a ~1-minute liveness check (nod + blink). Link expires in 120s — re-init with the ' +
240
+ 'same `group_id` to refresh.\n' +
241
+ '• action="status" (FREE) — poll the `group_id` until status="active" (person finished the phone check).\n' +
242
+ '• action="enroll" ($0.01 USDC) — upload the face photo (`image_url`, public https) + `group_id`; returns ' +
243
+ 'the `ta_xxx` asset id. No charge if the group isn\'t active (425) or the face doesn\'t match (422).\n' +
244
+ '• action="list" (FREE) — list this wallet\'s enrolled RealFace assets.\n' +
245
+ 'Typical sequence: init → (user scans) → status → enroll → VideoGen with real_face_asset_id.',
246
+ input_schema: {
247
+ type: 'object',
248
+ properties: {
249
+ action: {
250
+ type: 'string',
251
+ enum: ['init', 'status', 'enroll', 'list'],
252
+ description: 'Which step of the RealFace flow to run.',
253
+ },
254
+ name: { type: 'string', description: 'Display name of the real person (1–64 chars). Required for init and enroll.' },
255
+ group_id: { type: 'string', description: 'The group id ("legacy_rf_<digits>") returned by action="init". Required for status and enroll; optional on init to refresh an expired h5_link.' },
256
+ image_url: { type: 'string', description: 'Public http(s) URL to the face photo (JPG/PNG/WEBP, ≤10 MB). Required for enroll. Fetched server-side — local paths / data: URIs are rejected.' },
257
+ },
258
+ required: ['action'],
259
+ },
260
+ },
261
+ concurrent: false,
262
+ execute,
263
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Surf — function-call tools for BlockRun's crypto data API.
3
+ *
4
+ * Three category tools (SurfMarket / SurfChain / SurfSocial) that mirror the
5
+ * /surf-market, /surf-chain, /surf-social skills. Unlike the generic BlockRun
6
+ * primitive (free-form `path` string), these expose the valid endpoints as an
7
+ * `endpoint` enum so the model picks instead of guessing, and they sign the
8
+ * x402 payment internally — the model never touches paths or payment, same UX
9
+ * as VideoGen / ImageGen.
10
+ *
11
+ * The endpoint tables below are derived from the gateway's SURF_ENDPOINTS
12
+ * registry (blockrun/src/lib/surf.ts). They are hand-maintained for now; a
13
+ * follow-up will generate them so the gateway stays the single source of truth.
14
+ *
15
+ * x402 signing mirrors src/tools/blockrun.ts (kept as copy-paste per the same
16
+ * rationale documented there — refactoring into a shared module is out of scope).
17
+ */
18
+ import type { CapabilityHandler } from '../agent/types.js';
19
+ export declare const surfMarketCapability: CapabilityHandler;
20
+ export declare const surfChainCapability: CapabilityHandler;
21
+ export declare const surfSocialCapability: CapabilityHandler;
22
+ export declare const surfCapabilities: CapabilityHandler[];
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Surf — function-call tools for BlockRun's crypto data API.
3
+ *
4
+ * Three category tools (SurfMarket / SurfChain / SurfSocial) that mirror the
5
+ * /surf-market, /surf-chain, /surf-social skills. Unlike the generic BlockRun
6
+ * primitive (free-form `path` string), these expose the valid endpoints as an
7
+ * `endpoint` enum so the model picks instead of guessing, and they sign the
8
+ * x402 payment internally — the model never touches paths or payment, same UX
9
+ * as VideoGen / ImageGen.
10
+ *
11
+ * The endpoint tables below are derived from the gateway's SURF_ENDPOINTS
12
+ * registry (blockrun/src/lib/surf.ts). They are hand-maintained for now; a
13
+ * follow-up will generate them so the gateway stays the single source of truth.
14
+ *
15
+ * x402 signing mirrors src/tools/blockrun.ts (kept as copy-paste per the same
16
+ * rationale documented there — refactoring into a shared module is out of scope).
17
+ */
18
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
19
+ import { loadChain, API_URLS, USER_AGENT } from '../config.js';
20
+ import { recordUsage } from '../stats/tracker.js';
21
+ import { logger } from '../logger.js';
22
+ const TIMEOUT_MS = 30_000;
23
+ // ── Endpoint tables (derived from gateway SURF_ENDPOINTS) ───────────────────
24
+ const MARKET_ENDPOINTS = [
25
+ { path: 'market/ranking', method: 'GET', required: [], desc: 'Token rankings (market cap, volume, 24h change).' },
26
+ { path: 'market/fear-greed', method: 'GET', required: [], desc: 'Fear & Greed index history.' },
27
+ { path: 'market/futures', method: 'GET', required: [], desc: 'Futures market overview.' },
28
+ { path: 'market/price', method: 'GET', required: ['symbol'], desc: 'Token price history.' },
29
+ { path: 'market/etf', method: 'GET', required: ['symbol'], desc: 'Spot ETF flow history (BTC/ETH).' },
30
+ { path: 'market/options', method: 'GET', required: ['symbol'], desc: 'Options skew / IV / volume.' },
31
+ { path: 'market/liquidation/exchange-list', method: 'GET', required: [], desc: 'Liquidations by exchange.' },
32
+ { path: 'market/liquidation/order', method: 'GET', required: [], desc: 'Large (whale) liquidation orders.' },
33
+ { path: 'market/liquidation/chart', method: 'GET', required: ['symbol'], desc: 'Liquidation chart over time.' },
34
+ { path: 'market/onchain-indicator', method: 'GET', required: ['symbol', 'metric'], desc: 'On-chain indicators (NUPL/SOPR/MVRV/Puell/NVT).' },
35
+ { path: 'market/price-indicator', method: 'GET', required: ['indicator', 'symbol'], desc: 'Technical indicators (RSI/MACD/BBANDS/EMA).' },
36
+ { path: 'exchange/markets', method: 'GET', required: [], desc: 'CEX trading pairs catalog.' },
37
+ { path: 'exchange/price', method: 'GET', required: ['pair'], desc: 'CEX ticker price for a pair.' },
38
+ { path: 'exchange/perp', method: 'GET', required: ['pair'], desc: 'Perpetual contract snapshot.' },
39
+ { path: 'exchange/depth', method: 'GET', required: ['pair'], desc: 'Order book depth.' },
40
+ { path: 'exchange/klines', method: 'GET', required: ['pair'], desc: 'OHLCV candles.' },
41
+ { path: 'exchange/funding-history', method: 'GET', required: ['pair'], desc: 'Funding rate history.' },
42
+ { path: 'exchange/long-short-ratio', method: 'GET', required: ['pair'], desc: 'Long/short account ratio.' },
43
+ { path: 'fund/detail', method: 'GET', required: [], desc: 'VC fund profile detail.' },
44
+ { path: 'fund/portfolio', method: 'GET', required: [], desc: 'VC fund portfolio holdings.' },
45
+ { path: 'fund/ranking', method: 'GET', required: ['metric'], desc: 'Top VC funds ranking.' },
46
+ { path: 'news/feed', method: 'GET', required: [], desc: 'AI-curated crypto news feed.' },
47
+ { path: 'news/detail', method: 'GET', required: ['id'], desc: 'Full article detail by id.' },
48
+ { path: 'project/detail', method: 'GET', required: [], desc: 'Project profile.' },
49
+ { path: 'project/defi/metrics', method: 'GET', required: ['metric'], desc: 'DeFi protocol metrics.' },
50
+ { path: 'project/defi/ranking', method: 'GET', required: ['metric'], desc: 'DeFi protocol ranking.' },
51
+ ];
52
+ const CHAIN_ENDPOINTS = [
53
+ { path: 'onchain/bridge/ranking', method: 'GET', required: [], desc: 'Bridge protocol ranking by volume.' },
54
+ { path: 'onchain/yield/ranking', method: 'GET', required: [], desc: 'Yield pool ranking (lending/LP/staking).' },
55
+ { path: 'onchain/gas-price', method: 'GET', required: ['chain'], desc: 'Current gas price for a chain.' },
56
+ { path: 'onchain/tx', method: 'GET', required: ['hash', 'chain'], desc: 'Transaction details by hash.' },
57
+ { path: 'onchain/schema', method: 'GET', required: [], desc: 'Schema introspection for the SQL tables.' },
58
+ { path: 'onchain/query', method: 'POST', required: [], desc: 'Structured chain query (POST body).' },
59
+ { path: 'onchain/sql', method: 'POST', required: [], desc: 'Raw SQL against 80+ indexed chain tables (POST body, Tier-3 $0.02).' },
60
+ { path: 'token/tokenomics', method: 'GET', required: [], desc: 'Token supply / unlock / distribution.' },
61
+ { path: 'token/dex-trades', method: 'GET', required: ['address'], desc: 'Recent DEX trades for a token.' },
62
+ { path: 'token/holders', method: 'GET', required: ['address', 'chain'], desc: 'Top holders / concentration.' },
63
+ { path: 'token/transfers', method: 'GET', required: ['address', 'chain'], desc: 'Token transfer history.' },
64
+ { path: 'wallet/detail', method: 'GET', required: ['address'], desc: 'Wallet overview.' },
65
+ { path: 'wallet/history', method: 'GET', required: ['address'], desc: 'Wallet activity history.' },
66
+ { path: 'wallet/net-worth', method: 'GET', required: ['address'], desc: 'Wallet net worth.' },
67
+ { path: 'wallet/transfers', method: 'GET', required: ['address'], desc: 'Wallet transfers.' },
68
+ { path: 'wallet/protocols', method: 'GET', required: ['address'], desc: 'Protocols the wallet interacts with.' },
69
+ { path: 'wallet/labels/batch', method: 'GET', required: ['addresses'], desc: 'Batch wallet labels (CEX/Whale/Bridge/MEV).' },
70
+ ];
71
+ const SOCIAL_ENDPOINTS = [
72
+ { path: 'social/detail', method: 'GET', required: [], desc: 'Social signal detail.' },
73
+ { path: 'social/ranking', method: 'GET', required: [], desc: 'KOL / account influence ranking.' },
74
+ { path: 'social/smart-followers/history', method: 'GET', required: [], desc: 'Smart-follower growth history.' },
75
+ { path: 'social/mindshare', method: 'GET', required: ['q', 'interval'], desc: 'Topic/token mindshare over an interval.' },
76
+ { path: 'social/tweets', method: 'GET', required: ['ids'], desc: 'Tweets by ids.' },
77
+ { path: 'social/tweet/replies', method: 'GET', required: ['tweet_id'], desc: 'Replies to a tweet.' },
78
+ { path: 'social/user', method: 'GET', required: ['handle'], desc: 'User profile.' },
79
+ { path: 'social/user/followers', method: 'GET', required: ['handle'], desc: 'User followers.' },
80
+ { path: 'social/user/following', method: 'GET', required: ['handle'], desc: 'User followings.' },
81
+ { path: 'social/user/posts', method: 'GET', required: ['handle'], desc: 'User posts.' },
82
+ { path: 'social/user/replies', method: 'GET', required: ['handle'], desc: 'User replies.' },
83
+ ];
84
+ // ── x402 signing (mirrors blockrun.ts) ──────────────────────────────────────
85
+ async function extractPaymentReq(response) {
86
+ let header = response.headers.get('payment-required');
87
+ if (!header) {
88
+ try {
89
+ const body = (await response.clone().json());
90
+ if (body.x402 || body.accepts)
91
+ header = btoa(JSON.stringify(body));
92
+ }
93
+ catch { /* not JSON */ }
94
+ }
95
+ return header;
96
+ }
97
+ async function signPayment(response, chain, endpoint, resourceDescription) {
98
+ try {
99
+ const paymentHeader = await extractPaymentReq(response);
100
+ if (!paymentHeader)
101
+ return null;
102
+ const paymentRequired = parsePaymentRequired(paymentHeader);
103
+ if (chain === 'solana') {
104
+ const wallet = await getOrCreateSolanaWallet();
105
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
106
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
107
+ const feePayer = details.extra?.feePayer || details.recipient;
108
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
109
+ resourceUrl: details.resource?.url || endpoint,
110
+ resourceDescription: details.resource?.description || resourceDescription,
111
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
112
+ extra: details.extra,
113
+ });
114
+ return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 };
115
+ }
116
+ const wallet = getOrCreateWallet();
117
+ const details = extractPaymentDetails(paymentRequired);
118
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
119
+ resourceUrl: details.resource?.url || endpoint,
120
+ resourceDescription: details.resource?.description || resourceDescription,
121
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 300,
122
+ extra: details.extra,
123
+ });
124
+ return { headers: { 'PAYMENT-SIGNATURE': payload }, amountUsd: Number(details.amount) / 1_000_000 };
125
+ }
126
+ catch (err) {
127
+ logger.warn(`[franklin] Surf payment error: ${err.message}`);
128
+ return null;
129
+ }
130
+ }
131
+ // ── Shared call: resolve endpoint → sign x402 → return data ──────────────────
132
+ async function callSurf(toolName, table, input, ctx) {
133
+ const endpoint = typeof input.endpoint === 'string' ? input.endpoint.trim().replace(/^\/+|\/+$/g, '') : '';
134
+ let entry = table.find((e) => e.path === endpoint);
135
+ if (!entry) {
136
+ // Tolerate a weak model dropping the category prefix ("fear-greed" instead
137
+ // of "market/fear-greed") — accept a suffix match when it's unambiguous.
138
+ const matches = table.filter((e) => e.path === endpoint || e.path.endsWith(`/${endpoint}`));
139
+ if (matches.length === 1) {
140
+ entry = matches[0];
141
+ }
142
+ else if (matches.length > 1) {
143
+ return { output: `Ambiguous ${toolName} endpoint "${endpoint}". Did you mean: ${matches.map((m) => m.path).join(', ')}?`, isError: true };
144
+ }
145
+ else {
146
+ return { output: `Unknown ${toolName} endpoint: "${endpoint}". Valid: ${table.map((e) => e.path).join(', ')}`, isError: true };
147
+ }
148
+ }
149
+ // Collect query params: the named fields the caller provided (everything
150
+ // except `endpoint`/`body`), plus an explicit `params` object if given.
151
+ const query = {};
152
+ for (const [k, v] of Object.entries(input)) {
153
+ if (k === 'endpoint' || k === 'body' || k === 'params')
154
+ continue;
155
+ if (v !== undefined && v !== null && v !== '')
156
+ query[k] = v;
157
+ }
158
+ if (input.params && typeof input.params === 'object')
159
+ Object.assign(query, input.params);
160
+ const missing = entry.required.filter((p) => query[p] === undefined);
161
+ if (missing.length > 0) {
162
+ return {
163
+ output: `${toolName} ${endpoint} needs: ${entry.required.join(', ')}. Missing: ${missing.join(', ')}.`,
164
+ isError: true,
165
+ };
166
+ }
167
+ const chain = loadChain();
168
+ const base = API_URLS[chain]; // ends in /api
169
+ let url = `${base}/v1/surf/${entry.path}`;
170
+ const body = entry.method === 'POST'
171
+ ? (input.body && typeof input.body === 'object' ? input.body : query)
172
+ : undefined;
173
+ if (entry.method === 'GET' && Object.keys(query).length > 0) {
174
+ const usp = new URLSearchParams();
175
+ for (const [k, v] of Object.entries(query)) {
176
+ if (Array.isArray(v))
177
+ for (const x of v)
178
+ usp.append(k, String(x));
179
+ else
180
+ usp.append(k, String(v));
181
+ }
182
+ url += `?${usp.toString()}`;
183
+ }
184
+ const start = Date.now();
185
+ const ctrl = new AbortController();
186
+ const onAbort = () => ctrl.abort();
187
+ ctx.abortSignal.addEventListener('abort', onAbort, { once: true });
188
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
189
+ const headers = { Accept: 'application/json', 'User-Agent': USER_AGENT };
190
+ if (entry.method === 'POST')
191
+ headers['Content-Type'] = 'application/json';
192
+ const payload = body !== undefined ? JSON.stringify(body) : undefined;
193
+ const resourceDescription = `Surf ${entry.method} /v1/surf/${entry.path}`;
194
+ try {
195
+ let response = await fetch(url, { method: entry.method, signal: ctrl.signal, headers, body: payload });
196
+ let paidUsd = 0;
197
+ if (response.status === 402) {
198
+ const signed = await signPayment(response, chain, url, resourceDescription);
199
+ if (!signed)
200
+ return { output: `${toolName} ${endpoint}: payment signing failed`, isError: true };
201
+ paidUsd = signed.amountUsd;
202
+ response = await fetch(url, {
203
+ method: entry.method, signal: ctrl.signal,
204
+ headers: { ...headers, ...signed.headers }, body: payload,
205
+ });
206
+ }
207
+ if (!response.ok)
208
+ paidUsd = 0;
209
+ const raw = await response.text().catch(() => '');
210
+ try {
211
+ recordUsage(`${toolName}:${entry.path}`, 0, 0, paidUsd, Date.now() - start);
212
+ }
213
+ catch { /* best-effort */ }
214
+ if (!response.ok) {
215
+ return {
216
+ output: `${toolName} ${endpoint} failed (status ${response.status}). No charge if 4xx pre-payment.\n${raw.slice(0, 800)}`,
217
+ isError: true,
218
+ };
219
+ }
220
+ const head = `Surf /v1/surf/${entry.path} → $${paidUsd.toFixed(4)} · ${Date.now() - start}ms`;
221
+ return { output: `${head}\n\n\`\`\`json\n${raw}\n\`\`\`` };
222
+ }
223
+ catch (err) {
224
+ return { output: `${toolName} ${endpoint} error: ${err.message}`, isError: true };
225
+ }
226
+ finally {
227
+ clearTimeout(timer);
228
+ ctx.abortSignal.removeEventListener('abort', onAbort);
229
+ }
230
+ }
231
+ // ── Tool specs ───────────────────────────────────────────────────────────────
232
+ function makeSurfTool(name, blurb, table, extraParams) {
233
+ const endpointList = table.map((e) => `\`${e.path}\`${e.required.length ? ` (needs ${e.required.join('+')})` : ''} — ${e.desc}`).join('\n');
234
+ return {
235
+ spec: {
236
+ name,
237
+ description: `${blurb} Picks an endpoint from a fixed list and signs the x402 USDC payment from the wallet automatically — ` +
238
+ `you do not build paths or handle payment. Tier-1 $0.001, Tier-2 $0.005, Tier-3 $0.02.\n\nEndpoints:\n${endpointList}`,
239
+ input_schema: {
240
+ type: 'object',
241
+ properties: {
242
+ endpoint: {
243
+ type: 'string',
244
+ enum: table.map((e) => e.path),
245
+ description: 'Which Surf endpoint to call (see list in the tool description).',
246
+ },
247
+ ...extraParams,
248
+ body: { type: 'object', description: 'Request body for POST endpoints (onchain/query, onchain/sql).' },
249
+ },
250
+ required: ['endpoint'],
251
+ },
252
+ },
253
+ concurrent: true,
254
+ execute: (input, ctx) => callSurf(name, table, input, ctx),
255
+ };
256
+ }
257
+ 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, {
258
+ symbol: { type: 'string', description: 'Token symbol, e.g. "BTC". Required by price/etf/options/liquidation-chart/indicators.' },
259
+ pair: { type: 'string', description: 'Exchange pair, e.g. "BTC-USDT". Required by exchange/* endpoints.' },
260
+ metric: { type: 'string', description: 'Metric name (e.g. "NUPL" for onchain-indicator, ranking metric for fund/project).' },
261
+ indicator: { type: 'string', description: 'Technical indicator, e.g. "RSI", "MACD", "BBANDS".' },
262
+ id: { type: 'string', description: 'Article id for news/detail.' },
263
+ });
264
+ 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, {
265
+ chain: { type: 'string', description: 'Chain name, e.g. "ethereum", "base". Required by gas-price/tx/holders/transfers.' },
266
+ hash: { type: 'string', description: 'Transaction hash for onchain/tx.' },
267
+ address: { type: 'string', description: 'Token or wallet address.' },
268
+ addresses: { type: 'string', description: 'Comma-separated addresses for wallet/labels/batch.' },
269
+ });
270
+ 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, {
271
+ q: { type: 'string', description: 'Query/topic for mindshare.' },
272
+ interval: { type: 'string', description: 'Time interval for mindshare, e.g. "24h", "7d".' },
273
+ handle: { type: 'string', description: 'Twitter/X handle for social/user* endpoints.' },
274
+ ids: { type: 'string', description: 'Comma-separated tweet ids for social/tweets.' },
275
+ tweet_id: { type: 'string', description: 'Tweet id for social/tweet/replies.' },
276
+ });
277
+ export const surfCapabilities = [
278
+ surfMarketCapability,
279
+ surfChainCapability,
280
+ surfSocialCapability,
281
+ ];
@@ -49,6 +49,13 @@ export const CORE_TOOL_NAMES = new Set([
49
49
  // category. Cross-platform pair lookup is unique to the gateway and
50
50
  // is the kind of data a non-wallet agent fundamentally cannot reach.
51
51
  'PredictionMarket',
52
+ // Crypto market data — fear/greed, token rankings, ETF flows, options,
53
+ // liquidations, technical & on-chain indicators. The "what's the crypto
54
+ // mood / which coins are pumping / BTC's RSI" category. Core so the agent
55
+ // reaches for it on natural crypto questions instead of falling back to
56
+ // TradingMarket prices + guessing the Fear & Greed index. SurfChain /
57
+ // SurfSocial stay activation-gated (lower-frequency, long-tail surface).
58
+ 'SurfMarket',
52
59
  // Research — synthesized answers with real citations, semantic web
53
60
  // search, and clean URL fetching. Any factual current-events question
54
61
  // ("why did X drop?") should route here rather than the model's prior.