refactor xmr rate module to exchange rate module for multi crypto support

This commit is contained in:
2026-09-02 14:53:50 +02:00
parent 6818f08dbe
commit 0510695598
29 changed files with 630 additions and 388 deletions
@@ -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;
}
};