diff --git a/oracle/price_engine.js b/oracle/price_engine.js new file mode 100644 index 0000000..550cd33 --- /dev/null +++ b/oracle/price_engine.js @@ -0,0 +1,293 @@ +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); + } +}