From 79d96899ec4cfc81f975be42e1ebc7be21b0e345 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:09:13 +0200 Subject: [PATCH 1/4] feat(realunit): surface price-source (Aktionariat) outage as 503 on buy/sell quote (#3825) * feat(realunit): surface price-source (Aktionariat) outage as 503 on buy/sell quote When the external RealUnit price provider (Aktionariat) is down, the buy and sell payment-info endpoints used to leak a generic 500, so clients could not tell the failure apart from any other error. Wrap the price-dependent quote calls in getPaymentInfo/getSellPaymentInfo: on failure, if the live price is currently unavailable, throw a 503 PriceSourceUnavailableException with code PRICE_SOURCE_UNAVAILABLE so the apps can show an explicit "price provider unavailable" message; otherwise the original error is rethrown unchanged. The /v1/realunit/price endpoints are unchanged (still 200 + null) so the price chart keeps rendering history during the outage. * fix(realunit): detect price-source outage via PriceInvalidException instead of re-querying price The previous guard called getRealUnitPrice() after a failure and checked chf == null to detect an Aktionariat outage. This was unreliable: the secondary call uses PriceValidity.ANY and may return a stale cached price with chf != null even when the source is down, causing the guard to miss the outage window and fall back to a generic 500. PricingService.getAssetPrice always throws PriceInvalidException on any pricing failure, so checking instanceof PriceInvalidException is a direct, synchronous signal with no second network call and no cache timing issue. Non-price errors (KYC, validation, IBAN) are unaffected. * style: fix prettier formatting in realunit.service.spec.ts --------- Co-authored-by: David May --- .../__tests__/realunit.service.spec.ts | 31 ++++++++++ .../price-source-unavailable.exception.ts | 10 ++++ .../supporting/realunit/realunit.service.ts | 58 ++++++++++++------- 3 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 src/subdomains/supporting/realunit/exceptions/price-source-unavailable.exception.ts diff --git a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts index ab690a0564..592a1fb9b6 100644 --- a/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts +++ b/src/subdomains/supporting/realunit/__tests__/realunit.service.spec.ts @@ -27,7 +27,9 @@ import { TransactionService } from 'src/subdomains/supporting/payment/services/t import { AssetPricesService } from '../../pricing/services/asset-prices.service'; import { PricingService } from '../../pricing/services/pricing.service'; import { RealUnitRegistrationState, RealUnitRegistrationStatus } from '../dto/realunit-registration.dto'; +import { PriceInvalidException } from '../../pricing/domain/exceptions/price-invalid.exception'; import { RealUnitDevService } from '../realunit-dev.service'; +import { PriceSourceUnavailableException } from '../exceptions/price-source-unavailable.exception'; import { RealUnitService } from '../realunit.service'; jest.mock('src/config/config', () => ({ @@ -610,4 +612,33 @@ describe('RealUnitService', () => { expect(status.userData!.lang).toBe('EN'); }); }); + + describe('withPriceSourceGuard (Aktionariat price source)', () => { + it('rethrows as PriceSourceUnavailableException (503) when a PriceInvalidException is thrown', async () => { + let caught: unknown; + try { + await (service as any).withPriceSourceGuard(() => + Promise.reject(new PriceInvalidException('No valid price found for REALU -> CHF')), + ); + } catch (e) { + caught = e; + } + + expect(caught).toBeInstanceOf(PriceSourceUnavailableException); + expect((caught as PriceSourceUnavailableException).getStatus()).toBe(503); + expect((caught as PriceSourceUnavailableException).getResponse()).toMatchObject({ + code: 'PRICE_SOURCE_UNAVAILABLE', + }); + }); + + it('rethrows the original error for non-price failures', async () => { + const original = new Error('some unrelated failure'); + + await expect((service as any).withPriceSourceGuard(() => Promise.reject(original))).rejects.toBe(original); + }); + + it('returns the result unchanged on success', async () => { + await expect((service as any).withPriceSourceGuard(() => Promise.resolve('ok'))).resolves.toBe('ok'); + }); + }); }); diff --git a/src/subdomains/supporting/realunit/exceptions/price-source-unavailable.exception.ts b/src/subdomains/supporting/realunit/exceptions/price-source-unavailable.exception.ts new file mode 100644 index 0000000000..0a1da4847e --- /dev/null +++ b/src/subdomains/supporting/realunit/exceptions/price-source-unavailable.exception.ts @@ -0,0 +1,10 @@ +import { ServiceUnavailableException } from '@nestjs/common'; + +export class PriceSourceUnavailableException extends ServiceUnavailableException { + constructor(message = 'RealUnit price source (Aktionariat) is currently unavailable') { + super({ + code: 'PRICE_SOURCE_UNAVAILABLE', + message, + }); + } +} diff --git a/src/subdomains/supporting/realunit/realunit.service.ts b/src/subdomains/supporting/realunit/realunit.service.ts index 1fa582aa37..aa42630a50 100644 --- a/src/subdomains/supporting/realunit/realunit.service.ts +++ b/src/subdomains/supporting/realunit/realunit.service.ts @@ -102,7 +102,9 @@ import { TimeFrame, TokenInfoDto, } from './dto/realunit.dto'; +import { PriceInvalidException } from '../pricing/domain/exceptions/price-invalid.exception'; import { KycLevelRequiredException, RegistrationRequiredException } from './exceptions/buy-exceptions'; +import { PriceSourceUnavailableException } from './exceptions/price-source-unavailable.exception'; import { RealUnitDevService } from './realunit-dev.service'; import { getAccountHistoryQuery, getAccountSummaryQuery, getHoldersQuery, getTokenInfoQuery } from './utils/queries'; import { TimeseriesUtils } from './utils/timeseries-utils'; @@ -408,6 +410,18 @@ export class RealUnitService { // --- Buy Payment Info Methods --- + // Runs a quote computation that depends on the RealUnit price. If it fails and + // the pricing service throws a PriceInvalidException (external source Aktionariat down), + // surface that explicitly as 503 instead of leaking a generic 500. + private async withPriceSourceGuard(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + if (e instanceof PriceInvalidException) throw new PriceSourceUnavailableException(); + throw e; + } + } + async getPaymentInfo(user: User, dto: RealUnitBuyDto): Promise { const userData = user.userData; const currencyName = dto.currency ?? 'CHF'; @@ -434,14 +448,16 @@ export class RealUnitService { const buy = await this.buyService.createBuy(user, user.address, { asset: realuAsset }, true); // 4. Call BuyService to get payment info (handles fees, rates, IBAN creation, QR codes, etc.) - const buyPaymentInfo = await this.buyService.toPaymentInfoDto(user.id, buy, { - amount: dto.amount, - targetAmount: undefined, - currency, - asset: realuAsset, - paymentMethod: FiatPaymentMethod.BANK, - exactPrice: false, - }); + const buyPaymentInfo = await this.withPriceSourceGuard(() => + this.buyService.toPaymentInfoDto(user.id, buy, { + amount: dto.amount, + targetAmount: undefined, + currency, + asset: realuAsset, + paymentMethod: FiatPaymentMethod.BANK, + exactPrice: false, + }), + ); // 5. Override recipient info with RealUnit company address const { bank: realunitBank, address: realunitAddress } = GetConfig().blockchain.realunit; @@ -1150,18 +1166,20 @@ export class RealUnitService { ); // 6. Call SellService to get payment info (handles fees, rates, transaction request creation, etc.) - const sellPaymentInfo = await this.sellService.toPaymentInfoDto( - user.id, - sell, - { - iban: dto.iban, - asset: realuAsset, - currency, - amount: dto.amount, - targetAmount: dto.targetAmount, - exactPrice: false, - }, - false, // includeTx + const sellPaymentInfo = await this.withPriceSourceGuard(() => + this.sellService.toPaymentInfoDto( + user.id, + sell, + { + iban: dto.iban, + asset: realuAsset, + currency, + amount: dto.amount, + targetAmount: dto.targetAmount, + exactPrice: false, + }, + false, // includeTx + ), ); // 7. Check if limit exceeded From cf3418553bd93790ed1b230911d5e2d789580c11 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:09:22 +0200 Subject: [PATCH 2/4] fix(realunit): use last known price when Aktionariat price source is down (#3829) * fix(realunit): use last known price when Aktionariat price source is down The buy/sell quote depends on the live RealUnit price provider (Aktionariat). When that source is unavailable, the price lookup throws, so the quote and the payment-info endpoints fail and the app becomes unusable. Let the RealUnit pricing provider fall back to the last persisted price (Asset.approxPriceChf/Eur, refreshed hourly and surviving restarts) when the live fetch fails. The fallback price is flagged invalid so strict consumers (exact quotes, the price snapshot job) keep rejecting the stale value, while estimates (non-exact quotes, min volume, fees) keep working during an outage. * fix(pricing): do not persist an invalid price as a rule's current price Guard doUpdatePriceFor so a price flagged invalid is never written to PriceRule.currentPrice. This keeps the RealUnit last-known-price fallback from leaking into VALID_ONLY consumers: estimates (ANY) still use the last price, while the snapshot job and exact lookups keep skipping the stale value instead of recording it as current. --- .../pricing-realunit.service.spec.ts | 71 +++++++++++++++++++ .../integration/pricing-realunit.service.ts | 57 +++++++++++++-- .../pricing/services/pricing.service.ts | 1 + 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 src/subdomains/supporting/pricing/services/integration/__tests__/pricing-realunit.service.spec.ts diff --git a/src/subdomains/supporting/pricing/services/integration/__tests__/pricing-realunit.service.spec.ts b/src/subdomains/supporting/pricing/services/integration/__tests__/pricing-realunit.service.spec.ts new file mode 100644 index 0000000000..e2474a2c58 --- /dev/null +++ b/src/subdomains/supporting/pricing/services/integration/__tests__/pricing-realunit.service.spec.ts @@ -0,0 +1,71 @@ +import { createMock } from '@golevelup/ts-jest'; +import { ModuleRef } from '@nestjs/core'; +import { ConfigService, Configuration } from 'src/config/config'; +import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { PricingRealUnitService } from '../pricing-realunit.service'; + +describe('PricingRealUnitService', () => { + let service: PricingRealUnitService; + let realunitService: jest.Mocked; + let assetService: jest.Mocked; + + beforeAll(() => { + new ConfigService(new Configuration()); + }); + + beforeEach(() => { + realunitService = createMock(); + assetService = createMock(); + + const moduleRef = { + get: (token: unknown) => (token === RealUnitBlockchainService ? realunitService : assetService), + } as unknown as ModuleRef; + + service = new PricingRealUnitService(moduleRef); + service.onModuleInit(); + }); + + it('returns the live price (valid) for REALU -> ZCHF', async () => { + realunitService.getRealUnitPriceChf.mockResolvedValue(2); + + const price = await service.getPrice('REALU', 'ZCHF'); + + expect(price.price).toBe(0.5); + expect(price.isValid).toBe(true); + expect(assetService.getAssetByQuery).not.toHaveBeenCalled(); + }); + + it('falls back to the last persisted CHF price (invalid) when Aktionariat is down', async () => { + realunitService.getRealUnitPriceChf.mockRejectedValue(new Error('Aktionariat down')); + const timestamp = new Date('2026-06-01T00:00:00Z'); + assetService.getAssetByQuery.mockResolvedValue({ approxPriceChf: 4, approxPriceEur: 5, updated: timestamp } as any); + + const price = await service.getPrice('REALU', 'ZCHF'); + + expect(price.price).toBe(0.25); + expect(price.isValid).toBe(false); + expect(price.timestamp).toEqual(timestamp); + }); + + it('uses the last persisted EUR price for EUR pairs in fallback', async () => { + realunitService.getRealUnitPriceEur.mockRejectedValue(new Error('Aktionariat down')); + assetService.getAssetByQuery.mockResolvedValue({ + approxPriceChf: 4, + approxPriceEur: 5, + updated: new Date(), + } as any); + + const price = await service.getPrice('REALU', 'EUR'); + + expect(price.price).toBe(0.2); + expect(price.isValid).toBe(false); + }); + + it('throws when neither a live nor a persisted price is available', async () => { + realunitService.getRealUnitPriceChf.mockRejectedValue(new Error('Aktionariat down')); + assetService.getAssetByQuery.mockResolvedValue({ approxPriceChf: undefined, updated: new Date() } as any); + + await expect(service.getPrice('REALU', 'ZCHF')).rejects.toThrow('No price available'); + }); +}); diff --git a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts index c62d673ffa..05f036537e 100644 --- a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts +++ b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts @@ -1,12 +1,19 @@ import { Injectable, OnModuleInit } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; +import { Config, Environment } from 'src/config/config'; import { RealUnitBlockchainService } from 'src/integration/blockchain/realunit/realunit-blockchain.service'; +import { Blockchain } from 'src/integration/blockchain/shared/enums/blockchain.enum'; +import { AssetType } from 'src/shared/models/asset/asset.entity'; +import { AssetService } from 'src/shared/models/asset/asset.service'; +import { DfxLogger } from 'src/shared/services/dfx-logger'; import { Util } from 'src/shared/utils/util'; import { Price } from '../../domain/entities/price'; import { PricingProvider } from './pricing-provider'; @Injectable() export class PricingRealUnitService extends PricingProvider implements OnModuleInit { + private readonly logger = new DfxLogger(PricingRealUnitService); + private static readonly REALU = 'REALU'; private static readonly ZCHF = 'ZCHF'; private static readonly EUR = 'EUR'; @@ -17,7 +24,12 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI PricingRealUnitService.EUR, ]; + private readonly tokenBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) + ? Blockchain.SEPOLIA + : Blockchain.ETHEREUM; + private realunitService: RealUnitBlockchainService; + private assetService: AssetService; constructor(private readonly moduleRef: ModuleRef) { super(); @@ -25,6 +37,7 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI onModuleInit() { this.realunitService = this.moduleRef.get(RealUnitBlockchainService, { strict: false }); + this.assetService = this.moduleRef.get(AssetService, { strict: false }); } async getPrice(from: string, to: string): Promise { @@ -35,14 +48,48 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI const isEurPair = [from, to].includes(PricingRealUnitService.EUR); - const realunitPrice = isEurPair - ? await this.realunitService.getRealUnitPriceEur() - : await this.realunitService.getRealUnitPriceChf(); + const livePrice = await this.getLivePrice(isEurPair); + if (livePrice != null) return this.toPrice(from, to, livePrice); + + // The live price source (Aktionariat) is unavailable: fall back to the last + // persisted RealUnit price so estimates (non-exact quotes) keep working during + // an outage. The price is flagged invalid, so it is not stored as a fresh price + // by the rule update and strict consumers (VALID_ONLY, e.g. the price snapshot + // job) still skip it instead of recording the stale value as current. + const fallback = await this.getLastKnownPrice(isEurPair); + if (fallback == null) throw new Error(`No price available for ${from} -> ${to}`); + + return this.toPrice(from, to, fallback.price, false, fallback.timestamp); + } + + // --- HELPER METHODS --- // + private async getLivePrice(isEurPair: boolean): Promise { + try { + const price = isEurPair + ? await this.realunitService.getRealUnitPriceEur() + : await this.realunitService.getRealUnitPriceChf(); - if (realunitPrice == null) throw new Error(`No price available for ${from} -> ${to}`); + return price ?? null; + } catch (e) { + this.logger.info('RealUnit live price (Aktionariat) unavailable, falling back to last known price:', e); + return null; + } + } + + private async getLastKnownPrice(isEurPair: boolean): Promise<{ price: number; timestamp: Date } | null> { + const asset = await this.assetService + .getAssetByQuery({ name: PricingRealUnitService.REALU, blockchain: this.tokenBlockchain, type: AssetType.TOKEN }) + .catch(() => null); + + const price = isEurPair ? asset?.approxPriceEur : asset?.approxPriceChf; + if (!price) return null; + + return { price, timestamp: asset.updated }; + } + private toPrice(from: string, to: string, realunitPrice: number, isValid = true, timestamp = new Date()): Price { const assetPrice = from === PricingRealUnitService.REALU ? 1 / realunitPrice : realunitPrice; - return Price.create(from, to, Util.round(assetPrice, 8)); + return Price.create(from, to, Util.round(assetPrice, 8), isValid, timestamp); } } diff --git a/src/subdomains/supporting/pricing/services/pricing.service.ts b/src/subdomains/supporting/pricing/services/pricing.service.ts index 1bc62878c2..aa5284ec39 100644 --- a/src/subdomains/supporting/pricing/services/pricing.service.ts +++ b/src/subdomains/supporting/pricing/services/pricing.service.ts @@ -275,6 +275,7 @@ export class PricingService implements OnModuleInit { const target = rule.reference?.name ?? price.target; if ( + price.isValid && (await this.isPriceValid(source, target, price.price, rule.check1, check1Price)) && (await this.isPriceValid(source, target, price.price, rule.check2, check2Price)) ) { From fd78e0dd5e9ec7e253d0cb0d72c35a312d3a7d42 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:20:02 +0200 Subject: [PATCH 3/4] fix(realunit): throw clear error when Aktionariat price response is empty (#3830) The price fetch crashed with a cryptic "Cannot read properties of undefined (reading 'priceInCHF')" when the Aktionariat getPrice endpoint returned an empty/invalid body. Validate the response after the cache (AsyncCache swallows callback errors with fallbackToCache) and throw an explicit, source-attributing error instead. --- .../realunit-blockchain.service.spec.ts | 26 +++++++++++++++++++ .../realunit/realunit-blockchain.service.ts | 7 ++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts index 49afa8cbbd..8057154c02 100644 --- a/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts +++ b/src/integration/blockchain/realunit/__tests__/realunit-blockchain.service.spec.ts @@ -90,6 +90,32 @@ describe('RealUnitBlockchainService', () => { jest.clearAllMocks(); }); + describe('getRealUnitPrice', () => { + it('should return the CHF price from Aktionariat', async () => { + httpService.post.mockResolvedValue({ priceInCHF: 1.42, priceInEUR: 1.55, availableShares: 500 }); + + await expect(service.getRealUnitPriceChf()).resolves.toBe(1.42); + }); + + it('should return the EUR price from Aktionariat', async () => { + httpService.post.mockResolvedValue({ priceInCHF: 1.42, priceInEUR: 1.55, availableShares: 500 }); + + await expect(service.getRealUnitPriceEur()).resolves.toBe(1.55); + }); + + it('should throw a clear error when Aktionariat returns an empty body', async () => { + httpService.post.mockResolvedValue(undefined as any); + + await expect(service.getRealUnitPriceChf()).rejects.toThrow('Aktionariat getPrice returned an invalid response'); + }); + + it('should throw a clear error when Aktionariat returns a body without prices', async () => { + httpService.post.mockResolvedValue({ availableShares: 500 } as any); + + await expect(service.getRealUnitPriceEur()).rejects.toThrow('Aktionariat getPrice returned an invalid response'); + }); + }); + describe('getBrokerbotSellPrice', () => { it('should query BrokerBot contract and return exact amount', async () => { // BrokerBot returns 1000 ZCHF (in Wei) for 10 shares diff --git a/src/integration/blockchain/realunit/realunit-blockchain.service.ts b/src/integration/blockchain/realunit/realunit-blockchain.service.ts index 0184c0dcd6..0ec6e88aaa 100644 --- a/src/integration/blockchain/realunit/realunit-blockchain.service.ts +++ b/src/integration/blockchain/realunit/realunit-blockchain.service.ts @@ -48,7 +48,7 @@ export class RealUnitBlockchainService { constructor(private readonly http: HttpService) {} private async fetchPrice(): Promise { - return this.priceCache.get( + const response = await this.priceCache.get( 'price', async () => { const { url, key } = GetConfig().blockchain.realunit.api; @@ -59,6 +59,11 @@ export class RealUnitBlockchainService { undefined, true, ); + + if (response?.priceInCHF == null || response.priceInEUR == null) + throw new Error('Aktionariat getPrice returned an invalid response (missing price)'); + + return response; } async getRealUnitPriceChf(): Promise { From a0d38ac0cca53eb722a3c77ddf48056f8f8262f2 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Mon, 8 Jun 2026 10:42:27 +0200 Subject: [PATCH 4/4] fix(realunit): defer Config read in pricing service to avoid bootstrap crash (#3832) PricingRealUnitService read Config.environment in a class field initializer. Because of a circular import, Config is undefined at construction time, so NestJS DI threw "Cannot read properties of undefined (reading 'environment')" and the whole API failed to bootstrap (crash loop, all health checks down). Convert the field into a lazy getter so Config is read at runtime, after the module graph is fully initialized. --- .../services/integration/pricing-realunit.service.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts index 05f036537e..ed3566e92c 100644 --- a/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts +++ b/src/subdomains/supporting/pricing/services/integration/pricing-realunit.service.ts @@ -24,9 +24,11 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI PricingRealUnitService.EUR, ]; - private readonly tokenBlockchain = [Environment.DEV, Environment.LOC].includes(Config.environment) - ? Blockchain.SEPOLIA - : Blockchain.ETHEREUM; + // Lazily evaluated: reading Config during field initialization crashes module bootstrap, + // because Config is still undefined at construction time due to a circular import. + private get tokenBlockchain(): Blockchain { + return [Environment.DEV, Environment.LOC].includes(Config.environment) ? Blockchain.SEPOLIA : Blockchain.ETHEREUM; + } private realunitService: RealUnitBlockchainService; private assetService: AssetService;