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}}