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
+4 -4
View File
@@ -9,7 +9,7 @@ import {
getCoingeckoConfig, getCoingeckoConfig,
getEncryptionConfig, getEncryptionConfig,
getJwtConfig, getJwtConfig,
getKrakenConfig, getCoinPaprikaConfig,
getOrderConfig, getOrderConfig,
getInvoiceConfig, getInvoiceConfig,
getMoneroWalletConfig, getMoneroWalletConfig,
@@ -35,7 +35,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
import { XmrRateModule } from './modules/xmrRate/XmrRateModule'; import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
import { Config } from './types/Config'; import { Config } from './types/Config';
@Module({ @Module({
@@ -49,7 +49,7 @@ import { Config } from './types/Config';
registerAs('app', getAppConfig), registerAs('app', getAppConfig),
registerAs('jwt', getJwtConfig), registerAs('jwt', getJwtConfig),
registerAs('coingecko', getCoingeckoConfig), registerAs('coingecko', getCoingeckoConfig),
registerAs('kraken', getKrakenConfig), registerAs('coinPaprika', getCoinPaprikaConfig),
registerAs('encryption', getEncryptionConfig), registerAs('encryption', getEncryptionConfig),
registerAs('shopSettings', getShopSettingsConfig), registerAs('shopSettings', getShopSettingsConfig),
registerAs('order', getOrderConfig), registerAs('order', getOrderConfig),
@@ -74,7 +74,7 @@ import { Config } from './types/Config';
}; };
} }
}), }),
XmrRateModule, ExchangeRateModule,
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'], useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
inject: [ConfigService] inject: [ConfigService]
+2 -1
View File
@@ -191,7 +191,8 @@ export const getOrderConfig = (): OrderConfig => ({
export const getInvoiceConfig = (): InvoiceConfig => ({ export const getInvoiceConfig = (): InvoiceConfig => ({
minByMethod: { 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 { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule'; import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice'; import { Invoice } from './entities/Invoice';
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails'; import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
import { InvoicePayment } from './entities/InvoicePayment'; import { InvoicePayment } from './entities/InvoicePayment';
@@ -12,7 +12,7 @@ import { InvoiceService } from './services/InvoiceService';
imports: [ imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]), TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
MoneroWalletModule, MoneroWalletModule,
XmrRateModule ExchangeRateModule
], ],
providers: [InvoicePaymentService, InvoiceService], providers: [InvoicePaymentService, InvoiceService],
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])] exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
@@ -2,7 +2,7 @@ import { Logger, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; 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 { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason'; import { InvoiceReason } from '../types/InvoiceReason';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
@@ -22,8 +22,8 @@ describe('InvoiceService', () => {
let walletRpcClient: { let walletRpcClient: {
createAddress: jest.Mock; createAddress: jest.Mock;
}; };
let xmrRateService: { let exchangeRateService: {
getLiveFiatPerXmr: jest.Mock; getLiveFiatPerCrypto: jest.Mock;
}; };
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>; let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
@@ -60,15 +60,15 @@ describe('InvoiceService', () => {
}) })
}; };
xmrRateService = { exchangeRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(150) getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
}; };
service = new InvoiceService( service = new InvoiceService(
invoiceRepo as unknown as Repository<Invoice>, invoiceRepo as unknown as Repository<Invoice>,
configService as unknown as ConfigService, configService as unknown as ConfigService,
walletRpcClient as unknown as MoneroWalletRpcClient, 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 () => { 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( await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") 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 () => { it('uses shipping-specific messages and address labels for shipping invoices', async () => {
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect( await expect(
service.issueInvoice({ service.issueInvoice({
@@ -184,7 +184,7 @@ describe('InvoiceService', () => {
) )
); );
xmrRateService.getLiveFiatPerXmr.mockReturnValue(150); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect( await expect(
@@ -9,7 +9,7 @@ import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic'; import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations'; import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { XmrRateService } from '../../xmrRate/services/XmrRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason'; import { InvoiceReason } from '../types/InvoiceReason';
import type { IssueInvoiceData } from '../types/IssueInvoiceData'; import type { IssueInvoiceData } from '../types/IssueInvoiceData';
@@ -25,10 +25,10 @@ export class InvoiceService {
private readonly invoiceRepo: Repository<Invoice>, private readonly invoiceRepo: Repository<Invoice>,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly walletRpcClient: MoneroWalletRpcClient, 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) { switch (data.paymentMethod) {
case PaymentMethod.Xmr: case PaymentMethod.Xmr:
return this.issueXmrInvoice(data); return this.issueXmrInvoice(data);
@@ -44,7 +44,7 @@ export class InvoiceService {
contextId contextId
); );
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr(); const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
if (fiatPerXmr === null) { if (fiatPerXmr === null) {
throw new ServiceUnavailableException(rateUnavailableMessage); throw new ServiceUnavailableException(rateUnavailableMessage);
@@ -1,3 +1,4 @@
export enum PaymentMethod { export enum PaymentMethod {
Xmr = 'xmr' Xmr = 'xmr',
Btc = 'btc'
} }
@@ -3,14 +3,14 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
import { ProductsModule } from '../product/ProductsModule'; import { ProductsModule } from '../product/ProductsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule'; import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontCartController } from './controllers/StorefrontCartController'; import { StorefrontCartController } from './controllers/StorefrontCartController';
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver'; import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
import { StorefrontCartService } from './services/StorefrontCartService'; import { StorefrontCartService } from './services/StorefrontCartService';
import { StorefrontDiscountService } from './services/StorefrontDiscountService'; import { StorefrontDiscountService } from './services/StorefrontDiscountService';
@Module({ @Module({
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, XmrRateModule], imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
controllers: [StorefrontCartController], controllers: [StorefrontCartController],
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver], providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
exports: [StorefrontCartService] exports: [StorefrontCartService]
@@ -1,7 +1,8 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DeliveryMode } from '../../product/types/DeliveryMode'; import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService'; 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 { CookieCartLineExtended } from '../types/CookieCartLineExtended';
import type { StorefrontDiscountService } from './StorefrontDiscountService'; import type { StorefrontDiscountService } from './StorefrontDiscountService';
import { StorefrontCartService } from './StorefrontCartService'; import { StorefrontCartService } from './StorefrontCartService';
@@ -29,8 +30,8 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock; getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock; getStorefrontVariant: jest.Mock;
}; };
let xmrRateService: { let exchangeRateService: {
getLiveFiatPerXmr: jest.Mock; getLiveFiatPerCrypto: jest.Mock;
}; };
let discountService: { let discountService: {
getDiscountStateForCart: jest.Mock; getDiscountStateForCart: jest.Mock;
@@ -43,8 +44,8 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant()) getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
}; };
xmrRateService = { exchangeRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(150) getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
}; };
discountService = { discountService = {
@@ -58,7 +59,7 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService( service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService, productsService as unknown as StorefrontProductsService,
xmrRateService as unknown as XmrRateService, exchangeRateService as unknown as ExchangeRateService,
discountService as unknown as StorefrontDiscountService discountService as unknown as StorefrontDiscountService
); );
}); });
@@ -118,7 +119,7 @@ describe('StorefrontCartService', () => {
}); });
it('leaves cartTotalXmr null when no live rate is available', async () => { 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 }], []); 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 { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
import { DeliveryMode } from '../../product/types/DeliveryMode'; import { DeliveryMode } from '../../product/types/DeliveryMode';
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService'; 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 { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { CookieCartLineDto } from '../dto/CookieCartLineDto'; import { CookieCartLineDto } from '../dto/CookieCartLineDto';
import type { CookieCartExtended } from '../types/CookieCartExtended'; import type { CookieCartExtended } from '../types/CookieCartExtended';
@@ -22,7 +23,7 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService { export class StorefrontCartService {
constructor( constructor(
private readonly productsService: StorefrontProductsService, private readonly productsService: StorefrontProductsService,
private readonly xmrRateService: XmrRateService, private readonly exchangeRateService: ExchangeRateService,
private readonly discountService: StorefrontDiscountService private readonly discountService: StorefrontDiscountService
) {} ) {}
@@ -77,7 +78,7 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal'); const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended); 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 cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual); const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto); const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { EncryptionModule } from '../encryption/EncryptionModule'; import { EncryptionModule } from '../encryption/EncryptionModule';
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule'; import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontErrorController } from './controllers/StorefrontErrorController'; import { StorefrontErrorController } from './controllers/StorefrontErrorController';
import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController'; import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController';
import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter'; import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter';
@@ -18,7 +18,7 @@ import { StorefrontSignedCookieService } from './services/StorefrontSignedCookie
import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService'; import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService';
@Module({ @Module({
imports: [ShopSettingsModule, XmrRateModule, EncryptionModule], imports: [ShopSettingsModule, ExchangeRateModule, EncryptionModule],
controllers: [StorefrontErrorController, StorefrontPreferencesController], controllers: [StorefrontErrorController, StorefrontPreferencesController],
providers: [ providers: [
StorefrontSignedCookieService, StorefrontSignedCookieService,
@@ -2,7 +2,7 @@ import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { StorefrontShopViewService } from './StorefrontShopViewService'; import { StorefrontShopViewService } from './StorefrontShopViewService';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { XmrRateService } from '../../xmrRate/services/XmrRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { StorefrontCartCookieService } from './StorefrontCartCookieService'; import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
@@ -50,9 +50,9 @@ describe('StorefrontShopViewService', () => {
req: reqOverrides = {} req: reqOverrides = {}
} = overrides; } = overrides;
const xmrRateService = { const exchangeRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr) getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
} as unknown as XmrRateService; } as unknown as ExchangeRateService;
const configService = { const configService = {
get: jest.fn().mockReturnValue(shopSettings) get: jest.fn().mockReturnValue(shopSettings)
} as unknown as ConfigService; } as unknown as ConfigService;
@@ -73,7 +73,7 @@ describe('StorefrontShopViewService', () => {
} as unknown as StorefrontThemeCookieService; } as unknown as StorefrontThemeCookieService;
const service = new StorefrontShopViewService( const service = new StorefrontShopViewService(
xmrRateService, exchangeRateService,
configService, configService,
shopSettingsService, shopSettingsService,
cartCookieService, cartCookieService,
@@ -7,7 +7,8 @@ import { getTotalCartQtyFromCart } from '../../../utils/cart/getTotalCartQtyFrom
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId'; import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl'; import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; 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 { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
import type { ShopRenderLocals } from '../types/ShopRenderLocals'; import type { ShopRenderLocals } from '../types/ShopRenderLocals';
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta'; import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
@@ -22,7 +23,7 @@ import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@Injectable() @Injectable()
export class StorefrontShopViewService { export class StorefrontShopViewService {
constructor( constructor(
private readonly xmrRateService: XmrRateService, private readonly exchangeRateService: ExchangeRateService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly shopSettingsService: ShopSettingsService, private readonly shopSettingsService: ShopSettingsService,
private readonly cartCookieService: StorefrontCartCookieService, private readonly cartCookieService: StorefrontCartCookieService,
@@ -40,7 +41,7 @@ export class StorefrontShopViewService {
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings']; 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 } = const { logoUrl, faviconUrl, simplexLink, shippingNote } =
await this.shopSettingsService.getStorefrontBranding(); 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();
}
}