From 1215b8f2c0657cd50fa038836e650caa18333648 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:37:58 +0200 Subject: [PATCH 1/2] Retry transient RPC errors on equity price reads (#71) --- src/monitoringV2/price.service.ts | 4 ++-- src/monitoringV2/provider.service.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index 20bee81..0745b96 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -152,9 +152,9 @@ export class PriceService { const underlying = specialTokens.get(formattedAddress); if (!underlying) continue; // Not a special token - // Fetch price from underlying equity contract + // Fetch price from underlying equity contract (with transient-error retry) const equityContract = new ethers.Contract(underlying, EquityABI, this.providerService.provider); - const nativePrice = await equityContract.price(); + const nativePrice = await this.providerService.call(() => equityContract.price()); let formattedPrice = ethers.formatUnits(nativePrice, 18); // For WFPS, convert CHF to EUR diff --git a/src/monitoringV2/provider.service.ts b/src/monitoringV2/provider.service.ts index c463efa..027d7ab 100644 --- a/src/monitoringV2/provider.service.ts +++ b/src/monitoringV2/provider.service.ts @@ -141,6 +141,10 @@ export class ProviderService { return results; } + async call(thunk: () => Promise, retries = 5): Promise { + return this.withRetry(thunk, { retries }); + } + async getBlock(blockNumber: number): Promise { if (this.blockCache.has(blockNumber)) { return this.blockCache.get(blockNumber); From 1bc6a5b4b5944fbea962913b4283f07b6d83f628 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:39:00 +0200 Subject: [PATCH 2/2] Pricing + contract-registry correctness fixes (#70) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refuse to serve stale or fabricated prices on upstream failure 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. * Upsert core contracts so authoritative types override prior MINTER classification 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 +- src/monitoringV2/price.service.ts | 101 ++++++------------ .../repositories/contract.repository.ts | 22 ++++ 3 files changed, 58 insertions(+), 67 deletions(-) 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/price.service.ts b/src/monitoringV2/price.service.ts index 0745b96..da89f7c 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 { 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({