You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
293 lines
7.5 KiB
293 lines
7.5 KiB
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); |
|
} |
|
}
|
|
|