From 17e533218678bc54514545edc227433e52fd05e6 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 20 May 2026 09:43:55 +0200 Subject: [PATCH 1/2] fix(monitoringV2): refuse to serve stale or fabricated prices on upstream failure PriceService masked GeckoTerminal / CoinGecko / equity-call failures by silently returning the fresh-cache subset, dropping uncovered tokens on the floor; and for WCBTC the BTC fetcher could return null, which made getWcbtcPrices reuse expired cache via `if (!btcPrice) return cached`. All paths violated the rule that pricing must fail loud rather than return wrong or partial data. - getGeckoTerminalPricesInUSD: let errors propagate instead of swallowing. - getBtcPriceInUsd: drop the catch / null fallback; tighten the parse to reject zero/negative or non-finite values. - getWcbtcPrices: remove the silent-null escape now that BTC throws. - getEquityPrice: drop the per-iteration try/catch that masked provider failures; hoist the multicall-shared contract instance out of the loop. The 5-min monitoring cycle wraps PriceService calls in a consecutive- failure escalation, so a failed fetch aborts the current cycle instead of writing wrong values to the DB. --- src/monitoringV2/price.service.ts | 102 ++++++++++++++---------------- 1 file changed, 46 insertions(+), 56 deletions(-) diff --git a/src/monitoringV2/price.service.ts b/src/monitoringV2/price.service.ts index b4963a0..f432e96 100644 --- a/src/monitoringV2/price.service.ts +++ b/src/monitoringV2/price.service.ts @@ -91,7 +91,6 @@ export class PriceService { if (remaining.length === 0) return cached; const btcPrice = await this.getBtcPriceInUsd(); - if (!btcPrice) return cached; const prices: { [key: string]: string } = {}; for (const addr of remaining) { @@ -129,30 +128,30 @@ export class PriceService { return { baseUrl, headers }; } - private async getBtcPriceInUsd(): Promise { + private async getBtcPriceInUsd(): Promise { const cached = this.priceCache.get('btc-usd'); if (cached && Date.now() - cached.timestamp < this.CACHE_TTL_MS) { return cached.value; } - try { - const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); - const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=bitcoin&vs_currencies=usd`, { - headers, - timeout: 10000, - }); - - const price = String(response.data.bitcoin.usd); - const now = Date.now(); - this.priceCache.set('btc-usd', { value: price, timestamp: now }); - this.btcLastSuccessMs = now; - this.btcStalenessAlertedAt = null; - this.logger.log(`BTC price: $${price}`); - return price; - } catch (error) { - this.logger.error(`Failed to fetch BTC price: ${error.message}`); - return cached?.value ?? null; + const { baseUrl, headers } = this.resolveCoingeckoEndpoint(); + const response = await axios.get(`${baseUrl}/api/v3/simple/price?ids=bitcoin&vs_currencies=usd`, { + headers, + timeout: 10000, + }); + + const priceNum = Number(response.data?.bitcoin?.usd); + if (!Number.isFinite(priceNum) || priceNum <= 0) { + throw new Error(`CoinGecko returned invalid BTC price: bitcoin.usd=${response.data?.bitcoin?.usd}`); } + + const price = String(priceNum); + const now = Date.now(); + this.priceCache.set('btc-usd', { value: price, timestamp: now }); + this.btcLastSuccessMs = now; + this.btcStalenessAlertedAt = null; + this.logger.log(`BTC price: $${price}`); + return price; } /** @@ -185,31 +184,26 @@ export class PriceService { const baseUrl = this.appConfigService.geckoTerminalBaseUrl; - try { - const response = await axios.get( - `${baseUrl}/api/v2/simple/networks/citrea/token_price/${remaining.map((a) => a.toLowerCase()).join(',')}`, - { - headers: { accept: 'application/json' }, - timeout: 10000, - } - ); - - 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/citrea/token_price/${remaining.map((a) => a.toLowerCase()).join(',')}`, + { + headers: { accept: 'application/json' }, + timeout: 10000, } + ); - 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.message); - 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); + } } + + this.logger.log(`Fetched prices for ${Object.keys(normalizedPrices).length} tokens from GeckoTerminal`); + return { ...cached, ...normalizedPrices }; } private async getEquityPrice(requestedAddresses: string[]): Promise<{ [key: string]: string }> { @@ -219,24 +213,20 @@ export class PriceService { const remaining = requestedAddresses.filter((addr) => !cached[addr]); if (remaining.length === 0) return cached; + const equityContract = new ethers.Contract( + ADDRESS[this.appConfigService.blockchainId].equity, + EquityABI, + this.providerService.provider + ); + const nativePrice = await equityContract.price(); + const formattedPrice = ethers.formatUnits(nativePrice, 18); + const prices: { [key: string]: string } = {}; for (const requestedAddress of remaining) { - try { - const equityContract = new ethers.Contract( - ADDRESS[this.appConfigService.blockchainId].equity, - EquityABI, - this.providerService.provider - ); - const nativePrice = await equityContract.price(); - const formattedPrice = ethers.formatUnits(nativePrice, 18); - - prices[requestedAddress] = formattedPrice; - this.setCache(requestedAddress, formattedPrice); - this.logger.debug(`Fetched equity price: ${formattedPrice}`); - } catch (error) { - this.logger.error(`Failed to fetch equity price: ${error.message}`); - } + prices[requestedAddress] = formattedPrice; + this.setCache(requestedAddress, formattedPrice); } + this.logger.debug(`Fetched equity price: ${formattedPrice}`); return { ...cached, ...prices }; } From dbbf834bb682bf9387b494d5b4556d132654b4bb Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Wed, 20 May 2026 09:43:55 +0200 Subject: [PATCH 2/2] fix(monitoringV2): 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 @juicedollar/jusd) were persisted via createMany({skipDuplicates: true}). If a contract was first seen through a MinterApplied event it would be stored as generic MINTER and stay that way forever — once the package shipped the address under a known role the type was never updated, and the contract kept logging "No ABI mapped for contract type MINTER" WARNings while EventService dropped the events undecoded. Add ContractRepository.upsertCore and use it in registerCoreContracts so the hard-coded type from the SDK is authoritative on every boot. JUSD does not currently exhibit the symptom (the dEURO V3 Savings rollout is what surfaced it) but the fix is applied in parallel for consistency. --- 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 2490672..bb4fba4 100644 --- a/src/monitoringV2/contract.service.ts +++ b/src/monitoringV2/contract.service.ts @@ -74,7 +74,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 2d6ca2d..3b724fb 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 + // @juicedollar/jusd. Their `type` is authoritative and must override any + // earlier classification (e.g. a contract first persisted as generic + // MINTER via MinterApplied and later promoted to a known type when 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({