1 changed files with 293 additions and 0 deletions
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue