morphit/apps/indexer/scripts/crypto-fetcher-smoke.ts

380 lines
18 KiB
TypeScript

/**
* crypto-fetcher-smoke (cp372)
*
* Parse + error-handling tests for the crypto→USD fetchers in the
* multi-source median: CoinPaprika, Kraken, CryptoCompare, Binance,
* Coinbase, OKX, Bybit, CoinLore, CoinCap (key-gated), Messari
* (key-gated). Each must extract the right price on a good response and
* return null (never throw) on rate-limit, HTTP error, malformed
* body, missing fields, non-positive values, or a thrown fetch.
*
* Run: npx tsx --tsconfig ../../tsconfig.smoke.json scripts/crypto-fetcher-smoke.ts
*/
import { createCoinpaprikaFetcher } from '../src/indexer/price/coinpaprikaFetcher';
import { createKrakenFetcher } from '../src/indexer/price/krakenFetcher';
import { createCryptocompareFetcher } from '../src/indexer/price/cryptocompareFetcher';
import { createBinanceFetcher } from '../src/indexer/price/binanceFetcher';
import { createCoinbaseFetcher } from '../src/indexer/price/coinbaseFetcher';
import { createOkxFetcher } from '../src/indexer/price/okxFetcher';
import { createBybitFetcher } from '../src/indexer/price/bybitFetcher';
import { createCoincapFetcher } from '../src/indexer/price/coincapFetcher';
import { createCoinloreFetcher } from '../src/indexer/price/coinloreFetcher';
import { createMessariFetcher } from '../src/indexer/price/messariFetcher';
import { createBlurtBlogFetcher } from '../src/indexer/price/blurtBlogFetcher';
let passed = 0;
let failed = 0;
function check(name: string, cond: boolean): void {
if (cond) {
passed++;
console.log(` \u2713 ${name}`);
} else {
failed++;
console.log(` \u2717 ${name}`);
}
}
/** Minimal Response stub: no content-length (skips the cap
* pre-check), no body stream (forces the res.text() fallback path
* in readPriceBodyCapped). */
function mockRes(status: number, jsonObj: unknown): Response {
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (_n: string) => null },
body: undefined,
text: async () => JSON.stringify(jsonObj)
} as unknown as Response;
}
const fetchReturning = (res: Response): typeof globalThis.fetch =>
(async () => res) as unknown as typeof globalThis.fetch;
const fetchThrowing = (): typeof globalThis.fetch =>
(async () => {
throw new Error('network boom');
}) as unknown as typeof globalThis.fetch;
async function main(): Promise<void> {
console.log('\n\u2500\u2500\u2500 crypto-fetcher smoke (cp372) \u2500\u2500\u2500');
// ── CoinPaprika ─────────────────────────────────────────────
const cpBase = { baseUrl: 'https://api.coinpaprika.com/v1', coinId: 'btc-bitcoin', vsCurrency: 'USD', timeoutMs: 1000 };
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(200, { quotes: { USD: { price: 65000.5 } } })) });
check('coinpaprika: parses quotes.USD.price', (await f()) === 65000.5);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(200, { quotes: {} })) });
check('coinpaprika: missing USD quote → null', (await f()) === null);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(200, { nope: true })) });
check('coinpaprika: missing quotes object → null', (await f()) === null);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(200, { quotes: { USD: { price: 0 } } })) });
check('coinpaprika: non-positive price → null', (await f()) === null);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(200, { quotes: { USD: { price: 'abc' } } })) });
check('coinpaprika: non-numeric price → null', (await f()) === null);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(429, {})) });
check('coinpaprika: 429 rate-limit → null', (await f()) === null);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchReturning(mockRes(500, {})) });
check('coinpaprika: 500 → null', (await f()) === null);
}
{
const f = createCoinpaprikaFetcher({ ...cpBase, fetchImpl: fetchThrowing() });
check('coinpaprika: fetch throws → null (never propagates)', (await f()) === null);
}
// ── Kraken ──────────────────────────────────────────────────
const krBase = { baseUrl: 'https://api.kraken.com/0/public', pair: 'XBTUSD', timeoutMs: 1000 };
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchReturning(mockRes(200, { error: [], result: { XXBTZUSD: { c: ['65000.1', '0.01'] } } })) });
check('kraken: parses result[firstKey].c[0]', (await f()) === 65000.1);
}
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchReturning(mockRes(200, { error: ['EQuery:Unknown asset pair'], result: {} })) });
check('kraken: non-empty error array → null', (await f()) === null);
}
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchReturning(mockRes(200, { error: [], result: {} })) });
check('kraken: empty result → null', (await f()) === null);
}
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchReturning(mockRes(200, { error: [], result: { XXBTZUSD: { c: [] } } })) });
check('kraken: empty c array → null', (await f()) === null);
}
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchReturning(mockRes(200, { error: [], result: { XXBTZUSD: { c: ['0'] } } })) });
check('kraken: non-positive last price → null', (await f()) === null);
}
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchReturning(mockRes(429, {})) });
check('kraken: 429 rate-limit → null', (await f()) === null);
}
{
const f = createKrakenFetcher({ ...krBase, fetchImpl: fetchThrowing() });
check('kraken: fetch throws → null (never propagates)', (await f()) === null);
}
// ── CryptoCompare (symbol-keyed; covers BLURT) ──────────────
const ccBase = { baseUrl: 'https://min-api.cryptocompare.com', symbol: 'BTC', timeoutMs: 1000 };
{
const f = createCryptocompareFetcher({ ...ccBase, fetchImpl: fetchReturning(mockRes(200, { USD: 65000.5 })) });
check('cryptocompare: parses USD', (await f()) === 65000.5);
}
{
const f = createCryptocompareFetcher({ ...ccBase, fetchImpl: fetchReturning(mockRes(200, { Response: 'Error', Message: 'bad sym' })) });
check('cryptocompare: Response:Error (200) → null', (await f()) === null);
}
{
const f = createCryptocompareFetcher({ ...ccBase, fetchImpl: fetchReturning(mockRes(200, { USD: 0 })) });
check('cryptocompare: non-positive → null', (await f()) === null);
}
{
const f = createCryptocompareFetcher({ ...ccBase, fetchImpl: fetchReturning(mockRes(429, {})) });
check('cryptocompare: 429 → null', (await f()) === null);
}
{
const f = createCryptocompareFetcher({ ...ccBase, fetchImpl: fetchThrowing() });
check('cryptocompare: throws → null', (await f()) === null);
}
// ── Binance ─────────────────────────────────────────────────
const bnBase = { baseUrl: 'https://api.binance.com', symbol: 'BTCUSDT', timeoutMs: 1000 };
{
const f = createBinanceFetcher({ ...bnBase, fetchImpl: fetchReturning(mockRes(200, { symbol: 'BTCUSDT', price: '65000.10' })) });
check('binance: parses price', (await f()) === 65000.1);
}
{
const f = createBinanceFetcher({ ...bnBase, fetchImpl: fetchReturning(mockRes(400, { code: -1121, msg: 'Invalid symbol.' })) });
check('binance: bad symbol (400) → null', (await f()) === null);
}
{
const f = createBinanceFetcher({ ...bnBase, fetchImpl: fetchReturning(mockRes(418, {})) });
check('binance: 418 ban → null', (await f()) === null);
}
{
const f = createBinanceFetcher({ ...bnBase, fetchImpl: fetchThrowing() });
check('binance: throws → null', (await f()) === null);
}
// ── Coinbase ────────────────────────────────────────────────
const cbBase = { baseUrl: 'https://api.exchange.coinbase.com', product: 'BTC-USD', timeoutMs: 1000 };
{
const f = createCoinbaseFetcher({ ...cbBase, fetchImpl: fetchReturning(mockRes(200, { price: '65000.12', time: 't' })) });
check('coinbase: parses price', (await f()) === 65000.12);
}
{
const f = createCoinbaseFetcher({ ...cbBase, fetchImpl: fetchReturning(mockRes(404, { message: 'NotFound' })) });
check('coinbase: 404 → null', (await f()) === null);
}
{
const f = createCoinbaseFetcher({ ...cbBase, fetchImpl: fetchReturning(mockRes(200, { price: '-5' })) });
check('coinbase: negative → null', (await f()) === null);
}
{
const f = createCoinbaseFetcher({ ...cbBase, fetchImpl: fetchThrowing() });
check('coinbase: throws → null', (await f()) === null);
}
// ── OKX ─────────────────────────────────────────────────────
const okBase = { baseUrl: 'https://www.okx.com', instId: 'BTC-USDT', timeoutMs: 1000 };
{
const f = createOkxFetcher({ ...okBase, fetchImpl: fetchReturning(mockRes(200, { code: '0', data: [{ last: '65000.3' }] })) });
check('okx: parses data[0].last', (await f()) === 65000.3);
}
{
const f = createOkxFetcher({ ...okBase, fetchImpl: fetchReturning(mockRes(200, { code: '51001', msg: 'no inst', data: [] })) });
check('okx: error code → null', (await f()) === null);
}
{
const f = createOkxFetcher({ ...okBase, fetchImpl: fetchReturning(mockRes(200, { code: '0', data: [] })) });
check('okx: empty data → null', (await f()) === null);
}
{
const f = createOkxFetcher({ ...okBase, fetchImpl: fetchThrowing() });
check('okx: throws → null', (await f()) === null);
}
// ── Bybit ───────────────────────────────────────────────────
const byBase = { baseUrl: 'https://api.bybit.com', symbol: 'BTCUSDT', timeoutMs: 1000 };
{
const f = createBybitFetcher({ ...byBase, fetchImpl: fetchReturning(mockRes(200, { retCode: 0, result: { list: [{ lastPrice: '65000.4' }] } })) });
check('bybit: parses result.list[0].lastPrice', (await f()) === 65000.4);
}
{
const f = createBybitFetcher({ ...byBase, fetchImpl: fetchReturning(mockRes(200, { retCode: 10001, result: {} })) });
check('bybit: retCode error → null', (await f()) === null);
}
{
const f = createBybitFetcher({ ...byBase, fetchImpl: fetchReturning(mockRes(200, { retCode: 0, result: { list: [] } })) });
check('bybit: empty list → null', (await f()) === null);
}
{
const f = createBybitFetcher({ ...byBase, fetchImpl: fetchThrowing() });
check('bybit: throws → null', (await f()) === null);
}
// ── CoinLore (numeric-id; array body) ───────────────────────
const clBase = { baseUrl: 'https://api.coinlore.net', assetId: '90', timeoutMs: 1000 };
{
const f = createCoinloreFetcher({ ...clBase, fetchImpl: fetchReturning(mockRes(200, [{ id: '90', symbol: 'BTC', price_usd: '65000.6' }])) });
check('coinlore: parses [0].price_usd', (await f()) === 65000.6);
}
{
const f = createCoinloreFetcher({ ...clBase, fetchImpl: fetchReturning(mockRes(200, [])) });
check('coinlore: empty array → null', (await f()) === null);
}
{
const f = createCoinloreFetcher({ ...clBase, fetchImpl: fetchReturning(mockRes(200, { error: 'not found' })) });
check('coinlore: non-array (error) → null', (await f()) === null);
}
{
const f = createCoinloreFetcher({ ...clBase, fetchImpl: fetchThrowing() });
check('coinlore: throws → null', (await f()) === null);
}
// ── CoinCap (key-gated; data.priceUsd) ──────────────────────
const ccapBase = { baseUrl: 'https://rest.coincap.io/v3', assetId: 'bitcoin', apiKey: 'k', timeoutMs: 1000 };
{
const f = createCoincapFetcher({ ...ccapBase, fetchImpl: fetchReturning(mockRes(200, { data: { id: 'bitcoin', priceUsd: '65000.7' } })) });
check('coincap: parses data.priceUsd', (await f()) === 65000.7);
}
{
const f = createCoincapFetcher({ ...ccapBase, fetchImpl: fetchReturning(mockRes(401, {})) });
check('coincap: 401 (bad/no key) → null', (await f()) === null);
}
{
const f = createCoincapFetcher({ ...ccapBase, fetchImpl: fetchReturning(mockRes(200, { data: null })) });
check('coincap: null data → null', (await f()) === null);
}
{
const f = createCoincapFetcher({ ...ccapBase, fetchImpl: fetchThrowing() });
check('coincap: throws → null', (await f()) === null);
}
// ── Messari (key-gated; data.market_data.price_usd) ─────────
const msBase = { baseUrl: 'https://data.messari.io', slug: 'bitcoin', apiKey: 'k', timeoutMs: 1000 };
{
const f = createMessariFetcher({ ...msBase, fetchImpl: fetchReturning(mockRes(200, { data: { market_data: { price_usd: 65000.8 } } })) });
check('messari: parses data.market_data.price_usd', (await f()) === 65000.8);
}
{
const f = createMessariFetcher({ ...msBase, fetchImpl: fetchReturning(mockRes(404, {})) });
check('messari: 404 → null', (await f()) === null);
}
{
const f = createMessariFetcher({ ...msBase, fetchImpl: fetchReturning(mockRes(200, { data: { market_data: null } })) });
check('messari: null market_data → null', (await f()) === null);
}
{
const f = createMessariFetcher({ ...msBase, fetchImpl: fetchThrowing() });
check('messari: throws → null', (await f()) === null);
}
// ── cp425: Blurt-native price feed (api.blurt.blog/price_info) ──
// The parser is DEFENSIVE (the exact shape is verified on deploy):
// it accepts several plausible shapes, but ONLY a value inside the
// plausibility band, so a volume / percent / market-cap field can
// never be mistaken for a sub-cent price.
const bfBase = {
url: 'https://api.blurt.blog/price_info',
plausibleMin: 0.0001,
plausibleMax: 0.1,
timeoutMs: 5_000
};
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 0.0019 })) });
check('blurt_feed: { price } → value', (await f()) === 0.0019);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: '0.0019' })) });
check('blurt_feed: numeric-string price → value', (await f()) === 0.0019);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, 0.0019)) });
check('blurt_feed: bare number body → value', (await f()) === 0.0019);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { usd: 0.0019 })) });
check('blurt_feed: { usd } → value', (await f()) === 0.0019);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { data: { price: 0.0019 } })) });
check('blurt_feed: { data: { price } } → value', (await f()) === 0.0019);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { blurt: { usd: 0.0019 } })) });
check('blurt_feed: { blurt: { usd } } → value', (await f()) === 0.0019);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { last_price: 0.0019 })) });
check('blurt_feed: { last_price } → value', (await f()) === 0.0019);
}
{
// A `price` field that's out-of-band (e.g. a percent) is skipped;
// a real in-band price under `usd` is found instead.
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 5.2, usd: 0.0019 })) });
check('blurt_feed: out-of-band price skipped, in-band usd wins', (await f()) === 0.0019);
}
{
// The real price wins over a co-present in-band change field.
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 0.0019, change_24h: 0.05 })) });
check('blurt_feed: real price wins over co-present change', (await f()) === 0.0019);
}
{
// In-band values under NON-price keys are never picked up.
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { change_24h: 0.05, foo: 0.02 })) });
check('blurt_feed: in-band non-price keys ignored → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 250 })) });
check('blurt_feed: above plausibleMax (volume-like) → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 0.00001 })) });
check('blurt_feed: below plausibleMin → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 0 })) });
check('blurt_feed: non-positive → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, { price: 'abc' })) });
check('blurt_feed: non-numeric → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(200, {})) });
check('blurt_feed: empty object → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(429, {})) });
check('blurt_feed: 429 → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchReturning(mockRes(500, {})) });
check('blurt_feed: 500 → null', (await f()) === null);
}
{
const f = createBlurtBlogFetcher({ ...bfBase, fetchImpl: fetchThrowing() });
check('blurt_feed: throws → null', (await f()) === null);
}
console.log('\u2500'.repeat(56));
if (failed === 0) {
console.log(`\u2713 all ${passed} crypto-fetcher scenarios passed`);
} else {
console.log(`${passed} passed, ${failed} failed (${passed + failed} total)`);
console.log('crypto-fetcher-smoke FAILED');
process.exit(1);
}
}
void main();