From 9d3eed0cf5b92b528e75618bc02c2da38e460c75 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 20 May 2026 09:43:30 +0200 Subject: [PATCH 1/2] Refuse to serve stale or fabricated prices on upstream failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PriceService masked GeckoTerminal/CoinGecko failures by returning the fresh-cache subset (silently dropping uncovered tokens) and, for FX, by falling back to USD/EUR=USD/CHF=1 while rewriting the cache timestamp to `now` — a stale value would then be served as fresh for a full CACHE_TTL_MS window. Both behaviours violate the rule that pricing must fail loud rather than return wrong data. Remove the catch-all fallbacks: GeckoTerminal and CoinGecko errors now propagate to the caller. The 5-min monitoring cycle is already wrapped in a robust try/catch with consecutive-failure escalation, so a failed fetch aborts only the current cycle instead of writing wrong values to the DB. Additionally tighten the FX validity check: reject zero/negative or non-finite rates instead of treating them as a transient blip. --- src/monitoringV2/price.service.ts | 101 +++++++++++------------------- 1 file changed, 35 insertions(+), 66 deletions(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 20bee81..8ee6e2d 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -106,36 +106,26 @@ export class PriceService { const baseUrl = this.appConfigService.geckoTerminalBaseUrl; - try { - const response = await axios.get( - `${baseUrl}/api/v2/simple/networks/eth/token_price/${remaining.map((a) => a.toLowerCase()).join(',')}`, - { - headers: { accept: 'application/json' }, - timeout: 10000, // 10 second timeout - } - ); - - const apiPrices = response.data.data.attributes.token_prices; - const normalizedPrices: { [key: string]: string } = {}; - for (const inputAddress of remaining) { - const price = apiPrices[inputAddress.toLowerCase()]; - if (price) { - normalizedPrices[inputAddress] = price; - this.setCache(inputAddress, price); - } + const response = await axios.get( + `${baseUrl}/api/v2/simple/networks/eth/token_price/${remaining.map((a) => a.toLowerCase()).join(',')}`, + { + headers: { accept: 'application/json' }, + timeout: 10000, // 10 second timeout } + ); - this.logger.log(`Fetched prices for ${Object.keys(normalizedPrices).length} tokens from GeckoTerminal`); - return { ...cached, ...normalizedPrices }; - } catch (error) { - this.logger.error('Failed to fetch token prices from GeckoTerminal:', error); - if (cached) { - this.logger.warn('Returning expired cached prices due to API error'); - return cached; + const apiPrices = response.data.data.attributes.token_prices; + const normalizedPrices: { [key: string]: string } = {}; + for (const inputAddress of remaining) { + const price = apiPrices[inputAddress.toLowerCase()]; + if (price) { + normalizedPrices[inputAddress] = price; + this.setCache(inputAddress, price); } - - return {}; } + + this.logger.log(`Fetched prices for ${Object.keys(normalizedPrices).length} tokens from GeckoTerminal`); + return { ...cached, ...normalizedPrices }; } private async getSpecialTokenPrices(requestedAddresses: string[]): Promise<{ [key: string]: string }> { @@ -191,7 +181,7 @@ export class PriceService { // Deduplicate concurrent requests if (this.pendingFxRates) return this.pendingFxRates; - this.pendingFxRates = this.fetchFxRates(eurCached, chfCached); + this.pendingFxRates = this.fetchFxRates(); try { return await this.pendingFxRates; } finally { @@ -226,49 +216,28 @@ export class PriceService { return { baseUrl, headers }; } - private async fetchFxRates( - eurCached: PriceCacheEntry | undefined, - chfCached: PriceCacheEntry | undefined - ): Promise<{ eur: number; chf: number }> { - try { - const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); - const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=usd&vs_currencies=eur,chf`, { - headers, - timeout: 10000, - }); - - const eur = Number(response.data.usd.eur); - const chf = Number(response.data.usd.chf); - - if (Number.isNaN(eur) || Number.isNaN(chf)) { - this.logger.error('CoinGecko returned non-numeric FX rates', response.data); - return { - eur: !Number.isNaN(eur) ? eur : eurCached ? Number(eurCached.value) : 1, - chf: !Number.isNaN(chf) ? chf : chfCached ? Number(chfCached.value) : 1, - }; - } - - const now = Date.now(); - this.priceCache.set('usd-eur-rate', { value: String(eur), timestamp: now }); - this.priceCache.set('usd-chf-rate', { value: String(chf), timestamp: now }); - this.fxLastSuccessMs = now; - this.fxStalenessAlertedAt = null; + private async fetchFxRates(): Promise<{ eur: number; chf: number }> { + const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); + const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=usd&vs_currencies=eur,chf`, { + headers, + timeout: 10000, + }); - this.logger.debug(`FX rates: USD/EUR=${eur}, USD/CHF=${chf}`); - return { eur, chf }; - } catch (error) { - this.logger.error('Failed to fetch FX rates:', error.message || error); + const eur = Number(response.data?.usd?.eur); + const chf = Number(response.data?.usd?.chf); - const eur = eurCached ? Number(eurCached.value) : 1; - const chf = chfCached ? Number(chfCached.value) : 1; + if (!Number.isFinite(eur) || !Number.isFinite(chf) || eur <= 0 || chf <= 0) { + throw new Error(`CoinGecko returned invalid FX rates: usd.eur=${response.data?.usd?.eur}, usd.chf=${response.data?.usd?.chf}`); + } - // Refresh cache timestamps so we don't retry on every call while rate-limited - const now = Date.now(); - this.priceCache.set('usd-eur-rate', { value: String(eur), timestamp: now }); - this.priceCache.set('usd-chf-rate', { value: String(chf), timestamp: now }); + const now = Date.now(); + this.priceCache.set('usd-eur-rate', { value: String(eur), timestamp: now }); + this.priceCache.set('usd-chf-rate', { value: String(chf), timestamp: now }); + this.fxLastSuccessMs = now; + this.fxStalenessAlertedAt = null; - return { eur, chf }; - } + this.logger.debug(`FX rates: USD/EUR=${eur}, USD/CHF=${chf}`); + return { eur, chf }; } private isSpecialToken(address: string): boolean { From adefb27b6c785132d558e2d288a391ef3af2b97a Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 20 May 2026 09:43:41 +0200 Subject: [PATCH 2/2] Upsert core contracts so authoritative types override prior MINTER classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core protocol contracts (the hard-coded addresses from @deuro/eurocoin) were persisted via createMany({skipDuplicates: true}). When a contract was first seen through a MinterApplied event — as happened for the V3 Savings 0x760233b90e45d186A9A98E911B115F7F4B90d3D9 — it was stored as generic MINTER. Once the package release shipped the address as ADDRESS[chainId].savings (SAVINGS_V3), the type never got updated and the contract kept logging "No ABI mapped for contract type MINTER" WARNings, with EventService dropping the events undecoded. Add ContractRepository.upsertCore and use it in registerCoreContracts so the hard-coded type from the SDK is authoritative on every boot. --- src/monitoringV2/contract.service.ts | 2 +- .../repositories/contract.repository.ts | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/monitoringV2/contract.service.ts b/src/monitoringV2/contract.service.ts index 3801f37..581caa5 100644 --- a/src/monitoringV2/contract.service.ts +++ b/src/monitoringV2/contract.service.ts @@ -100,7 +100,7 @@ export class ContractService { ); } - await this.contractRepo.createMany(coreContracts); + await this.contractRepo.upsertCore(coreContracts); this.logger.log(`Registry initialized with ${coreContracts.length} core contracts`); this.logger.log(`Registered addresses: ${coreContracts.map((c) => c.address).join(', ')}`); } diff --git a/src/monitoringV2/prisma/repositories/contract.repository.ts b/src/monitoringV2/prisma/repositories/contract.repository.ts index 6ca5ea3..3cf233a 100644 --- a/src/monitoringV2/prisma/repositories/contract.repository.ts +++ b/src/monitoringV2/prisma/repositories/contract.repository.ts @@ -30,6 +30,28 @@ export class ContractRepository { } } + // Core contracts are the canonical, hard-coded protocol addresses from + // @deuro/eurocoin. Their `type` is authoritative and must override any + // earlier classification (e.g. a Savings deployed via MinterApplied was + // first persisted as generic MINTER, then later promoted to SAVINGS_V3 + // once the package shipped its address). Use upsert so re-registration + // corrects the type instead of being silently skipped. + async upsertCore(contracts: Contract[]): Promise { + if (contracts.length === 0) return; + + for (const contract of contracts) { + const address = contract.address.toLowerCase(); + const metadata = contract.metadata || {}; + await this.prisma.contract.upsert({ + where: { address }, + create: { address, type: contract.type, timestamp: contract.timestamp, metadata }, + update: { type: contract.type, metadata }, + }); + } + + this.logger.log(`Upserted ${contracts.length} core contracts`); + } + async findAll(): Promise { try { const contracts = await this.prisma.contract.findMany({