From 0510695598a3e68b8edd69f13d52fc01135357af Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Wed, 2 Sep 2026 14:53:50 +0200 Subject: [PATCH] refactor xmr rate module to exchange rate module for multi crypto support --- backend/src/AppModule.ts | 8 +- backend/src/config/index.ts | 3 +- .../exchangeRate/ExchangeRateModule.ts | 8 + .../exchangeRate/const/coinPaprikaIds.ts | 7 + .../exchangeRate/const/coingeckoIds.ts | 7 + .../const/exchangeRatePaymentMethods.ts | 5 + .../dto/CoinPaprikaTickerResponseDto.ts | 43 +++ .../dto/CoingeckoSimplePriceResponseDto.ts | 55 ++++ .../services/ExchangeRateService.spec.ts | 245 ++++++++++++++++++ .../services/ExchangeRateService.ts | 156 +++++++++++ .../exchangeRate/types/FiatPerCryptoRates.ts | 3 + .../utils/extractCoinPaprikaTickerPrice.ts | 25 ++ .../utils/extractCoingeckoSimplePrice.ts | 28 ++ backend/src/modules/payment/PaymentModule.ts | 4 +- .../payment/services/InvoiceService.spec.ts | 18 +- .../payment/services/InvoiceService.ts | 8 +- .../modules/payment/types/PaymentMethod.ts | 3 +- .../storefrontCart/StorefrontCartModule.ts | 4 +- .../services/StorefrontCartService.spec.ts | 15 +- .../services/StorefrontCartService.ts | 7 +- .../storefrontCore/StorefrontCoreModule.ts | 4 +- .../StorefrontShopViewService.spec.ts | 10 +- .../services/StorefrontShopViewService.ts | 7 +- backend/src/modules/xmrRate/XmrRateModule.ts | 8 - .../dto/CoingeckoSimplePriceResponseDto.ts | 39 --- .../xmrRate/dto/KrakenTickerResponseDto.ts | 56 ---- backend/src/modules/xmrRate/krakenXmrPairs.ts | 10 - .../xmrRate/services/XmrRateService.spec.ts | 127 --------- .../xmrRate/services/XmrRateService.ts | 105 -------- 29 files changed, 630 insertions(+), 388 deletions(-) create mode 100644 backend/src/modules/exchangeRate/ExchangeRateModule.ts create mode 100644 backend/src/modules/exchangeRate/const/coinPaprikaIds.ts create mode 100644 backend/src/modules/exchangeRate/const/coingeckoIds.ts create mode 100644 backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts create mode 100644 backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts create mode 100644 backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts create mode 100644 backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts create mode 100644 backend/src/modules/exchangeRate/services/ExchangeRateService.ts create mode 100644 backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts create mode 100644 backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts create mode 100644 backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts delete mode 100644 backend/src/modules/xmrRate/XmrRateModule.ts delete mode 100644 backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts delete mode 100644 backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts delete mode 100644 backend/src/modules/xmrRate/krakenXmrPairs.ts delete mode 100644 backend/src/modules/xmrRate/services/XmrRateService.spec.ts delete mode 100644 backend/src/modules/xmrRate/services/XmrRateService.ts diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index 4914e9e..c209f63 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -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] diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index dcd7597..5c90218 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -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')) } }); diff --git a/backend/src/modules/exchangeRate/ExchangeRateModule.ts b/backend/src/modules/exchangeRate/ExchangeRateModule.ts new file mode 100644 index 0000000..33dfe7c --- /dev/null +++ b/backend/src/modules/exchangeRate/ExchangeRateModule.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ExchangeRateService } from './services/ExchangeRateService'; + +@Module({ + providers: [ExchangeRateService], + exports: [ExchangeRateService] +}) +export class ExchangeRateModule {} diff --git a/backend/src/modules/exchangeRate/const/coinPaprikaIds.ts b/backend/src/modules/exchangeRate/const/coinPaprikaIds.ts new file mode 100644 index 0000000..b67e74a --- /dev/null +++ b/backend/src/modules/exchangeRate/const/coinPaprikaIds.ts @@ -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; diff --git a/backend/src/modules/exchangeRate/const/coingeckoIds.ts b/backend/src/modules/exchangeRate/const/coingeckoIds.ts new file mode 100644 index 0000000..04c6bbc --- /dev/null +++ b/backend/src/modules/exchangeRate/const/coingeckoIds.ts @@ -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; diff --git a/backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts b/backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts new file mode 100644 index 0000000..6fba53a --- /dev/null +++ b/backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts @@ -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]; diff --git a/backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts b/backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts new file mode 100644 index 0000000..edc1f99 --- /dev/null +++ b/backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts @@ -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)[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 }; +} diff --git a/backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts b/backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts new file mode 100644 index 0000000..8329b1a --- /dev/null +++ b/backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts @@ -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, 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>; +} diff --git a/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts b/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts new file mode 100644 index 0000000..7aa0f29 --- /dev/null +++ b/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts @@ -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; + +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; + let errorLogSpy: jest.SpiedFunction; + let logSpy: jest.SpiedFunction; + + 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); + }); +}); diff --git a/backend/src/modules/exchangeRate/services/ExchangeRateService.ts b/backend/src/modules/exchangeRate/services/ExchangeRateService.ts new file mode 100644 index 0000000..4996013 --- /dev/null +++ b/backend/src/modules/exchangeRate/services/ExchangeRateService.ts @@ -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; + + constructor(private readonly configService: ConfigService) {} + + async onModuleInit(): Promise { + await this.fetchLiveRates(); + } + + getLiveFiatPerCrypto(method: ExchangeRatePaymentMethod): number | null { + return this.liveFiatPerCrypto[method]; + } + + @Cron(CronExpression.EVERY_5_MINUTES) + async fetchLiveRates(): Promise { + 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 { + 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(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 { + 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(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]; + } + } +} diff --git a/backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts b/backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts new file mode 100644 index 0000000..97b4ac7 --- /dev/null +++ b/backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts @@ -0,0 +1,3 @@ +import type { ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods'; + +export type FiatPerCryptoRates = Record; diff --git a/backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts b/backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts new file mode 100644 index 0000000..1e32739 --- /dev/null +++ b/backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts @@ -0,0 +1,25 @@ +import Decimal from 'decimal.js'; +import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; + +export const extractCoinPaprikaTickerPrice = ( + response: { quotes: Record }, + 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; + } +}; diff --git a/backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts b/backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts new file mode 100644 index 0000000..9222da9 --- /dev/null +++ b/backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts @@ -0,0 +1,28 @@ +import Decimal from 'decimal.js'; +import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; + +export const extractCoingeckoSimplePrice = ( + coins: Record>, + 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; + } +}; diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts index 981c2ef..55681cd 100644 --- a/backend/src/modules/payment/PaymentModule.ts +++ b/backend/src/modules/payment/PaymentModule.ts @@ -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])] diff --git a/backend/src/modules/payment/services/InvoiceService.spec.ts b/backend/src/modules/payment/services/InvoiceService.spec.ts index ae829e2..6117671 100644 --- a/backend/src/modules/payment/services/InvoiceService.spec.ts +++ b/backend/src/modules/payment/services/InvoiceService.spec.ts @@ -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; @@ -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, 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( diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts index ba1300e..79614ed 100644 --- a/backend/src/modules/payment/services/InvoiceService.ts +++ b/backend/src/modules/payment/services/InvoiceService.ts @@ -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, private readonly configService: ConfigService, private readonly walletRpcClient: MoneroWalletRpcClient, - private readonly xmrRateService: XmrRateService + private readonly exchangeRateService: ExchangeRateService ) {} - async issueInvoice(data: IssueInvoiceData): Promise { + async issueInvoice(data: IssueInvoiceData): Promise { 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); diff --git a/backend/src/modules/payment/types/PaymentMethod.ts b/backend/src/modules/payment/types/PaymentMethod.ts index 28b6d58..9299073 100644 --- a/backend/src/modules/payment/types/PaymentMethod.ts +++ b/backend/src/modules/payment/types/PaymentMethod.ts @@ -1,3 +1,4 @@ export enum PaymentMethod { - Xmr = 'xmr' + Xmr = 'xmr', + Btc = 'btc' } diff --git a/backend/src/modules/storefrontCart/StorefrontCartModule.ts b/backend/src/modules/storefrontCart/StorefrontCartModule.ts index 9635bdc..ee0fa12 100644 --- a/backend/src/modules/storefrontCart/StorefrontCartModule.ts +++ b/backend/src/modules/storefrontCart/StorefrontCartModule.ts @@ -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] diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts index cf5ca2c..ed0c8b3 100644 --- a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts +++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts @@ -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 }], []); diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts index 853dba3..34fc757 100644 --- a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts +++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts @@ -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); diff --git a/backend/src/modules/storefrontCore/StorefrontCoreModule.ts b/backend/src/modules/storefrontCore/StorefrontCoreModule.ts index 254f66e..1074546 100644 --- a/backend/src/modules/storefrontCore/StorefrontCoreModule.ts +++ b/backend/src/modules/storefrontCore/StorefrontCoreModule.ts @@ -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, diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts index d3aa91b..791f598 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts @@ -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, diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts index 33e4bd9..99e4fd3 100644 --- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts +++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts @@ -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(); diff --git a/backend/src/modules/xmrRate/XmrRateModule.ts b/backend/src/modules/xmrRate/XmrRateModule.ts deleted file mode 100644 index 9d11ca0..0000000 --- a/backend/src/modules/xmrRate/XmrRateModule.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { XmrRateService } from './services/XmrRateService'; - -@Module({ - providers: [XmrRateService], - exports: [XmrRateService] -}) -export class XmrRateModule {} diff --git a/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts b/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts deleted file mode 100644 index d2dd690..0000000 --- a/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts +++ /dev/null @@ -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, 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; -} diff --git a/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts b/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts deleted file mode 100644 index 10468d1..0000000 --- a/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts +++ /dev/null @@ -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)[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; -} diff --git a/backend/src/modules/xmrRate/krakenXmrPairs.ts b/backend/src/modules/xmrRate/krakenXmrPairs.ts deleted file mode 100644 index 667c005..0000000 --- a/backend/src/modules/xmrRate/krakenXmrPairs.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ShopFiatCurrency } from '../../types/ShopFiatCurrency'; - -export const KRAKEN_XMR_PAIR_BY_FIAT: Record = { - [ShopFiatCurrency.Usd]: 'XMRUSD', - [ShopFiatCurrency.Eur]: 'XMREUR', - [ShopFiatCurrency.Gbp]: 'XMRGBP', - [ShopFiatCurrency.Cad]: 'XMRCAD', - [ShopFiatCurrency.Aud]: 'XMRAUD', - [ShopFiatCurrency.Chf]: 'XMRCHF' -}; diff --git a/backend/src/modules/xmrRate/services/XmrRateService.spec.ts b/backend/src/modules/xmrRate/services/XmrRateService.spec.ts deleted file mode 100644 index c66f2e1..0000000 --- a/backend/src/modules/xmrRate/services/XmrRateService.spec.ts +++ /dev/null @@ -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; - -describe('XmrRateService', () => { - let service: XmrRateService; - let configService: { - get: jest.Mock; - }; - let warnLogSpy: jest.SpiedFunction; - let errorLogSpy: jest.SpiedFunction; - let logSpy: jest.SpiedFunction; - - 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); - }); -}); diff --git a/backend/src/modules/xmrRate/services/XmrRateService.ts b/backend/src/modules/xmrRate/services/XmrRateService.ts deleted file mode 100644 index ceac404..0000000 --- a/backend/src/modules/xmrRate/services/XmrRateService.ts +++ /dev/null @@ -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 { - await this.fetchFiatPerXmrRate(); - } - - @Cron(CronExpression.EVERY_30_SECONDS) - async fetchFiatPerXmrRate(): Promise { - 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 { - const { xmrRateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko']; - const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; - - const { data } = await axios.get(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 { - const { xmrRateFetchTimeoutMs } = this.configService.get('kraken') as Config['kraken']; - - const { data } = await axios.get(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(); - } -}