const fs = require('fs'); const path = require('path'); const ORACLE_DIR = __dirname; const ASSETS_FILE = path.join(ORACLE_DIR, 'assets.json'); const SOURCES_FILE = path.join(ORACLE_DIR, 'sources.json'); const CACHE_FILE = path.join(ORACLE_DIR, 'price_cache.json'); function readJson(filePath) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (err) { throw new Error(`Failed to read JSON file ${filePath}: ${err.message}`); } } function writeJson(filePath, data) { const tmpFile = `${filePath}.tmp`; fs.writeFileSync(tmpFile, JSON.stringify(data, null, 2) + '\n', 'utf8'); fs.renameSync(tmpFile, filePath); } function nowIso() { return new Date().toISOString(); } function ageSeconds(isoTime) { if (!isoTime) return null; const ts = new Date(isoTime).getTime(); if (Number.isNaN(ts)) return null; return Math.max(0, Math.floor((Date.now() - ts) / 1000)); } function getFreshnessStatus(age) { if (age === null) return 'stale'; if (age <= 120) return 'fresh'; if (age <= 300) return 'degraded'; return 'stale'; } function loadAssets() { return readJson(ASSETS_FILE); } function loadSources() { return readJson(SOURCES_FILE); } function loadCache() { return readJson(CACHE_FILE); } function saveCache(cacheData) { writeJson(CACHE_FILE, cacheData); } function listAssets() { const assetsDoc = loadAssets(); const assets = assetsDoc.assets || {}; const out = Object.entries(assets) .sort((a, b) => { const pa = a[1].quote_priority ?? 9999; const pb = b[1].quote_priority ?? 9999; return pa - pb; }) .map(([pairKey, asset]) => ({ pair_key: pairKey, symbol: asset.symbol, name: asset.name, chain: asset.chain, type: asset.type, contract: asset.contract || null, decimals: asset.decimals, billing_enabled: !!asset.billing_enabled, quote_priority: asset.quote_priority, primary_source: asset.primary_source || null, fallback_source: asset.fallback_source || null })); return { assets: out }; } function getPrices() { const cache = loadCache(); const assetsDoc = loadAssets(); const cacheAssets = cache.assets || {}; const assetDefs = assetsDoc.assets || {}; const resultAssets = {}; for (const [pairKey, assetDef] of Object.entries(assetDefs)) { const cached = cacheAssets[pairKey] || {}; const age = ageSeconds(cached.updated_at); const freshness = getFreshnessStatus(age); resultAssets[pairKey] = { pair_key: pairKey, symbol: assetDef.symbol, name: assetDef.name, chain: assetDef.chain, type: assetDef.type, contract: assetDef.contract || null, decimals: assetDef.decimals, billing_enabled: !!assetDef.billing_enabled, quote_priority: assetDef.quote_priority, price_usd: cached.price_usd ?? null, price_cad: cached.price_cad ?? null, source: cached.source ?? null, source_status: cached.source_status ?? null, updated_at: cached.updated_at ?? null, age_seconds: age, freshness, stale: freshness === 'stale' }; } const overallAges = Object.values(resultAssets) .map(a => a.age_seconds) .filter(a => a !== null); let overallStatus = 'stale'; if (overallAges.length > 0) { const maxAge = Math.max(...overallAges); overallStatus = getFreshnessStatus(maxAge); } return { version: cache.version || '0.1', updated_at: cache.updated_at || null, status: overallStatus, assets: resultAssets }; } function roundAmount(value, decimals = 8) { if (typeof value !== 'number' || !Number.isFinite(value)) return null; return Number(value.toFixed(decimals)); } function buildQuote({ fiat = 'CAD', amount }) { const normalizedFiat = String(fiat || 'CAD').toUpperCase(); const numericAmount = Number(amount); if (!Number.isFinite(numericAmount) || numericAmount <= 0) { throw new Error('Invalid quote amount'); } if (normalizedFiat !== 'CAD') { throw new Error('Only CAD quotes are supported in v0.1'); } const prices = getPrices(); const quoteTtlSeconds = 900; const quotedAt = nowIso(); const expiresAt = new Date(Date.now() + quoteTtlSeconds * 1000).toISOString(); const quotes = Object.values(prices.assets) .filter(asset => asset.billing_enabled) .sort((a, b) => (a.quote_priority ?? 9999) - (b.quote_priority ?? 9999)) .map(asset => { if (!asset.price_cad || asset.price_cad <= 0) { return { pair_key: asset.pair_key, symbol: asset.symbol, chain: asset.chain, crypto_amount: null, display_amount: null, price_cad: asset.price_cad, recommended: asset.quote_priority === 1, available: false, reason: 'missing_price' }; } const cryptoAmount = numericAmount / asset.price_cad; const precision = asset.symbol === 'USDC' ? 6 : 8; return { pair_key: asset.pair_key, symbol: asset.symbol, chain: asset.chain, crypto_amount: roundAmount(cryptoAmount, precision), display_amount: roundAmount(cryptoAmount, precision)?.toFixed(precision) || null, price_cad: asset.price_cad, recommended: asset.quote_priority === 1, available: !asset.stale, reason: asset.stale ? 'stale_price' : null }; }); return { fiat: normalizedFiat, amount: roundAmount(numericAmount, 2), quoted_at: quotedAt, expires_at: expiresAt, ttl_seconds: quoteTtlSeconds, source_status: prices.status, quotes }; } function updateCachedPrice(pairKey, update) { const assetsDoc = loadAssets(); const cache = loadCache(); if (!assetsDoc.assets || !assetsDoc.assets[pairKey]) { throw new Error(`Unknown asset pair key: ${pairKey}`); } if (!cache.assets) { cache.assets = {}; } const assetDef = assetsDoc.assets[pairKey]; const existing = cache.assets[pairKey] || {}; cache.assets[pairKey] = { symbol: assetDef.symbol, chain: assetDef.chain, price_usd: update.price_usd ?? existing.price_usd ?? null, price_cad: update.price_cad ?? existing.price_cad ?? null, source: update.source ?? existing.source ?? null, source_status: update.source_status ?? existing.source_status ?? 'primary', updated_at: update.updated_at ?? nowIso(), age_seconds: 0, stale: false }; cache.updated_at = nowIso(); cache.status = 'fresh'; saveCache(cache); return cache.assets[pairKey]; } function getStatus() { const sources = loadSources(); const prices = getPrices(); const sourceStatuses = Object.values(prices.assets).map(a => ({ pair_key: a.pair_key, symbol: a.symbol, chain: a.chain, source: a.source, source_status: a.source_status, updated_at: a.updated_at, age_seconds: a.age_seconds, freshness: a.freshness })); return { service: 'otb-oracle', version: '0.1.0', time_utc: nowIso(), overall_status: prices.status, configured_sources: Object.keys(sources.sources || {}), assets: sourceStatuses }; } module.exports = { loadAssets, loadSources, loadCache, saveCache, listAssets, getPrices, buildQuote, updateCachedPrice, getStatus }; if (require.main === module) { try { console.log(JSON.stringify({ health: { ok: true, service: 'otb-oracle', version: '0.1.0', time_utc: nowIso(), status: 'healthy' }, assets: listAssets(), prices: getPrices() }, null, 2)); } catch (err) { console.error(`price_engine.js failed: ${err.message}`); process.exit(1); } }