morphit/apps/indexer/scripts/multi-asset-factory-smoke.ts

223 lines
8.8 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Structural smoke for cp130 multi-asset price-source factory.
*
* What this verifies (without spinning up Postgres or Coingecko —
* those are integration concerns):
*
* 1. Module exports the public surface we expect:
* createAssetPriceSource, createPriceSource (backwards-compat
* wrapper), createMultiAssetPriceSources, CP130_ASSET_DEFAULTS.
* 2. CP130_ASSET_DEFAULTS covers BLURT + BTC + XMR with the
* correct shape (asset/coingeckoCoinId/staticFloor).
* 3. The external chain is Coingecko-only (Klingex, the prior
* BLURT primary, went out of business in 2026 and was removed).
* 4. Coingecko coin IDs match the well-known Coingecko ids.
* 5. createMultiAssetPriceSources returns a Map keyed by asset
* ticker with exactly the cp130 launch set (BLURT, BTC, XMR).
* 6. Each source is a separate BlurtPriceSource instance (no
* shared cache, no aliasing).
* 7. Per-asset static-floor wiring: BLURT reads
* priceFeedStaticFloor; BTC reads priceFeedBtcStaticFloor;
* XMR reads priceFeedXmrStaticFloor.
* 8. Backwards-compatibility: createPriceSource returns a
* BLURT-shaped source (same upstream chain as pre-cp130 except
* morphit_native is denomination-aware).
*/
import {
createAssetPriceSource,
createPriceSource,
createMultiAssetPriceSources,
CP130_ASSET_DEFAULTS
} from '../src/indexer/price/factory';
import { fakeConfig } from '../test/testutils/context';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FACTORY_PATH = join(__dirname, '..', 'src', 'indexer', 'price', 'factory.ts');
let pass = 0;
let fail = 0;
function scenario(name: string, fn: () => void): void {
try {
fn();
console.log(`${name}`);
pass++;
} catch (err) {
console.log(`${name}: ${err}`);
fail++;
}
}
function assertEq<T>(actual: T, expected: T, label: string): void {
if (actual !== expected) {
throw new Error(`${label}: expected ${String(expected)}, got ${String(actual)}`);
}
}
console.log('\n── cp130 multi-asset factory smoke ──\n');
// ─── CP130-1: public surface ──
scenario('CP130-1: createAssetPriceSource is a function', () => {
if (typeof createAssetPriceSource !== 'function') throw new Error('not a function');
});
scenario('CP130-1: createPriceSource is a function (backwards-compat wrapper)', () => {
if (typeof createPriceSource !== 'function') throw new Error('not a function');
});
scenario('CP130-1: createMultiAssetPriceSources is a function', () => {
if (typeof createMultiAssetPriceSources !== 'function') throw new Error('not a function');
});
scenario('CP130-1: CP130_ASSET_DEFAULTS is an object', () => {
if (typeof CP130_ASSET_DEFAULTS !== 'object') throw new Error('not an object');
});
// ─── CP130-2: defaults shape + cp130 launch set ──
scenario('CP130-2: CP130_ASSET_DEFAULTS covers BLURT + BTC + XMR (the cp130 launch set)', () => {
const keys = Object.keys(CP130_ASSET_DEFAULTS).sort();
const expected = ['BLURT', 'BTC', 'XMR'].sort();
assertEq(JSON.stringify(keys), JSON.stringify(expected), 'launch-set keys');
});
scenario('CP130-2: each default entry has the expected shape', () => {
for (const [key, opts] of Object.entries(CP130_ASSET_DEFAULTS)) {
if (typeof opts.asset !== 'string') throw new Error(`${key}.asset not string`);
if (typeof opts.coingeckoCoinId !== 'string')
throw new Error(`${key}.coingeckoCoinId not string`);
if (typeof opts.staticFloor !== 'number')
throw new Error(`${key}.staticFloor not number`);
if (opts.staticFloor <= 0) throw new Error(`${key}.staticFloor not positive`);
if (opts.asset !== key) throw new Error(`${key}.asset != key`);
}
});
// ─── CP130-3: external chain is Coingecko-only (Klingex removed 2026) ──
scenario('CP130-3: no asset default carries the removed enableKlingex field', () => {
for (const [key, opts] of Object.entries(CP130_ASSET_DEFAULTS)) {
if ('enableKlingex' in opts)
throw new Error(`${key} still carries enableKlingex (Klingex was removed)`);
}
});
scenario('CP130-3: factory source no longer imports a Klingex fetcher', () => {
const src = readFileSync(FACTORY_PATH, 'utf-8');
if (/createKlingexFetcher|klingexFetcher/.test(src))
throw new Error('factory.ts still references the deleted Klingex fetcher');
});
// ─── CP130-4: Coingecko coin IDs ──
scenario('CP130-4: BLURT coingeckoCoinId is "blurt"', () => {
assertEq(CP130_ASSET_DEFAULTS.BLURT!.coingeckoCoinId, 'blurt', 'BLURT CG id');
});
scenario('CP130-4: BTC coingeckoCoinId is "bitcoin" (well-known CG id)', () => {
assertEq(CP130_ASSET_DEFAULTS.BTC!.coingeckoCoinId, 'bitcoin', 'BTC CG id');
});
scenario('CP130-4: XMR coingeckoCoinId is "monero" (well-known CG id)', () => {
assertEq(CP130_ASSET_DEFAULTS.XMR!.coingeckoCoinId, 'monero', 'XMR CG id');
});
// ─── CP130-5: createMultiAssetPriceSources behavior ──
scenario('CP130-5: createMultiAssetPriceSources returns a Map with BLURT, BTC, XMR keys', () => {
const cfg = fakeConfig({ priceFeedEnabled: true });
const sources = createMultiAssetPriceSources(cfg, undefined);
const keys = [...sources.keys()].sort();
assertEq(JSON.stringify(keys), JSON.stringify(['BLURT', 'BTC', 'XMR']), 'map keys');
});
scenario('CP130-5: each entry in the map is a distinct BlurtPriceSource instance', () => {
const cfg = fakeConfig({ priceFeedEnabled: true });
const sources = createMultiAssetPriceSources(cfg, undefined);
const blurtSource = sources.get('BLURT');
const btcSource = sources.get('BTC');
const xmrSource = sources.get('XMR');
if (!blurtSource || !btcSource || !xmrSource)
throw new Error('missing source');
if (blurtSource === btcSource) throw new Error('BLURT and BTC aliased');
if (blurtSource === xmrSource) throw new Error('BLURT and XMR aliased');
if (btcSource === xmrSource) throw new Error('BTC and XMR aliased');
});
// ─── CP130-6: backwards-compatibility wrapper ──
scenario('CP130-6: createPriceSource returns a usable BlurtPriceSource', () => {
const cfg = fakeConfig({ priceFeedEnabled: true });
const source = createPriceSource(cfg, undefined);
if (typeof source.currentDetailed !== 'function')
throw new Error('source.currentDetailed missing');
if (typeof source.start !== 'function') throw new Error('source.start missing');
if (typeof source.stop !== 'function') throw new Error('source.stop missing');
});
// ─── CP130-7: per-asset static-floor wiring ──
scenario('CP130-7: BTC source uses priceFeedBtcStaticFloor config (smoke: source builds without throw)', () => {
const cfg = fakeConfig({
priceFeedEnabled: true,
priceFeedBtcStaticFloor: 55_000
});
const sources = createMultiAssetPriceSources(cfg, undefined);
if (!sources.has('BTC')) throw new Error('BTC missing');
});
scenario('CP130-7: XMR source uses priceFeedXmrStaticFloor config (smoke: source builds without throw)', () => {
const cfg = fakeConfig({
priceFeedEnabled: true,
priceFeedXmrStaticFloor: 180
});
const sources = createMultiAssetPriceSources(cfg, undefined);
if (!sources.has('XMR')) throw new Error('XMR missing');
});
// ─── CP130-8: empty / disabled paths ──
scenario('CP130-8: createAssetPriceSource works with empty db (no morphit_native)', () => {
const cfg = fakeConfig({ priceFeedEnabled: true, priceFeedNativeEnabled: false });
const source = createAssetPriceSource(cfg, CP130_ASSET_DEFAULTS.BTC!, undefined);
if (typeof source.currentDetailed !== 'function') throw new Error('source malformed');
});
// ─── CP130-9: denomination flows through to all assets ──
scenario('CP130-9: EUR denomination flows through to all per-asset sources (smoke: build with EUR)', () => {
const cfg = fakeConfig({
priceFeedEnabled: true,
priceFeedDenominationFiat: 'EUR'
});
const sources = createMultiAssetPriceSources(cfg, undefined);
// All three should build; the EUR denomination is wired through
// to each source's coingeckoFetcher's vsCurrency parameter.
assertEq(sources.size, 3, 'three sources');
});
// ─── CP130-10: doc-comment design pillars ──
scenario('CP130-10: factory.ts source still documents the cp130 architecture decisions', () => {
const src = readFileSync(FACTORY_PATH, 'utf-8');
const markers = [
'cp130',
'went out of business', // the Klingex-removal historical note
'preemptive complexity', // why per-asset denomination wasn't added
'multi-asset',
'BLURT/USD',
'BTC/USD',
'XMR/USD'
];
for (const m of markers) {
if (!src.includes(m)) {
throw new Error(`missing marker: ${m}`);
}
}
});
// Settle + report
console.log('\n' + '─'.repeat(56));
if (fail === 0) {
console.log(`✓ all ${pass} scenarios passed`);
process.exit(0);
} else {
console.log(`${fail} of ${pass + fail} scenarios FAILED`);
process.exit(1);
}