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 { 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..ed3566e92c 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,14 @@ export class PricingRealUnitService extends PricingProvider implements OnModuleI PricingRealUnitService.EUR, ]; + // 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; constructor(private readonly moduleRef: ModuleRef) { super(); @@ -25,6 +39,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 +50,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(); + + 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; - if (realunitPrice == null) throw new Error(`No price available for ${from} -> ${to}`); + 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)) ) { 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