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

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);
}
}