From 93182aed7556e5a04f179930aad53f9df6afed94 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Mon, 31 Aug 2026 17:29:38 +0200
Subject: [PATCH 01/47] change coingecko and kraken rate fetch timeout config
name
---
.env.example | 4 ++--
backend/src/config/index.ts | 4 ++--
backend/src/config/validate.ts | 4 ++--
backend/src/types/Config.ts | 4 ++--
4 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.env.example b/.env.example
index 3d85b87..06a8e8d 100644
--- a/.env.example
+++ b/.env.example
@@ -80,10 +80,10 @@ SIGNED_COOKIE_THEME_NAME=storefront_theme
SIGNED_COOKIE_THEME_EXPIRES_IN_MS=31536000000 # 365 days
COINGECKO_API_BASE_URL=https://api.coingecko.com/api/v3
-COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS=5000
+COINGECKO_RATE_FETCH_TIMEOUT_MS=5000
KRAKEN_API_BASE_URL=https://api.kraken.com/0/public
-KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS=5000
+KRAKEN_RATE_FETCH_TIMEOUT_MS=5000
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index 56a5497..e6ae381 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -159,14 +159,14 @@ export const getDigitalStockAttachmentMulterConfig = (): MulterConfig => {
export const getCoingeckoConfig = (): CoingeckoConfig => {
return {
apiBaseUrl: env('COINGECKO_API_BASE_URL'),
- xmrRateFetchTimeoutMs: envInt('COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS')
+ rateFetchTimeoutMs: envInt('COINGECKO_RATE_FETCH_TIMEOUT_MS')
};
};
export const getKrakenConfig = (): KrakenConfig => {
return {
apiBaseUrl: env('KRAKEN_API_BASE_URL'),
- xmrRateFetchTimeoutMs: envInt('KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS')
+ rateFetchTimeoutMs: envInt('KRAKEN_RATE_FETCH_TIMEOUT_MS')
};
};
diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts
index 6a4d07b..ec9d5ff 100644
--- a/backend/src/config/validate.ts
+++ b/backend/src/config/validate.ts
@@ -251,12 +251,12 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsNumber()
@Min(1)
- KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS: number;
+ KRAKEN_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
- COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS: number;
+ COINGECKO_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty()
@IsString()
diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts
index cc9cc63..5dd2b58 100644
--- a/backend/src/types/Config.ts
+++ b/backend/src/types/Config.ts
@@ -84,12 +84,12 @@ export interface MulterConfig {
export interface CoingeckoConfig {
apiBaseUrl: string;
- xmrRateFetchTimeoutMs: number;
+ rateFetchTimeoutMs: number;
}
export interface KrakenConfig {
apiBaseUrl: string;
- xmrRateFetchTimeoutMs: number;
+ rateFetchTimeoutMs: number;
}
export interface EncryptionConfig {
From 6818f08dbe8906887e4def4954ad8e57352a235e Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Tue, 1 Sep 2026 16:51:26 +0200
Subject: [PATCH 02/47] add coinpaprika config
---
.env.example | 4 ++--
backend/src/config/index.ts | 10 +++++-----
backend/src/config/validate.ts | 4 ++--
backend/src/types/Config.ts | 4 ++--
4 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/.env.example b/.env.example
index 06a8e8d..c9ee101 100644
--- a/.env.example
+++ b/.env.example
@@ -82,8 +82,8 @@ SIGNED_COOKIE_THEME_EXPIRES_IN_MS=31536000000 # 365 days
COINGECKO_API_BASE_URL=https://api.coingecko.com/api/v3
COINGECKO_RATE_FETCH_TIMEOUT_MS=5000
-KRAKEN_API_BASE_URL=https://api.kraken.com/0/public
-KRAKEN_RATE_FETCH_TIMEOUT_MS=5000
+COINPAPRIKA_API_BASE_URL=https://api.coinpaprika.com/v1
+COINPAPRIKA_RATE_FETCH_TIMEOUT_MS=5000
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index e6ae381..dcd7597 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -4,7 +4,7 @@ import {
EncryptionConfig,
InvoiceConfig,
JwtConfig,
- KrakenConfig,
+ CoinPaprikaConfig,
MulterConfig,
OrderConfig,
PostgresConfig,
@@ -163,10 +163,10 @@ export const getCoingeckoConfig = (): CoingeckoConfig => {
};
};
-export const getKrakenConfig = (): KrakenConfig => {
+export const getCoinPaprikaConfig = (): CoinPaprikaConfig => {
return {
- apiBaseUrl: env('KRAKEN_API_BASE_URL'),
- rateFetchTimeoutMs: envInt('KRAKEN_RATE_FETCH_TIMEOUT_MS')
+ apiBaseUrl: env('COINPAPRIKA_API_BASE_URL'),
+ rateFetchTimeoutMs: envInt('COINPAPRIKA_RATE_FETCH_TIMEOUT_MS')
};
};
@@ -214,7 +214,7 @@ export default () => ({
app: getAppConfig(),
jwt: getJwtConfig(),
coingecko: getCoingeckoConfig(),
- kraken: getKrakenConfig(),
+ coinPaprika: getCoinPaprikaConfig(),
encryption: getEncryptionConfig(),
shopSettings: getShopSettingsConfig(),
order: getOrderConfig(),
diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts
index ec9d5ff..ba4eb57 100644
--- a/backend/src/config/validate.ts
+++ b/backend/src/config/validate.ts
@@ -246,12 +246,12 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsString()
- KRAKEN_API_BASE_URL: string;
+ COINPAPRIKA_API_BASE_URL: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
- KRAKEN_RATE_FETCH_TIMEOUT_MS: number;
+ COINPAPRIKA_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty()
@IsNumber()
diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts
index 5dd2b58..a9f940c 100644
--- a/backend/src/types/Config.ts
+++ b/backend/src/types/Config.ts
@@ -87,7 +87,7 @@ export interface CoingeckoConfig {
rateFetchTimeoutMs: number;
}
-export interface KrakenConfig {
+export interface CoinPaprikaConfig {
apiBaseUrl: string;
rateFetchTimeoutMs: number;
}
@@ -112,7 +112,7 @@ export interface Config {
postgres: PostgresConfig;
jwt: JwtConfig;
coingecko: CoingeckoConfig;
- kraken: KrakenConfig;
+ coinPaprika: CoinPaprikaConfig;
encryption: EncryptionConfig;
shopSettings: ShopSettingsConfig;
order: OrderConfig;
From 0510695598a3e68b8edd69f13d52fc01135357af Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Wed, 2 Sep 2026 14:53:50 +0200
Subject: [PATCH 03/47] refactor xmr rate module to exchange rate module for
multi crypto support
---
backend/src/AppModule.ts | 8 +-
backend/src/config/index.ts | 3 +-
.../exchangeRate/ExchangeRateModule.ts | 8 +
.../exchangeRate/const/coinPaprikaIds.ts | 7 +
.../exchangeRate/const/coingeckoIds.ts | 7 +
.../const/exchangeRatePaymentMethods.ts | 5 +
.../dto/CoinPaprikaTickerResponseDto.ts | 43 +++
.../dto/CoingeckoSimplePriceResponseDto.ts | 55 ++++
.../services/ExchangeRateService.spec.ts | 245 ++++++++++++++++++
.../services/ExchangeRateService.ts | 156 +++++++++++
.../exchangeRate/types/FiatPerCryptoRates.ts | 3 +
.../utils/extractCoinPaprikaTickerPrice.ts | 25 ++
.../utils/extractCoingeckoSimplePrice.ts | 28 ++
backend/src/modules/payment/PaymentModule.ts | 4 +-
.../payment/services/InvoiceService.spec.ts | 18 +-
.../payment/services/InvoiceService.ts | 8 +-
.../modules/payment/types/PaymentMethod.ts | 3 +-
.../storefrontCart/StorefrontCartModule.ts | 4 +-
.../services/StorefrontCartService.spec.ts | 15 +-
.../services/StorefrontCartService.ts | 7 +-
.../storefrontCore/StorefrontCoreModule.ts | 4 +-
.../StorefrontShopViewService.spec.ts | 10 +-
.../services/StorefrontShopViewService.ts | 7 +-
backend/src/modules/xmrRate/XmrRateModule.ts | 8 -
.../dto/CoingeckoSimplePriceResponseDto.ts | 39 ---
.../xmrRate/dto/KrakenTickerResponseDto.ts | 56 ----
backend/src/modules/xmrRate/krakenXmrPairs.ts | 10 -
.../xmrRate/services/XmrRateService.spec.ts | 127 ---------
.../xmrRate/services/XmrRateService.ts | 105 --------
29 files changed, 630 insertions(+), 388 deletions(-)
create mode 100644 backend/src/modules/exchangeRate/ExchangeRateModule.ts
create mode 100644 backend/src/modules/exchangeRate/const/coinPaprikaIds.ts
create mode 100644 backend/src/modules/exchangeRate/const/coingeckoIds.ts
create mode 100644 backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts
create mode 100644 backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts
create mode 100644 backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts
create mode 100644 backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts
create mode 100644 backend/src/modules/exchangeRate/services/ExchangeRateService.ts
create mode 100644 backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts
create mode 100644 backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts
create mode 100644 backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts
delete mode 100644 backend/src/modules/xmrRate/XmrRateModule.ts
delete mode 100644 backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts
delete mode 100644 backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts
delete mode 100644 backend/src/modules/xmrRate/krakenXmrPairs.ts
delete mode 100644 backend/src/modules/xmrRate/services/XmrRateService.spec.ts
delete mode 100644 backend/src/modules/xmrRate/services/XmrRateService.ts
diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts
index 4914e9e..c209f63 100644
--- a/backend/src/AppModule.ts
+++ b/backend/src/AppModule.ts
@@ -9,7 +9,7 @@ import {
getCoingeckoConfig,
getEncryptionConfig,
getJwtConfig,
- getKrakenConfig,
+ getCoinPaprikaConfig,
getOrderConfig,
getInvoiceConfig,
getMoneroWalletConfig,
@@ -35,7 +35,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
-import { XmrRateModule } from './modules/xmrRate/XmrRateModule';
+import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
import { Config } from './types/Config';
@Module({
@@ -49,7 +49,7 @@ import { Config } from './types/Config';
registerAs('app', getAppConfig),
registerAs('jwt', getJwtConfig),
registerAs('coingecko', getCoingeckoConfig),
- registerAs('kraken', getKrakenConfig),
+ registerAs('coinPaprika', getCoinPaprikaConfig),
registerAs('encryption', getEncryptionConfig),
registerAs('shopSettings', getShopSettingsConfig),
registerAs('order', getOrderConfig),
@@ -74,7 +74,7 @@ import { Config } from './types/Config';
};
}
}),
- XmrRateModule,
+ ExchangeRateModule,
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
inject: [ConfigService]
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index dcd7597..5c90218 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -191,7 +191,8 @@ export const getOrderConfig = (): OrderConfig => ({
export const getInvoiceConfig = (): InvoiceConfig => ({
minByMethod: {
- [PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC'))
+ [PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')),
+ [PaymentMethod.Btc]: String(envInt('BTC_MIN_INCOMING_ATOMIC'))
}
});
diff --git a/backend/src/modules/exchangeRate/ExchangeRateModule.ts b/backend/src/modules/exchangeRate/ExchangeRateModule.ts
new file mode 100644
index 0000000..33dfe7c
--- /dev/null
+++ b/backend/src/modules/exchangeRate/ExchangeRateModule.ts
@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+import { ExchangeRateService } from './services/ExchangeRateService';
+
+@Module({
+ providers: [ExchangeRateService],
+ exports: [ExchangeRateService]
+})
+export class ExchangeRateModule {}
diff --git a/backend/src/modules/exchangeRate/const/coinPaprikaIds.ts b/backend/src/modules/exchangeRate/const/coinPaprikaIds.ts
new file mode 100644
index 0000000..b67e74a
--- /dev/null
+++ b/backend/src/modules/exchangeRate/const/coinPaprikaIds.ts
@@ -0,0 +1,7 @@
+import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
+
+export const COINPAPRIKA_ID_BY_PAYMENT_METHOD = {
+ [PaymentMethod.Xmr]: 'xmr-monero',
+ [PaymentMethod.Btc]: 'btc-bitcoin'
+} as const satisfies Record;
diff --git a/backend/src/modules/exchangeRate/const/coingeckoIds.ts b/backend/src/modules/exchangeRate/const/coingeckoIds.ts
new file mode 100644
index 0000000..04c6bbc
--- /dev/null
+++ b/backend/src/modules/exchangeRate/const/coingeckoIds.ts
@@ -0,0 +1,7 @@
+import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
+
+export const COINGECKO_ID_BY_PAYMENT_METHOD = {
+ [PaymentMethod.Xmr]: 'monero',
+ [PaymentMethod.Btc]: 'bitcoin'
+} as const satisfies Record;
diff --git a/backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts b/backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts
new file mode 100644
index 0000000..6fba53a
--- /dev/null
+++ b/backend/src/modules/exchangeRate/const/exchangeRatePaymentMethods.ts
@@ -0,0 +1,5 @@
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
+
+export const EXCHANGE_RATE_PAYMENT_METHODS = [PaymentMethod.Xmr, PaymentMethod.Btc] as const;
+
+export type ExchangeRatePaymentMethod = (typeof EXCHANGE_RATE_PAYMENT_METHODS)[number];
diff --git a/backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts b/backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts
new file mode 100644
index 0000000..edc1f99
--- /dev/null
+++ b/backend/src/modules/exchangeRate/dto/CoinPaprikaTickerResponseDto.ts
@@ -0,0 +1,43 @@
+import {
+ IsEnum,
+ IsNotEmpty,
+ Validate,
+ ValidatorConstraint,
+ type ValidationArguments,
+ type ValidatorConstraintInterface
+} from 'class-validator';
+import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
+
+@ValidatorConstraint({ name: 'coinPaprikaResponse' })
+class CoinPaprikaResponseConstraint implements ValidatorConstraintInterface {
+ validate(response: unknown, args: ValidationArguments): boolean {
+ const { shopFiatCurrency } = args.object as CoinPaprikaTickerResponseDto;
+
+ if (!shopFiatCurrency || typeof response !== 'object' || response === null) {
+ return false;
+ }
+
+ const quotes = (response as { quotes?: unknown }).quotes;
+
+ if (typeof quotes !== 'object' || quotes === null) {
+ return false;
+ }
+
+ const quote = (quotes as Record)[shopFiatCurrency];
+
+ return typeof quote?.price === 'number' && Number.isFinite(quote.price) && quote.price > 0;
+ }
+
+ defaultMessage(): string {
+ return 'CoinPaprika fiat quote price must be a positive number';
+ }
+}
+
+export class CoinPaprikaTickerResponseDto {
+ @IsEnum(ShopFiatCurrency)
+ shopFiatCurrency: ShopFiatCurrency;
+
+ @IsNotEmpty()
+ @Validate(CoinPaprikaResponseConstraint)
+ response: { quotes: Record };
+}
diff --git a/backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts b/backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts
new file mode 100644
index 0000000..8329b1a
--- /dev/null
+++ b/backend/src/modules/exchangeRate/dto/CoingeckoSimplePriceResponseDto.ts
@@ -0,0 +1,55 @@
+import {
+ IsArray,
+ IsEnum,
+ IsNotEmpty,
+ IsObject,
+ IsString,
+ Validate,
+ ValidatorConstraint,
+ type ValidationArguments,
+ type ValidatorConstraintInterface
+} from 'class-validator';
+import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
+
+@ValidatorConstraint({ name: 'coingeckoResponse' })
+class CoingeckoResponseConstraint implements ValidatorConstraintInterface {
+ validate(response: Record, args: ValidationArguments): boolean {
+ const { shopFiatCurrency, coinIds } = args.object as CoingeckoSimplePriceResponseDto;
+
+ if (!shopFiatCurrency || coinIds.length === 0) {
+ return false;
+ }
+
+ const fiatKey = shopFiatCurrency.toLowerCase();
+
+ return coinIds.every(coinId => {
+ const coin = response[coinId];
+
+ if (typeof coin !== 'object' || coin === null) {
+ return false;
+ }
+
+ const rate = coin[fiatKey];
+
+ return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
+ });
+ }
+
+ defaultMessage(): string {
+ return 'CoinGecko coin fiat rate must be a positive number';
+ }
+}
+
+export class CoingeckoSimplePriceResponseDto {
+ @IsEnum(ShopFiatCurrency)
+ shopFiatCurrency: ShopFiatCurrency;
+
+ @IsArray()
+ @IsString({ each: true })
+ coinIds: string[];
+
+ @IsNotEmpty()
+ @IsObject()
+ @Validate(CoingeckoResponseConstraint)
+ response: Record>;
+}
diff --git a/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts b/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts
new file mode 100644
index 0000000..7aa0f29
--- /dev/null
+++ b/backend/src/modules/exchangeRate/services/ExchangeRateService.spec.ts
@@ -0,0 +1,245 @@
+import { Logger } from '@nestjs/common';
+import type { ConfigService } from '@nestjs/config';
+import axios from 'axios';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
+import { ExchangeRateService } from './ExchangeRateService';
+
+jest.mock('axios');
+
+const mockedAxios = axios as jest.Mocked;
+
+const buildCoinPaprikaTicker = (price: number, fiat = 'USD') => ({
+ quotes: { [fiat]: { price } }
+});
+
+const buildCoinPaprikaAxiosMock =
+ (xmrPrice: number, btcPrice: number, fiat = 'USD') =>
+ (url: string) => {
+ if (url === `https://coinpaprika.test/v1/tickers/xmr-monero?quotes=${fiat}`) {
+ return Promise.resolve({ data: buildCoinPaprikaTicker(xmrPrice, fiat) });
+ }
+
+ if (url === `https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=${fiat}`) {
+ return Promise.resolve({ data: buildCoinPaprikaTicker(btcPrice, fiat) });
+ }
+
+ return Promise.reject(new Error(`unexpected url: ${url}`));
+ };
+
+describe('ExchangeRateService', () => {
+ let service: ExchangeRateService;
+ let configService: {
+ get: jest.Mock;
+ };
+ let warnLogSpy: jest.SpiedFunction;
+ let errorLogSpy: jest.SpiedFunction;
+ let logSpy: jest.SpiedFunction;
+
+ beforeEach(() => {
+ warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
+ errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
+ logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
+
+ configService = {
+ get: jest.fn((key: string) => {
+ if (key === 'shopSettings') {
+ return { shopFiatCurrency: 'USD' };
+ }
+
+ if (key === 'coingecko') {
+ return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
+ }
+
+ if (key === 'coinPaprika') {
+ return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
+ }
+
+ return undefined;
+ })
+ };
+
+ service = new ExchangeRateService(configService as unknown as ConfigService);
+ mockedAxios.get.mockReset();
+ });
+
+ afterEach(() => {
+ warnLogSpy.mockRestore();
+ errorLogSpy.mockRestore();
+ logSpy.mockRestore();
+ });
+
+ it('stores batched CoinGecko rates when the fetch succeeds', async () => {
+ mockedAxios.get.mockResolvedValueOnce({
+ data: {
+ monero: { usd: 152.3456 },
+ bitcoin: { usd: 95_432.1987 }
+ }
+ });
+
+ await service.fetchLiveRates();
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=usd',
+ { timeout: 5000 }
+ );
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(152.35);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_432.2);
+ });
+
+ it('falls back to CoinPaprika when CoinGecko fails', async () => {
+ mockedAxios.get
+ .mockRejectedValueOnce(new Error('coingecko down'))
+ .mockImplementation(buildCoinPaprikaAxiosMock(149.876, 94_999.5));
+
+ await service.fetchLiveRates();
+
+ expect(warnLogSpy).toHaveBeenCalled();
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD',
+ { timeout: 5000 }
+ );
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD',
+ { timeout: 5000 }
+ );
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.88);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_999.5);
+ });
+
+ it('leaves cached rates null when both providers fail on the first fetch', async () => {
+ mockedAxios.get
+ .mockRejectedValueOnce(new Error('coingecko down'))
+ .mockRejectedValue(new Error('coinpaprika down'));
+
+ await service.fetchLiveRates();
+
+ expect(errorLogSpy).toHaveBeenCalled();
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
+ });
+
+ it('falls back to CoinPaprika when CoinGecko returns an invalid payload', async () => {
+ mockedAxios.get
+ .mockResolvedValueOnce({ data: { monero: { usd: -1 }, bitcoin: { usd: 95_000 } } })
+ .mockImplementation(buildCoinPaprikaAxiosMock(151.11, 95_111.11));
+
+ await service.fetchLiveRates();
+
+ expect(warnLogSpy).toHaveBeenCalled();
+ expect(logSpy).toHaveBeenCalledWith('Successfully fetched live rates from CoinPaprika for USD');
+ expect(mockedAxios.get).toHaveBeenCalledTimes(3);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(151.11);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_111.11);
+ });
+
+ it('falls back to CoinPaprika when CoinGecko omits a coin', async () => {
+ mockedAxios.get
+ .mockResolvedValueOnce({ data: { monero: { usd: 150 } } })
+ .mockImplementation(buildCoinPaprikaAxiosMock(149.5, 94_000));
+
+ await service.fetchLiveRates();
+
+ expect(warnLogSpy).toHaveBeenCalled();
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.5);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_000);
+ });
+
+ it('leaves cached rates null when CoinPaprika returns an invalid payload for one coin', async () => {
+ mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
+ if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
+ return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
+ }
+
+ if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
+ return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
+ }
+
+ return Promise.reject(new Error(`unexpected url: ${url}`));
+ });
+
+ await service.fetchLiveRates();
+
+ expect(errorLogSpy).toHaveBeenCalled();
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
+ });
+
+ it('keeps the previous live rates when a refresh fails after a successful fetch', async () => {
+ mockedAxios.get.mockResolvedValueOnce({
+ data: {
+ monero: { usd: 140 },
+ bitcoin: { usd: 90_000 }
+ }
+ });
+ await service.fetchLiveRates();
+
+ mockedAxios.get
+ .mockRejectedValueOnce(new Error('coingecko down'))
+ .mockRejectedValue(new Error('coinpaprika down'));
+ await service.fetchLiveRates();
+
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
+ });
+
+ it('keeps the previous live rates when CoinPaprika returns an invalid payload for one coin on refresh', async () => {
+ mockedAxios.get.mockResolvedValueOnce({
+ data: {
+ monero: { usd: 140 },
+ bitcoin: { usd: 90_000 }
+ }
+ });
+ await service.fetchLiveRates();
+
+ mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
+ if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
+ return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
+ }
+
+ if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
+ return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
+ }
+
+ return Promise.reject(new Error(`unexpected url: ${url}`));
+ });
+ await service.fetchLiveRates();
+
+ expect(errorLogSpy).toHaveBeenCalled();
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
+ });
+
+ it('uses the shop fiat currency when building provider URLs', async () => {
+ configService.get.mockImplementation((key: string) => {
+ if (key === 'shopSettings') {
+ return { shopFiatCurrency: 'EUR' };
+ }
+
+ if (key === 'coingecko') {
+ return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
+ }
+
+ if (key === 'coinPaprika') {
+ return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
+ }
+
+ return undefined;
+ });
+
+ mockedAxios.get.mockResolvedValueOnce({
+ data: {
+ monero: { eur: 130.5 },
+ bitcoin: { eur: 88_000.4 }
+ }
+ });
+
+ await service.fetchLiveRates();
+
+ expect(mockedAxios.get).toHaveBeenCalledWith(
+ 'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=eur',
+ { timeout: 5000 }
+ );
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(130.5);
+ expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(88_000.4);
+ });
+});
diff --git a/backend/src/modules/exchangeRate/services/ExchangeRateService.ts b/backend/src/modules/exchangeRate/services/ExchangeRateService.ts
new file mode 100644
index 0000000..4996013
--- /dev/null
+++ b/backend/src/modules/exchangeRate/services/ExchangeRateService.ts
@@ -0,0 +1,156 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { Cron, CronExpression } from '@nestjs/schedule';
+import { plainToInstance } from 'class-transformer';
+import { validateSync } from 'class-validator';
+import axios from 'axios';
+import type { Config } from '../../../types/Config';
+import { getErrorMessage } from '../../../utils/getErrorMessage';
+import { COINGECKO_ID_BY_PAYMENT_METHOD } from '../const/coingeckoIds';
+import { COINPAPRIKA_ID_BY_PAYMENT_METHOD } from '../const/coinPaprikaIds';
+import { EXCHANGE_RATE_PAYMENT_METHODS, type ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
+import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
+import { CoinPaprikaTickerResponseDto } from '../dto/CoinPaprikaTickerResponseDto';
+import type { FiatPerCryptoRates } from '../types/FiatPerCryptoRates';
+import { extractCoingeckoSimplePrice } from '../utils/extractCoingeckoSimplePrice';
+import { extractCoinPaprikaTickerPrice } from '../utils/extractCoinPaprikaTickerPrice';
+
+@Injectable()
+export class ExchangeRateService implements OnModuleInit {
+ private readonly logger = new Logger(ExchangeRateService.name);
+
+ private liveFiatPerCrypto = Object.fromEntries(
+ EXCHANGE_RATE_PAYMENT_METHODS.map(method => [method, null])
+ ) as Record;
+
+ constructor(private readonly configService: ConfigService) {}
+
+ async onModuleInit(): Promise {
+ await this.fetchLiveRates();
+ }
+
+ getLiveFiatPerCrypto(method: ExchangeRatePaymentMethod): number | null {
+ return this.liveFiatPerCrypto[method];
+ }
+
+ @Cron(CronExpression.EVERY_5_MINUTES)
+ async fetchLiveRates(): Promise {
+ const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
+
+ try {
+ const rates = await this.fetchRatesFromCoingecko();
+
+ this.applyRates(rates);
+ } catch {
+ this.logger.warn(`CoinGecko rate fetch failed for ${shopFiatCurrency}, trying CoinPaprika`);
+
+ try {
+ const rates = await this.fetchRatesFromCoinPaprika();
+
+ this.applyRates(rates);
+
+ this.logger.log(`Successfully fetched live rates from CoinPaprika for ${shopFiatCurrency}`);
+ } catch (error) {
+ this.logger.error(
+ `Failed to fetch live rates from CoinGecko and CoinPaprika for ${shopFiatCurrency}: ${getErrorMessage(error)}`
+ );
+ }
+ }
+ }
+
+ private async fetchRatesFromCoingecko(): Promise {
+ const { rateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
+ const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
+
+ const url = this.buildCoingeckoRateUrl();
+
+ const { data } = await axios.get(url, {
+ timeout: rateFetchTimeoutMs
+ });
+
+ const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]);
+
+ const dto = plainToInstance(CoingeckoSimplePriceResponseDto, {
+ response: data,
+ shopFiatCurrency,
+ coinIds
+ });
+
+ const errors = validateSync(dto);
+
+ if (errors.length > 0) {
+ throw new Error('CoinGecko response validation failed');
+ }
+
+ const rates = {} as FiatPerCryptoRates;
+
+ for (const method of EXCHANGE_RATE_PAYMENT_METHODS) {
+ const coinId = COINGECKO_ID_BY_PAYMENT_METHOD[method];
+
+ const price = extractCoingeckoSimplePrice(dto.response, coinId, shopFiatCurrency);
+
+ if (price === null) {
+ throw new Error(`CoinGecko response validation failed for coin ${coinId}`);
+ }
+
+ rates[method] = price;
+ }
+
+ return rates;
+ }
+
+ private async fetchRatesFromCoinPaprika(): Promise {
+ const { apiBaseUrl, rateFetchTimeoutMs } = this.configService.get('coinPaprika') as Config['coinPaprika'];
+ const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
+
+ const rates = {} as FiatPerCryptoRates;
+
+ await Promise.all(
+ EXCHANGE_RATE_PAYMENT_METHODS.map(async method => {
+ const coinId = COINPAPRIKA_ID_BY_PAYMENT_METHOD[method];
+ const url = `${apiBaseUrl}/tickers/${coinId}?quotes=${shopFiatCurrency}`;
+
+ const { data } = await axios.get(url, {
+ timeout: rateFetchTimeoutMs
+ });
+
+ const dto = plainToInstance(CoinPaprikaTickerResponseDto, {
+ response: data,
+ shopFiatCurrency
+ });
+
+ const errors = validateSync(dto);
+
+ if (errors.length > 0) {
+ throw new Error(`CoinPaprika response validation failed for coin ${coinId}`);
+ }
+
+ const price = extractCoinPaprikaTickerPrice(dto.response, shopFiatCurrency);
+
+ if (price === null) {
+ throw new Error(`CoinPaprika response validation failed for coin ${coinId}`);
+ }
+
+ rates[method] = price;
+ })
+ );
+
+ return rates;
+ }
+
+ private buildCoingeckoRateUrl(): string {
+ const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
+ const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
+
+ const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]).join(',');
+ const vs = shopFiatCurrency.toLowerCase();
+
+ return `${apiBaseUrl}/simple/price?ids=${coinIds}&vs_currencies=${vs}`;
+ }
+
+ private applyRates(rates: FiatPerCryptoRates): void {
+ for (const method of EXCHANGE_RATE_PAYMENT_METHODS) {
+ this.liveFiatPerCrypto[method] = rates[method];
+ }
+ }
+}
diff --git a/backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts b/backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts
new file mode 100644
index 0000000..97b4ac7
--- /dev/null
+++ b/backend/src/modules/exchangeRate/types/FiatPerCryptoRates.ts
@@ -0,0 +1,3 @@
+import type { ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
+
+export type FiatPerCryptoRates = Record;
diff --git a/backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts b/backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts
new file mode 100644
index 0000000..1e32739
--- /dev/null
+++ b/backend/src/modules/exchangeRate/utils/extractCoinPaprikaTickerPrice.ts
@@ -0,0 +1,25 @@
+import Decimal from 'decimal.js';
+import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
+
+export const extractCoinPaprikaTickerPrice = (
+ response: { quotes: Record },
+ shopFiatCurrency: ShopFiatCurrency
+): number | null => {
+ const quote = response.quotes[shopFiatCurrency];
+
+ if (!quote) {
+ return null;
+ }
+
+ const rate = quote.price;
+
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
+ return null;
+ }
+
+ try {
+ return new Decimal(rate).toDecimalPlaces(2).toNumber();
+ } catch {
+ return null;
+ }
+};
diff --git a/backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts b/backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts
new file mode 100644
index 0000000..9222da9
--- /dev/null
+++ b/backend/src/modules/exchangeRate/utils/extractCoingeckoSimplePrice.ts
@@ -0,0 +1,28 @@
+import Decimal from 'decimal.js';
+import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
+
+export const extractCoingeckoSimplePrice = (
+ coins: Record>,
+ coinId: string,
+ shopFiatCurrency: ShopFiatCurrency
+): number | null => {
+ const coin = coins[coinId];
+
+ if (!coin) {
+ return null;
+ }
+
+ const fiatKey = shopFiatCurrency.toLowerCase();
+
+ const rate = coin[fiatKey];
+
+ if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
+ return null;
+ }
+
+ try {
+ return new Decimal(rate).toDecimalPlaces(2).toNumber();
+ } catch {
+ return null;
+ }
+};
diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts
index 981c2ef..55681cd 100644
--- a/backend/src/modules/payment/PaymentModule.ts
+++ b/backend/src/modules/payment/PaymentModule.ts
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
-import { XmrRateModule } from '../xmrRate/XmrRateModule';
+import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice';
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
import { InvoicePayment } from './entities/InvoicePayment';
@@ -12,7 +12,7 @@ import { InvoiceService } from './services/InvoiceService';
imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
MoneroWalletModule,
- XmrRateModule
+ ExchangeRateModule
],
providers: [InvoicePaymentService, InvoiceService],
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
diff --git a/backend/src/modules/payment/services/InvoiceService.spec.ts b/backend/src/modules/payment/services/InvoiceService.spec.ts
index ae829e2..6117671 100644
--- a/backend/src/modules/payment/services/InvoiceService.spec.ts
+++ b/backend/src/modules/payment/services/InvoiceService.spec.ts
@@ -2,7 +2,7 @@ import { Logger, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
-import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
+import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason';
import { PaymentMethod } from '../types/PaymentMethod';
@@ -22,8 +22,8 @@ describe('InvoiceService', () => {
let walletRpcClient: {
createAddress: jest.Mock;
};
- let xmrRateService: {
- getLiveFiatPerXmr: jest.Mock;
+ let exchangeRateService: {
+ getLiveFiatPerCrypto: jest.Mock;
};
let errorLogSpy: jest.SpiedFunction;
@@ -60,15 +60,15 @@ describe('InvoiceService', () => {
})
};
- xmrRateService = {
- getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
+ exchangeRateService = {
+ getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
service = new InvoiceService(
invoiceRepo as unknown as Repository,
configService as unknown as ConfigService,
walletRpcClient as unknown as MoneroWalletRpcClient,
- xmrRateService as unknown as XmrRateService
+ exchangeRateService as unknown as ExchangeRateService
);
});
@@ -85,7 +85,7 @@ describe('InvoiceService', () => {
});
it('throws when the live XMR rate is unavailable for checkout invoices', async () => {
- xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
+ exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
@@ -169,7 +169,7 @@ describe('InvoiceService', () => {
});
it('uses shipping-specific messages and address labels for shipping invoices', async () => {
- xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
+ exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect(
service.issueInvoice({
@@ -184,7 +184,7 @@ describe('InvoiceService', () => {
)
);
- xmrRateService.getLiveFiatPerXmr.mockReturnValue(150);
+ exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(
diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts
index ba1300e..79614ed 100644
--- a/backend/src/modules/payment/services/InvoiceService.ts
+++ b/backend/src/modules/payment/services/InvoiceService.ts
@@ -9,7 +9,7 @@ import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
-import { XmrRateService } from '../../xmrRate/services/XmrRateService';
+import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason';
import type { IssueInvoiceData } from '../types/IssueInvoiceData';
@@ -25,10 +25,10 @@ export class InvoiceService {
private readonly invoiceRepo: Repository,
private readonly configService: ConfigService,
private readonly walletRpcClient: MoneroWalletRpcClient,
- private readonly xmrRateService: XmrRateService
+ private readonly exchangeRateService: ExchangeRateService
) {}
- async issueInvoice(data: IssueInvoiceData): Promise {
+ async issueInvoice(data: IssueInvoiceData): Promise {
switch (data.paymentMethod) {
case PaymentMethod.Xmr:
return this.issueXmrInvoice(data);
@@ -44,7 +44,7 @@ export class InvoiceService {
contextId
);
- const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
+ const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
if (fiatPerXmr === null) {
throw new ServiceUnavailableException(rateUnavailableMessage);
diff --git a/backend/src/modules/payment/types/PaymentMethod.ts b/backend/src/modules/payment/types/PaymentMethod.ts
index 28b6d58..9299073 100644
--- a/backend/src/modules/payment/types/PaymentMethod.ts
+++ b/backend/src/modules/payment/types/PaymentMethod.ts
@@ -1,3 +1,4 @@
export enum PaymentMethod {
- Xmr = 'xmr'
+ Xmr = 'xmr',
+ Btc = 'btc'
}
diff --git a/backend/src/modules/storefrontCart/StorefrontCartModule.ts b/backend/src/modules/storefrontCart/StorefrontCartModule.ts
index 9635bdc..ee0fa12 100644
--- a/backend/src/modules/storefrontCart/StorefrontCartModule.ts
+++ b/backend/src/modules/storefrontCart/StorefrontCartModule.ts
@@ -3,14 +3,14 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
import { ProductsModule } from '../product/ProductsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
-import { XmrRateModule } from '../xmrRate/XmrRateModule';
+import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontCartController } from './controllers/StorefrontCartController';
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
import { StorefrontCartService } from './services/StorefrontCartService';
import { StorefrontDiscountService } from './services/StorefrontDiscountService';
@Module({
- imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, XmrRateModule],
+ imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, ExchangeRateModule],
controllers: [StorefrontCartController],
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
exports: [StorefrontCartService]
diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
index cf5ca2c..ed0c8b3 100644
--- a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
+++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
@@ -1,7 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
-import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
+import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
import type { StorefrontDiscountService } from './StorefrontDiscountService';
import { StorefrontCartService } from './StorefrontCartService';
@@ -29,8 +30,8 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock;
};
- let xmrRateService: {
- getLiveFiatPerXmr: jest.Mock;
+ let exchangeRateService: {
+ getLiveFiatPerCrypto: jest.Mock;
};
let discountService: {
getDiscountStateForCart: jest.Mock;
@@ -43,8 +44,8 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
};
- xmrRateService = {
- getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
+ exchangeRateService = {
+ getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
discountService = {
@@ -58,7 +59,7 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService,
- xmrRateService as unknown as XmrRateService,
+ exchangeRateService as unknown as ExchangeRateService,
discountService as unknown as StorefrontDiscountService
);
});
@@ -118,7 +119,7 @@ describe('StorefrontCartService', () => {
});
it('leaves cartTotalXmr null when no live rate is available', async () => {
- xmrRateService.getLiveFiatPerXmr.mockReturnValue(null);
+ exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
index 853dba3..34fc757 100644
--- a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
+++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
@@ -7,7 +7,8 @@ import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart';
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
-import { XmrRateService } from '../../xmrRate/services/XmrRateService';
+import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
import type { CookieCartExtended } from '../types/CookieCartExtended';
@@ -22,7 +23,7 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService {
constructor(
private readonly productsService: StorefrontProductsService,
- private readonly xmrRateService: XmrRateService,
+ private readonly exchangeRateService: ExchangeRateService,
private readonly discountService: StorefrontDiscountService
) {}
@@ -77,7 +78,7 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
- const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
+ const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
const cartTotalXmr = fiatPerXmr !== null ? convertFiatToXmr(discountState.cartTotalPrice, fiatPerXmr) : null;
const hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
diff --git a/backend/src/modules/storefrontCore/StorefrontCoreModule.ts b/backend/src/modules/storefrontCore/StorefrontCoreModule.ts
index 254f66e..1074546 100644
--- a/backend/src/modules/storefrontCore/StorefrontCoreModule.ts
+++ b/backend/src/modules/storefrontCore/StorefrontCoreModule.ts
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common';
import { EncryptionModule } from '../encryption/EncryptionModule';
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
-import { XmrRateModule } from '../xmrRate/XmrRateModule';
+import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontErrorController } from './controllers/StorefrontErrorController';
import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController';
import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter';
@@ -18,7 +18,7 @@ import { StorefrontSignedCookieService } from './services/StorefrontSignedCookie
import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService';
@Module({
- imports: [ShopSettingsModule, XmrRateModule, EncryptionModule],
+ imports: [ShopSettingsModule, ExchangeRateModule, EncryptionModule],
controllers: [StorefrontErrorController, StorefrontPreferencesController],
providers: [
StorefrontSignedCookieService,
diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts
index d3aa91b..791f598 100644
--- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts
+++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts
@@ -2,7 +2,7 @@ import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { StorefrontShopViewService } from './StorefrontShopViewService';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
-import { XmrRateService } from '../../xmrRate/services/XmrRateService';
+import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
@@ -50,9 +50,9 @@ describe('StorefrontShopViewService', () => {
req: reqOverrides = {}
} = overrides;
- const xmrRateService = {
- getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr)
- } as unknown as XmrRateService;
+ const exchangeRateService = {
+ getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr)
+ } as unknown as ExchangeRateService;
const configService = {
get: jest.fn().mockReturnValue(shopSettings)
} as unknown as ConfigService;
@@ -73,7 +73,7 @@ describe('StorefrontShopViewService', () => {
} as unknown as StorefrontThemeCookieService;
const service = new StorefrontShopViewService(
- xmrRateService,
+ exchangeRateService,
configService,
shopSettingsService,
cartCookieService,
diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts
index 33e4bd9..99e4fd3 100644
--- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts
+++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts
@@ -7,7 +7,8 @@ import { getTotalCartQtyFromCart } from '../../../utils/cart/getTotalCartQtyFrom
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
-import { XmrRateService } from '../../xmrRate/services/XmrRateService';
+import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
@@ -22,7 +23,7 @@ import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@Injectable()
export class StorefrontShopViewService {
constructor(
- private readonly xmrRateService: XmrRateService,
+ private readonly exchangeRateService: ExchangeRateService,
private readonly configService: ConfigService,
private readonly shopSettingsService: ShopSettingsService,
private readonly cartCookieService: StorefrontCartCookieService,
@@ -40,7 +41,7 @@ export class StorefrontShopViewService {
const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
- const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
+ const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
await this.shopSettingsService.getStorefrontBranding();
diff --git a/backend/src/modules/xmrRate/XmrRateModule.ts b/backend/src/modules/xmrRate/XmrRateModule.ts
deleted file mode 100644
index 9d11ca0..0000000
--- a/backend/src/modules/xmrRate/XmrRateModule.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { Module } from '@nestjs/common';
-import { XmrRateService } from './services/XmrRateService';
-
-@Module({
- providers: [XmrRateService],
- exports: [XmrRateService]
-})
-export class XmrRateModule {}
diff --git a/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts b/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts
deleted file mode 100644
index d2dd690..0000000
--- a/backend/src/modules/xmrRate/dto/CoingeckoSimplePriceResponseDto.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import {
- IsEnum,
- IsNotEmpty,
- IsObject,
- Validate,
- ValidatorConstraint,
- type ValidatorConstraintInterface,
- type ValidationArguments
-} from 'class-validator';
-import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
-
-@ValidatorConstraint({ name: 'coingeckoMoneroFiat' })
-class CoingeckoMoneroFiatConstraint implements ValidatorConstraintInterface {
- validate(monero: Record, args: ValidationArguments): boolean {
- const { shopFiatCurrency } = args.object as CoingeckoSimplePriceResponseDto;
-
- if (!shopFiatCurrency) {
- return false;
- }
-
- const rate = monero[shopFiatCurrency.toLowerCase()];
-
- return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
- }
-
- defaultMessage(): string {
- return 'CoinGecko monero fiat rate must be a positive number';
- }
-}
-
-export class CoingeckoSimplePriceResponseDto {
- @IsEnum(ShopFiatCurrency)
- shopFiatCurrency: ShopFiatCurrency;
-
- @IsNotEmpty()
- @IsObject()
- @Validate(CoingeckoMoneroFiatConstraint)
- monero: Record;
-}
diff --git a/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts b/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts
deleted file mode 100644
index 10468d1..0000000
--- a/backend/src/modules/xmrRate/dto/KrakenTickerResponseDto.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import {
- ArrayMaxSize,
- IsArray,
- IsNotEmpty,
- IsString,
- Validate,
- ValidatorConstraint,
- type ValidatorConstraintInterface
-} from 'class-validator';
-import Decimal from 'decimal.js';
-
-@ValidatorConstraint({ name: 'krakenResult' })
-class KrakenResultConstraint implements ValidatorConstraintInterface {
- validate(result: unknown): boolean {
- if (typeof result !== 'object' || result === null) {
- return false;
- }
-
- const pair = Object.values(result as Record)[0];
-
- if (typeof pair !== 'object' || pair === null) {
- return false;
- }
-
- if (!('c' in pair)) {
- return false;
- }
-
- if (!Array.isArray(pair.c) || pair.c.length < 1 || typeof pair.c[0] !== 'string') {
- return false;
- }
-
- try {
- const price = new Decimal(pair.c[0]);
-
- return price.isFinite() && price.gt(0);
- } catch {
- return false;
- }
- }
-
- defaultMessage(): string {
- return 'Kraken ticker last price (c[0]) must be a positive number';
- }
-}
-
-export class KrakenTickerResponseDto {
- @IsArray()
- @ArrayMaxSize(0)
- @IsString({ each: true })
- error: string[];
-
- @IsNotEmpty()
- @Validate(KrakenResultConstraint)
- result: Record;
-}
diff --git a/backend/src/modules/xmrRate/krakenXmrPairs.ts b/backend/src/modules/xmrRate/krakenXmrPairs.ts
deleted file mode 100644
index 667c005..0000000
--- a/backend/src/modules/xmrRate/krakenXmrPairs.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import { ShopFiatCurrency } from '../../types/ShopFiatCurrency';
-
-export const KRAKEN_XMR_PAIR_BY_FIAT: Record = {
- [ShopFiatCurrency.Usd]: 'XMRUSD',
- [ShopFiatCurrency.Eur]: 'XMREUR',
- [ShopFiatCurrency.Gbp]: 'XMRGBP',
- [ShopFiatCurrency.Cad]: 'XMRCAD',
- [ShopFiatCurrency.Aud]: 'XMRAUD',
- [ShopFiatCurrency.Chf]: 'XMRCHF'
-};
diff --git a/backend/src/modules/xmrRate/services/XmrRateService.spec.ts b/backend/src/modules/xmrRate/services/XmrRateService.spec.ts
deleted file mode 100644
index c66f2e1..0000000
--- a/backend/src/modules/xmrRate/services/XmrRateService.spec.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { Logger } from '@nestjs/common';
-import type { ConfigService } from '@nestjs/config';
-import axios from 'axios';
-import { XmrRateService } from './XmrRateService';
-
-jest.mock('axios');
-
-const mockedAxios = axios as jest.Mocked;
-
-describe('XmrRateService', () => {
- let service: XmrRateService;
- let configService: {
- get: jest.Mock;
- };
- let warnLogSpy: jest.SpiedFunction;
- let errorLogSpy: jest.SpiedFunction;
- let logSpy: jest.SpiedFunction;
-
- beforeEach(() => {
- warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
- errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
- logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
-
- configService = {
- get: jest.fn((key: string) => {
- if (key === 'shopSettings') {
- return { shopFiatCurrency: 'USD' };
- }
-
- if (key === 'coingecko') {
- return { apiBaseUrl: 'https://coingecko.test', xmrRateFetchTimeoutMs: 5000 };
- }
-
- if (key === 'kraken') {
- return { apiBaseUrl: 'https://kraken.test', xmrRateFetchTimeoutMs: 5000 };
- }
-
- return undefined;
- })
- };
-
- service = new XmrRateService(configService as unknown as ConfigService);
- mockedAxios.get.mockReset();
- });
-
- afterEach(() => {
- warnLogSpy.mockRestore();
- errorLogSpy.mockRestore();
- logSpy.mockRestore();
- });
-
- it('stores the CoinGecko rate when the fetch succeeds', async () => {
- mockedAxios.get.mockResolvedValueOnce({
- data: { monero: { usd: 152.3456 } }
- });
-
- await service.fetchFiatPerXmrRate();
-
- expect(mockedAxios.get).toHaveBeenCalledWith(
- 'https://coingecko.test/simple/price?ids=monero&vs_currencies=usd',
- { timeout: 5000 }
- );
- expect(service.getLiveFiatPerXmr()).toBe(152.35);
- });
-
- it('falls back to Kraken when CoinGecko fails', async () => {
- mockedAxios.get
- .mockRejectedValueOnce(new Error('coingecko down'))
- .mockResolvedValueOnce({
- data: {
- error: [],
- result: {
- XMRUSD: { c: ['149.876'] }
- }
- }
- });
-
- await service.fetchFiatPerXmrRate();
-
- expect(warnLogSpy).toHaveBeenCalled();
- expect(mockedAxios.get).toHaveBeenNthCalledWith(
- 2,
- 'https://kraken.test/Ticker?pair=XMRUSD',
- { timeout: 5000 }
- );
- expect(service.getLiveFiatPerXmr()).toBe(149.88);
- });
-
- it('leaves the cached rate null when both providers fail', async () => {
- mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
-
- await service.fetchFiatPerXmrRate();
-
- expect(errorLogSpy).toHaveBeenCalled();
- expect(service.getLiveFiatPerXmr()).toBeNull();
- });
-
- it('falls back to Kraken when CoinGecko returns an invalid payload', async () => {
- mockedAxios.get
- .mockResolvedValueOnce({ data: { monero: { usd: -1 } } })
- .mockResolvedValueOnce({
- data: {
- error: [],
- result: {
- XMRUSD: { c: ['151.11'] }
- }
- }
- });
-
- await service.fetchFiatPerXmrRate();
-
- expect(mockedAxios.get).toHaveBeenCalledTimes(2);
- expect(service.getLiveFiatPerXmr()).toBe(151.11);
- });
-
- it('keeps the previous live rate when a refresh fails after a successful fetch', async () => {
- mockedAxios.get.mockResolvedValueOnce({
- data: { monero: { usd: 140 } }
- });
- await service.fetchFiatPerXmrRate();
-
- mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
- await service.fetchFiatPerXmrRate();
-
- expect(service.getLiveFiatPerXmr()).toBe(140);
- });
-});
diff --git a/backend/src/modules/xmrRate/services/XmrRateService.ts b/backend/src/modules/xmrRate/services/XmrRateService.ts
deleted file mode 100644
index ceac404..0000000
--- a/backend/src/modules/xmrRate/services/XmrRateService.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
-import { Cron, CronExpression } from '@nestjs/schedule';
-import { plainToInstance } from 'class-transformer';
-import { validateSync } from 'class-validator';
-import axios from 'axios';
-import Decimal from 'decimal.js';
-import type { Config } from '../../../types/Config';
-import { getErrorMessage } from '../../../utils/getErrorMessage';
-import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
-import { KrakenTickerResponseDto } from '../dto/KrakenTickerResponseDto';
-import { KRAKEN_XMR_PAIR_BY_FIAT } from '../krakenXmrPairs';
-
-@Injectable()
-export class XmrRateService implements OnModuleInit {
- private readonly logger = new Logger(XmrRateService.name);
- private fiatPerXmr: number | null = null;
-
- constructor(private readonly configService: ConfigService) {}
-
- async onModuleInit(): Promise {
- await this.fetchFiatPerXmrRate();
- }
-
- @Cron(CronExpression.EVERY_30_SECONDS)
- async fetchFiatPerXmrRate(): Promise {
- const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
-
- try {
- this.fiatPerXmr = await this.fetchFiatPerXmrFromCoingecko();
- } catch {
- this.logger.warn(`CoinGecko XMR/${shopFiatCurrency} rate fetch failed, trying Kraken`);
-
- try {
- this.fiatPerXmr = await this.fetchFiatPerXmrFromKraken();
-
- this.logger.log(`Successfully fetched XMR/${shopFiatCurrency} rate from Kraken`);
- } catch (error) {
- this.logger.error(
- `Failed to fetch XMR/${shopFiatCurrency} rate from CoinGecko and Kraken: ${getErrorMessage(error)}`
- );
- }
- }
- }
-
- getLiveFiatPerXmr(): number | null {
- return this.fiatPerXmr;
- }
-
- private buildCoingeckoRateUrl(): string {
- const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
- const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
- const vs = shopFiatCurrency.toLowerCase();
-
- return `${apiBaseUrl}/simple/price?ids=monero&vs_currencies=${vs}`;
- }
-
- private buildKrakenRateUrl(): string {
- const { apiBaseUrl } = this.configService.get('kraken') as Config['kraken'];
- const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
-
- const pair = KRAKEN_XMR_PAIR_BY_FIAT[shopFiatCurrency];
-
- return `${apiBaseUrl}/Ticker?pair=${pair}`;
- }
-
- private async fetchFiatPerXmrFromCoingecko(): Promise {
- const { xmrRateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
- const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
-
- const { data } = await axios.get(this.buildCoingeckoRateUrl(), {
- timeout: xmrRateFetchTimeoutMs
- });
-
- const body = plainToInstance(CoingeckoSimplePriceResponseDto, data);
- body.shopFiatCurrency = shopFiatCurrency;
-
- const errors = validateSync(body);
-
- if (errors.length > 0) {
- throw new Error('CoinGecko response validation failed');
- }
-
- return new Decimal(body.monero[shopFiatCurrency.toLowerCase()]).toDecimalPlaces(2).toNumber();
- }
-
- private async fetchFiatPerXmrFromKraken(): Promise {
- const { xmrRateFetchTimeoutMs } = this.configService.get('kraken') as Config['kraken'];
-
- const { data } = await axios.get(this.buildKrakenRateUrl(), {
- timeout: xmrRateFetchTimeoutMs
- });
-
- const body = plainToInstance(KrakenTickerResponseDto, data);
- const errors = validateSync(body);
-
- if (errors.length > 0) {
- throw new Error('Kraken response validation failed');
- }
-
- const pair = Object.values(body.result)[0];
-
- return new Decimal(pair.c[0]).toDecimalPlaces(2).toNumber();
- }
-}
From 5db64ea9054f2adeb9937b5aa759ad8ec935cada Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Wed, 2 Sep 2026 15:51:33 +0200
Subject: [PATCH 04/47] add bitcoin utils
---
backend/src/consts/btcAtomicPerBtc.ts | 3 +++
backend/src/types/BitcoinConfirmationTier.ts | 4 ++++
.../utils/bitcoin/convertBtcAtomicToBtc.spec.ts | 11 +++++++++++
.../src/utils/bitcoin/convertBtcAtomicToBtc.ts | 9 +++++++++
.../utils/bitcoin/convertBtcToBtcAtomic.spec.ts | 16 ++++++++++++++++
.../src/utils/bitcoin/convertBtcToBtcAtomic.ts | 6 ++++++
.../src/utils/bitcoin/convertFiatToBtc.spec.ts | 11 +++++++++++
backend/src/utils/bitcoin/convertFiatToBtc.ts | 5 +++++
8 files changed, 65 insertions(+)
create mode 100644 backend/src/consts/btcAtomicPerBtc.ts
create mode 100644 backend/src/types/BitcoinConfirmationTier.ts
create mode 100644 backend/src/utils/bitcoin/convertBtcAtomicToBtc.spec.ts
create mode 100644 backend/src/utils/bitcoin/convertBtcAtomicToBtc.ts
create mode 100644 backend/src/utils/bitcoin/convertBtcToBtcAtomic.spec.ts
create mode 100644 backend/src/utils/bitcoin/convertBtcToBtcAtomic.ts
create mode 100644 backend/src/utils/bitcoin/convertFiatToBtc.spec.ts
create mode 100644 backend/src/utils/bitcoin/convertFiatToBtc.ts
diff --git a/backend/src/consts/btcAtomicPerBtc.ts b/backend/src/consts/btcAtomicPerBtc.ts
new file mode 100644
index 0000000..761558e
--- /dev/null
+++ b/backend/src/consts/btcAtomicPerBtc.ts
@@ -0,0 +1,3 @@
+import Decimal from 'decimal.js';
+
+export const BTC_ATOMIC_PER_BTC = new Decimal(100_000_000);
diff --git a/backend/src/types/BitcoinConfirmationTier.ts b/backend/src/types/BitcoinConfirmationTier.ts
new file mode 100644
index 0000000..c84cff6
--- /dev/null
+++ b/backend/src/types/BitcoinConfirmationTier.ts
@@ -0,0 +1,4 @@
+export interface BitcoinConfirmationTier {
+ upToTotalFiat?: string;
+ minConfirmations: number;
+}
diff --git a/backend/src/utils/bitcoin/convertBtcAtomicToBtc.spec.ts b/backend/src/utils/bitcoin/convertBtcAtomicToBtc.spec.ts
new file mode 100644
index 0000000..bbb1ea7
--- /dev/null
+++ b/backend/src/utils/bitcoin/convertBtcAtomicToBtc.spec.ts
@@ -0,0 +1,11 @@
+import { convertBtcAtomicToBtc } from './convertBtcAtomicToBtc';
+
+describe('convertBtcAtomicToBtc', () => {
+ it('converts atomic units to BTC', () => {
+ expect(convertBtcAtomicToBtc('100000000')).toBe('1.00000000');
+ });
+
+ it('converts a single atomic unit to BTC', () => {
+ expect(convertBtcAtomicToBtc('1')).toBe('0.00000001');
+ });
+});
diff --git a/backend/src/utils/bitcoin/convertBtcAtomicToBtc.ts b/backend/src/utils/bitcoin/convertBtcAtomicToBtc.ts
new file mode 100644
index 0000000..9bff2fb
--- /dev/null
+++ b/backend/src/utils/bitcoin/convertBtcAtomicToBtc.ts
@@ -0,0 +1,9 @@
+import Decimal from 'decimal.js';
+import { BTC_ATOMIC_PER_BTC } from '../../consts/btcAtomicPerBtc';
+
+export const convertBtcAtomicToBtc = (
+ amountAtomic: string,
+ rounding: Decimal.Rounding = Decimal.ROUND_HALF_UP
+): string => {
+ return new Decimal(amountAtomic).div(BTC_ATOMIC_PER_BTC).toDecimalPlaces(8, rounding).toFixed(8);
+};
diff --git a/backend/src/utils/bitcoin/convertBtcToBtcAtomic.spec.ts b/backend/src/utils/bitcoin/convertBtcToBtcAtomic.spec.ts
new file mode 100644
index 0000000..f93ad10
--- /dev/null
+++ b/backend/src/utils/bitcoin/convertBtcToBtcAtomic.spec.ts
@@ -0,0 +1,16 @@
+import { convertBtcToBtcAtomic } from './convertBtcToBtcAtomic';
+
+describe('convertBtcToBtcAtomic', () => {
+ it('converts one BTC to atomic units', () => {
+ expect(convertBtcToBtcAtomic('1')).toBe('100000000');
+ });
+
+ it('converts the smallest display unit to atomic units', () => {
+ expect(convertBtcToBtcAtomic('0.00000001')).toBe('1');
+ });
+
+ it('returns an integer string without scientific notation', () => {
+ expect(convertBtcToBtcAtomic('0.00125907')).toBe('125907');
+ expect(convertBtcToBtcAtomic('0.00125907')).not.toMatch(/e/i);
+ });
+});
diff --git a/backend/src/utils/bitcoin/convertBtcToBtcAtomic.ts b/backend/src/utils/bitcoin/convertBtcToBtcAtomic.ts
new file mode 100644
index 0000000..4a72cab
--- /dev/null
+++ b/backend/src/utils/bitcoin/convertBtcToBtcAtomic.ts
@@ -0,0 +1,6 @@
+import Decimal from 'decimal.js';
+import { BTC_ATOMIC_PER_BTC } from '../../consts/btcAtomicPerBtc';
+
+export const convertBtcToBtcAtomic = (amountBtc: string): string => {
+ return new Decimal(amountBtc).mul(BTC_ATOMIC_PER_BTC).toDecimalPlaces(0, Decimal.ROUND_HALF_UP).toString();
+};
diff --git a/backend/src/utils/bitcoin/convertFiatToBtc.spec.ts b/backend/src/utils/bitcoin/convertFiatToBtc.spec.ts
new file mode 100644
index 0000000..9c0041c
--- /dev/null
+++ b/backend/src/utils/bitcoin/convertFiatToBtc.spec.ts
@@ -0,0 +1,11 @@
+import { convertFiatToBtc } from './convertFiatToBtc';
+
+describe('convertFiatToBtc', () => {
+ it('formats small amounts without scientific notation', () => {
+ expect(convertFiatToBtc(0.01, 100_000)).toBe('0.00000010');
+ });
+
+ it('converts fiat to BTC at the shop rate', () => {
+ expect(convertFiatToBtc(50_000, 100_000)).toBe('0.50000000');
+ });
+});
diff --git a/backend/src/utils/bitcoin/convertFiatToBtc.ts b/backend/src/utils/bitcoin/convertFiatToBtc.ts
new file mode 100644
index 0000000..dfe38cb
--- /dev/null
+++ b/backend/src/utils/bitcoin/convertFiatToBtc.ts
@@ -0,0 +1,5 @@
+import Decimal from 'decimal.js';
+
+export const convertFiatToBtc = (fiatAmount: number, fiatPerBtc: number): string => {
+ return new Decimal(fiatAmount).div(fiatPerBtc).toDecimalPlaces(8, Decimal.ROUND_HALF_UP).toFixed(8);
+};
From a7282a18889de8266487e375443b2808479c3145 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 00:28:17 +0200
Subject: [PATCH 05/47] refactor confirmation tiers to shared crypto-agnostic
types and validation
---
backend/src/config/index.ts | 4 ++--
backend/src/config/validate.ts | 4 ++--
.../payment/services/InvoiceService.ts | 2 +-
.../types/ShopSettingsMoneroView.ts | 4 ++--
backend/src/types/BitcoinConfirmationTier.ts | 4 ----
backend/src/types/Config.ts | 4 ++--
...onfirmationTier.ts => ConfirmationTier.ts} | 2 +-
.../resolveMinConfirmations.spec.ts | 0
.../resolveMinConfirmations.ts | 4 ++--
...rs.spec.ts => isConfirmationTiers.spec.ts} | 10 +++++-----
...rmationTiers.ts => isConfirmationTiers.ts} | 20 +++++++++----------
11 files changed, 27 insertions(+), 31 deletions(-)
delete mode 100644 backend/src/types/BitcoinConfirmationTier.ts
rename backend/src/types/{MoneroConfirmationTier.ts => ConfirmationTier.ts} (58%)
rename backend/src/utils/{monero => confirmation}/resolveMinConfirmations.spec.ts (100%)
rename backend/src/utils/{monero => confirmation}/resolveMinConfirmations.ts (76%)
rename backend/src/validation/decorators/{isMoneroConfirmationTiers.spec.ts => isConfirmationTiers.spec.ts} (91%)
rename backend/src/validation/decorators/{isMoneroConfirmationTiers.ts => isConfirmationTiers.ts} (65%)
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index 5c90218..a785cf7 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -12,7 +12,7 @@ import {
} from '../types/Config';
import { MoneroNetwork } from '../types/MoneroNetwork';
import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
-import { MoneroConfirmationTier } from '../types/MoneroConfirmationTier';
+import { ConfirmationTier } from '../types/ConfirmationTier';
import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
@@ -178,7 +178,7 @@ export const getShopSettingsConfig = (): ShopSettingsConfig => ({
shopName: env('SHOP_NAME'),
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
monero: {
- confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as MoneroConfirmationTier[]
+ confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as ConfirmationTier[]
}
});
diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts
index ba4eb57..5663dc0 100644
--- a/backend/src/config/validate.ts
+++ b/backend/src/config/validate.ts
@@ -3,7 +3,7 @@ import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSy
import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { IsBase64 } from '../validation/decorators/isBase64';
-import { IsMoneroConfirmationTiers } from '../validation/decorators/isMoneroConfirmationTiers';
+import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers';
import { MoneroNetwork } from '../types/MoneroNetwork';
class EnvironmentVariables {
@@ -265,7 +265,7 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsString()
- @IsMoneroConfirmationTiers()
+ @IsConfirmationTiers()
MONERO_CONFIRMATION_TIERS: string;
@IsNotEmpty()
diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts
index 79614ed..c916ca2 100644
--- a/backend/src/modules/payment/services/InvoiceService.ts
+++ b/backend/src/modules/payment/services/InvoiceService.ts
@@ -7,7 +7,7 @@ import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
-import { resolveMinConfirmations } from '../../../utils/monero/resolveMinConfirmations';
+import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
diff --git a/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts b/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts
index e6b7c6d..5dc253b 100644
--- a/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts
+++ b/backend/src/modules/shopSettings/types/ShopSettingsMoneroView.ts
@@ -1,5 +1,5 @@
-import { MoneroConfirmationTier } from '../../../types/MoneroConfirmationTier';
+import { ConfirmationTier } from '../../../types/ConfirmationTier';
export interface ShopSettingsMoneroView {
- confirmationTiers: MoneroConfirmationTier[];
+ confirmationTiers: ConfirmationTier[];
}
diff --git a/backend/src/types/BitcoinConfirmationTier.ts b/backend/src/types/BitcoinConfirmationTier.ts
deleted file mode 100644
index c84cff6..0000000
--- a/backend/src/types/BitcoinConfirmationTier.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export interface BitcoinConfirmationTier {
- upToTotalFiat?: string;
- minConfirmations: number;
-}
diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts
index a9f940c..24248cb 100644
--- a/backend/src/types/Config.ts
+++ b/backend/src/types/Config.ts
@@ -1,4 +1,4 @@
-import { MoneroConfirmationTier } from './MoneroConfirmationTier';
+import { ConfirmationTier } from './ConfirmationTier';
import { MoneroWalletConfig } from './MoneroWalletConfig';
import { NodeEnv } from './NodeEnv';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
@@ -55,7 +55,7 @@ export interface ShopSettingsConfig {
shopName: string;
shopFiatCurrency: ShopFiatCurrency;
monero: {
- confirmationTiers: MoneroConfirmationTier[];
+ confirmationTiers: ConfirmationTier[];
};
}
diff --git a/backend/src/types/MoneroConfirmationTier.ts b/backend/src/types/ConfirmationTier.ts
similarity index 58%
rename from backend/src/types/MoneroConfirmationTier.ts
rename to backend/src/types/ConfirmationTier.ts
index 776773d..42926bc 100644
--- a/backend/src/types/MoneroConfirmationTier.ts
+++ b/backend/src/types/ConfirmationTier.ts
@@ -1,4 +1,4 @@
-export interface MoneroConfirmationTier {
+export interface ConfirmationTier {
upToTotalFiat?: string;
minConfirmations: number;
}
diff --git a/backend/src/utils/monero/resolveMinConfirmations.spec.ts b/backend/src/utils/confirmation/resolveMinConfirmations.spec.ts
similarity index 100%
rename from backend/src/utils/monero/resolveMinConfirmations.spec.ts
rename to backend/src/utils/confirmation/resolveMinConfirmations.spec.ts
diff --git a/backend/src/utils/monero/resolveMinConfirmations.ts b/backend/src/utils/confirmation/resolveMinConfirmations.ts
similarity index 76%
rename from backend/src/utils/monero/resolveMinConfirmations.ts
rename to backend/src/utils/confirmation/resolveMinConfirmations.ts
index b423736..bbfc2f3 100644
--- a/backend/src/utils/monero/resolveMinConfirmations.ts
+++ b/backend/src/utils/confirmation/resolveMinConfirmations.ts
@@ -1,7 +1,7 @@
import Decimal from 'decimal.js';
-import type { MoneroConfirmationTier } from '../../types/MoneroConfirmationTier';
+import type { ConfirmationTier } from '../../types/ConfirmationTier';
-export const resolveMinConfirmations = (totalFiat: number, tiers: MoneroConfirmationTier[]): number => {
+export const resolveMinConfirmations = (totalFiat: number, tiers: ConfirmationTier[]): number => {
for (const tier of tiers) {
if (tier.upToTotalFiat === undefined) {
return tier.minConfirmations;
diff --git a/backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts b/backend/src/validation/decorators/isConfirmationTiers.spec.ts
similarity index 91%
rename from backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts
rename to backend/src/validation/decorators/isConfirmationTiers.spec.ts
index 307da47..b755d0d 100644
--- a/backend/src/validation/decorators/isMoneroConfirmationTiers.spec.ts
+++ b/backend/src/validation/decorators/isConfirmationTiers.spec.ts
@@ -1,13 +1,13 @@
import { validateSync } from 'class-validator';
-import { IsMoneroConfirmationTiers } from './isMoneroConfirmationTiers';
+import { IsConfirmationTiers } from './isConfirmationTiers';
class TestDto {
- @IsMoneroConfirmationTiers()
- MONERO_CONFIRMATION_TIERS: string;
+ @IsConfirmationTiers()
+ CONFIRMATION_TIERS: string;
}
const validateTiers = (value: string) => {
- const dto = Object.assign(new TestDto(), { MONERO_CONFIRMATION_TIERS: value });
+ const dto = Object.assign(new TestDto(), { CONFIRMATION_TIERS: value });
return validateSync(dto);
};
@@ -15,7 +15,7 @@ const validateTiers = (value: string) => {
const validTiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
-describe('IsMoneroConfirmationTiers', () => {
+describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => {
expect(validateTiers(validTiers)).toHaveLength(0);
});
diff --git a/backend/src/validation/decorators/isMoneroConfirmationTiers.ts b/backend/src/validation/decorators/isConfirmationTiers.ts
similarity index 65%
rename from backend/src/validation/decorators/isMoneroConfirmationTiers.ts
rename to backend/src/validation/decorators/isConfirmationTiers.ts
index 5a09a10..7b1d02f 100644
--- a/backend/src/validation/decorators/isMoneroConfirmationTiers.ts
+++ b/backend/src/validation/decorators/isConfirmationTiers.ts
@@ -1,5 +1,5 @@
import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
-import type { MoneroConfirmationTier } from '../../types/MoneroConfirmationTier';
+import type { ConfirmationTier } from '../../types/ConfirmationTier';
const isPositiveDecimalString = (value: string): boolean => {
const trimmed = value.trim();
@@ -16,12 +16,12 @@ const isPositiveDecimalString = (value: string): boolean => {
const isMinConfirmations = (value: unknown): boolean =>
typeof value === 'number' && Number.isInteger(value) && value >= 0;
-const isMoneroConfirmationTier = (value: unknown): value is MoneroConfirmationTier => {
+const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
if (typeof value !== 'object' || value === null) {
return false;
}
- const tier = value as MoneroConfirmationTier;
+ const tier = value as ConfirmationTier;
if (!isMinConfirmations(tier.minConfirmations)) {
return false;
@@ -34,7 +34,7 @@ const isMoneroConfirmationTier = (value: unknown): value is MoneroConfirmationTi
return typeof tier.upToTotalFiat === 'string' && isPositiveDecimalString(tier.upToTotalFiat);
};
-const isValidMoneroConfirmationTiersJson = (raw: string): boolean => {
+const isValidConfirmationTiersJson = (raw: string): boolean => {
let parsed: unknown;
try {
@@ -43,7 +43,7 @@ const isValidMoneroConfirmationTiersJson = (raw: string): boolean => {
return false;
}
- if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isMoneroConfirmationTier)) {
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every(isConfirmationTier)) {
return false;
}
@@ -75,19 +75,19 @@ const isValidMoneroConfirmationTiersJson = (raw: string): boolean => {
return true;
};
-@ValidatorConstraint({ name: 'isMoneroConfirmationTiers' })
-class IsMoneroConfirmationTiersConstraint implements ValidatorConstraintInterface {
+@ValidatorConstraint({ name: 'isConfirmationTiers' })
+class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string' || !value) {
return false;
}
- return isValidMoneroConfirmationTiersJson(value);
+ return isValidConfirmationTiersJson(value);
}
defaultMessage(): string {
- return '$property must be a non-empty JSON array of Monero confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
+ return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
}
}
-export const IsMoneroConfirmationTiers = () => Validate(IsMoneroConfirmationTiersConstraint);
+export const IsConfirmationTiers = () => Validate(IsConfirmationTiersConstraint);
From 11c94c7834046d53a2e65edaf3d2fba8ea1b6121 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 01:00:07 +0200
Subject: [PATCH 06/47] add bitcoin shop config and payment method enablement
---
.env.example | 8 +++--
backend/src/config/index.ts | 7 +++-
backend/src/config/validate.ts | 17 +++++++++
.../services/ShopSettingsService.ts | 5 ++-
.../types/ShopSettingsBitcoinView.ts | 5 +++
.../shopSettings/types/ShopSettingsView.ts | 2 ++
backend/src/types/Config.ts | 4 +++
.../utils/payment/isPaymentMethodEnabled.ts | 6 ++++
.../src/utils/payment/isPaymentMethodValue.ts | 5 +++
.../parseEnabledPaymentMethods.spec.ts | 25 +++++++++++++
.../payment/parseEnabledPaymentMethods.ts | 12 +++++++
.../isEnabledPaymentMethods.spec.ts | 35 +++++++++++++++++++
.../decorators/isEnabledPaymentMethods.ts | 32 +++++++++++++++++
.../shopSettings/BitcoinConfirmationTier.ts | 4 +++
cms/src/types/shopSettings/ShopSettings.ts | 2 ++
.../types/shopSettings/ShopSettingsBitcoin.ts | 5 +++
16 files changed, 170 insertions(+), 4 deletions(-)
create mode 100644 backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts
create mode 100644 backend/src/utils/payment/isPaymentMethodEnabled.ts
create mode 100644 backend/src/utils/payment/isPaymentMethodValue.ts
create mode 100644 backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts
create mode 100644 backend/src/utils/payment/parseEnabledPaymentMethods.ts
create mode 100644 backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts
create mode 100644 backend/src/validation/decorators/isEnabledPaymentMethods.ts
create mode 100644 cms/src/types/shopSettings/BitcoinConfirmationTier.ts
create mode 100644 cms/src/types/shopSettings/ShopSettingsBitcoin.ts
diff --git a/.env.example b/.env.example
index c9ee101..132d859 100644
--- a/.env.example
+++ b/.env.example
@@ -87,6 +87,8 @@ COINPAPRIKA_RATE_FETCH_TIMEOUT_MS=5000
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32
+PAYMENT_METHODS_ENABLED=xmr,btc
+
MONERO_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
MONERO_VERSION=0.18.3.4
MONERO_NETWORK=stagenet
@@ -99,6 +101,10 @@ MONERO_WALLET_RPC_TIMEOUT_MS=10000
MONERO_WALLET_DIR=./monero-wallet-rpc/wallet
MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me
+MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
+
+BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]'
+BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
SIMPLEX_CHAT_VERSION=v6.5.6
SIMPLEX_WS_URL=ws://simplex-cli:5225
@@ -110,8 +116,6 @@ ORDER_CHECKOUT_STATUS_REFRESH_SEC=15
ORDER_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours
ORDER_DATA_RETENTION_DAYS=30
-MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR
-
VITE_API_BASE_URL=http://localhost:3000/api
VITE_SHOP_FIAT_CURRENCY=USD
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index a785cf7..b844f67 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -17,6 +17,7 @@ import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
import { SimplexConfig } from '../types/SimplexConfig';
+import { parseEnabledPaymentMethods } from '../utils/payment/parseEnabledPaymentMethods';
const env = (key: string): string => process.env[key] || '';
@@ -177,8 +178,12 @@ export const getEncryptionConfig = (): EncryptionConfig => ({
export const getShopSettingsConfig = (): ShopSettingsConfig => ({
shopName: env('SHOP_NAME'),
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
+ enabledPaymentMethods: parseEnabledPaymentMethods(env('PAYMENT_METHODS_ENABLED')),
monero: {
confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as ConfirmationTier[]
+ },
+ bitcoin: {
+ confirmationTiers: JSON.parse(env('BITCOIN_CONFIRMATION_TIERS')) as ConfirmationTier[]
}
});
@@ -192,7 +197,7 @@ export const getOrderConfig = (): OrderConfig => ({
export const getInvoiceConfig = (): InvoiceConfig => ({
minByMethod: {
[PaymentMethod.Xmr]: String(envInt('MONERO_MIN_INCOMING_ATOMIC')),
- [PaymentMethod.Btc]: String(envInt('BTC_MIN_INCOMING_ATOMIC'))
+ [PaymentMethod.Btc]: String(envInt('BITCOIN_MIN_INCOMING_ATOMIC'))
}
});
diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts
index 5663dc0..ea802cf 100644
--- a/backend/src/config/validate.ts
+++ b/backend/src/config/validate.ts
@@ -4,6 +4,7 @@ import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { IsBase64 } from '../validation/decorators/isBase64';
import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers';
+import { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods';
import { MoneroNetwork } from '../types/MoneroNetwork';
class EnvironmentVariables {
@@ -268,6 +269,16 @@ class EnvironmentVariables {
@IsConfirmationTiers()
MONERO_CONFIRMATION_TIERS: string;
+ @IsNotEmpty()
+ @IsString()
+ @IsConfirmationTiers()
+ BITCOIN_CONFIRMATION_TIERS: string;
+
+ @IsNotEmpty()
+ @IsString()
+ @IsEnabledPaymentMethods()
+ PAYMENT_METHODS_ENABLED: string;
+
@IsNotEmpty()
@IsNumber()
@Min(60000)
@@ -294,6 +305,12 @@ class EnvironmentVariables {
@Max(Number.MAX_SAFE_INTEGER)
MONERO_MIN_INCOMING_ATOMIC: number;
+ @IsNotEmpty()
+ @IsNumber()
+ @Min(0)
+ @Max(Number.MAX_SAFE_INTEGER)
+ BITCOIN_MIN_INCOMING_ATOMIC: number;
+
@IsNotEmpty()
@IsString()
MONERO_DAEMON_ADDRESS: string;
diff --git a/backend/src/modules/shopSettings/services/ShopSettingsService.ts b/backend/src/modules/shopSettings/services/ShopSettingsService.ts
index 8df7153..293e154 100644
--- a/backend/src/modules/shopSettings/services/ShopSettingsService.ts
+++ b/backend/src/modules/shopSettings/services/ShopSettingsService.ts
@@ -196,7 +196,9 @@ export class ShopSettingsService {
}: ShopSettings): ShopSettingsView {
const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote });
- const { shopName, shopFiatCurrency, monero } = this.configService.get('shopSettings') as Config['shopSettings'];
+ const { shopName, shopFiatCurrency, monero, bitcoin } = this.configService.get(
+ 'shopSettings'
+ ) as Config['shopSettings'];
const logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null;
const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null;
@@ -208,6 +210,7 @@ export class ShopSettingsService {
shopName,
shopFiatCurrency,
monero,
+ bitcoin,
logoUrl,
faviconUrl,
simplexLink,
diff --git a/backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts b/backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts
new file mode 100644
index 0000000..5579930
--- /dev/null
+++ b/backend/src/modules/shopSettings/types/ShopSettingsBitcoinView.ts
@@ -0,0 +1,5 @@
+import { ConfirmationTier } from '../../../types/ConfirmationTier';
+
+export interface ShopSettingsBitcoinView {
+ confirmationTiers: ConfirmationTier[];
+}
diff --git a/backend/src/modules/shopSettings/types/ShopSettingsView.ts b/backend/src/modules/shopSettings/types/ShopSettingsView.ts
index 6e4cfef..9c889de 100644
--- a/backend/src/modules/shopSettings/types/ShopSettingsView.ts
+++ b/backend/src/modules/shopSettings/types/ShopSettingsView.ts
@@ -1,12 +1,14 @@
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
import { SetupChecklist } from './SetupChecklist';
import { ShopSettingsMoneroView } from './ShopSettingsMoneroView';
+import { ShopSettingsBitcoinView } from './ShopSettingsBitcoinView';
export interface ShopSettingsView {
id: string | null;
shopName: string;
shopFiatCurrency: ShopFiatCurrency;
monero: ShopSettingsMoneroView;
+ bitcoin: ShopSettingsBitcoinView;
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts
index 24248cb..66f5584 100644
--- a/backend/src/types/Config.ts
+++ b/backend/src/types/Config.ts
@@ -54,9 +54,13 @@ export interface AppConfig {
export interface ShopSettingsConfig {
shopName: string;
shopFiatCurrency: ShopFiatCurrency;
+ enabledPaymentMethods: PaymentMethod[];
monero: {
confirmationTiers: ConfirmationTier[];
};
+ bitcoin: {
+ confirmationTiers: ConfirmationTier[];
+ };
}
export interface JwtConfig {
diff --git a/backend/src/utils/payment/isPaymentMethodEnabled.ts b/backend/src/utils/payment/isPaymentMethodEnabled.ts
new file mode 100644
index 0000000..d9fcd4a
--- /dev/null
+++ b/backend/src/utils/payment/isPaymentMethodEnabled.ts
@@ -0,0 +1,6 @@
+import type { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
+
+export const isPaymentMethodEnabled = (
+ method: PaymentMethod,
+ enabledMethods: readonly PaymentMethod[]
+): boolean => enabledMethods.includes(method);
diff --git a/backend/src/utils/payment/isPaymentMethodValue.ts b/backend/src/utils/payment/isPaymentMethodValue.ts
new file mode 100644
index 0000000..900832b
--- /dev/null
+++ b/backend/src/utils/payment/isPaymentMethodValue.ts
@@ -0,0 +1,5 @@
+import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
+
+const PAYMENT_METHOD_VALUES = new Set(Object.values(PaymentMethod));
+
+export const isPaymentMethodValue = (value: string): value is PaymentMethod => PAYMENT_METHOD_VALUES.has(value);
diff --git a/backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts b/backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts
new file mode 100644
index 0000000..d651ed2
--- /dev/null
+++ b/backend/src/utils/payment/parseEnabledPaymentMethods.spec.ts
@@ -0,0 +1,25 @@
+import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
+import { parseEnabledPaymentMethods } from './parseEnabledPaymentMethods';
+
+describe('parseEnabledPaymentMethods', () => {
+ it('parses a single enabled method', () => {
+ expect(parseEnabledPaymentMethods('xmr')).toEqual([PaymentMethod.Xmr]);
+ });
+
+ it('parses multiple enabled methods', () => {
+ expect(parseEnabledPaymentMethods('xmr, btc')).toEqual([PaymentMethod.Xmr, PaymentMethod.Btc]);
+ });
+
+ it('returns an empty array for empty input', () => {
+ expect(parseEnabledPaymentMethods('')).toEqual([]);
+ });
+
+ it('filters out unsupported methods', () => {
+ expect(parseEnabledPaymentMethods('eth')).toEqual([]);
+ expect(parseEnabledPaymentMethods('xmr,eth')).toEqual([PaymentMethod.Xmr]);
+ });
+
+ it('filters out duplicate methods', () => {
+ expect(parseEnabledPaymentMethods('xmr,xmr')).toEqual([PaymentMethod.Xmr]);
+ });
+});
diff --git a/backend/src/utils/payment/parseEnabledPaymentMethods.ts b/backend/src/utils/payment/parseEnabledPaymentMethods.ts
new file mode 100644
index 0000000..3753e58
--- /dev/null
+++ b/backend/src/utils/payment/parseEnabledPaymentMethods.ts
@@ -0,0 +1,12 @@
+import type { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
+import { isPaymentMethodValue } from './isPaymentMethodValue';
+
+export const parseEnabledPaymentMethods = (raw: string): PaymentMethod[] => {
+ const methods = raw
+ .split(',')
+ .map(value => value.trim())
+ .filter(Boolean)
+ .filter(isPaymentMethodValue);
+
+ return [...new Set(methods)];
+};
diff --git a/backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts b/backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts
new file mode 100644
index 0000000..fb7c7fd
--- /dev/null
+++ b/backend/src/validation/decorators/isEnabledPaymentMethods.spec.ts
@@ -0,0 +1,35 @@
+import { validateSync } from 'class-validator';
+import { IsEnabledPaymentMethods } from './isEnabledPaymentMethods';
+
+class TestDto {
+ @IsEnabledPaymentMethods()
+ PAYMENT_METHODS_ENABLED: string;
+}
+
+const validateMethods = (value: string) => {
+ const dto = Object.assign(new TestDto(), { PAYMENT_METHODS_ENABLED: value });
+
+ return validateSync(dto);
+};
+
+describe('IsEnabledPaymentMethods', () => {
+ it('accepts xmr only', () => {
+ expect(validateMethods('xmr')).toHaveLength(0);
+ });
+
+ it('accepts xmr and btc', () => {
+ expect(validateMethods('xmr,btc')).toHaveLength(0);
+ });
+
+ it('rejects empty string', () => {
+ expect(validateMethods('').length).toBeGreaterThan(0);
+ });
+
+ it('rejects unsupported methods', () => {
+ expect(validateMethods('eth').length).toBeGreaterThan(0);
+ });
+
+ it('rejects duplicate methods', () => {
+ expect(validateMethods('xmr,xmr').length).toBeGreaterThan(0);
+ });
+});
diff --git a/backend/src/validation/decorators/isEnabledPaymentMethods.ts b/backend/src/validation/decorators/isEnabledPaymentMethods.ts
new file mode 100644
index 0000000..7d2de7d
--- /dev/null
+++ b/backend/src/validation/decorators/isEnabledPaymentMethods.ts
@@ -0,0 +1,32 @@
+import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
+import { isPaymentMethodValue } from '../../utils/payment/isPaymentMethodValue';
+
+@ValidatorConstraint({ name: 'isEnabledPaymentMethods' })
+class IsEnabledPaymentMethodsConstraint implements ValidatorConstraintInterface {
+ validate(value: unknown): boolean {
+ if (typeof value !== 'string' || !value.trim()) {
+ return false;
+ }
+
+ const tokens = value
+ .split(',')
+ .map(token => token.trim())
+ .filter(Boolean);
+
+ if (tokens.length === 0) {
+ return false;
+ }
+
+ if (new Set(tokens).size !== tokens.length) {
+ return false;
+ }
+
+ return tokens.every(isPaymentMethodValue);
+ }
+
+ defaultMessage(): string {
+ return '$property must be a comma-separated list of supported payment methods without duplicates';
+ }
+}
+
+export const IsEnabledPaymentMethods = () => Validate(IsEnabledPaymentMethodsConstraint);
diff --git a/cms/src/types/shopSettings/BitcoinConfirmationTier.ts b/cms/src/types/shopSettings/BitcoinConfirmationTier.ts
new file mode 100644
index 0000000..c84cff6
--- /dev/null
+++ b/cms/src/types/shopSettings/BitcoinConfirmationTier.ts
@@ -0,0 +1,4 @@
+export interface BitcoinConfirmationTier {
+ upToTotalFiat?: string;
+ minConfirmations: number;
+}
diff --git a/cms/src/types/shopSettings/ShopSettings.ts b/cms/src/types/shopSettings/ShopSettings.ts
index 2e41c52..c68dc5b 100644
--- a/cms/src/types/shopSettings/ShopSettings.ts
+++ b/cms/src/types/shopSettings/ShopSettings.ts
@@ -1,3 +1,4 @@
+import type { ShopSettingsBitcoin } from './ShopSettingsBitcoin';
import type { ShopSettingsMonero } from './ShopSettingsMonero';
import type { SetupChecklist } from './SetupChecklist';
@@ -6,6 +7,7 @@ export interface ShopSettings {
shopName: string;
shopFiatCurrency: string;
monero: ShopSettingsMonero;
+ bitcoin: ShopSettingsBitcoin;
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
diff --git a/cms/src/types/shopSettings/ShopSettingsBitcoin.ts b/cms/src/types/shopSettings/ShopSettingsBitcoin.ts
new file mode 100644
index 0000000..873922b
--- /dev/null
+++ b/cms/src/types/shopSettings/ShopSettingsBitcoin.ts
@@ -0,0 +1,5 @@
+import type { BitcoinConfirmationTier } from './BitcoinConfirmationTier';
+
+export interface ShopSettingsBitcoin {
+ confirmationTiers: BitcoinConfirmationTier[];
+}
From 947dd23dd588be56383e06d0cbd2a57e6c0469f0 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 11:05:44 +0200
Subject: [PATCH 07/47] add btc details invoice relation
---
.../1784800000000-add-btc-invoice-details.ts | 20 ++++++++++++++++
backend/src/modules/payment/PaymentModule.ts | 3 ++-
.../src/modules/payment/entities/Invoice.ts | 4 ++++
.../payment/entities/InvoiceBtcDetails.ts | 24 +++++++++++++++++++
cms/src/types/payment/Invoice.ts | 2 ++
cms/src/types/payment/InvoiceBtcDetails.ts | 5 ++++
cms/src/types/payment/PaymentMethod.ts | 6 +++--
7 files changed, 61 insertions(+), 3 deletions(-)
create mode 100644 backend/src/database/migrations/1784800000000-add-btc-invoice-details.ts
create mode 100644 backend/src/modules/payment/entities/InvoiceBtcDetails.ts
create mode 100644 cms/src/types/payment/InvoiceBtcDetails.ts
diff --git a/backend/src/database/migrations/1784800000000-add-btc-invoice-details.ts b/backend/src/database/migrations/1784800000000-add-btc-invoice-details.ts
new file mode 100644
index 0000000..0c46b14
--- /dev/null
+++ b/backend/src/database/migrations/1784800000000-add-btc-invoice-details.ts
@@ -0,0 +1,20 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddBtcInvoiceDetails1784800000000 implements MigrationInterface {
+ name = 'AddBtcInvoiceDetails1784800000000';
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TYPE "public"."invoices_paymentmethod_enum" ADD VALUE 'btc'`);
+ await queryRunner.query(
+ `CREATE TABLE "invoice_btc_details" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fiatPerBtcAtCreation" numeric(12,2) NOT NULL, "requiredConfirmations" integer NOT NULL, "invoiceId" uuid, CONSTRAINT "UQ_invoice_btc_details_invoice_id" UNIQUE ("invoiceId"), CONSTRAINT "PK_invoice_btc_details" PRIMARY KEY ("id"))`
+ );
+ await queryRunner.query(
+ `ALTER TABLE "invoice_btc_details" ADD CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE CASCADE ON UPDATE NO ACTION`
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "invoice_btc_details" DROP CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d"`);
+ await queryRunner.query(`DROP TABLE "invoice_btc_details"`);
+ }
+}
diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts
index 55681cd..01f931f 100644
--- a/backend/src/modules/payment/PaymentModule.ts
+++ b/backend/src/modules/payment/PaymentModule.ts
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice';
+import { InvoiceBtcDetails } from './entities/InvoiceBtcDetails';
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
import { InvoicePayment } from './entities/InvoicePayment';
import { InvoicePaymentService } from './services/InvoicePaymentService';
@@ -10,7 +11,7 @@ import { InvoiceService } from './services/InvoiceService';
@Module({
imports: [
- TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
+ TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule,
ExchangeRateModule
],
diff --git a/backend/src/modules/payment/entities/Invoice.ts b/backend/src/modules/payment/entities/Invoice.ts
index f29eec4..117b61a 100644
--- a/backend/src/modules/payment/entities/Invoice.ts
+++ b/backend/src/modules/payment/entities/Invoice.ts
@@ -3,6 +3,7 @@ import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer'
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
import { PaymentMethod } from '../types/PaymentMethod';
import { InvoiceReason } from '../types/InvoiceReason';
+import { InvoiceBtcDetails } from './InvoiceBtcDetails';
import { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
import { InvoicePayment } from './InvoicePayment';
@@ -43,6 +44,9 @@ export class Invoice {
@OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true })
moneroDetails: InvoiceMoneroDetails | null;
+ @OneToOne(() => InvoiceBtcDetails, btcDetails => btcDetails.invoice, { cascade: true })
+ btcDetails: InvoiceBtcDetails | null;
+
@CreateDateColumn()
createdAt: Date;
}
diff --git a/backend/src/modules/payment/entities/InvoiceBtcDetails.ts b/backend/src/modules/payment/entities/InvoiceBtcDetails.ts
new file mode 100644
index 0000000..86fd836
--- /dev/null
+++ b/backend/src/modules/payment/entities/InvoiceBtcDetails.ts
@@ -0,0 +1,24 @@
+import { Column, Entity, JoinColumn, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
+import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
+import { Invoice } from './Invoice';
+
+@Entity('invoice_btc_details')
+export class InvoiceBtcDetails {
+ @PrimaryGeneratedColumn('uuid')
+ id: string;
+
+ @OneToOne(() => Invoice, invoice => invoice.btcDetails, { onDelete: 'CASCADE' })
+ @JoinColumn()
+ invoice: Invoice;
+
+ @Column({
+ type: 'numeric',
+ precision: 12,
+ scale: 2,
+ transformer: new ColumnNumericTransformer()
+ })
+ fiatPerBtcAtCreation: number;
+
+ @Column({ type: 'int' })
+ requiredConfirmations: number;
+}
diff --git a/cms/src/types/payment/Invoice.ts b/cms/src/types/payment/Invoice.ts
index 03c0f3c..ad95ba0 100644
--- a/cms/src/types/payment/Invoice.ts
+++ b/cms/src/types/payment/Invoice.ts
@@ -1,3 +1,4 @@
+import type { InvoiceBtcDetails } from './InvoiceBtcDetails';
import type { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
import type { InvoicePayment } from './InvoicePayment';
import type { InvoiceReason } from './InvoiceReason';
@@ -14,5 +15,6 @@ export type Invoice = {
expectedTotalAtomic: string;
createdAt: string;
moneroDetails?: InvoiceMoneroDetails | null;
+ btcDetails?: InvoiceBtcDetails | null;
payments?: InvoicePayment[];
};
diff --git a/cms/src/types/payment/InvoiceBtcDetails.ts b/cms/src/types/payment/InvoiceBtcDetails.ts
new file mode 100644
index 0000000..ea57713
--- /dev/null
+++ b/cms/src/types/payment/InvoiceBtcDetails.ts
@@ -0,0 +1,5 @@
+export type InvoiceBtcDetails = {
+ id: string;
+ fiatPerBtcAtCreation: number;
+ requiredConfirmations: number;
+};
diff --git a/cms/src/types/payment/PaymentMethod.ts b/cms/src/types/payment/PaymentMethod.ts
index ca816cc..d4b138b 100644
--- a/cms/src/types/payment/PaymentMethod.ts
+++ b/cms/src/types/payment/PaymentMethod.ts
@@ -1,7 +1,9 @@
export enum PaymentMethod {
- Xmr = 'xmr'
+ Xmr = 'xmr',
+ Btc = 'btc'
}
export const paymentMethodCryptoCurrency: Record = {
- [PaymentMethod.Xmr]: 'XMR'
+ [PaymentMethod.Xmr]: 'XMR',
+ [PaymentMethod.Btc]: 'BTC'
};
From 932cbf64c16e3c784d5777a55d35c30b26eb9643 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 11:39:33 +0200
Subject: [PATCH 08/47] fix derive invoice state spec
---
backend/src/utils/invoice/deriveInvoiceState.spec.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/backend/src/utils/invoice/deriveInvoiceState.spec.ts b/backend/src/utils/invoice/deriveInvoiceState.spec.ts
index ace25ec..30f21b7 100644
--- a/backend/src/utils/invoice/deriveInvoiceState.spec.ts
+++ b/backend/src/utils/invoice/deriveInvoiceState.spec.ts
@@ -110,6 +110,6 @@ describe('deriveInvoiceState', () => {
moneroDetails: undefined,
payments: [{ amountAtomic: '1000', confirmations: 10 }]
})
- ).toThrow('Invoice is missing Monero required confirmations');
+ ).toThrow('Invoice is missing xmr required confirmations');
});
});
From 5c888551db808252016065d2f016dec68c1b888d Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 11:40:57 +0200
Subject: [PATCH 09/47] refactor invoice required confirmations resolver to be
more scalable for future confirmations based coins
---
...getConfirmationBasedInvoiceDetails.spec.ts | 34 +++++++++++++++++++
.../getConfirmationBasedInvoiceDetails.ts | 16 +++++++++
...esolveInvoiceRequiredConfirmations.spec.ts | 24 +++++++++++--
.../resolveInvoiceRequiredConfirmations.ts | 18 ++++------
.../types/ConfirmationBasedInvoiceDetails.ts | 3 ++
.../types/InvoiceConfirmationsInput.ts | 11 +++---
.../utils/invoice/types/InvoiceStateInput.ts | 4 ++-
7 files changed, 89 insertions(+), 21 deletions(-)
create mode 100644 backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.spec.ts
create mode 100644 backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.ts
create mode 100644 backend/src/utils/invoice/types/ConfirmationBasedInvoiceDetails.ts
diff --git a/backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.spec.ts b/backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.spec.ts
new file mode 100644
index 0000000..1b874b2
--- /dev/null
+++ b/backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.spec.ts
@@ -0,0 +1,34 @@
+import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
+import { getConfirmationBasedInvoiceDetails } from './getConfirmationBasedInvoiceDetails';
+
+describe('getConfirmationBasedInvoiceDetails', () => {
+ it('returns monero details for XMR invoices', () => {
+ const moneroDetails = { requiredConfirmations: 3 };
+
+ expect(
+ getConfirmationBasedInvoiceDetails({
+ paymentMethod: PaymentMethod.Xmr,
+ moneroDetails
+ })
+ ).toBe(moneroDetails);
+ });
+
+ it('returns bitcoin details for BTC invoices', () => {
+ const btcDetails = { requiredConfirmations: 6 };
+
+ expect(
+ getConfirmationBasedInvoiceDetails({
+ paymentMethod: PaymentMethod.Btc,
+ btcDetails
+ })
+ ).toBe(btcDetails);
+ });
+
+ it('returns undefined for unsupported payment methods', () => {
+ expect(
+ getConfirmationBasedInvoiceDetails({
+ paymentMethod: 'eth' as PaymentMethod
+ })
+ ).toBeUndefined();
+ });
+});
diff --git a/backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.ts b/backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.ts
new file mode 100644
index 0000000..350a4ce
--- /dev/null
+++ b/backend/src/utils/invoice/getConfirmationBasedInvoiceDetails.ts
@@ -0,0 +1,16 @@
+import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
+import type { ConfirmationBasedInvoiceDetails } from './types/ConfirmationBasedInvoiceDetails';
+import type { InvoiceStateInput } from './types/InvoiceStateInput';
+
+export const getConfirmationBasedInvoiceDetails = (
+ invoice: Pick
+): ConfirmationBasedInvoiceDetails | null | undefined => {
+ switch (invoice.paymentMethod) {
+ case PaymentMethod.Xmr:
+ return invoice.moneroDetails;
+ case PaymentMethod.Btc:
+ return invoice.btcDetails;
+ default:
+ return undefined;
+ }
+};
diff --git a/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts
index ecd77fb..92d997a 100644
--- a/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts
+++ b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.spec.ts
@@ -17,14 +17,32 @@ describe('resolveInvoiceRequiredConfirmations', () => {
paymentMethod: PaymentMethod.Xmr,
moneroDetails: null
})
- ).toThrow('Invoice is missing Monero required confirmations');
+ ).toThrow('Invoice is missing xmr required confirmations');
+ });
+
+ it('returns required confirmations for BTC invoices', () => {
+ expect(
+ resolveInvoiceRequiredConfirmations({
+ paymentMethod: PaymentMethod.Btc,
+ btcDetails: { requiredConfirmations: 6 }
+ })
+ ).toBe(6);
+ });
+
+ it('throws when bitcoin details are missing', () => {
+ expect(() =>
+ resolveInvoiceRequiredConfirmations({
+ paymentMethod: PaymentMethod.Btc,
+ btcDetails: null
+ })
+ ).toThrow('Invoice is missing btc required confirmations');
});
it('throws for unsupported payment methods', () => {
expect(() =>
resolveInvoiceRequiredConfirmations({
- paymentMethod: 'btc' as PaymentMethod
+ paymentMethod: 'eth' as PaymentMethod
})
- ).toThrow('Unsupported payment method: btc');
+ ).toThrow('Invoice is missing eth required confirmations');
});
});
diff --git a/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts
index 0582717..e5d038c 100644
--- a/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts
+++ b/backend/src/utils/invoice/resolveInvoiceRequiredConfirmations.ts
@@ -1,18 +1,14 @@
-import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import type { InvoiceConfirmationsInput } from './types/InvoiceConfirmationsInput';
+import { getConfirmationBasedInvoiceDetails } from './getConfirmationBasedInvoiceDetails';
export const resolveInvoiceRequiredConfirmations = (invoice: InvoiceConfirmationsInput): number => {
- switch (invoice.paymentMethod) {
- case PaymentMethod.Xmr: {
- const requiredConfirmations = invoice.moneroDetails?.requiredConfirmations;
+ const details = getConfirmationBasedInvoiceDetails(invoice);
- if (requiredConfirmations === undefined) {
- throw new Error('Invoice is missing Monero required confirmations');
- }
+ const requiredConfirmations = details?.requiredConfirmations;
- return requiredConfirmations;
- }
- default:
- throw new Error(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
+ if (requiredConfirmations === undefined) {
+ throw new Error(`Invoice is missing ${invoice.paymentMethod} required confirmations`);
}
+
+ return requiredConfirmations;
};
diff --git a/backend/src/utils/invoice/types/ConfirmationBasedInvoiceDetails.ts b/backend/src/utils/invoice/types/ConfirmationBasedInvoiceDetails.ts
new file mode 100644
index 0000000..5507be0
--- /dev/null
+++ b/backend/src/utils/invoice/types/ConfirmationBasedInvoiceDetails.ts
@@ -0,0 +1,3 @@
+export type ConfirmationBasedInvoiceDetails = {
+ requiredConfirmations: number;
+};
diff --git a/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts b/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts
index 886a5a8..f4b4767 100644
--- a/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts
+++ b/backend/src/utils/invoice/types/InvoiceConfirmationsInput.ts
@@ -1,7 +1,6 @@
-import type { PaymentMethod } from '../../../modules/payment/types/PaymentMethod';
+import type { InvoiceStateInput } from './InvoiceStateInput';
-export type InvoiceConfirmationsInput = {
- paymentMethod: PaymentMethod;
- payments?: { confirmations: number }[];
- moneroDetails?: { requiredConfirmations: number } | null;
-};
+export type InvoiceConfirmationsInput = Pick<
+ InvoiceStateInput,
+ 'paymentMethod' | 'moneroDetails' | 'btcDetails' | 'payments'
+>;
diff --git a/backend/src/utils/invoice/types/InvoiceStateInput.ts b/backend/src/utils/invoice/types/InvoiceStateInput.ts
index 3186702..534054b 100644
--- a/backend/src/utils/invoice/types/InvoiceStateInput.ts
+++ b/backend/src/utils/invoice/types/InvoiceStateInput.ts
@@ -1,9 +1,11 @@
import type { PaymentMethod } from '../../../modules/payment/types/PaymentMethod';
+import type { ConfirmationBasedInvoiceDetails } from './ConfirmationBasedInvoiceDetails';
export type InvoiceStateInput = {
paymentMethod: PaymentMethod;
expectedTotalAtomic: string;
expiresAt: Date;
payments?: { confirmations: number; amountAtomic: string }[];
- moneroDetails?: { requiredConfirmations: number } | null;
+ moneroDetails?: ConfirmationBasedInvoiceDetails | null;
+ btcDetails?: ConfirmationBasedInvoiceDetails | null;
};
From 7ee3bcb271ee8f2769ec835eb209f73baf1be917 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 11:46:31 +0200
Subject: [PATCH 10/47] guard against missing shipping invoice
---
backend/src/modules/order/services/OrderService.ts | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/backend/src/modules/order/services/OrderService.ts b/backend/src/modules/order/services/OrderService.ts
index fb8b2eb..abc0a2d 100644
--- a/backend/src/modules/order/services/OrderService.ts
+++ b/backend/src/modules/order/services/OrderService.ts
@@ -250,6 +250,10 @@ export class OrderService {
amountFiat: deliveryCost
});
+ if (!shippingInvoice) {
+ throw new InternalServerErrorException('Failed to issue shipping invoice');
+ }
+
await this.orderRepo.update(orderId, {
shippingInvoice: { id: shippingInvoice.id },
quotedAt
From 9241474d48178a6255862830d243232add912ea4 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 12:41:46 +0200
Subject: [PATCH 11/47] add electrum config
---
.env.example | 12 +++++++++
backend/src/config/index.ts | 12 +++++++++
backend/src/config/validate.ts | 32 +++++++++++++++++++++++
backend/src/types/Config.ts | 2 ++
backend/src/types/ElectrumNetwork.ts | 4 +++
backend/src/types/ElectrumWalletConfig.ts | 10 +++++++
6 files changed, 72 insertions(+)
create mode 100644 backend/src/types/ElectrumNetwork.ts
create mode 100644 backend/src/types/ElectrumWalletConfig.ts
diff --git a/.env.example b/.env.example
index 132d859..eeacfd3 100644
--- a/.env.example
+++ b/.env.example
@@ -106,6 +106,18 @@ MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that mome
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]'
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
+ELECTRUM_VERSION=4.8.1
+ELECTRUM_NETWORK=testnet
+ELECTRUM_SERVER=electrum.blockstream.info:60002:s
+ELECTRUM_DAEMON_HOST=electrum-daemon
+ELECTRUM_DAEMON_PORT=7777
+ELECTRUM_DAEMON_RPC_USER=electrum
+ELECTRUM_DAEMON_RPC_PASSWORD=change-me
+ELECTRUM_DAEMON_RPC_TIMEOUT_MS=10000
+ELECTRUM_WALLET_DIR=./electrum-daemon/wallet
+ELECTRUM_WALLET_NAME=shop
+ELECTRUM_WALLET_PASSWORD=change-me
+
SIMPLEX_CHAT_VERSION=v6.5.6
SIMPLEX_WS_URL=ws://simplex-cli:5225
SIMPLEX_BOT_DISPLAY_NAME=NullCartBot
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index b844f67..afe3c0f 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -10,6 +10,8 @@ import {
PostgresConfig,
ShopSettingsConfig
} from '../types/Config';
+import { ElectrumNetwork } from '../types/ElectrumNetwork';
+import { ElectrumWalletConfig } from '../types/ElectrumWalletConfig';
import { MoneroNetwork } from '../types/MoneroNetwork';
import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
import { ConfirmationTier } from '../types/ConfirmationTier';
@@ -210,6 +212,15 @@ export const getMoneroWalletConfig = (): MoneroWalletConfig => ({
rpcTimeoutMs: envInt('MONERO_WALLET_RPC_TIMEOUT_MS')
});
+export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
+ network: env('ELECTRUM_NETWORK') as ElectrumNetwork,
+ server: env('ELECTRUM_SERVER'),
+ rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
+ username: env('ELECTRUM_DAEMON_RPC_USER'),
+ password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
+ rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
+});
+
export const getSimplexConfig = (): SimplexConfig => ({
wsUrl: env('SIMPLEX_WS_URL'),
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
@@ -226,5 +237,6 @@ export default () => ({
order: getOrderConfig(),
invoice: getInvoiceConfig(),
moneroWallet: getMoneroWalletConfig(),
+ electrumWallet: getElectrumWalletConfig(),
simplex: getSimplexConfig()
});
diff --git a/backend/src/config/validate.ts b/backend/src/config/validate.ts
index ea802cf..438d16e 100644
--- a/backend/src/config/validate.ts
+++ b/backend/src/config/validate.ts
@@ -5,6 +5,7 @@ import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { IsBase64 } from '../validation/decorators/isBase64';
import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers';
import { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods';
+import { ElectrumNetwork } from '../types/ElectrumNetwork';
import { MoneroNetwork } from '../types/MoneroNetwork';
class EnvironmentVariables {
@@ -342,6 +343,37 @@ class EnvironmentVariables {
@Min(1000)
MONERO_WALLET_RPC_TIMEOUT_MS: number;
+ @IsNotEmpty()
+ @IsEnum(ElectrumNetwork)
+ ELECTRUM_NETWORK: ElectrumNetwork;
+
+ @IsNotEmpty()
+ @IsString()
+ ELECTRUM_SERVER: string;
+
+ @IsNotEmpty()
+ @IsString()
+ ELECTRUM_DAEMON_HOST: string;
+
+ @IsNotEmpty()
+ @IsNumber()
+ @Min(1)
+ @Max(65535)
+ ELECTRUM_DAEMON_PORT: number;
+
+ @IsNotEmpty()
+ @IsString()
+ ELECTRUM_DAEMON_RPC_USER: string;
+
+ @IsNotEmpty()
+ @IsString()
+ ELECTRUM_DAEMON_RPC_PASSWORD: string;
+
+ @IsNotEmpty()
+ @IsNumber()
+ @Min(1000)
+ ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
+
@IsNotEmpty()
@IsString()
SIMPLEX_WS_URL: string;
diff --git a/backend/src/types/Config.ts b/backend/src/types/Config.ts
index 66f5584..485891c 100644
--- a/backend/src/types/Config.ts
+++ b/backend/src/types/Config.ts
@@ -1,4 +1,5 @@
import { ConfirmationTier } from './ConfirmationTier';
+import { ElectrumWalletConfig } from './ElectrumWalletConfig';
import { MoneroWalletConfig } from './MoneroWalletConfig';
import { NodeEnv } from './NodeEnv';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
@@ -122,5 +123,6 @@ export interface Config {
order: OrderConfig;
invoice: InvoiceConfig;
moneroWallet: MoneroWalletConfig;
+ electrumWallet: ElectrumWalletConfig;
simplex: SimplexConfig;
}
diff --git a/backend/src/types/ElectrumNetwork.ts b/backend/src/types/ElectrumNetwork.ts
new file mode 100644
index 0000000..7a5d863
--- /dev/null
+++ b/backend/src/types/ElectrumNetwork.ts
@@ -0,0 +1,4 @@
+export enum ElectrumNetwork {
+ Mainnet = 'mainnet',
+ Testnet = 'testnet'
+}
diff --git a/backend/src/types/ElectrumWalletConfig.ts b/backend/src/types/ElectrumWalletConfig.ts
new file mode 100644
index 0000000..da80fe4
--- /dev/null
+++ b/backend/src/types/ElectrumWalletConfig.ts
@@ -0,0 +1,10 @@
+import { ElectrumNetwork } from './ElectrumNetwork';
+
+export interface ElectrumWalletConfig {
+ network: ElectrumNetwork;
+ server: string;
+ rpcUrl: string;
+ username: string;
+ password: string;
+ rpcTimeoutMs: number;
+}
From cde7fab90c3a2d1185d59904087f333d312d88aa Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 23:03:49 +0200
Subject: [PATCH 12/47] move monero wallet creation into container entrypoint
---
Readme.md | 5 +-
deploy/DEPLOYMENT_GUIDE.md | 20 +-
docker-compose.dev.yml | 4 +-
docker-compose.prod.yml | 4 +-
monero-wallet-rpc/Dockerfile | 20 +-
monero-wallet-rpc/docker-entrypoint.sh | 37 ++++
monero-wallet-rpc/setup-monero-wallet.sh | 222 -----------------------
7 files changed, 56 insertions(+), 256 deletions(-)
create mode 100644 monero-wallet-rpc/docker-entrypoint.sh
delete mode 100755 monero-wallet-rpc/setup-monero-wallet.sh
diff --git a/Readme.md b/Readme.md
index a339846..d16ce4c 100644
--- a/Readme.md
+++ b/Readme.md
@@ -1,6 +1,6 @@
# NullCart
-Self-hosted Monero shop with clearnet (HTTPS) and Tor onion hosting.
+Self-hosted crypto shop with clearnet (HTTPS) and Tor onion hosting.
For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md).
@@ -12,9 +12,6 @@ For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md
# Create and edit .env.dev as needed
cp .env.example .env.dev
-# Create monero wallet used by shop
-./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.dev
-
# Build and start docker containers
docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
diff --git a/deploy/DEPLOYMENT_GUIDE.md b/deploy/DEPLOYMENT_GUIDE.md
index 569bf24..798af3a 100644
--- a/deploy/DEPLOYMENT_GUIDE.md
+++ b/deploy/DEPLOYMENT_GUIDE.md
@@ -74,13 +74,7 @@ Example (default in `.env.example`):
Orders up to 30 → 0 confirmations; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings.
-## 5. Create the Monero wallet
-
-```bash
-./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.prod
-```
-
-## 6. Bootstrap TLS certificates
+## 5. Bootstrap TLS certificates
Nginx needs certificate files before it can start on port 443. For the **first** deploy, create a temporary self-signed pair (replaced after Let's Encrypt):
@@ -88,9 +82,9 @@ Nginx needs certificate files before it can start on port 443. For the **first**
./deploy/scripts/bootstrap-certs.sh
```
-After the stack is running, obtain real certificates (step 8).
+After the stack is running, obtain real certificates (step 7).
-## 7. Start the stack
+## 6. Start the stack
```bash
./deploy/scripts/deploy.sh
@@ -102,7 +96,7 @@ Wait until `backend` and `nginx` are healthy:
docker compose --env-file .env.prod -f docker-compose.prod.yml ps
```
-## 8. Issue Let's Encrypt certificates
+## 7. Issue Let's Encrypt certificates
Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot cannot issue into the layout created by `bootstrap-certs.sh`):
@@ -142,20 +136,20 @@ Save and exit the editor. Optional — run once manually to verify:
/root/nullcart/deploy/scripts/renew-certs.sh
```
-## 9. Tor onion address
+## 8. Tor onion address
```bash
./deploy/scripts/show-onion.sh
```
-## 10. Complete shop setup
+## 9. Complete shop setup
1. Open the CMS on clearnet or onion (`/cms`).
2. Log in with `CMS_PASSWORD` from `.env.prod`.
3. Finish the setup checklist in settings.
4. Connect SimpleX notifications in shop settings.
-## 11. Updates
+## 10. Updates
```bash
./deploy/scripts/update.sh
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 3133756..c3e6689 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -50,8 +50,8 @@ services:
]
interval: 10s
timeout: 5s
- retries: 5
- start_period: 60s
+ retries: 10
+ start_period: 300s
simplex-cli:
build:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index f06b87f..bdd8a2c 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -47,8 +47,8 @@ services:
]
interval: 10s
timeout: 5s
- retries: 5
- start_period: 60s
+ retries: 10
+ start_period: 300s
simplex-cli:
build:
diff --git a/monero-wallet-rpc/Dockerfile b/monero-wallet-rpc/Dockerfile
index 0884075..343d639 100644
--- a/monero-wallet-rpc/Dockerfile
+++ b/monero-wallet-rpc/Dockerfile
@@ -13,6 +13,7 @@ RUN apt-get update \
&& curl -fsSL "https://downloads.getmonero.org/cli/monero-linux-${monero_arch}-v${MONERO_VERSION}.tar.bz2" \
| tar -xj -C /tmp \
&& install -m 755 "$(find /tmp -type f -name monero-wallet-rpc | head -n 1)" /monero-wallet-rpc \
+ && install -m 755 "$(find /tmp -type f -name monero-wallet-cli | head -n 1)" /monero-wallet-cli \
&& rm -rf /var/lib/apt/lists/*
FROM debian:bookworm-slim
@@ -22,17 +23,10 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /monero-wallet-rpc /usr/local/bin/monero-wallet-rpc
+COPY --from=build /monero-wallet-cli /usr/local/bin/monero-wallet-cli
-CMD ["/bin/sh", "-ec", "\
- exec monero-wallet-rpc \
- \"--${MONERO_NETWORK}\" \
- --daemon-address=\"${MONERO_DAEMON_ADDRESS}\" \
- --trusted-daemon \
- --no-initial-sync \
- --rpc-bind-ip=0.0.0.0 \
- --rpc-bind-port=${MONERO_WALLET_RPC_PORT} \
- --confirm-external-bind \
- --rpc-login=\"${MONERO_WALLET_RPC_USERNAME}:${MONERO_WALLET_RPC_PASSWORD}\" \
- --wallet-file=\"/monero/wallet/${MONERO_WALLET_NAME}\" \
- --password=\"${MONERO_WALLET_PASSWORD}\" \
-"]
+COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+
+RUN chmod +x /usr/local/bin/docker-entrypoint.sh
+
+ENTRYPOINT ["docker-entrypoint.sh"]
diff --git a/monero-wallet-rpc/docker-entrypoint.sh b/monero-wallet-rpc/docker-entrypoint.sh
new file mode 100644
index 0000000..3a3575e
--- /dev/null
+++ b/monero-wallet-rpc/docker-entrypoint.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+WALLET_DIR="/monero/wallet"
+WALLET_PATH="${WALLET_DIR}/${MONERO_WALLET_NAME}"
+NETWORK_FLAG="--${MONERO_NETWORK}"
+
+mkdir -p "${WALLET_DIR}"
+chmod 700 "${WALLET_DIR}"
+
+if [[ ! -f "${WALLET_PATH}" && ! -f "${WALLET_PATH}.keys" ]]; then
+ echo "Creating ${MONERO_NETWORK} wallet at ${WALLET_PATH}..."
+
+ monero-wallet-cli "${NETWORK_FLAG}" \
+ --offline \
+ --log-file /dev/null \
+ --generate-new-wallet "${WALLET_PATH}" \
+ --password "${MONERO_WALLET_PASSWORD}" \
+ --mnemonic-language English \
+ --command save
+
+ chmod 600 "${WALLET_PATH}" "${WALLET_PATH}.keys" 2>/dev/null || true
+
+ echo "Wallet created at ${WALLET_PATH}"
+fi
+
+exec monero-wallet-rpc \
+ "${NETWORK_FLAG}" \
+ --daemon-address="${MONERO_DAEMON_ADDRESS}" \
+ --trusted-daemon \
+ --no-initial-sync \
+ --rpc-bind-ip=0.0.0.0 \
+ --rpc-bind-port="${MONERO_WALLET_RPC_PORT}" \
+ --confirm-external-bind \
+ --rpc-login="${MONERO_WALLET_RPC_USERNAME}:${MONERO_WALLET_RPC_PASSWORD}" \
+ --wallet-file="${WALLET_PATH}" \
+ --password="${MONERO_WALLET_PASSWORD}"
diff --git a/monero-wallet-rpc/setup-monero-wallet.sh b/monero-wallet-rpc/setup-monero-wallet.sh
deleted file mode 100755
index 0b44e0a..0000000
--- a/monero-wallet-rpc/setup-monero-wallet.sh
+++ /dev/null
@@ -1,222 +0,0 @@
-#!/usr/bin/env bash
-set -euo pipefail
-
-ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
-ENV_FILE=""
-MONERO_CLI_INSTALL_PATH="/usr/local/bin/monero-wallet-cli"
-
-usage() {
- cat <&2
- usage >&2
- exit 1
- ;;
- esac
-done
-
-if [[ -z "$ENV_FILE" ]]; then
- echo "--env-file is required." >&2
- usage >&2
- exit 1
-fi
-
-if [[ "$ENV_FILE" != /* ]]; then
- ENV_FILE="${ROOT_DIR}/${ENV_FILE#./}"
-fi
-
-if [[ ! -f "$ENV_FILE" ]]; then
- echo "Env file not found: $ENV_FILE" >&2
- exit 1
-fi
-
-set -a
-# shellcheck disable=SC1090
-source "$ENV_FILE"
-set +a
-
-main() {
- validate_env
- ensure_dependencies
- setup_wallet_config
- create_wallet_if_missing
-}
-
-validate_env() {
- local missing=()
-
- for var in MONERO_VERSION MONERO_WALLET_DIR MONERO_WALLET_NAME MONERO_NETWORK MONERO_WALLET_PASSWORD; do
- if [[ -z "${!var:-}" ]]; then
- missing+=("$var")
- fi
- done
-
- if [[ ${#missing[@]} -gt 0 ]]; then
- echo "Missing required env vars in ${ENV_FILE}: ${missing[*]}" >&2
- exit 1
- fi
-
- case "$MONERO_NETWORK" in
- mainnet | stagenet | testnet) ;;
- *)
- echo "MONERO_NETWORK must be mainnet, stagenet, or testnet (got: ${MONERO_NETWORK})." >&2
- exit 1
- ;;
- esac
-}
-
-ensure_dependencies() {
- if ! command -v apt-get >/dev/null 2>&1; then
- echo "apt-get is required. This script supports Ubuntu/Debian only." >&2
- exit 1
- fi
-
- local missing=()
-
- for cmd in curl tar bzip2; do
- if command -v "$cmd" >/dev/null 2>&1; then
- log_skip "$cmd"
- else
- log_install "$cmd"
- missing+=("$cmd")
- fi
- done
-
- if [[ ${#missing[@]} -gt 0 ]]; then
- run_privileged apt-get update -qq
- run_privileged apt-get install -y -qq "${missing[@]}"
- fi
-
- install_monero_wallet_cli_if_missing
-}
-
-install_monero_wallet_cli_if_missing() {
- local arch archive_url tmp_dir extracted_cli
-
- if command -v monero-wallet-cli >/dev/null 2>&1; then
- log_skip "monero-wallet-cli"
- return
- fi
-
- log_install "monero-wallet-cli"
-
- arch="$(uname -m)"
-
- case "$arch" in
- x86_64 | amd64)
- archive_url="https://downloads.getmonero.org/cli/monero-linux-x64-v${MONERO_VERSION}.tar.bz2"
- ;;
- aarch64 | arm64)
- archive_url="https://downloads.getmonero.org/cli/monero-linux-armv8-v${MONERO_VERSION}.tar.bz2"
- ;;
- *)
- echo "Unsupported CPU architecture: ${arch}" >&2
- echo "Install monero-wallet-cli from https://www.getmonero.org/downloads/ and re-run." >&2
- exit 1
- ;;
- esac
-
- tmp_dir="$(mktemp -d)"
- trap "rm -rf '${tmp_dir}'" RETURN
-
- curl -fsSL "$archive_url" | tar -xj -C "$tmp_dir"
- extracted_cli="$(find "$tmp_dir" -type f -name monero-wallet-cli | head -n 1)"
-
- if [[ -z "$extracted_cli" ]]; then
- echo "Could not find monero-wallet-cli in the downloaded archive." >&2
- exit 1
- fi
-
- run_privileged install -m 755 "$extracted_cli" "$MONERO_CLI_INSTALL_PATH"
-}
-
-setup_wallet_config() {
- WALLET_DIR="$(resolve_path "$MONERO_WALLET_DIR")"
- WALLET_PATH="${WALLET_DIR}/${MONERO_WALLET_NAME}"
- NETWORK="$MONERO_NETWORK"
-}
-
-resolve_path() {
- local path="$1"
-
- if [[ "$path" != /* ]]; then
- path="${ROOT_DIR}/${path#./}"
- fi
-
- printf '%s' "$path"
-}
-
-create_wallet_if_missing() {
- mkdir -p "$WALLET_DIR"
- chmod 700 "$WALLET_DIR"
-
- if [[ -f "$WALLET_PATH" || -f "${WALLET_PATH}.keys" ]]; then
- echo "Wallet already exists at ${WALLET_PATH} — skipping creation."
- return
- fi
-
- echo "Creating ${NETWORK} wallet at ${WALLET_PATH}..."
-
- monero-wallet-cli "--${NETWORK}" \
- --offline \
- --log-file /dev/null \
- --generate-new-wallet "$WALLET_PATH" \
- --password "$MONERO_WALLET_PASSWORD" \
- --mnemonic-language English \
- --command save
-
- chmod 600 "${WALLET_PATH}" "${WALLET_PATH}.keys" 2>/dev/null || true
-
- cat </dev/null 2>&1; then
- sudo "$@"
- else
- echo "Root or sudo is required to install missing packages." >&2
- exit 1
- fi
-}
-
-log_skip() {
- echo "Package ${1} already installed — skipping."
-}
-
-log_install() {
- echo "Package ${1} is not installed — installing..."
-}
-
-main
From bfb7b013f93b8f007665eb6c80e7e11d96a7bff8 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 23:13:20 +0200
Subject: [PATCH 13/47] add electrum wallet daemon with container entrypoint
---
.gitignore | 1 +
deploy/DEPLOYMENT_GUIDE.md | 4 ++++
docker-compose.dev.yml | 24 ++++++++++++++++++++
docker-compose.prod.yml | 24 ++++++++++++++++++++
electrum-daemon/Dockerfile | 16 +++++++++++++
electrum-daemon/docker-entrypoint.sh | 34 ++++++++++++++++++++++++++++
6 files changed, 103 insertions(+)
create mode 100644 electrum-daemon/Dockerfile
create mode 100755 electrum-daemon/docker-entrypoint.sh
diff --git a/.gitignore b/.gitignore
index c6696df..d534014 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
/deploy/certbot/www
/monero-wallet-rpc/wallet
+/electrum-daemon/wallet
/backend/node_modules
/backend/dist
diff --git a/deploy/DEPLOYMENT_GUIDE.md b/deploy/DEPLOYMENT_GUIDE.md
index 798af3a..d929aa7 100644
--- a/deploy/DEPLOYMENT_GUIDE.md
+++ b/deploy/DEPLOYMENT_GUIDE.md
@@ -54,6 +54,10 @@ Edit `.env.prod`. Mandatory configuration:
| `MONERO_WALLET_RPC_USERNAME` | strong random username |
| `MONERO_WALLET_RPC_PASSWORD` | strong random password |
| `MONERO_WALLET_PASSWORD` | strong wallet password |
+| `ELECTRUM_NETWORK` | `mainnet` |
+| `ELECTRUM_SERVER` | Electrum peer as `host:port:s` (SSL) or `host:port:t` (TCP), e.g. `electrum.blockstream.info:50002:s` |
+| `ELECTRUM_WALLET_PASSWORD` | strong wallet password |
+| `ELECTRUM_DAEMON_RPC_PASSWORD` | strong random password |
| `VITE_API_BASE_URL` | `/api` |
| `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` |
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index c3e6689..886da56 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -53,6 +53,28 @@ services:
retries: 10
start_period: 300s
+ electrum-daemon:
+ build:
+ context: ./electrum-daemon
+ args:
+ ELECTRUM_VERSION: ${ELECTRUM_VERSION}
+ container_name: ${COMPOSE_PROJECT_NAME}_electrum_daemon
+ restart: unless-stopped
+ env_file:
+ - .env.dev
+ volumes:
+ - ${ELECTRUM_WALLET_DIR}:/electrum/wallet
+ healthcheck:
+ test:
+ [
+ 'CMD-SHELL',
+ 'curl -sf -u "$$ELECTRUM_DAEMON_RPC_USER:$$ELECTRUM_DAEMON_RPC_PASSWORD" -H ''Content-Type: application/json'' -d ''{"jsonrpc":"2.0","id":"health","method":"version","params":[]}'' http://127.0.0.1:$$ELECTRUM_DAEMON_PORT/'
+ ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 90s
+
simplex-cli:
build:
context: ./simplex-cli
@@ -83,6 +105,8 @@ services:
condition: service_healthy
monero-wallet-rpc:
condition: service_healthy
+ electrum-daemon:
+ condition: service_healthy
simplex-cli:
condition: service_healthy
env_file:
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index bdd8a2c..c549a5a 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -50,6 +50,28 @@ services:
retries: 10
start_period: 300s
+ electrum-daemon:
+ build:
+ context: ./electrum-daemon
+ args:
+ ELECTRUM_VERSION: ${ELECTRUM_VERSION}
+ container_name: ${COMPOSE_PROJECT_NAME}_electrum_daemon
+ restart: unless-stopped
+ env_file:
+ - .env.prod
+ volumes:
+ - ${ELECTRUM_WALLET_DIR}:/electrum/wallet
+ healthcheck:
+ test:
+ [
+ 'CMD-SHELL',
+ 'curl -sf -u "$$ELECTRUM_DAEMON_RPC_USER:$$ELECTRUM_DAEMON_RPC_PASSWORD" -H ''Content-Type: application/json'' -d ''{"jsonrpc":"2.0","id":"health","method":"version","params":[]}'' http://127.0.0.1:$$ELECTRUM_DAEMON_PORT/'
+ ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ start_period: 90s
+
simplex-cli:
build:
context: ./simplex-cli
@@ -80,6 +102,8 @@ services:
condition: service_healthy
monero-wallet-rpc:
condition: service_healthy
+ electrum-daemon:
+ condition: service_healthy
simplex-cli:
condition: service_healthy
env_file:
diff --git a/electrum-daemon/Dockerfile b/electrum-daemon/Dockerfile
new file mode 100644
index 0000000..93dd302
--- /dev/null
+++ b/electrum-daemon/Dockerfile
@@ -0,0 +1,16 @@
+FROM debian:bookworm-slim
+
+ARG ELECTRUM_VERSION
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates curl libsecp256k1-1 python3 python3-cryptography \
+ && curl -fsSL "https://download.electrum.org/${ELECTRUM_VERSION}/Electrum-${ELECTRUM_VERSION}.tar.gz" \
+ | tar -xz -C /opt \
+ && chmod +x "/opt/Electrum-${ELECTRUM_VERSION}/run_electrum" \
+ && ln -s "/opt/Electrum-${ELECTRUM_VERSION}/run_electrum" /usr/local/bin/electrum \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
+
+ENTRYPOINT ["docker-entrypoint.sh"]
diff --git a/electrum-daemon/docker-entrypoint.sh b/electrum-daemon/docker-entrypoint.sh
new file mode 100755
index 0000000..f96ec27
--- /dev/null
+++ b/electrum-daemon/docker-entrypoint.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+NETWORK_FLAG="--${ELECTRUM_NETWORK}"
+WALLET_PATH="/electrum/wallet/${ELECTRUM_WALLET_NAME}"
+DATA_DIR="/electrum/data"
+
+run_electrum() { electrum "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
+run_electrum_offline() { electrum --offline "${NETWORK_FLAG}" --dir "${DATA_DIR}" "$@"; }
+
+mkdir -p /electrum/wallet "${DATA_DIR}"
+chmod 700 /electrum/wallet
+
+if [[ ! -f "${WALLET_PATH}" ]]; then
+ echo "Creating ${ELECTRUM_NETWORK} wallet at ${WALLET_PATH}..."
+
+ run_electrum_offline create -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
+ chmod 600 "${WALLET_PATH}" 2>/dev/null || true
+
+ echo "Wallet created at ${WALLET_PATH}"
+fi
+
+run_electrum_offline setconfig rpcuser "${ELECTRUM_DAEMON_RPC_USER}"
+run_electrum_offline setconfig rpcpassword "${ELECTRUM_DAEMON_RPC_PASSWORD}"
+run_electrum_offline setconfig rpchost 0.0.0.0
+run_electrum_offline setconfig rpcport "${ELECTRUM_DAEMON_PORT}"
+run_electrum_offline setconfig server "${ELECTRUM_SERVER}"
+
+trap 'run_electrum daemon stop || true; exit 0' SIGTERM SIGINT
+
+run_electrum daemon -d
+run_electrum load_wallet -w "${WALLET_PATH}" --password "${ELECTRUM_WALLET_PASSWORD}"
+
+sleep infinity
From 1143b8873cfe19efe22c7db517a3b0569ece0870 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Thu, 3 Sep 2026 23:44:54 +0200
Subject: [PATCH 14/47] update deployment guide for bitcoin payments
---
deploy/DEPLOYMENT_GUIDE.md | 10 ++++------
1 file changed, 4 insertions(+), 6 deletions(-)
diff --git a/deploy/DEPLOYMENT_GUIDE.md b/deploy/DEPLOYMENT_GUIDE.md
index d929aa7..82859b1 100644
--- a/deploy/DEPLOYMENT_GUIDE.md
+++ b/deploy/DEPLOYMENT_GUIDE.md
@@ -2,8 +2,7 @@
## 1. Requirements
-- Ubuntu 22.04+ (or similar Linux)
-- Docker Engine and Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository)
+- Ubuntu 22.04+ or similar Linux with Docker Engine and the Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). Tested and recommended on Ubuntu 22.04 LTS.
- A domain name pointing at your server (A record for clearnet HTTPS)
## 2. Server setup
@@ -49,6 +48,7 @@ Edit `.env.prod`. Mandatory configuration:
| `SHOP_FIAT_CURRENCY` | `USD`, `EUR`, `GBP`, `CAD`, `AUD`, or `CHF` |
| `SIGNED_COOKIE_JWT_SECRET` | strong random secret |
| `BASE64_ENCRYPTION_KEY` | generate with `openssl rand -base64 32` |
+| `PAYMENT_METHODS_ENABLED` | e.g. `xmr,btc` |
| `MONERO_NETWORK` | `mainnet` |
| `MONERO_DAEMON_ADDRESS` | mainnet node `host:port` |
| `MONERO_WALLET_RPC_USERNAME` | strong random username |
@@ -61,11 +61,9 @@ Edit `.env.prod`. Mandatory configuration:
| `VITE_API_BASE_URL` | `/api` |
| `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` |
-Optional — adjust Monero payment confirmation rules:
+Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept unconfirmed / mempool); that tier cannot be the catch-all.
-**`MONERO_CONFIRMATION_TIERS`** — JSON array. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept on mempool); that tier cannot be the catch-all.
-
-Example (default in `.env.example`):
+Monero example (default in `.env.example`):
```json
[
From 8c0749875ef59d627728070f7c8af66feb6449f8 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 4 Sep 2026 11:32:12 +0200
Subject: [PATCH 15/47] add electrum wallet rpc client
---
.../services/ElectrumWalletRpcClient.spec.ts | 211 ++++++++++++++++++
.../services/ElectrumWalletRpcClient.ts | 171 ++++++++++++++
.../ElectrumWalletRpcConnectionService.ts | 20 ++
.../ElectrumWalletAddressHistoryEntry.ts | 4 +
.../ElectrumWalletDeserializedTransaction.ts | 8 +
.../types/ElectrumWalletGetBalanceResult.ts | 4 +
.../types/ElectrumWalletGetInfoResult.ts | 5 +
.../types/ElectrumWalletIncomingTransfer.ts | 5 +
.../types/ElectrumWalletRpcClientTest.ts | 12 +
.../types/ElectrumWalletRpcResponse.ts | 9 +
10 files changed, 449 insertions(+)
create mode 100644 backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts
create mode 100644 backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts
create mode 100644 backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts
diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts
new file mode 100644
index 0000000..ae4fe00
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts
@@ -0,0 +1,211 @@
+import { ConfigService } from '@nestjs/config';
+import axios from 'axios';
+import type { ElectrumWalletRpcClientTest } from '../types/ElectrumWalletRpcClientTest';
+import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
+
+jest.mock('axios');
+
+const mockedAxios = axios as jest.Mocked;
+
+describe('ElectrumWalletRpcClient', () => {
+ let client: ElectrumWalletRpcClient;
+ let clientTest: ElectrumWalletRpcClientTest;
+
+ beforeEach(() => {
+ client = new ElectrumWalletRpcClient({
+ get: jest.fn().mockReturnValue({
+ rpcUrl: 'http://electrum.test:7777',
+ username: 'electrum',
+ password: 'secret',
+ rpcTimeoutMs: 5000
+ })
+ } as unknown as ConfigService);
+
+ clientTest = client as unknown as ElectrumWalletRpcClientTest;
+ mockedAxios.post.mockReset();
+ });
+
+ describe('mapIncomingTransfer', () => {
+ it('maps confirmed transfers with confirmations derived from block height', () => {
+ expect(
+ clientTest.mapIncomingTransfer(
+ {
+ tx_hash: 'abc123',
+ height: 800_000
+ },
+ '50000',
+ 800_002
+ )
+ ).toEqual({
+ txHash: 'abc123',
+ amountAtomic: '50000',
+ confirmations: 3
+ });
+ });
+
+ it('returns zero confirmations for unconfirmed transfers', () => {
+ expect(
+ clientTest.mapIncomingTransfer(
+ {
+ tx_hash: 'abc123',
+ height: 0
+ },
+ '50000',
+ 800_002
+ )
+ ).toEqual({
+ txHash: 'abc123',
+ amountAtomic: '50000',
+ confirmations: 0
+ });
+ });
+
+ it('returns null for non-positive amounts', () => {
+ expect(
+ clientTest.mapIncomingTransfer(
+ {
+ tx_hash: 'abc123',
+ height: 800_000
+ },
+ '0',
+ 800_002
+ )
+ ).toBeNull();
+ });
+ });
+
+ describe('sumIncomingOutputValueAtomic', () => {
+ it('sums outputs paying to the target address', () => {
+ expect(
+ clientTest.sumIncomingOutputValueAtomic(
+ {
+ outputs: [
+ { address: 'bc1qother', value_sats: 10_000 },
+ { address: 'bc1qtest', value_sats: 50_000 },
+ { address: 'bc1qtest', value_sats: 25_000 }
+ ]
+ },
+ 'bc1qtest'
+ )
+ ).toBe('75000');
+ });
+
+ it('returns zero when no outputs match the address', () => {
+ expect(
+ clientTest.sumIncomingOutputValueAtomic(
+ {
+ outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
+ },
+ 'bc1qtest'
+ )
+ ).toBe('0');
+ });
+ });
+
+ describe('getIncomingTransfers', () => {
+ it('resolves incoming amounts from transaction outputs', async () => {
+ mockedAxios.post
+ .mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: [{ tx_hash: 'abc123', height: 800_000 }]
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: '01000000'
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: {
+ outputs: [
+ { address: 'bc1qother', value_sats: 10_000 },
+ { address: 'bc1qtest', value_sats: 50_000 }
+ ]
+ }
+ }
+ });
+
+ await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
+ {
+ txHash: 'abc123',
+ amountAtomic: '50000',
+ confirmations: 3
+ }
+ ]);
+
+ expect(mockedAxios.post).toHaveBeenNthCalledWith(
+ 2,
+ 'http://electrum.test:7777',
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method: 'gettransaction',
+ params: { txid: 'abc123' }
+ },
+ expect.any(Object)
+ );
+ expect(mockedAxios.post).toHaveBeenNthCalledWith(
+ 3,
+ 'http://electrum.test:7777',
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method: 'deserialize',
+ params: { tx: '01000000' }
+ },
+ expect.any(Object)
+ );
+ });
+ });
+
+ describe('createAddress', () => {
+ it('creates an address and sets a label when provided', async () => {
+ mockedAxios.post
+ .mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: 'bc1qtest' } })
+ .mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: true } });
+
+ await expect(client.createAddress('checkout - order-1')).resolves.toBe('bc1qtest');
+
+ expect(mockedAxios.post).toHaveBeenNthCalledWith(
+ 2,
+ 'http://electrum.test:7777',
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method: 'setlabel',
+ params: { key: 'bc1qtest', label: 'checkout - order-1' }
+ },
+ expect.objectContaining({
+ auth: { username: 'electrum', password: 'secret' }
+ })
+ );
+ });
+ });
+
+ describe('getBalance', () => {
+ it('returns confirmed and total balances in atomic units', async () => {
+ mockedAxios.post.mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: {
+ confirmed: '0.00025',
+ unconfirmed: '0.0001'
+ }
+ }
+ });
+
+ await expect(client.getBalance()).resolves.toEqual({
+ balanceAtomic: '35000',
+ confirmedBalanceAtomic: '25000'
+ });
+ });
+ });
+});
diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts
new file mode 100644
index 0000000..f2c4159
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts
@@ -0,0 +1,171 @@
+import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import axios from 'axios';
+import type { Config } from '../../../types/Config';
+import { addAtomic } from '../../../utils/atomic/addAtomic';
+import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
+import type { ElectrumWalletAddressHistoryEntry } from '../types/ElectrumWalletAddressHistoryEntry';
+import type { ElectrumWalletDeserializedTransaction } from '../types/ElectrumWalletDeserializedTransaction';
+import type { ElectrumWalletGetBalanceResult } from '../types/ElectrumWalletGetBalanceResult';
+import type { ElectrumWalletGetInfoResult } from '../types/ElectrumWalletGetInfoResult';
+import type { ElectrumWalletIncomingTransfer } from '../types/ElectrumWalletIncomingTransfer';
+import type { ElectrumWalletRpcResponse } from '../types/ElectrumWalletRpcResponse';
+
+@Injectable()
+export class ElectrumWalletRpcClient {
+ constructor(private readonly configService: ConfigService) {}
+
+ private async call(
+ method: string,
+ params: Record | unknown[] = {},
+ options: { timeoutMs?: number } = {}
+ ): Promise {
+ const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
+ 'electrumWallet'
+ ) as Config['electrumWallet'];
+
+ const { data } = await axios.post>(
+ rpcUrl,
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method,
+ params
+ },
+ {
+ timeout: options.timeoutMs ?? rpcTimeoutMs,
+ auth: {
+ username,
+ password
+ }
+ }
+ );
+
+ if (data.error) {
+ throw new Error(data.error.message);
+ }
+
+ if (data.result === undefined) {
+ throw new Error(`Electrum wallet RPC ${method} returned no result`);
+ }
+
+ return data.result;
+ }
+
+ async getVersion(): Promise {
+ const version = await this.call('version');
+
+ if (!version) {
+ throw new Error('Electrum wallet RPC version returned no version');
+ }
+
+ return version;
+ }
+
+ async isSynchronized(): Promise {
+ return this.call('is_synchronized');
+ }
+
+ async getInfo(): Promise {
+ return this.call('getinfo');
+ }
+
+ async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> {
+ const { confirmed, unconfirmed } = await this.call('getbalance');
+
+ if (confirmed === undefined) {
+ throw new Error('Electrum wallet RPC getbalance returned incomplete result');
+ }
+
+ const confirmedAtomic = convertBtcToBtcAtomic(confirmed);
+
+ const unconfirmedAtomic =
+ unconfirmed !== undefined && unconfirmed !== '0' ? convertBtcToBtcAtomic(unconfirmed) : '0';
+
+ return {
+ balanceAtomic: addAtomic(confirmedAtomic, unconfirmedAtomic),
+ confirmedBalanceAtomic: confirmedAtomic
+ };
+ }
+
+ async createAddress(label?: string): Promise {
+ const address = await this.call('createnewaddress');
+
+ if (!address) {
+ throw new Error('Electrum wallet RPC createnewaddress returned no address');
+ }
+
+ if (label) {
+ await this.call('setlabel', { key: address, label });
+ }
+
+ return address;
+ }
+
+ async getIncomingTransfers(address: string, blockHeight: number | null): Promise {
+ const history = await this.call('getaddresshistory', {
+ address
+ });
+
+ if (!Array.isArray(history)) {
+ throw new Error('Electrum wallet RPC getaddresshistory returned invalid result');
+ }
+
+ const transfers = await Promise.all(
+ history.map(async entry => {
+ const txHash = entry.tx_hash;
+
+ if (!txHash) {
+ return null;
+ }
+
+ const serializedTransaction = await this.call('gettransaction', { txid: txHash });
+
+ const transaction = await this.call('deserialize', {
+ tx: serializedTransaction
+ });
+
+ const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address);
+
+ return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
+ })
+ );
+
+ return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
+ }
+
+ private sumIncomingOutputValueAtomic(transaction: ElectrumWalletDeserializedTransaction, address: string): string {
+ if (!Array.isArray(transaction.outputs)) {
+ return '0';
+ }
+
+ return transaction.outputs.reduce((sum, output) => {
+ if (output.address !== address || !Number.isFinite(output.value_sats) || output.value_sats <= 0) {
+ return sum;
+ }
+
+ return addAtomic(sum, String(output.value_sats));
+ }, '0');
+ }
+
+ private mapIncomingTransfer(
+ entry: ElectrumWalletAddressHistoryEntry,
+ amountAtomic: string,
+ blockHeight: number | null
+ ): ElectrumWalletIncomingTransfer | null {
+ const txHash = entry.tx_hash;
+
+ if (!txHash || amountAtomic === '0') {
+ return null;
+ }
+
+ const confirmations =
+ entry.height > 0 && blockHeight !== null ? Math.max(blockHeight - entry.height + 1, 0) : 0;
+
+ return {
+ txHash,
+ amountAtomic,
+ confirmations
+ };
+ }
+}
diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts
new file mode 100644
index 0000000..be68408
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts
@@ -0,0 +1,20 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import { getErrorMessage } from '../../../utils/getErrorMessage';
+import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
+
+@Injectable()
+export class ElectrumWalletRpcConnectionService implements OnModuleInit {
+ private readonly logger = new Logger(ElectrumWalletRpcConnectionService.name);
+
+ constructor(private readonly walletRpcClient: ElectrumWalletRpcClient) {}
+
+ async onModuleInit(): Promise {
+ try {
+ const version = await this.walletRpcClient.getVersion();
+
+ this.logger.log(`Connected to electrum-daemon (version ${version})`);
+ } catch (error) {
+ this.logger.error(`Failed to reach electrum-daemon at startup: ${getErrorMessage(error)}`);
+ }
+ }
+}
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts
new file mode 100644
index 0000000..ed3004b
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts
@@ -0,0 +1,4 @@
+export type ElectrumWalletAddressHistoryEntry = {
+ tx_hash: string;
+ height: number;
+};
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts
new file mode 100644
index 0000000..bb31f5b
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts
@@ -0,0 +1,8 @@
+export type ElectrumWalletDeserializedOutput = {
+ address?: string;
+ value_sats: number;
+};
+
+export type ElectrumWalletDeserializedTransaction = {
+ outputs: ElectrumWalletDeserializedOutput[];
+};
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts
new file mode 100644
index 0000000..c860d9e
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts
@@ -0,0 +1,4 @@
+export type ElectrumWalletGetBalanceResult = {
+ confirmed: string;
+ unconfirmed?: string;
+};
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts
new file mode 100644
index 0000000..1bcc8b5
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts
@@ -0,0 +1,5 @@
+export type ElectrumWalletGetInfoResult = {
+ blockchain_height?: number;
+ server?: string;
+ server_height?: number;
+};
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts
new file mode 100644
index 0000000..57de350
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts
@@ -0,0 +1,5 @@
+export type ElectrumWalletIncomingTransfer = {
+ txHash: string;
+ amountAtomic: string;
+ confirmations: number;
+};
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts
new file mode 100644
index 0000000..ec84e95
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts
@@ -0,0 +1,12 @@
+import type { ElectrumWalletAddressHistoryEntry } from './ElectrumWalletAddressHistoryEntry';
+import type { ElectrumWalletDeserializedTransaction } from './ElectrumWalletDeserializedTransaction';
+import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTransfer';
+
+export type ElectrumWalletRpcClientTest = {
+ mapIncomingTransfer: (
+ entry: ElectrumWalletAddressHistoryEntry,
+ amountAtomic: string,
+ blockHeight: number | null
+ ) => ElectrumWalletIncomingTransfer | null;
+ sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
+};
diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts
new file mode 100644
index 0000000..03647ce
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts
@@ -0,0 +1,9 @@
+export type ElectrumWalletRpcResponse = {
+ id: string | number;
+ jsonrpc: string;
+ result?: T;
+ error?: {
+ code: number;
+ message: string;
+ };
+};
From cae5b4fe509089e5e397d33d62b757e8782555de Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 4 Sep 2026 11:59:06 +0200
Subject: [PATCH 16/47] add bitcoin wallet admin status endpoint
---
backend/src/AppModule.ts | 4 +
.../bitcoinWallet/BitcoinWalletModule.ts | 12 +++
.../controllers/BitcoinWalletController.ts | 14 ++++
.../BitcoinWalletAdminService.spec.ts | 74 +++++++++++++++++++
.../services/BitcoinWalletAdminService.ts | 61 +++++++++++++++
.../types/BitcoinWalletStatusView.ts | 12 +++
.../types/BitcoinWalletSyncStatus.ts | 5 ++
7 files changed, 182 insertions(+)
create mode 100644 backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts
create mode 100644 backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts
create mode 100644 backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
create mode 100644 backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts
create mode 100644 backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts
diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts
index c209f63..3e38a2e 100644
--- a/backend/src/AppModule.ts
+++ b/backend/src/AppModule.ts
@@ -13,6 +13,7 @@ import {
getOrderConfig,
getInvoiceConfig,
getMoneroWalletConfig,
+ getElectrumWalletConfig,
getPostgresConfig,
getShopSettingsConfig,
getSimplexConfig
@@ -22,6 +23,7 @@ import { AuthModule } from './modules/auth/AuthModule';
import { EncryptionModule } from './modules/encryption/EncryptionModule';
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
+import { BitcoinWalletModule } from './modules/bitcoinWallet/BitcoinWalletModule';
import { SimplexModule } from './modules/simplex/SimplexModule';
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
@@ -55,6 +57,7 @@ import { Config } from './types/Config';
registerAs('order', getOrderConfig),
registerAs('invoice', getInvoiceConfig),
registerAs('moneroWallet', getMoneroWalletConfig),
+ registerAs('electrumWallet', getElectrumWalletConfig),
registerAs('simplex', getSimplexConfig)
]
}),
@@ -91,6 +94,7 @@ import { Config } from './types/Config';
PaymentModule,
DataWipeModule,
MoneroWalletModule,
+ BitcoinWalletModule,
StorefrontCoreModule,
StorefrontProductModule,
StorefrontCartModule,
diff --git a/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts
new file mode 100644
index 0000000..d56c048
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { BitcoinWalletController } from './controllers/BitcoinWalletController';
+import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
+import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
+import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
+
+@Module({
+ controllers: [BitcoinWalletController],
+ providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
+ exports: [ElectrumWalletRpcClient]
+})
+export class BitcoinWalletModule {}
diff --git a/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts
new file mode 100644
index 0000000..e309946
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts
@@ -0,0 +1,14 @@
+import { Controller, Get, UseGuards } from '@nestjs/common';
+import { JwtGuard } from '../../../guards/JwtGuard';
+import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
+
+@Controller('bitcoin-wallet')
+@UseGuards(JwtGuard)
+export class BitcoinWalletController {
+ constructor(private readonly walletAdminService: BitcoinWalletAdminService) {}
+
+ @Get('/')
+ getStatus() {
+ return this.walletAdminService.getStatus();
+ }
+}
diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
new file mode 100644
index 0000000..bc32bbd
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
@@ -0,0 +1,74 @@
+import { ServiceUnavailableException } from '@nestjs/common';
+import type { ConfigService } from '@nestjs/config';
+import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
+import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
+import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
+
+describe('BitcoinWalletAdminService', () => {
+ let service: BitcoinWalletAdminService;
+ let walletRpcClient: {
+ getVersion: jest.Mock;
+ isSynchronized: jest.Mock;
+ getInfo: jest.Mock;
+ getBalance: jest.Mock;
+ };
+ let configService: {
+ get: jest.Mock;
+ };
+
+ beforeEach(() => {
+ walletRpcClient = {
+ getVersion: jest.fn().mockResolvedValue('4.8.1'),
+ isSynchronized: jest.fn().mockResolvedValue(true),
+ getInfo: jest.fn().mockResolvedValue({
+ blockchain_height: 900_000,
+ server_height: 900_000
+ }),
+ getBalance: jest.fn().mockResolvedValue({
+ balanceAtomic: '150000',
+ confirmedBalanceAtomic: '150000'
+ })
+ };
+
+ configService = {
+ get: jest.fn().mockReturnValue({
+ network: 'testnet'
+ })
+ };
+
+ service = new BitcoinWalletAdminService(
+ walletRpcClient as unknown as ElectrumWalletRpcClient,
+ configService as unknown as ConfigService
+ );
+ });
+
+ it('returns wallet status when RPC calls succeed', async () => {
+ const status = await service.getStatus();
+
+ expect(status).toEqual(
+ expect.objectContaining({
+ network: 'testnet',
+ rpcVersion: '4.8.1',
+ blockHeight: 900_000,
+ serverHeight: 900_000,
+ syncStatus: BitcoinWalletSyncStatus.Synced,
+ balanceBtc: '0.00150000',
+ confirmedBalanceBtc: '0.00150000'
+ })
+ );
+ });
+
+ it('reports syncing when the wallet is not synchronized', async () => {
+ walletRpcClient.isSynchronized.mockResolvedValue(false);
+
+ const status = await service.getStatus();
+
+ expect(status.syncStatus).toBe(BitcoinWalletSyncStatus.Syncing);
+ });
+
+ it('throws when RPC calls fail', async () => {
+ walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
+
+ await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
+ });
+});
diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts
new file mode 100644
index 0000000..a3dc27a
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts
@@ -0,0 +1,61 @@
+import { Injectable, ServiceUnavailableException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
+import type { Config } from '../../../types/Config';
+import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
+import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
+import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
+
+@Injectable()
+export class BitcoinWalletAdminService {
+ constructor(
+ private readonly walletRpcClient: ElectrumWalletRpcClient,
+ private readonly configService: ConfigService
+ ) {}
+
+ async getStatus(): Promise {
+ const { network } = this.configService.get('electrumWallet') as Config['electrumWallet'];
+
+ try {
+ const [rpcVersion, isSynchronized, info, { balanceAtomic, confirmedBalanceAtomic }] = await Promise.all([
+ this.walletRpcClient.getVersion(),
+ this.walletRpcClient.isSynchronized(),
+ this.walletRpcClient.getInfo(),
+ this.walletRpcClient.getBalance()
+ ]);
+
+ const blockHeight = info.blockchain_height ?? null;
+ const serverHeight = info.server_height ?? null;
+
+ return {
+ network,
+ rpcVersion,
+ blockHeight,
+ serverHeight,
+ syncStatus: this.resolveSyncStatus(isSynchronized, blockHeight, serverHeight),
+ balanceBtc: convertBtcAtomicToBtc(balanceAtomic),
+ confirmedBalanceBtc: convertBtcAtomicToBtc(confirmedBalanceAtomic)
+ };
+ } catch {
+ throw new ServiceUnavailableException(
+ 'Could not load wallet status. The Bitcoin wallet may be busy or unavailable.'
+ );
+ }
+ }
+
+ private resolveSyncStatus(
+ isSynchronized: boolean,
+ blockHeight: number | null,
+ serverHeight: number | null
+ ): BitcoinWalletSyncStatus {
+ if (isSynchronized) {
+ return BitcoinWalletSyncStatus.Synced;
+ }
+
+ if (blockHeight === null || serverHeight === null) {
+ return BitcoinWalletSyncStatus.Unknown;
+ }
+
+ return BitcoinWalletSyncStatus.Syncing;
+ }
+}
diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts
new file mode 100644
index 0000000..ca5d9af
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts
@@ -0,0 +1,12 @@
+import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
+import { BitcoinWalletSyncStatus } from './BitcoinWalletSyncStatus';
+
+export interface BitcoinWalletStatusView {
+ network: ElectrumNetwork;
+ rpcVersion: string;
+ blockHeight: number | null;
+ serverHeight: number | null;
+ syncStatus: BitcoinWalletSyncStatus;
+ balanceBtc: string;
+ confirmedBalanceBtc: string;
+}
diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts
new file mode 100644
index 0000000..ae39d08
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts
@@ -0,0 +1,5 @@
+export enum BitcoinWalletSyncStatus {
+ Synced = 'synced',
+ Syncing = 'syncing',
+ Unknown = 'unknown'
+}
From b4618cf3b1df35a3385bc70d17516e9d45bac9dd Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 4 Sep 2026 14:11:44 +0200
Subject: [PATCH 17/47] wire bitcoin invoice creation and payment polling
---
backend/src/modules/payment/PaymentModule.ts | 2 +
.../services/InvoicePaymentService.spec.ts | 258 ++++++++++++++----
.../payment/services/InvoicePaymentService.ts | 65 ++++-
.../payment/services/InvoiceService.spec.ts | 109 +++++++-
.../payment/services/InvoiceService.ts | 73 ++++-
.../payment/types/InvoiceIncomingTransfer.ts | 5 +
.../types/InvoicePaymentServiceTest.ts | 6 +-
7 files changed, 448 insertions(+), 70 deletions(-)
create mode 100644 backend/src/modules/payment/types/InvoiceIncomingTransfer.ts
diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts
index 01f931f..d1dc126 100644
--- a/backend/src/modules/payment/PaymentModule.ts
+++ b/backend/src/modules/payment/PaymentModule.ts
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice';
@@ -13,6 +14,7 @@ import { InvoiceService } from './services/InvoiceService';
imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule,
+ BitcoinWalletModule,
ExchangeRateModule
],
providers: [InvoicePaymentService, InvoiceService],
diff --git a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts
index cb919c5..fb31435 100644
--- a/backend/src/modules/payment/services/InvoicePaymentService.spec.ts
+++ b/backend/src/modules/payment/services/InvoicePaymentService.spec.ts
@@ -1,17 +1,22 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { DataSource, EntityManager, Repository } from 'typeorm';
+import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment';
+import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod';
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
import { InvoicePaymentService } from './InvoicePaymentService';
-const minIncomingAtomic = '100000000';
+const minXmrIncomingAtomic = '100000000';
+const minBtcIncomingAtomic = '7';
+const xmrBelowMinIncomingAtomic = String(BigInt(minXmrIncomingAtomic) - 1n);
+const btcBelowMinIncomingAtomic = String(BigInt(minBtcIncomingAtomic) - 1n);
-const buildTransfer = (
+const buildXmrTransfer = (
overrides: Partial = {}
): MoneroWalletRpcIncomingTransfer => ({
txHash: 'tx-hash-1',
@@ -21,7 +26,14 @@ const buildTransfer = (
...overrides
});
-const buildInvoice = (overrides: Partial = {}): Invoice =>
+const buildBtcTransfer = (overrides: Partial = {}): InvoiceIncomingTransfer => ({
+ txHash: 'tx-hash-1',
+ amountAtomic: '200000000',
+ confirmations: 1,
+ ...overrides
+});
+
+const buildXmrInvoice = (overrides: Partial = {}): Invoice =>
({
id: 'invoice-1',
paymentMethod: PaymentMethod.Xmr,
@@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial = {}): Invoice =>
...overrides
}) as Invoice;
+const buildBtcInvoice = (overrides: Partial = {}): Invoice =>
+ ({
+ id: 'invoice-btc-1',
+ paymentMethod: PaymentMethod.Btc,
+ paymentAddress: 'bc1qtest',
+ btcDetails: { requiredConfirmations: 1 },
+ payments: [],
+ ...overrides
+ }) as Invoice;
+
describe('InvoicePaymentService', () => {
let service: InvoicePaymentServiceTest;
let invoiceRepo: {
@@ -41,7 +63,11 @@ describe('InvoicePaymentService', () => {
andWhere: jest.Mock;
getMany: jest.Mock;
};
- let walletRpcClient: {
+ let moneroWalletRpcClient: {
+ getIncomingTransfers: jest.Mock;
+ };
+ let bitcoinWalletRpcClient: {
+ getInfo: jest.Mock;
getIncomingTransfers: jest.Mock;
};
let configService: {
@@ -130,14 +156,20 @@ describe('InvoicePaymentService', () => {
)
};
- walletRpcClient = {
+ moneroWalletRpcClient = {
+ getIncomingTransfers: jest.fn().mockResolvedValue([])
+ };
+
+ bitcoinWalletRpcClient = {
+ getInfo: jest.fn().mockResolvedValue({ blockchain_height: 900_000 }),
getIncomingTransfers: jest.fn().mockResolvedValue([])
};
configService = {
get: jest.fn().mockReturnValue({
minByMethod: {
- [PaymentMethod.Xmr]: minIncomingAtomic
+ [PaymentMethod.Xmr]: minXmrIncomingAtomic,
+ [PaymentMethod.Btc]: minBtcIncomingAtomic
}
})
};
@@ -145,7 +177,8 @@ describe('InvoicePaymentService', () => {
service = new InvoicePaymentService(
invoiceRepo as unknown as Repository,
dataSource as unknown as DataSource,
- walletRpcClient as unknown as MoneroWalletRpcClient,
+ moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
+ bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
configService as unknown as ConfigService
) as unknown as InvoicePaymentServiceTest;
@@ -157,87 +190,184 @@ describe('InvoicePaymentService', () => {
errorLogSpy.mockRestore();
});
- describe('pollInvoices', () => {
+ describe('pollMoneroInvoices', () => {
it('returns early when there are no open invoices', async () => {
pollQueryBuilder.getMany.mockResolvedValue([]);
- await service.pollInvoices();
+ await service.pollMoneroInvoices();
- expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
+ expect(moneroWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('returns early when incoming transfers cannot be fetched', async () => {
- pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]);
- walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
+ pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice()]);
+ moneroWalletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down'));
- await service.pollInvoices();
+ await service.pollMoneroInvoices();
- expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
+ expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(processInvoiceSpy).not.toHaveBeenCalled();
});
it('routes transfers to each invoice by subaddress index', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
- buildInvoice({
+ buildXmrInvoice({
id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}),
- buildInvoice({
+ buildXmrInvoice({
id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
})
]);
- walletRpcClient.getIncomingTransfers.mockResolvedValue([
- buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
- buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
+ moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
+ buildXmrTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
+ buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
]);
- await service.pollInvoices();
+ await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [
- expect.objectContaining({ txHash: 'tx-a', subaddrIndex: 3 })
+ expect.objectContaining({ txHash: 'tx-a' })
]);
expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-2', [
- expect.objectContaining({ txHash: 'tx-b', subaddrIndex: 7 })
+ expect.objectContaining({ txHash: 'tx-b' })
]);
});
it('continues processing other invoices when one invoice fails', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
- buildInvoice({ id: 'invoice-1' }),
- buildInvoice({ id: 'invoice-2' })
+ buildXmrInvoice({ id: 'invoice-1' }),
+ buildXmrInvoice({ id: 'invoice-2' })
]);
- walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
+ moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
processInvoiceSpy.mockRestore();
processInvoiceSpy = jest
.spyOn(service, 'processInvoice')
.mockRejectedValueOnce(new Error('invoice-1 failed'))
.mockResolvedValueOnce(undefined);
- await service.pollInvoices();
+ await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
it('deduplicates subaddress indices when fetching incoming transfers', async () => {
pollQueryBuilder.getMany.mockResolvedValue([
- buildInvoice({
+ buildXmrInvoice({
id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}),
- buildInvoice({
+ buildXmrInvoice({
id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
})
]);
- walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]);
+ moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
- await service.pollInvoices();
+ await service.pollMoneroInvoices();
- expect(walletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
+ expect(moneroWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith([3]);
expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
});
+ it('processes invoices with no matching transfers as an empty batch', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice({ id: 'invoice-1' })]);
+ moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
+ buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-other' })
+ ]);
+
+ await service.pollMoneroInvoices();
+
+ expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-1', []);
+ });
+ });
+
+ describe('pollBitcoinInvoices', () => {
+ it('returns early when there are no open invoices', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([]);
+
+ await service.pollBitcoinInvoices();
+
+ expect(bitcoinWalletRpcClient.getInfo).not.toHaveBeenCalled();
+ expect(processInvoiceSpy).not.toHaveBeenCalled();
+ });
+
+ it('returns early when wallet info cannot be fetched', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice()]);
+ bitcoinWalletRpcClient.getInfo.mockRejectedValue(new Error('rpc down'));
+
+ await service.pollBitcoinInvoices();
+
+ expect(bitcoinWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
+ expect(processInvoiceSpy).not.toHaveBeenCalled();
+ });
+
+ it('fetches transfers per invoice using the current block height', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([
+ buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }),
+ buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' })
+ ]);
+ bitcoinWalletRpcClient.getIncomingTransfers
+ .mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-a' })])
+ .mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]);
+
+ await service.pollBitcoinInvoices();
+
+ expect(bitcoinWalletRpcClient.getInfo).toHaveBeenCalled();
+ expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(1, 'bc1qone', 900_000);
+ expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenNthCalledWith(2, 'bc1qtwo', 900_000);
+ expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-btc-1', [
+ expect.objectContaining({ txHash: 'tx-a' })
+ ]);
+ expect(processInvoiceSpy).toHaveBeenNthCalledWith(2, 'invoice-btc-2', [
+ expect.objectContaining({ txHash: 'tx-b' })
+ ]);
+ });
+
+ it('continues processing other invoices when one invoice fails', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([
+ buildBtcInvoice({ id: 'invoice-btc-1' }),
+ buildBtcInvoice({ id: 'invoice-btc-2' })
+ ]);
+ bitcoinWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildBtcTransfer()]);
+ processInvoiceSpy.mockRestore();
+ processInvoiceSpy = jest
+ .spyOn(service, 'processInvoice')
+ .mockRejectedValueOnce(new Error('invoice-btc-1 failed'))
+ .mockResolvedValueOnce(undefined);
+
+ await service.pollBitcoinInvoices();
+
+ expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('continues processing other invoices when incoming transfers cannot be fetched for one invoice', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([
+ buildBtcInvoice({ id: 'invoice-btc-1', paymentAddress: 'bc1qone' }),
+ buildBtcInvoice({ id: 'invoice-btc-2', paymentAddress: 'bc1qtwo' })
+ ]);
+ bitcoinWalletRpcClient.getIncomingTransfers
+ .mockRejectedValueOnce(new Error('rpc down'))
+ .mockResolvedValueOnce([buildBtcTransfer({ txHash: 'tx-b' })]);
+
+ await service.pollBitcoinInvoices();
+
+ expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledTimes(2);
+ expect(processInvoiceSpy).toHaveBeenCalledTimes(1);
+ expect(processInvoiceSpy).toHaveBeenCalledWith('invoice-btc-2', [
+ expect.objectContaining({ txHash: 'tx-b' })
+ ]);
+ });
+
+ it('passes null block height when wallet info has no blockchain height', async () => {
+ pollQueryBuilder.getMany.mockResolvedValue([buildBtcInvoice({ paymentAddress: 'bc1qtest' })]);
+ bitcoinWalletRpcClient.getInfo.mockResolvedValue({ server_height: 900_000 });
+
+ await service.pollBitcoinInvoices();
+
+ expect(bitcoinWalletRpcClient.getIncomingTransfers).toHaveBeenCalledWith('bc1qtest', null);
+ });
});
describe('processInvoice', () => {
@@ -248,39 +378,48 @@ describe('InvoicePaymentService', () => {
it('does nothing when the invoice is missing inside the transaction', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null);
- await service.processInvoice('invoice-1', [buildTransfer()]);
+ await service.processInvoice('invoice-1', [buildXmrTransfer()]);
+
+ expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
+ expect(paymentRepo.update).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when there are no transfers to process', async () => {
+ transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
+
+ await service.processInvoice('invoice-1', []);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('skips transfers below the configured minimum', async () => {
- transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
+ transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', [
- buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' })
+ buildXmrTransfer({ amountAtomic: xmrBelowMinIncomingAtomic, txHash: 'dust-tx' })
]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('inserts a payment when the transfer amount equals the configured minimum', async () => {
- transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
+ transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', [
- buildTransfer({ txHash: 'min-tx', amountAtomic: minIncomingAtomic, confirmations: 1 })
+ buildXmrTransfer({ txHash: 'min-tx', amountAtomic: minXmrIncomingAtomic, confirmations: 1 })
]);
expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' },
txHash: 'min-tx',
- amountAtomic: minIncomingAtomic,
+ amountAtomic: minXmrIncomingAtomic,
confirmations: 1
});
});
it('processes a mixed batch of dust, new, and existing transfers', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
- buildInvoice({
+ buildXmrInvoice({
payments: [
{
id: 'payment-1',
@@ -293,9 +432,9 @@ describe('InvoicePaymentService', () => {
);
await service.processInvoice('invoice-1', [
- buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }),
- buildTransfer({ txHash: 'known-tx', confirmations: 4 }),
- buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
+ buildXmrTransfer({ txHash: 'dust-tx', amountAtomic: xmrBelowMinIncomingAtomic }),
+ buildXmrTransfer({ txHash: 'known-tx', confirmations: 4 }),
+ buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 });
@@ -310,8 +449,8 @@ describe('InvoicePaymentService', () => {
it('inserts a new payment for transfers at or above the minimum', async () => {
- transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice());
- const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
+ transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
+ const transfer = buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
await service.processInvoice('invoice-1', [transfer]);
@@ -327,7 +466,7 @@ describe('InvoicePaymentService', () => {
it('updates confirmations for an existing payment when they change', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
- buildInvoice({
+ buildXmrInvoice({
payments: [
{
id: 'payment-1',
@@ -339,7 +478,7 @@ describe('InvoicePaymentService', () => {
})
);
- await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 5 })]);
+ await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 5 })]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
@@ -347,7 +486,7 @@ describe('InvoicePaymentService', () => {
it('does not update an existing payment when confirmations are unchanged', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
- buildInvoice({
+ buildXmrInvoice({
payments: [
{
id: 'payment-1',
@@ -359,10 +498,35 @@ describe('InvoicePaymentService', () => {
})
);
- await service.processInvoice('invoice-1', [buildTransfer({ txHash: 'known-tx', confirmations: 3 })]);
+ await service.processInvoice('invoice-1', [buildXmrTransfer({ txHash: 'known-tx', confirmations: 3 })]);
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
+
+ it('skips transfers below the configured minimum for bitcoin invoices', async () => {
+ transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice());
+
+ await service.processInvoice('invoice-btc-1', [
+ buildBtcTransfer({ amountAtomic: btcBelowMinIncomingAtomic, txHash: 'dust-tx' })
+ ]);
+
+ expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
+ });
+
+ it('inserts a payment when the transfer amount equals the configured minimum for bitcoin invoices', async () => {
+ transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildBtcInvoice());
+
+ await service.processInvoice('invoice-btc-1', [
+ buildBtcTransfer({ txHash: 'min-tx', amountAtomic: minBtcIncomingAtomic, confirmations: 1 })
+ ]);
+
+ expect(insertQueryBuilder.values).toHaveBeenCalledWith({
+ invoice: { id: 'invoice-btc-1' },
+ txHash: 'min-tx',
+ amountAtomic: minBtcIncomingAtomic,
+ confirmations: 1
+ });
+ });
});
});
diff --git a/backend/src/modules/payment/services/InvoicePaymentService.ts b/backend/src/modules/payment/services/InvoicePaymentService.ts
index 23fe173..1d11f83 100644
--- a/backend/src/modules/payment/services/InvoicePaymentService.ts
+++ b/backend/src/modules/payment/services/InvoicePaymentService.ts
@@ -7,11 +7,13 @@ import type { Config } from '../../../types/Config';
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
import { getErrorMessage } from '../../../utils/getErrorMessage';
+import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
-import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment';
+import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod';
+import { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
@Injectable()
export class InvoicePaymentService {
@@ -21,12 +23,17 @@ export class InvoicePaymentService {
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository,
private readonly dataSource: DataSource,
- private readonly walletRpcClient: MoneroWalletRpcClient,
+ private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
+ private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly configService: ConfigService
) {}
@Cron(CronExpression.EVERY_10_SECONDS)
private async pollInvoices(): Promise {
+ await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]);
+ }
+
+ private async pollMoneroInvoices(): Promise {
const now = new Date();
const invoices = await this.invoiceRepo
@@ -55,7 +62,7 @@ export class InvoicePaymentService {
let transfers: MoneroWalletRpcIncomingTransfer[];
try {
- transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices);
+ transfers = await this.moneroWalletRpcClient.getIncomingTransfers(subaddrIndices);
} catch (error) {
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(error)}`);
@@ -77,7 +84,57 @@ export class InvoicePaymentService {
}
}
- private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise {
+ private async pollBitcoinInvoices(): Promise {
+ const now = new Date();
+
+ const invoices = await this.invoiceRepo
+ .createQueryBuilder('invoice')
+ .innerJoinAndSelect('invoice.btcDetails', 'btcDetails')
+ .where('invoice.paymentMethod = :paymentMethod', { paymentMethod: PaymentMethod.Btc })
+ .andWhere(
+ new Brackets(qb => {
+ qb.where('invoice.expiresAt > :now', { now }).orWhere(
+ `"btcDetails"."requiredConfirmations" > 0 AND EXISTS (
+ SELECT 1 FROM invoice_payments pollPayment
+ WHERE pollPayment."invoiceId" = invoice.id
+ AND pollPayment.confirmations < "btcDetails"."requiredConfirmations"
+ )`
+ );
+ })
+ )
+ .getMany();
+
+ if (invoices.length === 0) {
+ return;
+ }
+
+ let blockHeight: number | null;
+
+ try {
+ const info = await this.bitcoinWalletRpcClient.getInfo();
+
+ blockHeight = info.blockchain_height ?? null;
+ } catch (error) {
+ this.logger.error(`Failed to fetch Bitcoin wallet info: ${getErrorMessage(error)}`);
+
+ return;
+ }
+
+ for (const invoice of invoices) {
+ try {
+ const transfers = await this.bitcoinWalletRpcClient.getIncomingTransfers(
+ invoice.paymentAddress,
+ blockHeight
+ );
+
+ await this.processInvoice(invoice.id, transfers);
+ } catch (error) {
+ this.logger.error(`Failed to process invoice ${invoice.id}: ${getErrorMessage(error)}`);
+ }
+ }
+ }
+
+ private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => {
diff --git a/backend/src/modules/payment/services/InvoiceService.spec.ts b/backend/src/modules/payment/services/InvoiceService.spec.ts
index 6117671..3b959ab 100644
--- a/backend/src/modules/payment/services/InvoiceService.spec.ts
+++ b/backend/src/modules/payment/services/InvoiceService.spec.ts
@@ -1,6 +1,7 @@
import { Logger, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
+import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
@@ -19,7 +20,10 @@ describe('InvoiceService', () => {
let configService: {
get: jest.Mock;
};
- let walletRpcClient: {
+ let moneroWalletRpcClient: {
+ createAddress: jest.Mock;
+ };
+ let bitcoinWalletRpcClient: {
createAddress: jest.Mock;
};
let exchangeRateService: {
@@ -45,6 +49,10 @@ describe('InvoiceService', () => {
return { confirmationTiers };
}
+ if (key === 'shopSettings.bitcoin') {
+ return { confirmationTiers };
+ }
+
if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
}
@@ -53,13 +61,17 @@ describe('InvoiceService', () => {
})
};
- walletRpcClient = {
+ moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample',
address_index: 12
})
};
+ bitcoinWalletRpcClient = {
+ createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
+ };
+
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
@@ -67,7 +79,8 @@ describe('InvoiceService', () => {
service = new InvoiceService(
invoiceRepo as unknown as Repository,
configService as unknown as ConfigService,
- walletRpcClient as unknown as MoneroWalletRpcClient,
+ moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
+ bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
exchangeRateService as unknown as ExchangeRateService
);
});
@@ -76,9 +89,9 @@ describe('InvoiceService', () => {
errorLogSpy.mockRestore();
});
- const issueCheckoutInvoice = () =>
+ const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) =>
service.issueInvoice({
- paymentMethod: PaymentMethod.Xmr,
+ paymentMethod,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 15
@@ -91,11 +104,11 @@ describe('InvoiceService', () => {
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
);
- expect(walletRpcClient.createAddress).not.toHaveBeenCalled();
+ expect(moneroWalletRpcClient.createAddress).not.toHaveBeenCalled();
});
it('throws and logs when wallet address allocation fails for checkout invoices', async () => {
- walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
+ moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
@@ -108,7 +121,7 @@ describe('InvoiceService', () => {
it('creates a checkout invoice with converted totals and monero details', async () => {
const invoice = await issueCheckoutInvoice();
- expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
+ expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({
reason: InvoiceReason.Checkout,
@@ -145,6 +158,10 @@ describe('InvoiceService', () => {
};
}
+ if (key === 'shopSettings.bitcoin') {
+ return { confirmationTiers };
+ }
+
if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
}
@@ -185,7 +202,7 @@ describe('InvoiceService', () => {
);
exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
- walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
+ moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect(
service.issueInvoice({
@@ -200,7 +217,7 @@ describe('InvoiceService', () => {
)
);
- walletRpcClient.createAddress.mockResolvedValue({
+ moneroWalletRpcClient.createAddress.mockResolvedValue({
address: '4ShippingPaymentAddressExample',
address_index: 3
});
@@ -212,6 +229,76 @@ describe('InvoiceService', () => {
amountFiat: 5
});
- expect(walletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
+ expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('order-shipping - order-1');
+ });
+
+ it('throws when the live BTC rate is unavailable for checkout invoices', async () => {
+ exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
+ (method: PaymentMethod) => (method === PaymentMethod.Btc ? null : 150)
+ );
+
+ await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow(
+ new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.")
+ );
+
+ expect(bitcoinWalletRpcClient.createAddress).not.toHaveBeenCalled();
+ });
+
+ it('throws and logs when Bitcoin address allocation fails for checkout invoices', async () => {
+ exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
+ (method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150)
+ );
+ bitcoinWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
+
+ await expect(issueCheckoutInvoice(PaymentMethod.Btc)).rejects.toThrow(
+ new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.")
+ );
+
+ expect(errorLogSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to allocate Bitcoin payment address'));
+ expect(invoiceRepo.save).not.toHaveBeenCalled();
+ });
+
+ it('creates a checkout invoice with converted totals and bitcoin details', async () => {
+ exchangeRateService.getLiveFiatPerCrypto.mockImplementation(
+ (method: PaymentMethod) => (method === PaymentMethod.Btc ? 60_000 : 150)
+ );
+
+ const invoice = await issueCheckoutInvoice(PaymentMethod.Btc);
+
+ expect(bitcoinWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
+ expect(invoiceRepo.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ reason: InvoiceReason.Checkout,
+ paymentMethod: PaymentMethod.Btc,
+ amountFiat: 15,
+ fiatCurrency: 'USD',
+ paymentAddress: 'bc1qtestpaymentaddress',
+ expectedTotalAtomic: '25000',
+ expiresAt: expect.any(Date),
+ btcDetails: {
+ fiatPerBtcAtCreation: 60_000,
+ requiredConfirmations: 1
+ }
+ })
+ );
+ expect(invoiceRepo.save).toHaveBeenCalled();
+ expect(invoice).toEqual(expect.objectContaining({ id: 'invoice-1', amountFiat: 15 }));
+ });
+
+ it('uses BTC-specific shipping rate messages', async () => {
+ exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
+
+ await expect(
+ service.issueInvoice({
+ paymentMethod: PaymentMethod.Btc,
+ reason: InvoiceReason.Shipping,
+ contextId: 'order-1',
+ amountFiat: 5
+ })
+ ).rejects.toThrow(
+ new ServiceUnavailableException(
+ "We can't quote shipping in BTC right now. Please try again in a few minutes."
+ )
+ );
});
});
diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts
index c916ca2..1c5fcd0 100644
--- a/backend/src/modules/payment/services/InvoiceService.ts
+++ b/backend/src/modules/payment/services/InvoiceService.ts
@@ -5,9 +5,12 @@ import { Repository } from 'typeorm';
import dayjs from '../../../plugins/dayjs';
import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
+import { convertFiatToBtc } from '../../../utils/bitcoin/convertFiatToBtc';
+import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic';
import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations';
+import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice';
@@ -24,7 +27,8 @@ export class InvoiceService {
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository,
private readonly configService: ConfigService,
- private readonly walletRpcClient: MoneroWalletRpcClient,
+ private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
+ private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly exchangeRateService: ExchangeRateService
) {}
@@ -32,6 +36,8 @@ export class InvoiceService {
switch (data.paymentMethod) {
case PaymentMethod.Xmr:
return this.issueXmrInvoice(data);
+ case PaymentMethod.Btc:
+ return this.issueBtcInvoice(data);
}
}
@@ -41,7 +47,8 @@ export class InvoiceService {
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
reason,
- contextId
+ contextId,
+ PaymentMethod.Xmr
);
const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
@@ -54,7 +61,7 @@ export class InvoiceService {
let paymentAddressIndex: number;
try {
- const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel);
+ const { address, address_index } = await this.moneroWalletRpcClient.createAddress(addressLabel);
paymentAddress = address;
paymentAddressIndex = address_index;
@@ -89,8 +96,63 @@ export class InvoiceService {
return this.invoiceRepo.save(invoice);
}
- private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData {
+ private async issueBtcInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise {
+ const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
+ const { confirmationTiers } = this.configService.get('shopSettings.bitcoin') as Config['shopSettings']['bitcoin'];
+
+ const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
+ reason,
+ contextId,
+ PaymentMethod.Btc
+ );
+
+ const fiatPerBtc = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Btc);
+
+ if (fiatPerBtc === null) {
+ throw new ServiceUnavailableException(rateUnavailableMessage);
+ }
+
+ let paymentAddress: string;
+
+ try {
+ paymentAddress = await this.bitcoinWalletRpcClient.createAddress(addressLabel);
+ } catch (error) {
+ this.logger.error(`Failed to allocate Bitcoin payment address: ${getErrorMessage(error)}`);
+
+ throw new ServiceUnavailableException(unavailableMessage);
+ }
+
+ const requiredConfirmations = resolveMinConfirmations(amountFiat, confirmationTiers);
+
+ const expiresAt = dayjs().add(validityMs, 'millisecond').toDate();
+
+ const expectedTotalBtc = convertFiatToBtc(amountFiat, fiatPerBtc);
+ const expectedTotalAtomic = convertBtcToBtcAtomic(expectedTotalBtc);
+
+ const invoice = this.invoiceRepo.create({
+ reason,
+ paymentMethod: PaymentMethod.Btc,
+ amountFiat,
+ fiatCurrency: shopFiatCurrency,
+ expiresAt,
+ paymentAddress,
+ expectedTotalAtomic,
+ btcDetails: {
+ fiatPerBtcAtCreation: fiatPerBtc,
+ requiredConfirmations
+ }
+ });
+
+ return this.invoiceRepo.save(invoice);
+ }
+
+ private resolveReasonData(
+ reason: InvoiceReason,
+ contextId: string,
+ paymentMethod: PaymentMethod
+ ): InvoiceReasonData {
const { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
+ const cryptoLabel = paymentMethod === PaymentMethod.Btc ? 'BTC' : 'XMR';
switch (reason) {
case InvoiceReason.Checkout:
@@ -105,8 +167,7 @@ export class InvoiceService {
addressLabel: `order-shipping - ${contextId}`,
validityMs: shippingPaymentValidityMs,
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
- rateUnavailableMessage:
- "We can't quote shipping in XMR right now. Please try again in a few minutes."
+ rateUnavailableMessage: `We can't quote shipping in ${cryptoLabel} right now. Please try again in a few minutes.`
};
}
}
diff --git a/backend/src/modules/payment/types/InvoiceIncomingTransfer.ts b/backend/src/modules/payment/types/InvoiceIncomingTransfer.ts
new file mode 100644
index 0000000..e8bb594
--- /dev/null
+++ b/backend/src/modules/payment/types/InvoiceIncomingTransfer.ts
@@ -0,0 +1,5 @@
+export type InvoiceIncomingTransfer = {
+ txHash: string;
+ amountAtomic: string;
+ confirmations: number;
+};
diff --git a/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts b/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts
index be44805..0b5b39f 100644
--- a/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts
+++ b/backend/src/modules/payment/types/InvoicePaymentServiceTest.ts
@@ -1,6 +1,8 @@
-import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
+import type { InvoiceIncomingTransfer } from './InvoiceIncomingTransfer';
export type InvoicePaymentServiceTest = {
pollInvoices: () => Promise;
- processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise;
+ pollMoneroInvoices: () => Promise;
+ pollBitcoinInvoices: () => Promise;
+ processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise;
};
From 7b62bc41ada06531b57de2c02d94a74ace94ef17 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Fri, 4 Sep 2026 17:26:32 +0200
Subject: [PATCH 18/47] default dev electrum to testnet4
---
.env.example | 4 ++--
.../bitcoinWallet/services/BitcoinWalletAdminService.spec.ts | 4 ++--
backend/src/types/ElectrumNetwork.ts | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/.env.example b/.env.example
index eeacfd3..4d6887d 100644
--- a/.env.example
+++ b/.env.example
@@ -107,8 +107,8 @@ BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToT
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment)
ELECTRUM_VERSION=4.8.1
-ELECTRUM_NETWORK=testnet
-ELECTRUM_SERVER=electrum.blockstream.info:60002:s
+ELECTRUM_NETWORK=testnet4
+ELECTRUM_SERVER=testnet4-electrumx.wakiyamap.dev:51002:s
ELECTRUM_DAEMON_HOST=electrum-daemon
ELECTRUM_DAEMON_PORT=7777
ELECTRUM_DAEMON_RPC_USER=electrum
diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
index bc32bbd..f512062 100644
--- a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
+++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
@@ -32,7 +32,7 @@ describe('BitcoinWalletAdminService', () => {
configService = {
get: jest.fn().mockReturnValue({
- network: 'testnet'
+ network: 'testnet4'
})
};
@@ -47,7 +47,7 @@ describe('BitcoinWalletAdminService', () => {
expect(status).toEqual(
expect.objectContaining({
- network: 'testnet',
+ network: 'testnet4',
rpcVersion: '4.8.1',
blockHeight: 900_000,
serverHeight: 900_000,
diff --git a/backend/src/types/ElectrumNetwork.ts b/backend/src/types/ElectrumNetwork.ts
index 7a5d863..068661e 100644
--- a/backend/src/types/ElectrumNetwork.ts
+++ b/backend/src/types/ElectrumNetwork.ts
@@ -1,4 +1,4 @@
export enum ElectrumNetwork {
Mainnet = 'mainnet',
- Testnet = 'testnet'
+ Testnet4 = 'testnet4'
}
From 3b6323658eab91626a0cd608ab23c70cda581d82 Mon Sep 17 00:00:00 2001
From: nobswebdev
Date: Sat, 5 Sep 2026 14:48:01 +0200
Subject: [PATCH 19/47] remove crypto rate display from cart
---
.../services/StorefrontCartService.spec.ts | 26 ++-----------------
.../services/StorefrontCartService.ts | 10 -------
.../views/partials/cart-totals-panel.hbs | 12 ++-------
3 files changed, 4 insertions(+), 44 deletions(-)
diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
index ed0c8b3..ef2aa75 100644
--- a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
+++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
@@ -1,8 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
-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';
@@ -30,9 +28,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock;
};
- let exchangeRateService: {
- getLiveFiatPerCrypto: jest.Mock;
- };
let discountService: {
getDiscountStateForCart: jest.Mock;
applyDiscountCode: jest.Mock;
@@ -44,10 +39,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
};
- exchangeRateService = {
- getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
- };
-
discountService = {
getDiscountStateForCart: jest.fn().mockResolvedValue({
discounts: [],
@@ -59,7 +50,6 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService,
- exchangeRateService as unknown as ExchangeRateService,
discountService as unknown as StorefrontDiscountService
);
});
@@ -99,8 +89,6 @@ describe('StorefrontCartService', () => {
discounts: [],
cartDiscountTotal: 0,
cartTotalPrice: 0,
- cartTotalXmr: null,
- fiatPerXmr: null,
hasManualLines: false,
hasAutoLines: false,
cartTotalIssueMessage: null,
@@ -108,23 +96,13 @@ describe('StorefrontCartService', () => {
});
});
- it('converts the discounted total to XMR when a live rate is available', async () => {
+ it('returns cart totals and delivery flags', async () => {
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
expect(summary.cartSubtotal).toBe(10);
expect(summary.cartTotalPrice).toBe(10);
- expect(summary.fiatPerXmr).toBe(150);
- expect(summary.cartTotalXmr).toBe('0.06666667');
expect(summary.hasAutoLines).toBe(true);
- });
-
- it('leaves cartTotalXmr null when no live rate is available', async () => {
- exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
-
- const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
-
- expect(summary.cartTotalXmr).toBeNull();
- expect(summary.fiatPerXmr).toBeNull();
+ expect(summary.hasIssues).toBe(false);
});
it('flags manual and auto delivery lines separately', async () => {
diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
index 34fc757..2ec87a2 100644
--- a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
+++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
@@ -7,9 +7,6 @@ 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 { 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';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
@@ -23,7 +20,6 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService {
constructor(
private readonly productsService: StorefrontProductsService,
- private readonly exchangeRateService: ExchangeRateService,
private readonly discountService: StorefrontDiscountService
) {}
@@ -67,8 +63,6 @@ export class StorefrontCartService {
discounts: [],
cartDiscountTotal: 0,
cartTotalPrice: 0,
- cartTotalXmr: null,
- fiatPerXmr: null,
hasManualLines: false,
hasAutoLines: false,
cartTotalIssueMessage: null,
@@ -78,8 +72,6 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
- 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);
const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0);
@@ -92,8 +84,6 @@ export class StorefrontCartService {
cartExtended,
cartSubtotal,
...discountState,
- cartTotalXmr,
- fiatPerXmr,
hasManualLines,
hasAutoLines,
cartTotalIssueMessage,
diff --git a/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs b/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs
index 05235a8..0a83e79 100644
--- a/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs
+++ b/backend/src/modules/storefrontCore/views/partials/cart-totals-panel.hbs
@@ -52,13 +52,6 @@
{{cartTotalPrice}}
{{shopFiatCurrency}}
- {{#if cartTotalXmr}}
- = {{cartTotalXmr}} XMR
- {{else}}
-
- We couldn't determine the XMR rate right now. Please try again later.
-
- {{/if}}