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