@blockrun/franklin 3.8.8 → 3.8.10

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.
Files changed (50) hide show
  1. package/dist/agent/error-classifier.js +1 -0
  2. package/dist/agent/llm.d.ts +7 -0
  3. package/dist/agent/llm.js +48 -7
  4. package/dist/agent/loop.js +66 -3
  5. package/dist/agent/permissions.js +2 -2
  6. package/dist/agent/types.d.ts +7 -0
  7. package/dist/banner.js +15 -0
  8. package/dist/commands/start.d.ts +4 -0
  9. package/dist/commands/start.js +72 -2
  10. package/dist/index.js +11 -3
  11. package/dist/panel/html.js +111 -21
  12. package/dist/panel/server.js +15 -4
  13. package/dist/tools/activate.d.ts +29 -0
  14. package/dist/tools/activate.js +96 -0
  15. package/dist/tools/index.js +2 -0
  16. package/dist/tools/tool-categories.d.ts +22 -0
  17. package/dist/tools/tool-categories.js +44 -0
  18. package/dist/tools/trading-execute.d.ts +11 -21
  19. package/dist/tools/trading-execute.js +43 -130
  20. package/dist/tools/trading-views.d.ts +64 -0
  21. package/dist/tools/trading-views.js +115 -0
  22. package/dist/tools/trading.js +86 -7
  23. package/dist/tools/webhook.d.ts +18 -0
  24. package/dist/tools/webhook.js +185 -0
  25. package/dist/trading/data.d.ts +24 -1
  26. package/dist/trading/data.js +67 -102
  27. package/dist/trading/providers/blockrun/client.d.ts +48 -0
  28. package/dist/trading/providers/blockrun/client.js +253 -0
  29. package/dist/trading/providers/blockrun/price.d.ts +24 -0
  30. package/dist/trading/providers/blockrun/price.js +110 -0
  31. package/dist/trading/providers/coingecko/client.d.ts +20 -0
  32. package/dist/trading/providers/coingecko/client.js +87 -0
  33. package/dist/trading/providers/coingecko/markets.d.ts +3 -0
  34. package/dist/trading/providers/coingecko/markets.js +25 -0
  35. package/dist/trading/providers/coingecko/ohlcv.d.ts +3 -0
  36. package/dist/trading/providers/coingecko/ohlcv.js +29 -0
  37. package/dist/trading/providers/coingecko/price.d.ts +11 -0
  38. package/dist/trading/providers/coingecko/price.js +41 -0
  39. package/dist/trading/providers/coingecko/trending.d.ts +3 -0
  40. package/dist/trading/providers/coingecko/trending.js +22 -0
  41. package/dist/trading/providers/fetcher.d.ts +43 -0
  42. package/dist/trading/providers/fetcher.js +45 -0
  43. package/dist/trading/providers/registry.d.ts +45 -0
  44. package/dist/trading/providers/registry.js +82 -0
  45. package/dist/trading/providers/standard-models.d.ts +94 -0
  46. package/dist/trading/providers/standard-models.js +21 -0
  47. package/dist/trading/providers/telemetry.d.ts +51 -0
  48. package/dist/trading/providers/telemetry.js +115 -0
  49. package/dist/ui/app.js +28 -2
  50. package/package.json +1 -1
@@ -1,112 +1,77 @@
1
- const BASE = "https://api.coingecko.com/api/v3";
2
- const UA = "franklin/3.3.0 (trading)";
3
- const TIMEOUT = 10_000;
4
- const TICKER_MAP = {
5
- BTC: "bitcoin", ETH: "ethereum", SOL: "solana", BNB: "binancecoin", XRP: "ripple",
6
- ADA: "cardano", DOGE: "dogecoin", AVAX: "avalanche-2", DOT: "polkadot", MATIC: "matic-network",
7
- LINK: "chainlink", UNI: "uniswap", ATOM: "cosmos", LTC: "litecoin", NEAR: "near",
8
- APT: "aptos", ARB: "arbitrum", OP: "optimism", SUI: "sui", SEI: "sei-network",
9
- FIL: "filecoin", AAVE: "aave", MKR: "maker", SNX: "synthetix-network-token",
10
- COMP: "compound-governance-token", INJ: "injective-protocol", TIA: "celestia",
11
- PEPE: "pepe", WIF: "dogwifcoin", RENDER: "render-token",
12
- };
13
- const cache = new Map();
14
- function cached(key, ttlMs, fn) {
15
- const hit = cache.get(key);
16
- if (hit && hit.expiry > Date.now())
17
- return Promise.resolve(hit.data);
18
- return fn().then(data => {
19
- cache.set(key, { data, expiry: Date.now() + ttlMs });
20
- return data;
21
- });
1
+ /**
2
+ * Legacy data.ts — thin shim over the provider registry.
3
+ *
4
+ * Keeps the historical `string | Data` error-by-string return convention so
5
+ * the existing callers (`trading.ts`, `tools/index.ts` wiring into
6
+ * `LiveExchange`) keep working without a call-site rewrite. New code should
7
+ * import from `./providers/registry.js` and consume `PriceData | ProviderError`
8
+ * directly the structured error shape enables UI color-coding and retry
9
+ * classification the string convention can't express.
10
+ */
11
+ import { getProvider, getPriceProvider } from './providers/registry.js';
12
+ import { runFetcher } from './providers/fetcher.js';
13
+ import { isProviderError } from './providers/standard-models.js';
14
+ export function resolveId(ticker) {
15
+ // Delegated kept exported for backwards compat with any external caller.
16
+ return ticker.toLowerCase();
22
17
  }
23
- const TTL_PRICE = 5 * 60_000;
24
- const TTL_OHLCV = 60 * 60_000;
25
- const TTL_TRENDING = 15 * 60_000;
26
- // Fetch helper
27
- async function geckofetch(path) {
28
- const ctrl = new AbortController();
29
- const timer = setTimeout(() => ctrl.abort(), TIMEOUT);
30
- try {
31
- const res = await fetch(`${BASE}${path}`, {
32
- headers: { "User-Agent": UA },
33
- signal: ctrl.signal,
34
- });
35
- if (res.status === 429)
36
- return "rate-limited: CoinGecko 429 — retry later";
37
- if (!res.ok)
38
- return `CoinGecko error ${res.status}`;
39
- return await res.json();
40
- }
41
- catch (e) {
42
- if (e instanceof DOMException && e.name === "AbortError")
43
- return "request timed out";
44
- return String(e);
45
- }
46
- finally {
47
- clearTimeout(timer);
48
- }
18
+ /**
19
+ * Look up a spot price. `assetClass` defaults to 'crypto' so all existing
20
+ * crypto-only callers (`TradingSignal`, `LiveExchange.getPrice`) behave
21
+ * exactly as before. Pass 'fx' / 'commodity' / 'stock' (plus a `market`
22
+ * code for stocks) to hit the multi-asset Gateway endpoints.
23
+ */
24
+ export async function getPrice(ticker, assetClass = 'crypto', market) {
25
+ const result = await runFetcher(getPriceProvider(assetClass), { ticker, assetClass, market });
26
+ if (isProviderError(result))
27
+ return result.message;
28
+ return {
29
+ price: result.priceUsd,
30
+ change24h: result.change24hPct,
31
+ volume24h: result.volume24hUsd,
32
+ marketCap: result.marketCapUsd,
33
+ };
49
34
  }
50
- export function resolveId(ticker) {
51
- return TICKER_MAP[ticker.toUpperCase()] ?? ticker.toLowerCase();
35
+ /** Convenience: FX pair lookup (e.g. "EUR-USD"). */
36
+ export async function getFxPrice(ticker) {
37
+ return getPrice(ticker, 'fx');
38
+ }
39
+ /** Convenience: commodity lookup (e.g. "XAU-USD" for gold). */
40
+ export async function getCommodityPrice(ticker) {
41
+ return getPrice(ticker, 'commodity');
52
42
  }
53
- export async function getPrice(ticker) {
54
- const id = resolveId(ticker);
55
- return cached(`price:${id}`, TTL_PRICE, async () => {
56
- const raw = await geckofetch(`/simple/price?ids=${id}&vs_currencies=usd&include_24hr_change=true&include_market_cap=true&include_24hr_vol=true`);
57
- if (typeof raw === "string")
58
- return raw;
59
- const d = raw[id];
60
- if (!d)
61
- return `no data for ${ticker}`;
62
- return {
63
- price: d.usd,
64
- change24h: d.usd_24h_change,
65
- volume24h: d.usd_24h_vol,
66
- marketCap: d.usd_market_cap,
67
- };
68
- });
43
+ /** Convenience: stock lookup (e.g. "AAPL" on market "us"). */
44
+ export async function getStockPrice(ticker, market) {
45
+ return getPrice(ticker, 'stock', market);
69
46
  }
70
47
  export async function getOHLCV(ticker, days = 30) {
71
- const id = resolveId(ticker);
72
- return cached(`ohlcv:${id}:${days}`, TTL_OHLCV, async () => {
73
- const raw = await geckofetch(`/coins/${id}/market_chart?vs_currency=usd&days=${days}&interval=daily`);
74
- if (typeof raw === "string")
75
- return raw;
76
- const prices = raw.prices;
77
- return {
78
- timestamps: prices.map(p => p[0]),
79
- closes: prices.map(p => p[1]),
80
- };
81
- });
48
+ const result = await runFetcher(getProvider('ohlcv'), { ticker, days });
49
+ if (isProviderError(result))
50
+ return result.message;
51
+ return { closes: result.closes, timestamps: result.timestamps };
82
52
  }
83
53
  export async function getTrending() {
84
- return cached("trending", TTL_TRENDING, async () => {
85
- const raw = await geckofetch("/search/trending");
86
- if (typeof raw === "string")
87
- return raw;
88
- const coins = raw.coins;
89
- return coins.map(c => ({
90
- id: c.item.id,
91
- name: c.item.name,
92
- symbol: c.item.symbol,
93
- marketCapRank: c.item.market_cap_rank,
94
- }));
95
- });
54
+ const result = await runFetcher(getProvider('trending'), {});
55
+ if (isProviderError(result))
56
+ return result.message;
57
+ return result.map(c => ({
58
+ id: c.providerId,
59
+ name: c.name,
60
+ symbol: c.symbol,
61
+ marketCapRank: c.marketCapRank,
62
+ }));
96
63
  }
97
64
  export async function getMarketOverview() {
98
- return cached("markets", TTL_TRENDING, async () => {
99
- const raw = await geckofetch("/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=20&page=1");
100
- if (typeof raw === "string")
101
- return raw;
102
- return raw.map(c => ({
103
- id: c.id,
104
- symbol: c.symbol,
105
- name: c.name,
106
- price: c.current_price,
107
- change24h: c.price_change_percentage_24h,
108
- marketCap: c.market_cap,
109
- volume24h: c.total_volume,
110
- }));
111
- });
65
+ const result = await runFetcher(getProvider('markets'), { limit: 20 });
66
+ if (isProviderError(result))
67
+ return result.message;
68
+ return result.map(c => ({
69
+ id: c.providerId,
70
+ symbol: c.ticker.toLowerCase(),
71
+ name: c.name,
72
+ price: c.priceUsd,
73
+ change24h: c.change24hPct,
74
+ marketCap: c.marketCapUsd,
75
+ volume24h: c.volume24hUsd,
76
+ }));
112
77
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared BlockRun Gateway HTTP client + short-TTL cache.
3
+ *
4
+ * Used by every BlockRun-backed fetcher (price, future OHLCV, etc). Mirrors
5
+ * the shape of `coingecko/client.ts` so the two providers feel the same to
6
+ * callers and tests.
7
+ *
8
+ * Chain-aware: base URL follows `loadChain()` — Base mainnet users hit
9
+ * `blockrun.ai`, Solana users hit `sol.blockrun.ai`. Trading data endpoints
10
+ * live under `/v1/*` (not `/api/v1`, which is the LLM proxy surface).
11
+ *
12
+ * PR 1 scope: free endpoints only (crypto / fx / commodity price). Paid
13
+ * stocks endpoints (`/v1/stocks/{market}/price/{symbol}`) arrive in PR 2
14
+ * together with the x402 signing wrapper.
15
+ */
16
+ import type { ProviderError } from '../standard-models.js';
17
+ export declare function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T>;
18
+ /** For tests: wipe every cached entry. */
19
+ export declare function clearCache(): void;
20
+ /**
21
+ * Fire-and-parse: GET a BlockRun Gateway REST endpoint. Returns parsed JSON
22
+ * or a structured ProviderError — never throws. Records latency + outcome
23
+ * to the telemetry singleton so the Panel Markets page can show live health.
24
+ */
25
+ export declare function blockrunGet(path: string, opts?: {
26
+ endpoint: string;
27
+ paid?: boolean;
28
+ costUsd?: number;
29
+ }): Promise<unknown | ProviderError>;
30
+ /**
31
+ * GET a paid BlockRun Gateway endpoint with automatic x402 signing.
32
+ * Returns parsed JSON or a structured ProviderError. Never throws.
33
+ */
34
+ export declare function blockrunGetPaid(path: string, opts: {
35
+ endpoint: string;
36
+ costUsd: number;
37
+ }): Promise<unknown | ProviderError>;
38
+ /**
39
+ * Pyth-style symbols always end in `-USD`. Agents may pass `BTC` meaning
40
+ * `BTC-USD`; normalize so both shapes work.
41
+ */
42
+ export declare function normalizePythSymbol(ticker: string): string;
43
+ /** TTLs chosen to match CoinGecko's; Pyth pushes more often but we don't
44
+ * need sub-minute freshness for Franklin's agent cadence. */
45
+ export declare const TTL: {
46
+ readonly price: number;
47
+ readonly ohlcv: number;
48
+ };
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Shared BlockRun Gateway HTTP client + short-TTL cache.
3
+ *
4
+ * Used by every BlockRun-backed fetcher (price, future OHLCV, etc). Mirrors
5
+ * the shape of `coingecko/client.ts` so the two providers feel the same to
6
+ * callers and tests.
7
+ *
8
+ * Chain-aware: base URL follows `loadChain()` — Base mainnet users hit
9
+ * `blockrun.ai`, Solana users hit `sol.blockrun.ai`. Trading data endpoints
10
+ * live under `/v1/*` (not `/api/v1`, which is the LLM proxy surface).
11
+ *
12
+ * PR 1 scope: free endpoints only (crypto / fx / commodity price). Paid
13
+ * stocks endpoints (`/v1/stocks/{market}/price/{symbol}`) arrive in PR 2
14
+ * together with the x402 signing wrapper.
15
+ */
16
+ import { USER_AGENT, loadChain } from '../../../config.js';
17
+ import { recordFetch } from '../telemetry.js';
18
+ import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
19
+ const TIMEOUT_MS = 10_000;
20
+ function baseUrl() {
21
+ // `loadChain()` dispatches on env / ~/.blockrun/payment-chain. We match it
22
+ // every call so mid-session chain switches take effect without restart.
23
+ return loadChain() === 'solana' ? 'https://sol.blockrun.ai' : 'https://blockrun.ai';
24
+ }
25
+ const cache = new Map();
26
+ export async function cached(key, ttlMs, fn) {
27
+ const hit = cache.get(key);
28
+ if (hit && hit.expiry > Date.now())
29
+ return hit.data;
30
+ const data = await fn();
31
+ cache.set(key, { data, expiry: Date.now() + ttlMs });
32
+ return data;
33
+ }
34
+ /** For tests: wipe every cached entry. */
35
+ export function clearCache() {
36
+ cache.clear();
37
+ }
38
+ /**
39
+ * Fire-and-parse: GET a BlockRun Gateway REST endpoint. Returns parsed JSON
40
+ * or a structured ProviderError — never throws. Records latency + outcome
41
+ * to the telemetry singleton so the Panel Markets page can show live health.
42
+ */
43
+ export async function blockrunGet(path, opts = { endpoint: path }) {
44
+ const ctrl = new AbortController();
45
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
46
+ const url = `${baseUrl()}${path}`;
47
+ const startedAt = Date.now();
48
+ try {
49
+ const res = await fetch(url, {
50
+ headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
51
+ signal: ctrl.signal,
52
+ });
53
+ const latencyMs = Date.now() - startedAt;
54
+ if (res.status === 429) {
55
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
56
+ return {
57
+ kind: 'rate-limited',
58
+ message: `BlockRun Gateway rate-limited this request (HTTP 429). Retry shortly.`,
59
+ };
60
+ }
61
+ if (res.status === 404) {
62
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
63
+ return { kind: 'not-found', message: `BlockRun Gateway 404 for ${path}` };
64
+ }
65
+ if (res.status === 402) {
66
+ // Free-path client should never see a 402. If the Gateway starts
67
+ // charging for an endpoint that was free, surface an actionable
68
+ // error and let the caller migrate to `blockrunGetPaid`.
69
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
70
+ return {
71
+ kind: 'upstream-error',
72
+ code: 'insufficient-funds',
73
+ message: `Gateway unexpectedly requires payment for ${path}. Move this endpoint to blockrunGetPaid.`,
74
+ };
75
+ }
76
+ if (!res.ok) {
77
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
78
+ return { kind: 'upstream-error', message: `BlockRun Gateway HTTP ${res.status}` };
79
+ }
80
+ const data = await res.json();
81
+ recordFetch({
82
+ provider: 'blockrun',
83
+ endpoint: opts.endpoint,
84
+ ok: true,
85
+ latencyMs,
86
+ costUsd: opts.paid ? opts.costUsd ?? 0 : 0,
87
+ });
88
+ return data;
89
+ }
90
+ catch (e) {
91
+ const latencyMs = Date.now() - startedAt;
92
+ if (e instanceof DOMException && e.name === 'AbortError') {
93
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
94
+ return { kind: 'timeout', message: `BlockRun Gateway timed out after ${TIMEOUT_MS}ms` };
95
+ }
96
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
97
+ return { kind: 'unknown', message: String(e) };
98
+ }
99
+ finally {
100
+ clearTimeout(timer);
101
+ }
102
+ }
103
+ // ─── x402 paid GET ──────────────────────────────────────────────────────
104
+ //
105
+ // Mirrors the POST payment flow in `src/tools/exa.ts` but for GET requests
106
+ // against Pyth paid endpoints (stocks today; historical OHLCV tomorrow).
107
+ // Lazy-loads the wallet on first 402 so free endpoints never touch the
108
+ // wallet module.
109
+ //
110
+ // No budget gate, no pre-flight check, no soft refusal — Franklin's whole
111
+ // identity is "agent with a wallet that spends USDC for real work". $0.001
112
+ // per stock quote is not a category that warrants a permission prompt.
113
+ async function extractPaymentReq(response) {
114
+ let header = response.headers.get('payment-required');
115
+ if (!header) {
116
+ try {
117
+ const body = (await response.json());
118
+ if (body.x402 || body.accepts)
119
+ header = btoa(JSON.stringify(body));
120
+ }
121
+ catch { /* ignore */ }
122
+ }
123
+ return header;
124
+ }
125
+ async function signGatewayPayment(response, chain, endpoint) {
126
+ try {
127
+ const paymentHeader = await extractPaymentReq(response);
128
+ if (!paymentHeader)
129
+ return null;
130
+ if (chain === 'solana') {
131
+ const wallet = await getOrCreateSolanaWallet();
132
+ const paymentRequired = parsePaymentRequired(paymentHeader);
133
+ const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
134
+ const secretBytes = await solanaKeyToBytes(wallet.privateKey);
135
+ const feePayer = details.extra?.feePayer || details.recipient;
136
+ const payload = await createSolanaPaymentPayload(secretBytes, wallet.address, details.recipient, details.amount, feePayer, {
137
+ resourceUrl: details.resource?.url || endpoint,
138
+ resourceDescription: details.resource?.description || 'Franklin trading data',
139
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
140
+ extra: details.extra,
141
+ });
142
+ return { 'PAYMENT-SIGNATURE': payload };
143
+ }
144
+ const wallet = getOrCreateWallet();
145
+ const paymentRequired = parsePaymentRequired(paymentHeader);
146
+ const details = extractPaymentDetails(paymentRequired);
147
+ const payload = await createPaymentPayload(wallet.privateKey, wallet.address, details.recipient, details.amount, details.network || 'eip155:8453', {
148
+ resourceUrl: details.resource?.url || endpoint,
149
+ resourceDescription: details.resource?.description || 'Franklin trading data',
150
+ maxTimeoutSeconds: details.maxTimeoutSeconds || 60,
151
+ extra: details.extra,
152
+ });
153
+ return { 'PAYMENT-SIGNATURE': payload };
154
+ }
155
+ catch (err) {
156
+ // Bubble a typed error up so the caller can turn it into a
157
+ // ProviderError with code: 'insufficient-funds'.
158
+ throw new PaymentSignError(err.message);
159
+ }
160
+ }
161
+ class PaymentSignError extends Error {
162
+ constructor(message) { super(message); this.name = 'PaymentSignError'; }
163
+ }
164
+ /**
165
+ * GET a paid BlockRun Gateway endpoint with automatic x402 signing.
166
+ * Returns parsed JSON or a structured ProviderError. Never throws.
167
+ */
168
+ export async function blockrunGetPaid(path, opts) {
169
+ const ctrl = new AbortController();
170
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
171
+ const url = `${baseUrl()}${path}`;
172
+ const chain = loadChain();
173
+ const startedAt = Date.now();
174
+ const headers = {
175
+ 'User-Agent': USER_AGENT,
176
+ Accept: 'application/json',
177
+ };
178
+ try {
179
+ let res = await fetch(url, { headers, signal: ctrl.signal });
180
+ if (res.status === 402) {
181
+ try {
182
+ const paid = await signGatewayPayment(res, chain, url);
183
+ if (!paid) {
184
+ const latencyMs = Date.now() - startedAt;
185
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
186
+ return {
187
+ kind: 'upstream-error',
188
+ code: 'insufficient-funds',
189
+ message: 'Gateway required payment but did not supply payment-required header. Fund your wallet: franklin wallet fund',
190
+ };
191
+ }
192
+ res = await fetch(url, { headers: { ...headers, ...paid }, signal: ctrl.signal });
193
+ }
194
+ catch (e) {
195
+ const latencyMs = Date.now() - startedAt;
196
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
197
+ if (e instanceof PaymentSignError) {
198
+ return {
199
+ kind: 'upstream-error',
200
+ code: 'insufficient-funds',
201
+ message: `Payment failed: ${e.message}. Check wallet balance with "franklin wallet".`,
202
+ };
203
+ }
204
+ throw e;
205
+ }
206
+ }
207
+ const latencyMs = Date.now() - startedAt;
208
+ if (res.status === 429) {
209
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
210
+ return { kind: 'rate-limited', message: 'BlockRun Gateway rate-limited this request. Retry shortly.' };
211
+ }
212
+ if (res.status === 404) {
213
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
214
+ return { kind: 'not-found', message: `BlockRun Gateway 404 for ${path}` };
215
+ }
216
+ if (!res.ok) {
217
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
218
+ return { kind: 'upstream-error', message: `BlockRun Gateway HTTP ${res.status}` };
219
+ }
220
+ const data = await res.json();
221
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: true, latencyMs, costUsd: opts.costUsd });
222
+ return data;
223
+ }
224
+ catch (e) {
225
+ const latencyMs = Date.now() - startedAt;
226
+ recordFetch({ provider: 'blockrun', endpoint: opts.endpoint, ok: false, latencyMs });
227
+ if (e instanceof DOMException && e.name === 'AbortError') {
228
+ return { kind: 'timeout', message: `BlockRun Gateway timed out after ${TIMEOUT_MS}ms` };
229
+ }
230
+ return { kind: 'unknown', message: String(e) };
231
+ }
232
+ finally {
233
+ clearTimeout(timer);
234
+ }
235
+ }
236
+ /**
237
+ * Pyth-style symbols always end in `-USD`. Agents may pass `BTC` meaning
238
+ * `BTC-USD`; normalize so both shapes work.
239
+ */
240
+ export function normalizePythSymbol(ticker) {
241
+ const upper = ticker.trim().toUpperCase();
242
+ if (!upper)
243
+ return upper;
244
+ if (upper.includes('-'))
245
+ return upper;
246
+ return `${upper}-USD`;
247
+ }
248
+ /** TTLs chosen to match CoinGecko's; Pyth pushes more often but we don't
249
+ * need sub-minute freshness for Franklin's agent cadence. */
250
+ export const TTL = {
251
+ price: 5 * 60_000,
252
+ ohlcv: 60 * 60_000,
253
+ };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * BlockRun Gateway price fetcher.
3
+ *
4
+ * One fetcher, many asset classes. Dispatches on `PriceQueryParams.assetClass`
5
+ * to the right Pyth-backed Gateway endpoint:
6
+ *
7
+ * crypto → /api/v1/crypto/price/{ticker} free
8
+ * fx → /api/v1/fx/price/{ticker} free
9
+ * commodity → /api/v1/commodity/price/{ticker} free
10
+ * stock → /v1/stocks/{market}/price/{ticker} paid ($0.001 x402) — PR 2
11
+ *
12
+ * PR 1 scope: crypto / fx / commodity only. The stock branch returns a
13
+ * `ProviderError { code: 'insufficient-funds' }` until the x402 signing
14
+ * wrapper lands, so callers get a useful message instead of a wire-level
15
+ * 402.
16
+ *
17
+ * Response shape from Gateway: Pyth delivers `{ price, confidence, timestamp }`
18
+ * — no 24h change, no market cap, no volume. Legacy CoinGecko-shaped fields
19
+ * (`change24hPct`, `volume24hUsd`, `marketCapUsd`) come back as `NaN` for
20
+ * non-crypto classes; views treat NaN as "not applicable" and render a dash.
21
+ */
22
+ import type { Fetcher } from '../fetcher.js';
23
+ import type { PriceData, PriceQueryParams } from '../standard-models.js';
24
+ export declare const blockrunPriceFetcher: Fetcher<PriceQueryParams, PriceData>;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * BlockRun Gateway price fetcher.
3
+ *
4
+ * One fetcher, many asset classes. Dispatches on `PriceQueryParams.assetClass`
5
+ * to the right Pyth-backed Gateway endpoint:
6
+ *
7
+ * crypto → /api/v1/crypto/price/{ticker} free
8
+ * fx → /api/v1/fx/price/{ticker} free
9
+ * commodity → /api/v1/commodity/price/{ticker} free
10
+ * stock → /v1/stocks/{market}/price/{ticker} paid ($0.001 x402) — PR 2
11
+ *
12
+ * PR 1 scope: crypto / fx / commodity only. The stock branch returns a
13
+ * `ProviderError { code: 'insufficient-funds' }` until the x402 signing
14
+ * wrapper lands, so callers get a useful message instead of a wire-level
15
+ * 402.
16
+ *
17
+ * Response shape from Gateway: Pyth delivers `{ price, confidence, timestamp }`
18
+ * — no 24h change, no market cap, no volume. Legacy CoinGecko-shaped fields
19
+ * (`change24hPct`, `volume24hUsd`, `marketCapUsd`) come back as `NaN` for
20
+ * non-crypto classes; views treat NaN as "not applicable" and render a dash.
21
+ */
22
+ import { blockrunGet, blockrunGetPaid, cached, normalizePythSymbol, TTL } from './client.js';
23
+ function endpointFor(assetClass, ticker, market) {
24
+ switch (assetClass) {
25
+ case 'crypto':
26
+ return { path: `/api/v1/crypto/price/${ticker}`, endpoint: '/api/v1/crypto/price', paid: false };
27
+ case 'fx':
28
+ return { path: `/api/v1/fx/price/${ticker}`, endpoint: '/api/v1/fx/price', paid: false };
29
+ case 'commodity':
30
+ return { path: `/api/v1/commodity/price/${ticker}`, endpoint: '/api/v1/commodity/price', paid: false };
31
+ case 'stock':
32
+ if (!market) {
33
+ return {
34
+ kind: 'not-found',
35
+ code: 'missing-market-code',
36
+ message: `Stock queries require a market code (us/hk/jp/kr/gb/de/fr/nl/ie/lu/cn/ca). Got ticker "${ticker}" with none.`,
37
+ };
38
+ }
39
+ return {
40
+ path: `/api/v1/stocks/${market}/price/${ticker}`,
41
+ endpoint: `/api/v1/stocks/${market}/price`,
42
+ paid: true,
43
+ };
44
+ default: {
45
+ const _exhaust = assetClass;
46
+ void _exhaust;
47
+ return { kind: 'unknown', code: 'unsupported-asset-class', message: `Unsupported asset class` };
48
+ }
49
+ }
50
+ }
51
+ export const blockrunPriceFetcher = {
52
+ providerName: 'blockrun',
53
+ transformQuery(input) {
54
+ const assetClass = (input.assetClass ?? 'crypto');
55
+ const rawTicker = String(input.ticker ?? '').trim();
56
+ if (!rawTicker)
57
+ throw new Error('PriceQueryParams.ticker is required');
58
+ // Stocks: keep ticker as-is (`AAPL`, `7203`, `HSBA`). Everything else:
59
+ // Pyth-style `BASE-QUOTE`, upper-cased, `-USD` suffix defaulted.
60
+ const ticker = assetClass === 'stock'
61
+ ? rawTicker.toUpperCase()
62
+ : normalizePythSymbol(rawTicker);
63
+ return { ticker, assetClass, market: input.market };
64
+ },
65
+ async fetchData(query) {
66
+ const assetClass = (query.assetClass ?? 'crypto');
67
+ const resolved = endpointFor(assetClass, query.ticker, query.market);
68
+ if ('kind' in resolved)
69
+ return resolved;
70
+ const cacheKey = `blockrun:${assetClass}:${query.market ?? ''}:${query.ticker}`;
71
+ return cached(cacheKey, TTL.price, async () => {
72
+ if (resolved.paid) {
73
+ return blockrunGetPaid(resolved.path, {
74
+ endpoint: resolved.endpoint,
75
+ costUsd: 0.001,
76
+ });
77
+ }
78
+ return blockrunGet(resolved.path, {
79
+ endpoint: resolved.endpoint,
80
+ paid: false,
81
+ costUsd: 0,
82
+ });
83
+ });
84
+ },
85
+ transformData(raw, query) {
86
+ if (!raw || typeof raw !== 'object') {
87
+ return { kind: 'upstream-error', code: 'schema-mismatch', message: 'Gateway returned non-object payload' };
88
+ }
89
+ const payload = raw;
90
+ if (typeof payload.price !== 'number' || !Number.isFinite(payload.price)) {
91
+ return {
92
+ kind: 'upstream-error',
93
+ code: 'schema-mismatch',
94
+ message: `Gateway payload missing numeric 'price' for ${query.ticker}`,
95
+ };
96
+ }
97
+ // Pyth feeds don't carry 24h deltas; future Gateway upgrades might add
98
+ // them as optional fields, so we read defensively via indexed access.
99
+ const change = payload['change_24h_pct'];
100
+ const volume = payload['volume_24h_usd'];
101
+ const marketCap = payload['market_cap_usd'];
102
+ return {
103
+ ticker: query.ticker,
104
+ priceUsd: payload.price,
105
+ change24hPct: typeof change === 'number' ? change : NaN,
106
+ volume24hUsd: typeof volume === 'number' ? volume : NaN,
107
+ marketCapUsd: typeof marketCap === 'number' ? marketCap : NaN,
108
+ };
109
+ },
110
+ };
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Shared CoinGecko HTTP client + short-TTL cache.
3
+ *
4
+ * Carved out of the original `src/trading/data.ts` so every CoinGecko
5
+ * fetcher (price, ohlcv, trending, markets) shares the same rate-limit
6
+ * cooldown, user-agent, timeout, and in-memory cache.
7
+ */
8
+ import type { ProviderError } from '../standard-models.js';
9
+ export declare const TICKER_TO_ID: Record<string, string>;
10
+ export declare function resolveProviderId(ticker: string): string;
11
+ export declare function cached<T>(key: string, ttlMs: number, fn: () => Promise<T>): Promise<T>;
12
+ /** For tests: wipe every cached entry. */
13
+ export declare function clearCache(): void;
14
+ export declare function coingeckoGet(path: string): Promise<unknown | ProviderError>;
15
+ export declare const TTL: {
16
+ readonly price: number;
17
+ readonly ohlcv: number;
18
+ readonly trending: number;
19
+ readonly markets: number;
20
+ };