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