@blockrun/franklin 3.8.8 → 3.8.9
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.
- package/dist/agent/loop.js +61 -2
- package/dist/agent/permissions.js +2 -2
- package/dist/agent/types.d.ts +7 -0
- package/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +72 -2
- package/dist/index.js +9 -1
- package/dist/panel/html.js +111 -21
- package/dist/panel/server.js +15 -4
- package/dist/tools/activate.d.ts +29 -0
- package/dist/tools/activate.js +96 -0
- package/dist/tools/index.js +2 -0
- package/dist/tools/tool-categories.d.ts +22 -0
- package/dist/tools/tool-categories.js +44 -0
- package/dist/tools/trading-execute.d.ts +11 -21
- package/dist/tools/trading-execute.js +43 -130
- package/dist/tools/trading-views.d.ts +64 -0
- package/dist/tools/trading-views.js +115 -0
- package/dist/tools/trading.js +86 -7
- package/dist/tools/webhook.d.ts +18 -0
- package/dist/tools/webhook.js +185 -0
- package/dist/trading/data.d.ts +24 -1
- package/dist/trading/data.js +67 -102
- package/dist/trading/providers/blockrun/client.d.ts +48 -0
- package/dist/trading/providers/blockrun/client.js +253 -0
- package/dist/trading/providers/blockrun/price.d.ts +24 -0
- package/dist/trading/providers/blockrun/price.js +110 -0
- package/dist/trading/providers/coingecko/client.d.ts +20 -0
- package/dist/trading/providers/coingecko/client.js +87 -0
- package/dist/trading/providers/coingecko/markets.d.ts +3 -0
- package/dist/trading/providers/coingecko/markets.js +25 -0
- package/dist/trading/providers/coingecko/ohlcv.d.ts +3 -0
- package/dist/trading/providers/coingecko/ohlcv.js +29 -0
- package/dist/trading/providers/coingecko/price.d.ts +11 -0
- package/dist/trading/providers/coingecko/price.js +41 -0
- package/dist/trading/providers/coingecko/trending.d.ts +3 -0
- package/dist/trading/providers/coingecko/trending.js +22 -0
- package/dist/trading/providers/fetcher.d.ts +43 -0
- package/dist/trading/providers/fetcher.js +45 -0
- package/dist/trading/providers/registry.d.ts +45 -0
- package/dist/trading/providers/registry.js +82 -0
- package/dist/trading/providers/standard-models.d.ts +94 -0
- package/dist/trading/providers/standard-models.js +21 -0
- package/dist/trading/providers/telemetry.d.ts +51 -0
- package/dist/trading/providers/telemetry.js +115 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
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 { recordFetch } from '../telemetry.js';
|
|
9
|
+
const BASE = 'https://api.coingecko.com/api/v3';
|
|
10
|
+
const UA = 'franklin/3.8.9 (trading)';
|
|
11
|
+
const TIMEOUT_MS = 10_000;
|
|
12
|
+
// Ticker → CoinGecko slug. Not exhaustive; unknown tickers fall through to
|
|
13
|
+
// lowercase and let CoinGecko either accept the slug or 404.
|
|
14
|
+
export const TICKER_TO_ID = {
|
|
15
|
+
BTC: 'bitcoin', ETH: 'ethereum', SOL: 'solana', BNB: 'binancecoin', XRP: 'ripple',
|
|
16
|
+
ADA: 'cardano', DOGE: 'dogecoin', AVAX: 'avalanche-2', DOT: 'polkadot', MATIC: 'matic-network',
|
|
17
|
+
LINK: 'chainlink', UNI: 'uniswap', ATOM: 'cosmos', LTC: 'litecoin', NEAR: 'near',
|
|
18
|
+
APT: 'aptos', ARB: 'arbitrum', OP: 'optimism', SUI: 'sui', SEI: 'sei-network',
|
|
19
|
+
FIL: 'filecoin', AAVE: 'aave', MKR: 'maker', SNX: 'synthetix-network-token',
|
|
20
|
+
COMP: 'compound-governance-token', INJ: 'injective-protocol', TIA: 'celestia',
|
|
21
|
+
PEPE: 'pepe', WIF: 'dogwifcoin', RENDER: 'render-token',
|
|
22
|
+
};
|
|
23
|
+
export function resolveProviderId(ticker) {
|
|
24
|
+
// Accept both "BTC" and "BTC-USD" — Pyth-style callers may pass the pair
|
|
25
|
+
// form even when the registry routes them to CoinGecko.
|
|
26
|
+
const normalized = ticker.toUpperCase().replace(/-USD$/, '').replace(/USDT?$/, '');
|
|
27
|
+
return TICKER_TO_ID[normalized] ?? TICKER_TO_ID[ticker.toUpperCase()] ?? normalized.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
const cache = new Map();
|
|
30
|
+
export async function cached(key, ttlMs, fn) {
|
|
31
|
+
const hit = cache.get(key);
|
|
32
|
+
if (hit && hit.expiry > Date.now())
|
|
33
|
+
return hit.data;
|
|
34
|
+
const data = await fn();
|
|
35
|
+
cache.set(key, { data, expiry: Date.now() + ttlMs });
|
|
36
|
+
return data;
|
|
37
|
+
}
|
|
38
|
+
/** For tests: wipe every cached entry. */
|
|
39
|
+
export function clearCache() {
|
|
40
|
+
cache.clear();
|
|
41
|
+
}
|
|
42
|
+
export async function coingeckoGet(path) {
|
|
43
|
+
const ctrl = new AbortController();
|
|
44
|
+
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
|
|
45
|
+
const endpoint = path.split('?')[0];
|
|
46
|
+
const startedAt = Date.now();
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
49
|
+
headers: { 'User-Agent': UA },
|
|
50
|
+
signal: ctrl.signal,
|
|
51
|
+
});
|
|
52
|
+
const latencyMs = Date.now() - startedAt;
|
|
53
|
+
if (res.status === 429) {
|
|
54
|
+
recordFetch({ provider: 'coingecko', endpoint, ok: false, latencyMs });
|
|
55
|
+
return { kind: 'rate-limited', message: 'CoinGecko rate-limited this request (HTTP 429). Retry in a minute.' };
|
|
56
|
+
}
|
|
57
|
+
if (res.status === 404) {
|
|
58
|
+
recordFetch({ provider: 'coingecko', endpoint, ok: false, latencyMs });
|
|
59
|
+
return { kind: 'not-found', message: `CoinGecko returned 404 for path ${path}` };
|
|
60
|
+
}
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
recordFetch({ provider: 'coingecko', endpoint, ok: false, latencyMs });
|
|
63
|
+
return { kind: 'upstream-error', message: `CoinGecko HTTP ${res.status}` };
|
|
64
|
+
}
|
|
65
|
+
recordFetch({ provider: 'coingecko', endpoint, ok: true, latencyMs });
|
|
66
|
+
return await res.json();
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
const latencyMs = Date.now() - startedAt;
|
|
70
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
71
|
+
recordFetch({ provider: 'coingecko', endpoint, ok: false, latencyMs });
|
|
72
|
+
return { kind: 'timeout', message: `CoinGecko request timed out after ${TIMEOUT_MS}ms` };
|
|
73
|
+
}
|
|
74
|
+
recordFetch({ provider: 'coingecko', endpoint, ok: false, latencyMs });
|
|
75
|
+
return { kind: 'unknown', message: String(e) };
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// TTLs for cache reuse across fetchers.
|
|
82
|
+
export const TTL = {
|
|
83
|
+
price: 5 * 60_000,
|
|
84
|
+
ohlcv: 60 * 60_000,
|
|
85
|
+
trending: 15 * 60_000,
|
|
86
|
+
markets: 15 * 60_000,
|
|
87
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cached, coingeckoGet, TTL } from './client.js';
|
|
2
|
+
export const coingeckoMarketsFetcher = {
|
|
3
|
+
providerName: 'coingecko',
|
|
4
|
+
transformQuery(input) {
|
|
5
|
+
const limit = Math.max(1, Math.min(100, Math.round(Number(input.limit ?? 20))));
|
|
6
|
+
return { limit };
|
|
7
|
+
},
|
|
8
|
+
async fetchData(query) {
|
|
9
|
+
return cached(`markets:${query.limit}`, TTL.markets, async () => coingeckoGet(`/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=${query.limit}&page=1`));
|
|
10
|
+
},
|
|
11
|
+
transformData(raw, _query) {
|
|
12
|
+
if (!Array.isArray(raw)) {
|
|
13
|
+
return { kind: 'upstream-error', message: 'CoinGecko /coins/markets returned unexpected shape' };
|
|
14
|
+
}
|
|
15
|
+
return raw.map(c => ({
|
|
16
|
+
providerId: c.id,
|
|
17
|
+
ticker: c.symbol.toUpperCase(),
|
|
18
|
+
name: c.name,
|
|
19
|
+
priceUsd: c.current_price,
|
|
20
|
+
change24hPct: c.price_change_percentage_24h,
|
|
21
|
+
marketCapUsd: c.market_cap,
|
|
22
|
+
volume24hUsd: c.total_volume,
|
|
23
|
+
}));
|
|
24
|
+
},
|
|
25
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cached, coingeckoGet, resolveProviderId, TTL } from './client.js';
|
|
2
|
+
export const coingeckoOHLCVFetcher = {
|
|
3
|
+
providerName: 'coingecko',
|
|
4
|
+
transformQuery(input) {
|
|
5
|
+
const ticker = String(input.ticker ?? '').trim().toUpperCase();
|
|
6
|
+
if (!ticker)
|
|
7
|
+
throw new Error('OHLCVQueryParams.ticker is required');
|
|
8
|
+
const days = Math.max(1, Math.min(365, Math.round(Number(input.days ?? 30))));
|
|
9
|
+
return { ticker, days };
|
|
10
|
+
},
|
|
11
|
+
async fetchData(query) {
|
|
12
|
+
const id = resolveProviderId(query.ticker);
|
|
13
|
+
return cached(`ohlcv:${id}:${query.days}`, TTL.ohlcv, async () => {
|
|
14
|
+
return coingeckoGet(`/coins/${id}/market_chart?vs_currency=usd&days=${query.days}&interval=daily`);
|
|
15
|
+
});
|
|
16
|
+
},
|
|
17
|
+
transformData(raw, query) {
|
|
18
|
+
const payload = raw;
|
|
19
|
+
const prices = payload?.prices;
|
|
20
|
+
if (!Array.isArray(prices) || prices.length === 0) {
|
|
21
|
+
return { kind: 'not-found', message: `No OHLCV data for ${query.ticker} (${query.days}d)` };
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
ticker: query.ticker,
|
|
25
|
+
timestamps: prices.map(p => p[0]),
|
|
26
|
+
closes: prices.map(p => p[1]),
|
|
27
|
+
};
|
|
28
|
+
},
|
|
29
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoinGecko implementation of Fetcher<PriceQueryParams, PriceData>.
|
|
3
|
+
*
|
|
4
|
+
* Transforms: uppercase ticker → CoinGecko slug lookup.
|
|
5
|
+
* Fetches: /simple/price with 24h change/cap/volume flags.
|
|
6
|
+
* Coerces: the nested `{ [id]: { usd: ..., usd_24h_change: ... } }`
|
|
7
|
+
* response into the standard PriceData shape.
|
|
8
|
+
*/
|
|
9
|
+
import type { Fetcher } from '../fetcher.js';
|
|
10
|
+
import type { PriceData, PriceQueryParams } from '../standard-models.js';
|
|
11
|
+
export declare const coingeckoPriceFetcher: Fetcher<PriceQueryParams, PriceData>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CoinGecko implementation of Fetcher<PriceQueryParams, PriceData>.
|
|
3
|
+
*
|
|
4
|
+
* Transforms: uppercase ticker → CoinGecko slug lookup.
|
|
5
|
+
* Fetches: /simple/price with 24h change/cap/volume flags.
|
|
6
|
+
* Coerces: the nested `{ [id]: { usd: ..., usd_24h_change: ... } }`
|
|
7
|
+
* response into the standard PriceData shape.
|
|
8
|
+
*/
|
|
9
|
+
import { cached, coingeckoGet, resolveProviderId, TTL } from './client.js';
|
|
10
|
+
export const coingeckoPriceFetcher = {
|
|
11
|
+
providerName: 'coingecko',
|
|
12
|
+
transformQuery(input) {
|
|
13
|
+
const ticker = String(input.ticker ?? '').trim().toUpperCase();
|
|
14
|
+
if (!ticker) {
|
|
15
|
+
throw new Error('PriceQueryParams.ticker is required');
|
|
16
|
+
}
|
|
17
|
+
return { ticker };
|
|
18
|
+
},
|
|
19
|
+
async fetchData(query) {
|
|
20
|
+
const id = resolveProviderId(query.ticker);
|
|
21
|
+
return cached(`price:${id}`, TTL.price, async () => {
|
|
22
|
+
return coingeckoGet(`/simple/price?ids=${id}` +
|
|
23
|
+
`&vs_currencies=usd&include_24hr_change=true` +
|
|
24
|
+
`&include_market_cap=true&include_24hr_vol=true`);
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
transformData(raw, query) {
|
|
28
|
+
const id = resolveProviderId(query.ticker);
|
|
29
|
+
const entry = raw[id];
|
|
30
|
+
if (!entry) {
|
|
31
|
+
return { kind: 'not-found', message: `No CoinGecko data for ${query.ticker}` };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
ticker: query.ticker,
|
|
35
|
+
priceUsd: entry.usd,
|
|
36
|
+
change24hPct: entry.usd_24h_change,
|
|
37
|
+
volume24hUsd: entry.usd_24h_vol,
|
|
38
|
+
marketCapUsd: entry.usd_market_cap,
|
|
39
|
+
};
|
|
40
|
+
},
|
|
41
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cached, coingeckoGet, TTL } from './client.js';
|
|
2
|
+
export const coingeckoTrendingFetcher = {
|
|
3
|
+
providerName: 'coingecko',
|
|
4
|
+
transformQuery(_input) {
|
|
5
|
+
return {};
|
|
6
|
+
},
|
|
7
|
+
async fetchData(_query) {
|
|
8
|
+
return cached('trending', TTL.trending, async () => coingeckoGet('/search/trending'));
|
|
9
|
+
},
|
|
10
|
+
transformData(raw, _query) {
|
|
11
|
+
const payload = raw;
|
|
12
|
+
if (!payload || !Array.isArray(payload.coins)) {
|
|
13
|
+
return { kind: 'upstream-error', message: 'CoinGecko /search/trending returned unexpected shape' };
|
|
14
|
+
}
|
|
15
|
+
return payload.coins.map(c => ({
|
|
16
|
+
providerId: c.item.id,
|
|
17
|
+
name: c.item.name,
|
|
18
|
+
symbol: c.item.symbol,
|
|
19
|
+
marketCapRank: c.item.market_cap_rank,
|
|
20
|
+
}));
|
|
21
|
+
},
|
|
22
|
+
};
|