Slave/btc integration #4

Merged
nobswebdev merged 47 commits from slave/btc-integration into master 2026-09-07 11:11:10 +00:00
29 changed files with 630 additions and 388 deletions
Showing only changes of commit 0510695598 - Show all commits
+4 -4
View File
@@ -9,7 +9,7 @@ import {
getCoingeckoConfig,
getEncryptionConfig,
getJwtConfig,
getKrakenConfig,
getCoinPaprikaConfig,
getOrderConfig,
getInvoiceConfig,
getMoneroWalletConfig,
@@ -35,7 +35,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
import { XmrRateModule } from './modules/xmrRate/XmrRateModule';
import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
import { Config } from './types/Config';
@Module({
@@ -49,7 +49,7 @@ import { Config } from './types/Config';
registerAs('app', getAppConfig),
registerAs('jwt', getJwtConfig),
registerAs('coingecko', getCoingeckoConfig),
registerAs('kraken', getKrakenConfig),
registerAs('coinPaprika', getCoinPaprikaConfig),
registerAs('encryption', getEncryptionConfig),
registerAs('shopSettings', getShopSettingsConfig),
registerAs('order', getOrderConfig),
@@ -74,7 +74,7 @@ import { Config } from './types/Config';
};
}
}),
XmrRateModule,
ExchangeRateModule,
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
inject: [ConfigService]
+2 -1
View File
@@ -191,7 +191,8 @@ export const getOrderConfig = (): OrderConfig => ({
export const getInvoiceConfig = (): InvoiceConfig => ({
minByMethod: {
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC'))
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')),
[PaymentMethod.Btc]: String(envInt('BTC_MIN_INCOMING_ATOMIC'))
}
});
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ExchangeRateService } from './services/ExchangeRateService';
@Module({
providers: [ExchangeRateService],
exports: [ExchangeRateService]
})
export class ExchangeRateModule {}
@@ -0,0 +1,7 @@
import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
export const COINPAPRIKA_ID_BY_PAYMENT_METHOD = {
[PaymentMethod.Xmr]: 'xmr-monero',
[PaymentMethod.Btc]: 'btc-bitcoin'
} as const satisfies Record<ExchangeRatePaymentMethod, string>;
@@ -0,0 +1,7 @@
import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
export const COINGECKO_ID_BY_PAYMENT_METHOD = {
[PaymentMethod.Xmr]: 'monero',
[PaymentMethod.Btc]: 'bitcoin'
} as const satisfies Record<ExchangeRatePaymentMethod, string>;
@@ -0,0 +1,5 @@
import { PaymentMethod } from '../../payment/types/PaymentMethod';
export const EXCHANGE_RATE_PAYMENT_METHODS = [PaymentMethod.Xmr, PaymentMethod.Btc] as const;
export type ExchangeRatePaymentMethod = (typeof EXCHANGE_RATE_PAYMENT_METHODS)[number];
@@ -0,0 +1,43 @@
import {
IsEnum,
IsNotEmpty,
Validate,
ValidatorConstraint,
type ValidationArguments,
type ValidatorConstraintInterface
} from 'class-validator';
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
@ValidatorConstraint({ name: 'coinPaprikaResponse' })
class CoinPaprikaResponseConstraint implements ValidatorConstraintInterface {
validate(response: unknown, args: ValidationArguments): boolean {
const { shopFiatCurrency } = args.object as CoinPaprikaTickerResponseDto;
if (!shopFiatCurrency || typeof response !== 'object' || response === null) {
return false;
}
const quotes = (response as { quotes?: unknown }).quotes;
if (typeof quotes !== 'object' || quotes === null) {
return false;
}
const quote = (quotes as Record<string, { price?: number }>)[shopFiatCurrency];
return typeof quote?.price === 'number' && Number.isFinite(quote.price) && quote.price > 0;
}
defaultMessage(): string {
return 'CoinPaprika fiat quote price must be a positive number';
}
}
export class CoinPaprikaTickerResponseDto {
@IsEnum(ShopFiatCurrency)
shopFiatCurrency: ShopFiatCurrency;
@IsNotEmpty()
@Validate(CoinPaprikaResponseConstraint)
response: { quotes: Record<string, { price: number }> };
}
@@ -0,0 +1,55 @@
import {
IsArray,
IsEnum,
IsNotEmpty,
IsObject,
IsString,
Validate,
ValidatorConstraint,
type ValidationArguments,
type ValidatorConstraintInterface
} from 'class-validator';
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
@ValidatorConstraint({ name: 'coingeckoResponse' })
class CoingeckoResponseConstraint implements ValidatorConstraintInterface {
validate(response: Record<string, unknown>, args: ValidationArguments): boolean {
const { shopFiatCurrency, coinIds } = args.object as CoingeckoSimplePriceResponseDto;
if (!shopFiatCurrency || coinIds.length === 0) {
return false;
}
const fiatKey = shopFiatCurrency.toLowerCase();
return coinIds.every(coinId => {
const coin = response[coinId];
if (typeof coin !== 'object' || coin === null) {
return false;
}
const rate = coin[fiatKey];
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
});
}
defaultMessage(): string {
return 'CoinGecko coin fiat rate must be a positive number';
}
}
export class CoingeckoSimplePriceResponseDto {
@IsEnum(ShopFiatCurrency)
shopFiatCurrency: ShopFiatCurrency;
@IsArray()
@IsString({ each: true })
coinIds: string[];
@IsNotEmpty()
@IsObject()
@Validate(CoingeckoResponseConstraint)
response: Record<string, Record<string, number>>;
}
@@ -0,0 +1,245 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { ExchangeRateService } from './ExchangeRateService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const buildCoinPaprikaTicker = (price: number, fiat = 'USD') => ({
quotes: { [fiat]: { price } }
});
const buildCoinPaprikaAxiosMock =
(xmrPrice: number, btcPrice: number, fiat = 'USD') =>
(url: string) => {
if (url === `https://coinpaprika.test/v1/tickers/xmr-monero?quotes=${fiat}`) {
return Promise.resolve({ data: buildCoinPaprikaTicker(xmrPrice, fiat) });
}
if (url === `https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=${fiat}`) {
return Promise.resolve({ data: buildCoinPaprikaTicker(btcPrice, fiat) });
}
return Promise.reject(new Error(`unexpected url: ${url}`));
};
describe('ExchangeRateService', () => {
let service: ExchangeRateService;
let configService: {
get: jest.Mock;
};
let warnLogSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
beforeEach(() => {
warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
configService = {
get: jest.fn((key: string) => {
if (key === 'shopSettings') {
return { shopFiatCurrency: 'USD' };
}
if (key === 'coingecko') {
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
}
if (key === 'coinPaprika') {
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
}
return undefined;
})
};
service = new ExchangeRateService(configService as unknown as ConfigService);
mockedAxios.get.mockReset();
});
afterEach(() => {
warnLogSpy.mockRestore();
errorLogSpy.mockRestore();
logSpy.mockRestore();
});
it('stores batched CoinGecko rates when the fetch succeeds', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { usd: 152.3456 },
bitcoin: { usd: 95_432.1987 }
}
});
await service.fetchLiveRates();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=usd',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(152.35);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_432.2);
});
it('falls back to CoinPaprika when CoinGecko fails', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockImplementation(buildCoinPaprikaAxiosMock(149.876, 94_999.5));
await service.fetchLiveRates();
expect(warnLogSpy).toHaveBeenCalled();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD',
{ timeout: 5000 }
);
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.88);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_999.5);
});
it('leaves cached rates null when both providers fail on the first fetch', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockRejectedValue(new Error('coinpaprika down'));
await service.fetchLiveRates();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
});
it('falls back to CoinPaprika when CoinGecko returns an invalid payload', async () => {
mockedAxios.get
.mockResolvedValueOnce({ data: { monero: { usd: -1 }, bitcoin: { usd: 95_000 } } })
.mockImplementation(buildCoinPaprikaAxiosMock(151.11, 95_111.11));
await service.fetchLiveRates();
expect(warnLogSpy).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith('Successfully fetched live rates from CoinPaprika for USD');
expect(mockedAxios.get).toHaveBeenCalledTimes(3);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(151.11);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_111.11);
});
it('falls back to CoinPaprika when CoinGecko omits a coin', async () => {
mockedAxios.get
.mockResolvedValueOnce({ data: { monero: { usd: 150 } } })
.mockImplementation(buildCoinPaprikaAxiosMock(149.5, 94_000));
await service.fetchLiveRates();
expect(warnLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.5);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_000);
});
it('leaves cached rates null when CoinPaprika returns an invalid payload for one coin', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
}
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
}
return Promise.reject(new Error(`unexpected url: ${url}`));
});
await service.fetchLiveRates();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
});
it('keeps the previous live rates when a refresh fails after a successful fetch', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { usd: 140 },
bitcoin: { usd: 90_000 }
}
});
await service.fetchLiveRates();
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockRejectedValue(new Error('coinpaprika down'));
await service.fetchLiveRates();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
});
it('keeps the previous live rates when CoinPaprika returns an invalid payload for one coin on refresh', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { usd: 140 },
bitcoin: { usd: 90_000 }
}
});
await service.fetchLiveRates();
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
}
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
}
return Promise.reject(new Error(`unexpected url: ${url}`));
});
await service.fetchLiveRates();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
});
it('uses the shop fiat currency when building provider URLs', async () => {
configService.get.mockImplementation((key: string) => {
if (key === 'shopSettings') {
return { shopFiatCurrency: 'EUR' };
}
if (key === 'coingecko') {
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
}
if (key === 'coinPaprika') {
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
}
return undefined;
});
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { eur: 130.5 },
bitcoin: { eur: 88_000.4 }
}
});
await service.fetchLiveRates();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=eur',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(130.5);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(88_000.4);
});
});
@@ -0,0 +1,156 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import axios from 'axios';
import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { COINGECKO_ID_BY_PAYMENT_METHOD } from '../const/coingeckoIds';
import { COINPAPRIKA_ID_BY_PAYMENT_METHOD } from '../const/coinPaprikaIds';
import { EXCHANGE_RATE_PAYMENT_METHODS, type ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
import { CoinPaprikaTickerResponseDto } from '../dto/CoinPaprikaTickerResponseDto';
import type { FiatPerCryptoRates } from '../types/FiatPerCryptoRates';
import { extractCoingeckoSimplePrice } from '../utils/extractCoingeckoSimplePrice';
import { extractCoinPaprikaTickerPrice } from '../utils/extractCoinPaprikaTickerPrice';
@Injectable()
export class ExchangeRateService implements OnModuleInit {
private readonly logger = new Logger(ExchangeRateService.name);
private liveFiatPerCrypto = Object.fromEntries(
EXCHANGE_RATE_PAYMENT_METHODS.map(method => [method, null])
) as Record<ExchangeRatePaymentMethod, number | null>;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
await this.fetchLiveRates();
}
getLiveFiatPerCrypto(method: ExchangeRatePaymentMethod): number | null {
return this.liveFiatPerCrypto[method];
}
@Cron(CronExpression.EVERY_5_MINUTES)
async fetchLiveRates(): Promise<void> {
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
try {
const rates = await this.fetchRatesFromCoingecko();
this.applyRates(rates);
} catch {
this.logger.warn(`CoinGecko rate fetch failed for ${shopFiatCurrency}, trying CoinPaprika`);
try {
const rates = await this.fetchRatesFromCoinPaprika();
this.applyRates(rates);
this.logger.log(`Successfully fetched live rates from CoinPaprika for ${shopFiatCurrency}`);
} catch (error) {
this.logger.error(
`Failed to fetch live rates from CoinGecko and CoinPaprika for ${shopFiatCurrency}: ${getErrorMessage(error)}`
);
}
}
}
private async fetchRatesFromCoingecko(): Promise<FiatPerCryptoRates> {
const { rateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const url = this.buildCoingeckoRateUrl();
const { data } = await axios.get<unknown>(url, {
timeout: rateFetchTimeoutMs
});
const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]);
const dto = plainToInstance(CoingeckoSimplePriceResponseDto, {
response: data,
shopFiatCurrency,
coinIds
});
const errors = validateSync(dto);
if (errors.length > 0) {
throw new Error('CoinGecko response validation failed');
}
const rates = {} as FiatPerCryptoRates;
for (const method of EXCHANGE_RATE_PAYMENT_METHODS) {
const coinId = COINGECKO_ID_BY_PAYMENT_METHOD[method];
const price = extractCoingeckoSimplePrice(dto.response, coinId, shopFiatCurrency);
if (price === null) {
throw new Error(`CoinGecko response validation failed for coin ${coinId}`);
}
rates[method] = price;
}
return rates;
}
private async fetchRatesFromCoinPaprika(): Promise<FiatPerCryptoRates> {
const { apiBaseUrl, rateFetchTimeoutMs } = this.configService.get('coinPaprika') as Config['coinPaprika'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const rates = {} as FiatPerCryptoRates;
await Promise.all(
EXCHANGE_RATE_PAYMENT_METHODS.map(async method => {
const coinId = COINPAPRIKA_ID_BY_PAYMENT_METHOD[method];
const url = `${apiBaseUrl}/tickers/${coinId}?quotes=${shopFiatCurrency}`;
const { data } = await axios.get<unknown>(url, {
timeout: rateFetchTimeoutMs
});
const dto = plainToInstance(CoinPaprikaTickerResponseDto, {
response: data,
shopFiatCurrency
});
const errors = validateSync(dto);
if (errors.length > 0) {
throw new Error(`CoinPaprika response validation failed for coin ${coinId}`);
}
const price = extractCoinPaprikaTickerPrice(dto.response, shopFiatCurrency);
if (price === null) {
throw new Error(`CoinPaprika response validation failed for coin ${coinId}`);
}
rates[method] = price;
})
);
return rates;
}
private buildCoingeckoRateUrl(): string {
const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]).join(',');
const vs = shopFiatCurrency.toLowerCase();
return `${apiBaseUrl}/simple/price?ids=${coinIds}&vs_currencies=${vs}`;
}
private applyRates(rates: FiatPerCryptoRates): void {
for (const method of EXCHANGE_RATE_PAYMENT_METHODS) {
this.liveFiatPerCrypto[method] = rates[method];
}
}
}
@@ -0,0 +1,3 @@
import type { ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
export type FiatPerCryptoRates = Record<ExchangeRatePaymentMethod, number>;
@@ -0,0 +1,25 @@
import Decimal from 'decimal.js';
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
export const extractCoinPaprikaTickerPrice = (
response: { quotes: Record<string, { price: number }> },
shopFiatCurrency: ShopFiatCurrency
): number | null => {
const quote = response.quotes[shopFiatCurrency];
if (!quote) {
return null;
}
const rate = quote.price;
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
return null;
}
try {
return new Decimal(rate).toDecimalPlaces(2).toNumber();
} catch {
return null;
}
};
@@ -0,0 +1,28 @@
import Decimal from 'decimal.js';
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
export const extractCoingeckoSimplePrice = (
coins: Record<string, Record<string, number>>,
coinId: string,
shopFiatCurrency: ShopFiatCurrency
): number | null => {
const coin = coins[coinId];
if (!coin) {
return null;
}
const fiatKey = shopFiatCurrency.toLowerCase();
const rate = coin[fiatKey];
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
return null;
}
try {
return new Decimal(rate).toDecimalPlaces(2).toNumber();
} catch {
return null;
}
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice';
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
import { InvoicePayment } from './entities/InvoicePayment';
@@ -12,7 +12,7 @@ import { InvoiceService } from './services/InvoiceService';
imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
MoneroWalletModule,
XmrRateModule
ExchangeRateModule
],
providers: [InvoicePaymentService, InvoiceService],
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
@@ -2,7 +2,7 @@ import { Logger, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason';
import { PaymentMethod } from '../types/PaymentMethod';
@@ -22,8 +22,8 @@ describe('InvoiceService', () => {
let walletRpcClient: {
createAddress: jest.Mock;
};
let xmrRateService: {
getLiveFiatPerXmr: jest.Mock;
let exchangeRateService: {
getLiveFiatPerCrypto: jest.Mock;
};
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
@@ -60,15 +60,15 @@ describe('InvoiceService', () => {
})
};
xmrRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
service = new InvoiceService(
invoiceRepo as unknown as Repository<Invoice>,
configService as unknown as ConfigService,
walletRpcClient as unknown as MoneroWalletRpcClient,
xmrRateService as unknown as XmrRateService
exchangeRateService as unknown as ExchangeRateService
);
});
@@ -85,7 +85,7 @@ describe('InvoiceService', () => {
});
it('throws when the live XMR rate is unavailable for checkout invoices', async () => {
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
@@ -169,7 +169,7 @@ describe('InvoiceService', () => {
});
it('uses shipping-specific messages and address labels for shipping invoices', async () => {
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect(
service.issueInvoice({
@@ -184,7 +184,7 @@ describe('InvoiceService', () => {
)
);
xmrRateService.getLiveFiatPerXmr.mockReturnValue(150);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(
@@ -9,7 +9,7 @@ import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason';
import type { IssueInvoiceData } from '../types/IssueInvoiceData';
@@ -25,10 +25,10 @@ export class InvoiceService {
private readonly invoiceRepo: Repository<Invoice>,
private readonly configService: ConfigService,
private readonly walletRpcClient: MoneroWalletRpcClient,
private readonly xmrRateService: XmrRateService
private readonly exchangeRateService: ExchangeRateService
) {}
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> {
switch (data.paymentMethod) {
case PaymentMethod.Xmr:
return this.issueXmrInvoice(data);
@@ -44,7 +44,7 @@ export class InvoiceService {
contextId
);
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
if (fiatPerXmr === null) {
throw new ServiceUnavailableException(rateUnavailableMessage);
@@ -1,3 +1,4 @@
export enum PaymentMethod {
Xmr = 'xmr'
Xmr = 'xmr',
Btc = 'btc'
}
@@ -3,14 +3,14 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
import { ProductsModule } from '../product/ProductsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontCartController } from './controllers/StorefrontCartController';
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
import { StorefrontCartService } from './services/StorefrontCartService';
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
@Module({
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, XmrRateModule],
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
controllers: [StorefrontCartController],
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
exports: [StorefrontCartService]
@@ -1,7 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
import type { StorefrontDiscountService } from './StorefrontDiscountService';
import { StorefrontCartService } from './StorefrontCartService';
@@ -29,8 +30,8 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock;
};
let xmrRateService: {
getLiveFiatPerXmr: jest.Mock;
let exchangeRateService: {
getLiveFiatPerCrypto: jest.Mock;
};
let discountService: {
getDiscountStateForCart: jest.Mock;
@@ -43,8 +44,8 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
};
xmrRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
discountService = {
@@ -58,7 +59,7 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService,
xmrRateService as unknown as XmrRateService,
exchangeRateService as unknown as ExchangeRateService,
discountService as unknown as StorefrontDiscountService
);
});
@@ -118,7 +119,7 @@ describe('StorefrontCartService', () => {
});
it('leaves cartTotalXmr null when no live rate is available', async () => {
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
@@ -7,7 +7,8 @@ import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart';
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
import type { CookieCartExtended } from '../types/CookieCartExtended';
@@ -22,7 +23,7 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService {
constructor(
private readonly productsService: StorefrontProductsService,
private readonly xmrRateService: XmrRateService,
private readonly exchangeRateService: ExchangeRateService,
private readonly discountService: StorefrontDiscountService
) {}
@@ -77,7 +78,7 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { EncryptionModule } from '../encryption/EncryptionModule';
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontErrorController } from './controllers/StorefrontErrorController';
import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController';
import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter';
@@ -18,7 +18,7 @@ import { StorefrontSignedCookieService } from './services/StorefrontSignedCookie
import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService';
@Module({
imports: [ShopSettingsModule, XmrRateModule, EncryptionModule],
imports: [ShopSettingsModule, ExchangeRateModule, EncryptionModule],
controllers: [StorefrontErrorController, StorefrontPreferencesController],
providers: [
StorefrontSignedCookieService,
@@ -2,7 +2,7 @@ import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { StorefrontShopViewService } from './StorefrontShopViewService';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
@@ -50,9 +50,9 @@ describe('StorefrontShopViewService', () => {
req: reqOverrides = {}
} = overrides;
const xmrRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr)
} as unknown as XmrRateService;
const exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
} as unknown as ExchangeRateService;
const configService = {
get: jest.fn().mockReturnValue(shopSettings)
} as unknown as ConfigService;
@@ -73,7 +73,7 @@ describe('StorefrontShopViewService', () => {
} as unknown as StorefrontThemeCookieService;
const service = new StorefrontShopViewService(
xmrRateService,
exchangeRateService,
configService,
shopSettingsService,
cartCookieService,
@@ -7,7 +7,8 @@ import { getTotalCartQtyFromCart } from '../../../utils/cart/getTotalCartQtyFrom
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
@@ -22,7 +23,7 @@ import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@Injectable()
export class StorefrontShopViewService {
constructor(
private readonly xmrRateService: XmrRateService,
private readonly exchangeRateService: ExchangeRateService,
private readonly configService: ConfigService,
private readonly shopSettingsService: ShopSettingsService,
private readonly cartCookieService: StorefrontCartCookieService,
@@ -40,7 +41,7 @@ export class StorefrontShopViewService {
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
await this.shopSettingsService.getStorefrontBranding();
@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { XmrRateService } from './services/XmrRateService';
@Module({
providers: [XmrRateService],
exports: [XmrRateService]
})
export class XmrRateModule {}
@@ -1,39 +0,0 @@
import {
IsEnum,
IsNotEmpty,
IsObject,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface,
type ValidationArguments
} from 'class-validator';
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
@ValidatorConstraint({ name: 'coingeckoMoneroFiat' })
class CoingeckoMoneroFiatConstraint implements ValidatorConstraintInterface {
validate(monero: Record<string, number>, args: ValidationArguments): boolean {
const { shopFiatCurrency } = args.object as CoingeckoSimplePriceResponseDto;
if (!shopFiatCurrency) {
return false;
}
const rate = monero[shopFiatCurrency.toLowerCase()];
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
}
defaultMessage(): string {
return 'CoinGecko monero fiat rate must be a positive number';
}
}
export class CoingeckoSimplePriceResponseDto {
@IsEnum(ShopFiatCurrency)
shopFiatCurrency: ShopFiatCurrency;
@IsNotEmpty()
@IsObject()
@Validate(CoingeckoMoneroFiatConstraint)
monero: Record<string, number>;
}
@@ -1,56 +0,0 @@
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsString,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface
} from 'class-validator';
import Decimal from 'decimal.js';
@ValidatorConstraint({ name: 'krakenResult' })
class KrakenResultConstraint implements ValidatorConstraintInterface {
validate(result: unknown): boolean {
if (typeof result !== 'object' || result === null) {
return false;
}
const pair = Object.values(result as Record<string, unknown>)[0];
if (typeof pair !== 'object' || pair === null) {
return false;
}
if (!('c' in pair)) {
return false;
}
if (!Array.isArray(pair.c) || pair.c.length < 1 || typeof pair.c[0] !== 'string') {
return false;
}
try {
const price = new Decimal(pair.c[0]);
return price.isFinite() && price.gt(0);
} catch {
return false;
}
}
defaultMessage(): string {
return 'Kraken ticker last price (c[0]) must be a positive number';
}
}
export class KrakenTickerResponseDto {
@IsArray()
@ArrayMaxSize(0)
@IsString({ each: true })
error: string[];
@IsNotEmpty()
@Validate(KrakenResultConstraint)
result: Record<string, { c: string[] }>;
}
@@ -1,10 +0,0 @@
import { ShopFiatCurrency } from '../../types/ShopFiatCurrency';
export const KRAKEN_XMR_PAIR_BY_FIAT: Record<ShopFiatCurrency, string> = {
[ShopFiatCurrency.Usd]: 'XMRUSD',
[ShopFiatCurrency.Eur]: 'XMREUR',
[ShopFiatCurrency.Gbp]: 'XMRGBP',
[ShopFiatCurrency.Cad]: 'XMRCAD',
[ShopFiatCurrency.Aud]: 'XMRAUD',
[ShopFiatCurrency.Chf]: 'XMRCHF'
};
@@ -1,127 +0,0 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { XmrRateService } from './XmrRateService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('XmrRateService', () => {
let service: XmrRateService;
let configService: {
get: jest.Mock;
};
let warnLogSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
beforeEach(() => {
warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
configService = {
get: jest.fn((key: string) => {
if (key === 'shopSettings') {
return { shopFiatCurrency: 'USD' };
}
if (key === 'coingecko') {
return { apiBaseUrl: 'https://coingecko.test', xmrRateFetchTimeoutMs: 5000 };
}
if (key === 'kraken') {
return { apiBaseUrl: 'https://kraken.test', xmrRateFetchTimeoutMs: 5000 };
}
return undefined;
})
};
service = new XmrRateService(configService as unknown as ConfigService);
mockedAxios.get.mockReset();
});
afterEach(() => {
warnLogSpy.mockRestore();
errorLogSpy.mockRestore();
logSpy.mockRestore();
});
it('stores the CoinGecko rate when the fetch succeeds', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { monero: { usd: 152.3456 } }
});
await service.fetchFiatPerXmrRate();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coingecko.test/simple/price?ids=monero&vs_currencies=usd',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerXmr()).toBe(152.35);
});
it('falls back to Kraken when CoinGecko fails', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockResolvedValueOnce({
data: {
error: [],
result: {
XMRUSD: { c: ['149.876'] }
}
}
});
await service.fetchFiatPerXmrRate();
expect(warnLogSpy).toHaveBeenCalled();
expect(mockedAxios.get).toHaveBeenNthCalledWith(
2,
'https://kraken.test/Ticker?pair=XMRUSD',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerXmr()).toBe(149.88);
});
it('leaves the cached rate null when both providers fail', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
await service.fetchFiatPerXmrRate();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerXmr()).toBeNull();
});
it('falls back to Kraken when CoinGecko returns an invalid payload', async () => {
mockedAxios.get
.mockResolvedValueOnce({ data: { monero: { usd: -1 } } })
.mockResolvedValueOnce({
data: {
error: [],
result: {
XMRUSD: { c: ['151.11'] }
}
}
});
await service.fetchFiatPerXmrRate();
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
expect(service.getLiveFiatPerXmr()).toBe(151.11);
});
it('keeps the previous live rate when a refresh fails after a successful fetch', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { monero: { usd: 140 } }
});
await service.fetchFiatPerXmrRate();
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
await service.fetchFiatPerXmrRate();
expect(service.getLiveFiatPerXmr()).toBe(140);
});
});
@@ -1,105 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import axios from 'axios';
import Decimal from 'decimal.js';
import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
import { KrakenTickerResponseDto } from '../dto/KrakenTickerResponseDto';
import { KRAKEN_XMR_PAIR_BY_FIAT } from '../krakenXmrPairs';
@Injectable()
export class XmrRateService implements OnModuleInit {
private readonly logger = new Logger(XmrRateService.name);
private fiatPerXmr: number | null = null;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
await this.fetchFiatPerXmrRate();
}
@Cron(CronExpression.EVERY_30_SECONDS)
async fetchFiatPerXmrRate(): Promise<void> {
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
try {
this.fiatPerXmr = await this.fetchFiatPerXmrFromCoingecko();
} catch {
this.logger.warn(`CoinGecko XMR/${shopFiatCurrency} rate fetch failed, trying Kraken`);
try {
this.fiatPerXmr = await this.fetchFiatPerXmrFromKraken();
this.logger.log(`Successfully fetched XMR/${shopFiatCurrency} rate from Kraken`);
} catch (error) {
this.logger.error(
`Failed to fetch XMR/${shopFiatCurrency} rate from CoinGecko and Kraken: ${getErrorMessage(error)}`
);
}
}
}
getLiveFiatPerXmr(): number | null {
return this.fiatPerXmr;
}
private buildCoingeckoRateUrl(): string {
const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const vs = shopFiatCurrency.toLowerCase();
return `${apiBaseUrl}/simple/price?ids=monero&vs_currencies=${vs}`;
}
private buildKrakenRateUrl(): string {
const { apiBaseUrl } = this.configService.get('kraken') as Config['kraken'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const pair = KRAKEN_XMR_PAIR_BY_FIAT[shopFiatCurrency];
return `${apiBaseUrl}/Ticker?pair=${pair}`;
}
private async fetchFiatPerXmrFromCoingecko(): Promise<number> {
const { xmrRateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const { data } = await axios.get<unknown>(this.buildCoingeckoRateUrl(), {
timeout: xmrRateFetchTimeoutMs
});
const body = plainToInstance(CoingeckoSimplePriceResponseDto, data);
body.shopFiatCurrency = shopFiatCurrency;
const errors = validateSync(body);
if (errors.length > 0) {
throw new Error('CoinGecko response validation failed');
}
return new Decimal(body.monero[shopFiatCurrency.toLowerCase()]).toDecimalPlaces(2).toNumber();
}
private async fetchFiatPerXmrFromKraken(): Promise<number> {
const { xmrRateFetchTimeoutMs } = this.configService.get('kraken') as Config['kraken'];
const { data } = await axios.get<unknown>(this.buildKrakenRateUrl(), {
timeout: xmrRateFetchTimeoutMs
});
const body = plainToInstance(KrakenTickerResponseDto, data);
const errors = validateSync(body);
if (errors.length > 0) {
throw new Error('Kraken response validation failed');
}
const pair = Object.values(body.result)[0];
return new Decimal(pair.c[0]).toDecimalPlaces(2).toNumber();
}
}