@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.
- package/dist/agent/error-classifier.js +1 -0
- package/dist/agent/llm.d.ts +7 -0
- package/dist/agent/llm.js +48 -7
- package/dist/agent/loop.js +66 -3
- package/dist/agent/permissions.js +2 -2
- package/dist/agent/types.d.ts +7 -0
- package/dist/banner.js +15 -0
- package/dist/commands/start.d.ts +4 -0
- package/dist/commands/start.js +72 -2
- package/dist/index.js +11 -3
- 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/dist/ui/app.js +28 -2
- package/package.json +1 -1
package/dist/trading/data.js
CHANGED
|
@@ -1,112 +1,77 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
function
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
return
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
+
};
|