Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class RealUnitBlockchainService {
constructor(private readonly http: HttpService) {}

private async fetchPrice(): Promise<AktionariatPriceResponse> {
return this.priceCache.get(
const response = await this.priceCache.get(
'price',
async () => {
const { url, key } = GetConfig().blockchain.realunit.api;
Expand All @@ -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<number> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RealUnitBlockchainService>;
let assetService: jest.Mocked<AssetService>;

beforeAll(() => {
new ConfigService(new Configuration());
});

beforeEach(() => {
realunitService = createMock<RealUnitBlockchainService>();
assetService = createMock<AssetService>();

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');
});
});
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,14 +24,22 @@ 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();
}

onModuleInit() {
this.realunitService = this.moduleRef.get(RealUnitBlockchainService, { strict: false });
this.assetService = this.moduleRef.get(AssetService, { strict: false });
}

async getPrice(from: string, to: string): Promise<Price> {
Expand All @@ -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<number | null> {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -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,
});
}
}
58 changes: 38 additions & 20 deletions src/subdomains/supporting/realunit/realunit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (e) {
if (e instanceof PriceInvalidException) throw new PriceSourceUnavailableException();
throw e;
}
}

async getPaymentInfo(user: User, dto: RealUnitBuyDto): Promise<RealUnitPaymentInfoDto> {
const userData = user.userData;
const currencyName = dto.currency ?? 'CHF';
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading