diff --git a/backend/.env.example b/backend/.env.example index 62825dd..3bb9bec 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -42,7 +42,15 @@ STELLAR_ASSET_ISSUERS={"USDC":"GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3 # PRICE FEED CONFIGURATION # ============================================ -# CoinGecko API Configuration +# Reflector Oracle Configuration (primary on-chain price source) +# Get the contract ID from: https://reflector.network +# When unset the backend falls back to CoinGecko for all price feeds. +REFLECTOR_CONTRACT_ID=CDOR33VMS3KNGKZ3HKXKQZJSB3EQBMNOH3YNK4BFQZJMKPB7WPZPB4GYN + +# Soroban RPC endpoint used to simulate Reflector contract calls +SOROBAN_RPC_URL=https://soroban-testnet.stellar.org + +# CoinGecko API Configuration (fallback when Reflector is unavailable) # Get your API key from: https://www.coingecko.com/en/api/pricing COINGECKO_API_KEY= diff --git a/backend/src/services/reflector.ts b/backend/src/services/reflector.ts index 985c97e..fad54b2 100644 --- a/backend/src/services/reflector.ts +++ b/backend/src/services/reflector.ts @@ -1,7 +1,21 @@ -import { SorobanRpc } from '@stellar/stellar-sdk' +import { + SorobanRpc, + Contract, + TransactionBuilder, + Networks, + Account, + scValToNative, + xdr +} from '@stellar/stellar-sdk' import type { PricesMap, PriceData } from '../types/index.js' import { getFeatureFlags } from '../config/featureFlags.js' -import { logger } from '../utils/logger.js' // Added logger import +import { logger } from '../utils/logger.js' + +// Reflector oracle prices are scaled by 10^7 +const REFLECTOR_PRICE_SCALE = 1e7 + +// Dummy source account used only for Soroban simulation (no funds needed) +const SIMULATION_SOURCE_ACCOUNT = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN' export class ReflectorService { private coinGeckoApiKey: string @@ -10,18 +24,34 @@ export class ReflectorService { private readonly CACHE_DURATION = process.env.NODE_ENV === 'production' ? 600000 : 300000 // 10 min vs 5 min private lastRequestTime = 0 private readonly MIN_REQUEST_INTERVAL = 90000 // Increased to 1.5 minutes for Pro API + private reflectorContractId: string | null + private sorobanRpcUrl: string + // Maps asset codes to the symbols the Reflector contract recognises + private readonly reflectorAssetSymbols: Record = { + XLM: 'XLM', + BTC: 'BTC', + ETH: 'ETH', + USDC: 'USDC', + } constructor() { this.coinGeckoApiKey = process.env.COINGECKO_API_KEY || '' this.priceCache = new Map() + this.reflectorContractId = process.env.REFLECTOR_CONTRACT_ID || null + this.sorobanRpcUrl = process.env.SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org' - // FIXED: Correct CoinGecko ID mapping this.coinGeckoIds = { 'XLM': 'stellar', 'BTC': 'bitcoin', 'ETH': 'ethereum', 'USDC': 'usd-coin' } + + if (this.reflectorContractId) { + logger.info(`[Reflector] Oracle integration enabled (contract: ${this.reflectorContractId})`) + } else { + logger.warn('[Reflector] REFLECTOR_CONTRACT_ID not set — falling back to CoinGecko only') + } } async getCurrentPrices(): Promise { @@ -87,6 +117,53 @@ export class ReflectorService { return cachedPrices } + private async fetchPricesFromReflector(assets: string[]): Promise { + if (!this.reflectorContractId) return {} + + const network = process.env.STELLAR_NETWORK === 'mainnet' ? Networks.PUBLIC : Networks.TESTNET + const rpc = new SorobanRpc.Server(this.sorobanRpcUrl, { + allowHttp: this.sorobanRpcUrl.startsWith('http://') + }) + const contract = new Contract(this.reflectorContractId) + const sourceAccount = new Account(SIMULATION_SOURCE_ACCOUNT, '0') + const prices: PricesMap = {} + + for (const asset of assets) { + const symbol = this.reflectorAssetSymbols[asset] + if (!symbol) continue + + try { + const tx = new TransactionBuilder(sourceAccount, { + fee: '100', + networkPassphrase: network, + }) + .addOperation(contract.call('lastprice', xdr.ScVal.scvSymbol(symbol))) + .setTimeout(0) + .build() + + const simResult = await rpc.simulateTransaction(tx) + + if (SorobanRpc.Api.isSimulationSuccess(simResult) && simResult.result?.retval) { + const native = scValToNative(simResult.result.retval) + // Reflector returns Option — null when no price is available + if (native && native.price !== undefined) { + prices[asset] = { + price: Number(BigInt(native.price)) / REFLECTOR_PRICE_SCALE, + change: 0, // Reflector does not expose 24h change + timestamp: Number(native.timestamp), + source: 'reflector', + } + logger.info(`[Reflector] ${asset}: $${prices[asset].price}`) + } + } + } catch (err) { + logger.warn(`[Reflector] Price fetch failed for ${asset}:`, err) + } + } + + return prices + } + private async getFreshPrices(assets: string[]): Promise { const now = Date.now() @@ -98,6 +175,26 @@ export class ReflectorService { this.lastRequestTime = now + // Try Reflector oracle first; fall back to CoinGecko for any missing assets + const reflectorPrices = await this.fetchPricesFromReflector(assets).catch(err => { + logger.warn('[Reflector] Batch fetch failed, falling back to CoinGecko:', err) + return {} as PricesMap + }) + + const missingAssets = assets.filter(a => !reflectorPrices[a]) + + if (missingAssets.length === 0) { + // Cache and return Reflector prices directly + for (const [asset, data] of Object.entries(reflectorPrices)) { + this.priceCache.set(asset, { data, timestamp: Date.now() }) + } + return reflectorPrices + } + + if (reflectorPrices && Object.keys(reflectorPrices).length > 0) { + logger.info(`[Reflector] Got prices for ${Object.keys(reflectorPrices).join(', ')}; fetching ${missingAssets.join(', ')} from CoinGecko`) + } + try { const apiKey = this.coinGeckoApiKey @@ -111,8 +208,8 @@ export class ReflectorService { 'User-Agent': 'StellarPortfolioRebalancer/1.0' } - // FIXED: Build correct coin IDs - const coinIds = assets + // Only fetch from CoinGecko for assets not already provided by Reflector + const coinIds = missingAssets .map(asset => this.coinGeckoIds[asset]) .filter(Boolean) .join(',') @@ -172,9 +269,9 @@ export class ReflectorService { const data = await response.json() logger.info('[DEBUG] CoinGecko response data:', data) - const prices: PricesMap = {} + const coinGeckoPrices: PricesMap = {} - assets.forEach(asset => { + missingAssets.forEach(asset => { const coinId = this.coinGeckoIds[asset] const coinData = data[coinId] @@ -187,9 +284,8 @@ export class ReflectorService { volume: coinData.usd_24h_vol || 0 } - prices[asset] = priceData + coinGeckoPrices[asset] = priceData - // Cache the fresh data this.priceCache.set(asset, { data: priceData, timestamp: Date.now() @@ -201,11 +297,18 @@ export class ReflectorService { } }) - if (Object.keys(prices).length === 0) { - throw new Error('No valid price data received from CoinGecko') + // Cache Reflector prices alongside CoinGecko prices + for (const [asset, data] of Object.entries(reflectorPrices)) { + this.priceCache.set(asset, { data, timestamp: Date.now() }) + } + + const merged = { ...reflectorPrices, ...coinGeckoPrices } + + if (Object.keys(merged).length === 0) { + throw new Error('No valid price data received from any source') } - return prices + return merged } catch (error) { console.error('[ERROR] Fresh price fetch failed:', error) throw error