diff --git a/.env.example b/.env.example
index 3d85b87..972d3f9 100644
--- a/.env.example
+++ b/.env.example
@@ -53,6 +53,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
+VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
SIGNED_COOKIE_JWT_SECRET=change-me-in-production
@@ -80,13 +81,15 @@ 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
+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
+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 +102,22 @@ 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)
+
+ELECTRUM_VERSION=4.8.1
+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
+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
@@ -110,8 +129,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
@@ -130,4 +147,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
+VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
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/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/backend/src/AppModule.ts b/backend/src/AppModule.ts
index 4914e9e..3e38a2e 100644
--- a/backend/src/AppModule.ts
+++ b/backend/src/AppModule.ts
@@ -9,10 +9,11 @@ import {
getCoingeckoConfig,
getEncryptionConfig,
getJwtConfig,
- getKrakenConfig,
+ getCoinPaprikaConfig,
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';
@@ -35,7 +37,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,12 +51,13 @@ 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),
registerAs('invoice', getInvoiceConfig),
registerAs('moneroWallet', getMoneroWalletConfig),
+ registerAs('electrumWallet', getElectrumWalletConfig),
registerAs('simplex', getSimplexConfig)
]
}),
@@ -74,7 +77,7 @@ import { Config } from './types/Config';
};
}
}),
- XmrRateModule,
+ ExchangeRateModule,
TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
inject: [ConfigService]
@@ -91,6 +94,7 @@ import { Config } from './types/Config';
PaymentModule,
DataWipeModule,
MoneroWalletModule,
+ BitcoinWalletModule,
StorefrontCoreModule,
StorefrontProductModule,
StorefrontCartModule,
diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts
index 56a5497..ae81352 100644
--- a/backend/src/config/index.ts
+++ b/backend/src/config/index.ts
@@ -4,19 +4,22 @@ import {
EncryptionConfig,
InvoiceConfig,
JwtConfig,
- KrakenConfig,
+ CoinPaprikaConfig,
MulterConfig,
OrderConfig,
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 { 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';
import { SimplexConfig } from '../types/SimplexConfig';
+import { parseEnabledPaymentMethods } from '../utils/payment/parseEnabledPaymentMethods';
const env = (key: string): string => process.env[key] || '';
@@ -100,7 +103,8 @@ export const getAppConfig = (): AppConfig => {
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
- orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH')
+ orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH'),
+ bitcoinWithdrawMaxFeeRateSatVbyte: envInt('VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE')
}
};
};
@@ -159,14 +163,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 => {
+export const getCoinPaprikaConfig = (): CoinPaprikaConfig => {
return {
- apiBaseUrl: env('KRAKEN_API_BASE_URL'),
- xmrRateFetchTimeoutMs: envInt('KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS')
+ apiBaseUrl: env('COINPAPRIKA_API_BASE_URL'),
+ rateFetchTimeoutMs: envInt('COINPAPRIKA_RATE_FETCH_TIMEOUT_MS')
};
};
@@ -177,8 +181,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 MoneroConfirmationTier[]
+ confirmationTiers: JSON.parse(env('MONERO_CONFIRMATION_TIERS')) as ConfirmationTier[]
+ },
+ bitcoin: {
+ confirmationTiers: JSON.parse(env('BITCOIN_CONFIRMATION_TIERS')) as ConfirmationTier[]
}
});
@@ -191,7 +199,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('BITCOIN_MIN_INCOMING_ATOMIC'))
}
});
@@ -204,6 +213,16 @@ 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'),
+ walletPassword: env('ELECTRUM_WALLET_PASSWORD'),
+ rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
+});
+
export const getSimplexConfig = (): SimplexConfig => ({
wsUrl: env('SIMPLEX_WS_URL'),
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
@@ -214,11 +233,12 @@ export default () => ({
app: getAppConfig(),
jwt: getJwtConfig(),
coingecko: getCoingeckoConfig(),
- kraken: getKrakenConfig(),
+ coinPaprika: getCoinPaprikaConfig(),
encryption: getEncryptionConfig(),
shopSettings: getShopSettingsConfig(),
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 6a4d07b..87ae559 100644
--- a/backend/src/config/validate.ts
+++ b/backend/src/config/validate.ts
@@ -3,7 +3,9 @@ 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 { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods';
+import { ElectrumNetwork } from '../types/ElectrumNetwork';
import { MoneroNetwork } from '../types/MoneroNetwork';
class EnvironmentVariables {
@@ -151,6 +153,12 @@ class EnvironmentVariables {
@Max(10000)
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
+ @IsNotEmpty()
+ @IsNumber()
+ @Min(1)
+ @Max(1000)
+ VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: number;
+
@IsNotEmpty()
@IsNumber()
@Min(4)
@@ -246,17 +254,17 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsString()
- KRAKEN_API_BASE_URL: string;
+ COINPAPRIKA_API_BASE_URL: string;
@IsNotEmpty()
@IsNumber()
@Min(1)
- KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS: number;
+ COINPAPRIKA_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
- COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS: number;
+ COINGECKO_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty()
@IsString()
@@ -265,9 +273,19 @@ class EnvironmentVariables {
@IsNotEmpty()
@IsString()
- @IsMoneroConfirmationTiers()
+ @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 +312,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;
@@ -325,6 +349,41 @@ 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()
+ ELECTRUM_WALLET_PASSWORD: string;
+
@IsNotEmpty()
@IsString()
SIMPLEX_WS_URL: string;
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/consts/paymentMethodIconUrl.ts b/backend/src/consts/paymentMethodIconUrl.ts
new file mode 100644
index 0000000..1128fe1
--- /dev/null
+++ b/backend/src/consts/paymentMethodIconUrl.ts
@@ -0,0 +1,6 @@
+import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
+
+export const paymentMethodIconUrl: Record = {
+ [PaymentMethod.Xmr]: '/shop/assets/img/xmr.png',
+ [PaymentMethod.Btc]: '/shop/assets/img/btc.png'
+};
diff --git a/backend/src/consts/paymentMethodLabel.ts b/backend/src/consts/paymentMethodLabel.ts
new file mode 100644
index 0000000..04f003f
--- /dev/null
+++ b/backend/src/consts/paymentMethodLabel.ts
@@ -0,0 +1,6 @@
+import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
+
+export const paymentMethodLabel: Record = {
+ [PaymentMethod.Xmr]: 'XMR',
+ [PaymentMethod.Btc]: 'BTC'
+};
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/main.ts b/backend/src/main.ts
index f427162..84afdc1 100644
--- a/backend/src/main.ts
+++ b/backend/src/main.ts
@@ -61,7 +61,7 @@ async function bootstrap() {
{ path: '/', method: RequestMethod.GET },
{ path: 'shop/categories/:id', method: RequestMethod.GET },
{ path: 'shop/products/:id/variants/:variantId', method: RequestMethod.GET },
- { path: 'shop/assets/(.*)', method: RequestMethod.GET },
+ { path: 'shop/assets/{*path}', method: RequestMethod.GET },
{ path: 'shop/preferences/theme', method: RequestMethod.POST },
{ path: 'shop/error', method: RequestMethod.GET },
{ path: 'shop/cart', method: RequestMethod.GET },
diff --git a/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts
new file mode 100644
index 0000000..5996d3a
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts
@@ -0,0 +1,14 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/AuthModule';
+import { BitcoinWalletController } from './controllers/BitcoinWalletController';
+import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
+import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
+import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
+
+@Module({
+ imports: [AuthModule],
+ 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..f7a3b7e
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts
@@ -0,0 +1,30 @@
+import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
+import { Throttle } from '@nestjs/throttler';
+import { JwtGuard } from '../../../guards/JwtGuard';
+import { throttleProfiles } from '../../../config/throttleProfiles';
+import { BitcoinWalletRevealSeedDto } from '../dto/BitcoinWalletRevealSeedDto';
+import { BitcoinWalletWithdrawDto } from '../dto/BitcoinWalletWithdrawDto';
+import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
+
+@Controller('bitcoin-wallet')
+@UseGuards(JwtGuard)
+export class BitcoinWalletController {
+ constructor(private readonly walletAdminService: BitcoinWalletAdminService) {}
+
+ @Get('/')
+ getStatus() {
+ return this.walletAdminService.getStatus();
+ }
+
+ @Post('/withdraw')
+ @Throttle(throttleProfiles.walletWithdraw)
+ withdraw(@Body() { destinationAddress, feeRateSatVbyte, password }: BitcoinWalletWithdrawDto) {
+ return this.walletAdminService.withdrawAll(destinationAddress, feeRateSatVbyte, password);
+ }
+
+ @Post('/reveal-seed')
+ @Throttle(throttleProfiles.walletRevealSeed)
+ revealSeed(@Body() { password }: BitcoinWalletRevealSeedDto) {
+ return this.walletAdminService.revealSeed(password);
+ }
+}
diff --git a/backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts
new file mode 100644
index 0000000..e38499d
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts
@@ -0,0 +1,7 @@
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class BitcoinWalletRevealSeedDto {
+ @IsString()
+ @IsNotEmpty()
+ password: string;
+}
diff --git a/backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts
new file mode 100644
index 0000000..a7cbc89
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts
@@ -0,0 +1,25 @@
+import { Transform } from 'class-transformer';
+import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
+import { getAppConfig } from '../../../config';
+import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress';
+
+const {
+ validation: { bitcoinWithdrawMaxFeeRateSatVbyte }
+} = getAppConfig();
+
+export class BitcoinWalletWithdrawDto {
+ @Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
+ @IsString()
+ @IsNotEmpty()
+ @IsBitcoinAddress()
+ destinationAddress: string;
+
+ @IsInt()
+ @Min(1)
+ @Max(bitcoinWithdrawMaxFeeRateSatVbyte)
+ feeRateSatVbyte: number;
+
+ @IsString()
+ @IsNotEmpty()
+ password: string;
+}
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..9dfd1b3
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts
@@ -0,0 +1,147 @@
+import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
+import type { ConfigService } from '@nestjs/config';
+import type { AuthService } from '../../auth/services/AuthService';
+import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
+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;
+ sweepAll: jest.Mock;
+ getSeed: jest.Mock;
+ };
+ let authService: {
+ verifyPassword: 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'
+ }),
+ sweepAll: jest.fn().mockResolvedValue({
+ txHash: 'tx-hash-1',
+ amountAtomic: '140000'
+ }),
+ getSeed: jest.fn().mockResolvedValue('seed words')
+ };
+
+ authService = {
+ verifyPassword: jest.fn()
+ };
+
+ configService = {
+ get: jest.fn().mockReturnValue({
+ network: 'testnet4'
+ })
+ };
+
+ service = new BitcoinWalletAdminService(
+ walletRpcClient as unknown as ElectrumWalletRpcClient,
+ authService as unknown as AuthService,
+ 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: 'testnet4',
+ rpcVersion: '4.8.1',
+ blockHeight: 900_000,
+ serverHeight: 900_000,
+ syncStatus: WalletSyncStatus.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(WalletSyncStatus.Syncing);
+ });
+
+ it('throws when RPC calls fail', async () => {
+ walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
+
+ await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
+ });
+
+ it('rejects withdrawals when there is no confirmed balance', async () => {
+ walletRpcClient.getBalance.mockResolvedValue({
+ balanceAtomic: '0',
+ confirmedBalanceAtomic: '0'
+ });
+
+ await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
+ new BadRequestException('No confirmed balance to withdraw.')
+ );
+
+ expect(authService.verifyPassword).toHaveBeenCalledWith('password');
+ expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
+ });
+
+ it('rejects withdrawals while the wallet is still syncing', async () => {
+ walletRpcClient.isSynchronized.mockResolvedValue(false);
+
+ await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
+ new BadRequestException('Wallet is still syncing. Try again after sync completes.')
+ );
+ });
+
+ it('sweeps confirmed funds when the wallet is synced', async () => {
+ const result = await service.withdrawAll('bc1qdestination', 12, 'password');
+
+ expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('bc1qdestination', 12);
+ expect(result).toEqual({
+ txHash: 'tx-hash-1',
+ amountBtc: '0.00140000'
+ });
+ });
+
+ it('reveals the wallet seed after password verification', async () => {
+ await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
+
+ expect(authService.verifyPassword).toHaveBeenCalledWith('password');
+ expect(walletRpcClient.getSeed).toHaveBeenCalled();
+ });
+
+ it('throws when seed reveal fails', async () => {
+ walletRpcClient.getSeed.mockRejectedValue(new Error('rpc down'));
+
+ await expect(service.revealSeed('password')).rejects.toThrow(
+ new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.')
+ );
+ });
+
+ it('throws when withdrawal fails after prechecks pass', async () => {
+ walletRpcClient.sweepAll.mockRejectedValue(new Error('payto failed'));
+
+ await expect(service.withdrawAll('bc1qdestination', 8, 'password')).rejects.toThrow(
+ new ServiceUnavailableException(
+ 'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
+ )
+ );
+ });
+});
diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts
new file mode 100644
index 0000000..ec314a2
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts
@@ -0,0 +1,123 @@
+import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { AuthService } from '../../auth/services/AuthService';
+import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
+import type { Config } from '../../../types/Config';
+import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
+import type { BitcoinWalletRevealSeedResult } from '../types/BitcoinWalletRevealSeedResult';
+import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
+import type { BitcoinWalletWithdrawResult } from '../types/BitcoinWalletWithdrawResult';
+import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
+
+@Injectable()
+export class BitcoinWalletAdminService {
+ constructor(
+ private readonly walletRpcClient: ElectrumWalletRpcClient,
+ private readonly authService: AuthService,
+ 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.'
+ );
+ }
+ }
+
+ async withdrawAll(
+ destinationAddress: string,
+ feeRateSatVbyte: number,
+ password: string
+ ): Promise {
+ this.authService.verifyPassword(password);
+
+ let isSynchronized: boolean;
+ let confirmedBalanceAtomic: string;
+
+ try {
+ const [syncResult, balanceResult] = await Promise.all([
+ this.walletRpcClient.isSynchronized(),
+ this.walletRpcClient.getBalance()
+ ]);
+
+ isSynchronized = syncResult;
+ confirmedBalanceAtomic = balanceResult.confirmedBalanceAtomic;
+ } catch {
+ throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
+ }
+
+ if (confirmedBalanceAtomic === '0') {
+ throw new BadRequestException('No confirmed balance to withdraw.');
+ }
+
+ if (!isSynchronized) {
+ throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
+ }
+
+ let sweepResult: { txHash: string; amountAtomic: string };
+
+ try {
+ sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, feeRateSatVbyte);
+ } catch {
+ throw new ServiceUnavailableException(
+ 'Withdrawal failed. Funds may be unspendable dust, still unconfirmed, or the wallet may be out of sync. Refresh status and try again.'
+ );
+ }
+
+ return {
+ txHash: sweepResult.txHash,
+ amountBtc: convertBtcAtomicToBtc(sweepResult.amountAtomic)
+ };
+ }
+
+ async revealSeed(password: string): Promise {
+ this.authService.verifyPassword(password);
+
+ try {
+ const mnemonic = await this.walletRpcClient.getSeed();
+
+ return { mnemonic };
+ } catch {
+ throw new ServiceUnavailableException('Could not reach the Bitcoin wallet. Try again in a moment.');
+ }
+ }
+
+ private resolveSyncStatus(
+ isSynchronized: boolean,
+ blockHeight: number | null,
+ serverHeight: number | null
+ ): WalletSyncStatus {
+ if (isSynchronized) {
+ return WalletSyncStatus.Synced;
+ }
+
+ if (blockHeight === null || serverHeight === null) {
+ return WalletSyncStatus.Unknown;
+ }
+
+ return WalletSyncStatus.Syncing;
+ }
+}
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..537a354
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts
@@ -0,0 +1,288 @@
+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',
+ walletPassword: 'wallet-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('sumOutputValueAtomic', () => {
+ it('sums outputs paying to the target address', () => {
+ expect(
+ clientTest.sumOutputValueAtomic(
+ {
+ 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.sumOutputValueAtomic(
+ {
+ 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'
+ });
+ });
+ });
+
+ describe('sweepAll', () => {
+ it('creates, signs, and broadcasts a max-payment transaction', async () => {
+ mockedAxios.post
+ .mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: 'signed-tx'
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: {
+ outputs: [{ address: 'bc1qtest', value_sats: 140_000 }]
+ }
+ }
+ })
+ .mockResolvedValueOnce({
+ data: {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ result: 'tx-hash-1'
+ }
+ });
+
+ await expect(client.sweepAll('bc1qtest', 10)).resolves.toEqual({
+ txHash: 'tx-hash-1',
+ amountAtomic: '140000'
+ });
+
+ expect(mockedAxios.post).toHaveBeenNthCalledWith(
+ 1,
+ 'http://electrum.test:7777',
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method: 'payto',
+ params: {
+ destination: 'bc1qtest',
+ amount: '!',
+ feerate: '10',
+ password: 'wallet-secret'
+ }
+ },
+ expect.objectContaining({
+ timeout: 120_000
+ })
+ );
+ expect(mockedAxios.post).toHaveBeenNthCalledWith(
+ 2,
+ 'http://electrum.test:7777',
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method: 'deserialize',
+ params: { tx: 'signed-tx' }
+ },
+ expect.any(Object)
+ );
+ expect(mockedAxios.post).toHaveBeenNthCalledWith(
+ 3,
+ 'http://electrum.test:7777',
+ {
+ jsonrpc: '2.0',
+ id: 'nullcart',
+ method: 'broadcast',
+ params: { tx: 'signed-tx' }
+ },
+ expect.any(Object)
+ );
+ });
+ });
+
+});
diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts
new file mode 100644
index 0000000..40ab1f0
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts
@@ -0,0 +1,232 @@
+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 sweepAll(
+ destinationAddress: string,
+ feeRateSatVbyte: number
+ ): Promise<{ txHash: string; amountAtomic: string }> {
+ const signedTransaction = await this.payToMax(destinationAddress, feeRateSatVbyte);
+ const transaction = await this.deserializeTransaction(signedTransaction);
+
+ const amountAtomic = this.sumOutputValueAtomic(transaction, destinationAddress);
+
+ if (amountAtomic === '0') {
+ throw new Error('Withdrawal transaction has no spendable output to the destination address');
+ }
+
+ const txHash = await this.broadcast(signedTransaction);
+
+ return { txHash, amountAtomic };
+ }
+
+ private async payToMax(destinationAddress: string, feeRateSatVbyte: number): Promise {
+ const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
+
+ const signedTransaction = await this.call(
+ 'payto',
+ {
+ destination: destinationAddress,
+ amount: '!',
+ feerate: String(feeRateSatVbyte),
+ password: walletPassword
+ },
+ { timeoutMs: 120_000 }
+ );
+
+ if (!signedTransaction) {
+ throw new Error('Electrum wallet RPC payto returned no transaction');
+ }
+
+ return signedTransaction;
+ }
+
+ private async broadcast(signedTransaction: string): Promise {
+ const txHash = await this.call('broadcast', { tx: signedTransaction });
+
+ if (!txHash) {
+ throw new Error('Electrum wallet RPC broadcast returned no transaction hash');
+ }
+
+ return txHash;
+ }
+
+ private async deserializeTransaction(signedTransaction: string): Promise {
+ return this.call('deserialize', { tx: signedTransaction });
+ }
+
+ private sumOutputValueAtomic(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');
+ }
+
+ async getSeed(): Promise {
+ const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
+
+ const mnemonic = await this.call('getseed', { password: walletPassword });
+
+ if (!mnemonic) {
+ throw new Error('Electrum wallet RPC getseed returned no mnemonic');
+ }
+
+ return mnemonic;
+ }
+
+ 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.deserializeTransaction(serializedTransaction);
+ const amountAtomic = this.sumOutputValueAtomic(transaction, address);
+
+ return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
+ })
+ );
+
+ return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
+ }
+
+ 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/BitcoinWalletRevealSeedResult.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletRevealSeedResult.ts
new file mode 100644
index 0000000..6ba90ab
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletRevealSeedResult.ts
@@ -0,0 +1,3 @@
+export interface BitcoinWalletRevealSeedResult {
+ mnemonic: string;
+}
diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts
new file mode 100644
index 0000000..a886581
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts
@@ -0,0 +1,12 @@
+import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
+import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
+
+export interface BitcoinWalletStatusView {
+ network: ElectrumNetwork;
+ rpcVersion: string;
+ blockHeight: number | null;
+ serverHeight: number | null;
+ syncStatus: WalletSyncStatus;
+ balanceBtc: string;
+ confirmedBalanceBtc: string;
+}
diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts
new file mode 100644
index 0000000..63d4b29
--- /dev/null
+++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts
@@ -0,0 +1,4 @@
+export interface BitcoinWalletWithdrawResult {
+ txHash: string;
+ amountBtc: string;
+}
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..d7eb43c
--- /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;
+ sumOutputValueAtomic: (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;
+ };
+};
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/moneroWallet/controllers/MoneroWalletController.ts b/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts
index 8202632..860a3bf 100644
--- a/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts
+++ b/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts
@@ -18,8 +18,8 @@ export class MoneroWalletController {
@Post('/withdraw')
@Throttle(throttleProfiles.walletWithdraw)
- withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
- return this.walletAdminService.withdrawAll(destinationAddress, password);
+ withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) {
+ return this.walletAdminService.withdrawAll(destinationAddress, priority, password);
}
@Post('/reveal-seed')
diff --git a/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts b/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts
index 942c1c5..4eca8bd 100644
--- a/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts
+++ b/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer';
-import { IsNotEmpty, IsString } from 'class-validator';
+import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
+import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
export class MoneroWalletWithdrawDto {
@@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto {
@IsMoneroStandardAddress()
destinationAddress: string;
+ @IsNotEmpty()
+ @IsEnum(MoneroWithdrawPriority)
+ priority: MoneroWithdrawPriority;
+
@IsString()
@IsNotEmpty()
password: string;
diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts
index 75e7378..162c7c7 100644
--- a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts
+++ b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts
@@ -2,7 +2,8 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type { AuthService } from '../../auth/services/AuthService';
-import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
+import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
+import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
@@ -76,7 +77,7 @@ describe('MoneroWalletAdminService', () => {
rpcVersion: '0.18.3.1',
walletHeight: 3_000_000,
daemonHeight: 3_000_000,
- syncStatus: MoneroWalletSyncStatus.Synced,
+ syncStatus: WalletSyncStatus.Synced,
balanceXmr: '2.00000000',
unlockedBalanceXmr: '1.00000000'
})
@@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => {
unlockedBalanceAtomic: '0'
});
- await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
- new BadRequestException('No unlocked balance to withdraw.')
- );
+ await expect(
+ service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
+ ).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.'));
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
@@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => {
it('rejects withdrawals while the wallet is still syncing', async () => {
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
- await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
- new BadRequestException('Wallet is still syncing. Try again after sync completes.')
- );
+ await expect(
+ service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
+ ).rejects.toThrow(new BadRequestException('Wallet is still syncing. Try again after sync completes.'));
});
it('sweeps unlocked funds when the wallet is synced', async () => {
- const result = await service.withdrawAll('4DestinationAddressExample', 'password');
+ const result = await service.withdrawAll(
+ '4DestinationAddressExample',
+ MoneroWithdrawPriority.Fast,
+ 'password'
+ );
- expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
+ expect(walletRpcClient.sweepAll).toHaveBeenCalledWith(
+ '4DestinationAddressExample',
+ MoneroWithdrawPriority.Fast
+ );
expect(result).toEqual({
txHashes: ['tx-hash-1'],
amountXmr: '1.00000000'
@@ -137,7 +145,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus();
- expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
+ expect(status.syncStatus).toBe(WalletSyncStatus.Unknown);
expect(status.daemonHeight).toBeNull();
});
@@ -149,7 +157,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus();
- expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
+ expect(status.syncStatus).toBe(WalletSyncStatus.Synced);
});
it('throws when reveal seed RPC fails', async () => {
@@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => {
it('throws when sweep all fails after prechecks pass', async () => {
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
- await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
+ await expect(
+ service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
+ ).rejects.toThrow(
new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
)
diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts
index 4ebc2d1..e396f80 100644
--- a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts
+++ b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts
@@ -7,7 +7,8 @@ import type { Config } from '../../../types/Config';
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
-import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
+import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
+import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
@@ -49,7 +50,11 @@ export class MoneroWalletAdminService {
}
}
- async withdrawAll(destinationAddress: string, password: string): Promise {
+ async withdrawAll(
+ destinationAddress: string,
+ priority: MoneroWithdrawPriority,
+ password: string
+ ): Promise {
this.authService.verifyPassword(password);
await this.walletRpcClient.tryRefresh();
@@ -59,11 +64,15 @@ export class MoneroWalletAdminService {
let daemonHeight: number | null;
try {
- [{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
+ const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([
this.walletRpcClient.getBalance(),
this.walletRpcClient.getHeight(),
this.fetchDaemonHeight()
]);
+
+ unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic;
+ walletHeight = walletHeightResult;
+ daemonHeight = daemonHeightResult;
} catch {
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
}
@@ -72,7 +81,7 @@ export class MoneroWalletAdminService {
throw new BadRequestException('No unlocked balance to withdraw.');
}
- if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
+ if (this.resolveSyncStatus(walletHeight, daemonHeight) !== WalletSyncStatus.Synced) {
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
}
@@ -80,7 +89,10 @@ export class MoneroWalletAdminService {
let amountAtomic: string;
try {
- ({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
+ const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority);
+
+ txHashes = sweepResult.txHashes;
+ amountAtomic = sweepResult.amountAtomic;
} catch {
throw new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
@@ -125,11 +137,11 @@ export class MoneroWalletAdminService {
}
}
- private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
+ private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): WalletSyncStatus {
if (daemonHeight === null) {
- return MoneroWalletSyncStatus.Unknown;
+ return WalletSyncStatus.Unknown;
}
- return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
+ return walletHeight >= daemonHeight - 1 ? WalletSyncStatus.Synced : WalletSyncStatus.Syncing;
}
}
diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts
index f7748fd..e9713f3 100644
--- a/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts
+++ b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts
@@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
+import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
@@ -222,14 +223,17 @@ export class MoneroWalletRpcClient {
};
}
- async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> {
+ async sweepAll(
+ destinationAddress: string,
+ priority: MoneroWithdrawPriority
+ ): Promise<{ txHashes: string[]; amountAtomic: string }> {
const result = await this.call(
'sweep_all',
{
address: destinationAddress,
account_index: this.accountIndex,
subaddr_indices_all: true,
- priority: 1
+ priority
},
{ timeoutMs: 120_000 }
);
diff --git a/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts b/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts
index cfc2254..6be381d 100644
--- a/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts
+++ b/backend/src/modules/moneroWallet/types/MoneroWalletStatusView.ts
@@ -1,12 +1,12 @@
import { MoneroNetwork } from '../../../types/MoneroNetwork';
-import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
+import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
export interface MoneroWalletStatusView {
network: MoneroNetwork;
rpcVersion: string;
walletHeight: number;
daemonHeight: number | null;
- syncStatus: MoneroWalletSyncStatus;
+ syncStatus: WalletSyncStatus;
balanceXmr: string;
unlockedBalanceXmr: string;
}
diff --git a/backend/src/modules/order/services/OrderCreationService.ts b/backend/src/modules/order/services/OrderCreationService.ts
index 04b05f4..0e5a18a 100644
--- a/backend/src/modules/order/services/OrderCreationService.ts
+++ b/backend/src/modules/order/services/OrderCreationService.ts
@@ -36,6 +36,7 @@ export class OrderCreationService {
.leftJoinAndSelect('session.discounts', 'discount')
.leftJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
+ .leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order')
.where('session.id = :sessionId', { sessionId })
diff --git a/backend/src/modules/order/services/OrderService.spec.ts b/backend/src/modules/order/services/OrderService.spec.ts
index ab43bfa..a934b79 100644
--- a/backend/src/modules/order/services/OrderService.spec.ts
+++ b/backend/src/modules/order/services/OrderService.spec.ts
@@ -210,11 +210,45 @@ describe('OrderService', () => {
accessToken: 'plain-token',
checkoutInvoice: expect.objectContaining({
statusLabel: 'Payment confirmed',
+ paymentLabel: 'XMR',
expectedTotalCrypto: '0.10000000'
})
})
);
});
+
+ it('formats bitcoin checkout invoice amounts for the admin order view', async () => {
+ const storedOrder = {
+ ...buildStoredOrder(),
+ checkoutInvoice: {
+ id: 'invoice-1',
+ fiatCurrency: 'USD',
+ paymentMethod: PaymentMethod.Btc,
+ expectedTotalAtomic: '100000000',
+ expiresAt: new Date('2099-01-01T00:00:00.000Z'),
+ btcDetails: { fiatPerBtcAtCreation: 60_000, requiredConfirmations: 1 },
+ moneroDetails: null,
+ payments: [{ id: 'pay-1', amountAtomic: '100000000', confirmations: 1, txHash: 'tx-1' }],
+ reason: InvoiceReason.Checkout
+ }
+ } as unknown as Order;
+
+ orderDetailQueryBuilder.getOne.mockResolvedValue(storedOrder);
+
+ const result = await service.findById('order-1');
+
+ expect(result.checkoutInvoice).toEqual(
+ expect.objectContaining({
+ paymentLabel: 'BTC',
+ expectedTotalCrypto: '1.00000000',
+ payments: [
+ expect.objectContaining({
+ amountCrypto: '1.00000000'
+ })
+ ]
+ })
+ );
+ });
});
describe('setDeliveryCost', () => {
@@ -222,7 +256,7 @@ describe('OrderService', () => {
({
id: 'order-1',
lines: [{ deliveryMode: DeliveryMode.Manual }],
- checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD' }
+ checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD', paymentMethod: PaymentMethod.Xmr }
}) as Order;
it('throws when the order cannot be quoted', async () => {
@@ -300,6 +334,26 @@ describe('OrderService', () => {
});
expect(result).toBe(orderExtended);
});
+
+ it('uses the checkout invoice payment method for shipping invoices', async () => {
+ orderRepo.findOne.mockResolvedValue({
+ ...buildQuotableOrder(),
+ checkoutInvoice: {
+ id: 'checkout-invoice-1',
+ fiatCurrency: 'USD',
+ paymentMethod: PaymentMethod.Btc
+ }
+ });
+
+ await service.setDeliveryCost('order-1', { deliveryCost: 12.5 });
+
+ expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
+ paymentMethod: PaymentMethod.Btc,
+ reason: InvoiceReason.Shipping,
+ contextId: 'order-1',
+ amountFiat: 12.5
+ });
+ });
});
describe('fulfillManualLine', () => {
diff --git a/backend/src/modules/order/services/OrderService.ts b/backend/src/modules/order/services/OrderService.ts
index fb8b2eb..0edb1bb 100644
--- a/backend/src/modules/order/services/OrderService.ts
+++ b/backend/src/modules/order/services/OrderService.ts
@@ -8,14 +8,15 @@ import { formatInvoicePaymentConfirmationStatus } from '../../../utils/invoice/f
import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage';
import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations';
import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState';
-import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
+import type { CryptoAtomicConverter } from '../../../types/CryptoAtomicConverter';
+import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
+import { resolveAtomicToCryptoConverter } from '../../../utils/payment/resolveAtomicToCryptoConverter';
import type { Invoice } from '../../payment/entities/Invoice';
import type { InvoicePayment } from '../../payment/entities/InvoicePayment';
import type { InvoiceExtended } from '../../payment/types/InvoiceExtended';
import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService';
-import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { DeliveryMode } from '../../product/types/DeliveryMode';
import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto';
import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto';
@@ -99,9 +100,11 @@ export class OrderService {
'checkoutInvoice',
'checkoutInvoice.payments',
'checkoutInvoice.moneroDetails',
+ 'checkoutInvoice.btcDetails',
'shippingInvoice',
'shippingInvoice.payments',
'shippingInvoice.moneroDetails',
+ 'shippingInvoice.btcDetails',
'lines',
'lines.manualFulfillment',
'discounts',
@@ -181,24 +184,31 @@ export class OrderService {
const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null;
- const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
+ const convertAtomicToCrypto = resolveAtomicToCryptoConverter(invoice.paymentMethod);
+
+ const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
const payments = (invoice.payments ?? []).map(payment =>
- this.toInvoicePaymentExtended(payment, requiredConfirmations)
+ this.toInvoicePaymentExtended(payment, requiredConfirmations, convertAtomicToCrypto)
);
return {
...invoice,
statusLabel,
+ paymentLabel: paymentMethodLabel[invoice.paymentMethod],
expectedTotalCrypto,
payments
};
}
- private toInvoicePaymentExtended(payment: InvoicePayment, requiredConfirmations: number): InvoicePaymentExtended {
+ private toInvoicePaymentExtended(
+ payment: InvoicePayment,
+ requiredConfirmations: number,
+ convertAtomicToCrypto: CryptoAtomicConverter
+ ): InvoicePaymentExtended {
const isConfirmed = payment.confirmations >= requiredConfirmations;
- const amountCrypto = convertXmrAtomicToXmr(payment.amountAtomic);
+ const amountCrypto = convertAtomicToCrypto(payment.amountAtomic);
const confirmationsLabel = formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations,
@@ -244,7 +254,7 @@ export class OrderService {
}
const shippingInvoice = await this.invoiceService.issueInvoice({
- paymentMethod: PaymentMethod.Xmr,
+ paymentMethod: order.checkoutInvoice.paymentMethod,
reason: InvoiceReason.Shipping,
contextId: orderId,
amountFiat: deliveryCost
diff --git a/backend/src/modules/payment/PaymentModule.ts b/backend/src/modules/payment/PaymentModule.ts
index 981c2ef..d1dc126 100644
--- a/backend/src/modules/payment/PaymentModule.ts
+++ b/backend/src/modules/payment/PaymentModule.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
+import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
-import { XmrRateModule } from '../xmrRate/XmrRateModule';
+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,9 +12,10 @@ import { InvoiceService } from './services/InvoiceService';
@Module({
imports: [
- TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]),
+ TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule,
- XmrRateModule
+ BitcoinWalletModule,
+ ExchangeRateModule
],
providers: [InvoicePaymentService, InvoiceService],
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
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/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 ae829e2..e187ddd 100644
--- a/backend/src/modules/payment/services/InvoiceService.spec.ts
+++ b/backend/src/modules/payment/services/InvoiceService.spec.ts
@@ -1,8 +1,9 @@
-import { Logger, ServiceUnavailableException } from '@nestjs/common';
+import { Logger, InternalServerErrorException, 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 { 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';
@@ -19,11 +20,14 @@ describe('InvoiceService', () => {
let configService: {
get: jest.Mock;
};
- let walletRpcClient: {
+ let moneroWalletRpcClient: {
createAddress: jest.Mock;
};
- let xmrRateService: {
- getLiveFiatPerXmr: jest.Mock;
+ let bitcoinWalletRpcClient: {
+ createAddress: jest.Mock;
+ };
+ let exchangeRateService: {
+ getLiveFiatPerCrypto: jest.Mock;
};
let errorLogSpy: jest.SpiedFunction;
@@ -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,22 +61,27 @@ describe('InvoiceService', () => {
})
};
- walletRpcClient = {
+ moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample',
address_index: 12
})
};
- xmrRateService = {
- getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
+ bitcoinWalletRpcClient = {
+ createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
+ };
+
+ 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
+ moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
+ bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
+ exchangeRateService as unknown as ExchangeRateService
);
});
@@ -76,26 +89,26 @@ 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
});
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.")
);
- 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 };
}
@@ -169,7 +186,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,8 +201,8 @@ describe('InvoiceService', () => {
)
);
- xmrRateService.getLiveFiatPerXmr.mockReturnValue(150);
- walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
+ exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
+ 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,87 @@ 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."
+ )
+ );
+ });
+
+ it('throws for unsupported payment methods', async () => {
+ await expect(
+ service.issueInvoice({
+ paymentMethod: 'eth' as PaymentMethod,
+ reason: InvoiceReason.Checkout,
+ contextId: 'session-uuid',
+ amountFiat: 15
+ })
+ ).rejects.toThrow(new InternalServerErrorException('Unsupported payment method: eth'));
});
});
diff --git a/backend/src/modules/payment/services/InvoiceService.ts b/backend/src/modules/payment/services/InvoiceService.ts
index ba1300e..5a4f0fb 100644
--- a/backend/src/modules/payment/services/InvoiceService.ts
+++ b/backend/src/modules/payment/services/InvoiceService.ts
@@ -1,15 +1,18 @@
-import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
+import { Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
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/monero/resolveMinConfirmations';
+import { resolveMinConfirmations } from '../../../utils/confirmation/resolveMinConfirmations';
+import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
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';
@@ -24,14 +27,19 @@ export class InvoiceService {
@InjectRepository(Invoice)
private readonly invoiceRepo: Repository,
private readonly configService: ConfigService,
- private readonly walletRpcClient: MoneroWalletRpcClient,
- private readonly xmrRateService: XmrRateService
+ private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
+ private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
+ private readonly exchangeRateService: ExchangeRateService
) {}
async issueInvoice(data: IssueInvoiceData): Promise {
switch (data.paymentMethod) {
case PaymentMethod.Xmr:
return this.issueXmrInvoice(data);
+ case PaymentMethod.Btc:
+ return this.issueBtcInvoice(data);
+ default:
+ throw new InternalServerErrorException(`Unsupported payment method: ${String(data.paymentMethod)}`);
}
}
@@ -41,10 +49,11 @@ export class InvoiceService {
const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
reason,
- contextId
+ contextId,
+ PaymentMethod.Xmr
);
- const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
+ const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
if (fiatPerXmr === null) {
throw new ServiceUnavailableException(rateUnavailableMessage);
@@ -54,7 +63,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 +98,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 +169,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/InvoiceExtended.ts b/backend/src/modules/payment/types/InvoiceExtended.ts
index 9724f84..10a4833 100644
--- a/backend/src/modules/payment/types/InvoiceExtended.ts
+++ b/backend/src/modules/payment/types/InvoiceExtended.ts
@@ -4,6 +4,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
export type InvoiceExtended = Omit & {
statusLabel: InvoiceStatusLabel | null;
+ paymentLabel: string;
expectedTotalCrypto: string;
payments: InvoicePaymentExtended[];
};
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;
};
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/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/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/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/modules/storefrontCart/StorefrontCartModule.ts b/backend/src/modules/storefrontCart/StorefrontCartModule.ts
index 9635bdc..8967229 100644
--- a/backend/src/modules/storefrontCart/StorefrontCartModule.ts
+++ b/backend/src/modules/storefrontCart/StorefrontCartModule.ts
@@ -3,14 +3,13 @@ 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 { 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],
controllers: [StorefrontCartController],
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
exports: [StorefrontCartService]
diff --git a/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts b/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts
index d2046c8..e1d79ad 100644
--- a/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts
+++ b/backend/src/modules/storefrontCart/controllers/StorefrontCartController.ts
@@ -1,7 +1,11 @@
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { throttleProfiles } from '../../../config/throttleProfiles';
+import type { Config } from '../../../types/Config';
+import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
+import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
@@ -33,7 +37,8 @@ export class StorefrontCartController {
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
private readonly captchaService: StorefrontCaptchaService,
- private readonly captchaCookieService: StorefrontCaptchaCookieService
+ private readonly captchaCookieService: StorefrontCaptchaCookieService,
+ private readonly configService: ConfigService
) {}
@Get('shop/cart')
@@ -62,9 +67,18 @@ export class StorefrontCartController {
this.captchaCookieService.setAnswer(req, res, encryptedAnswer);
+ const { enabledPaymentMethods } = this.configService.get('shopSettings') as Config['shopSettings'];
+
+ const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({
+ paymentMethod,
+ paymentMethodLabel: paymentMethodLabel[paymentMethod],
+ iconUrl: paymentMethodIconUrl[paymentMethod]
+ }));
+
return res.render('cart-summary', {
...summary,
...shopLocals,
+ paymentMethods,
captchaSvg
});
}
diff --git a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
index cf5ca2c..ef2aa75 100644
--- a/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
+++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.spec.ts
@@ -1,7 +1,6 @@
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 { CookieCartLineExtended } from '../types/CookieCartLineExtended';
import type { StorefrontDiscountService } from './StorefrontDiscountService';
import { StorefrontCartService } from './StorefrontCartService';
@@ -29,9 +28,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock;
};
- let xmrRateService: {
- getLiveFiatPerXmr: jest.Mock;
- };
let discountService: {
getDiscountStateForCart: jest.Mock;
applyDiscountCode: jest.Mock;
@@ -43,10 +39,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
};
- xmrRateService = {
- getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
- };
-
discountService = {
getDiscountStateForCart: jest.fn().mockResolvedValue({
discounts: [],
@@ -58,7 +50,6 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService,
- xmrRateService as unknown as XmrRateService,
discountService as unknown as StorefrontDiscountService
);
});
@@ -98,8 +89,6 @@ describe('StorefrontCartService', () => {
discounts: [],
cartDiscountTotal: 0,
cartTotalPrice: 0,
- cartTotalXmr: null,
- fiatPerXmr: null,
hasManualLines: false,
hasAutoLines: false,
cartTotalIssueMessage: null,
@@ -107,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 () => {
- xmrRateService.getLiveFiatPerXmr.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 853dba3..2ec87a2 100644
--- a/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
+++ b/backend/src/modules/storefrontCart/services/StorefrontCartService.ts
@@ -7,8 +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 { XmrRateService } from '../../xmrRate/services/XmrRateService';
-import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { CookieCartLineDto } from '../dto/CookieCartLineDto';
import type { CookieCartExtended } from '../types/CookieCartExtended';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
@@ -22,7 +20,6 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService {
constructor(
private readonly productsService: StorefrontProductsService,
- private readonly xmrRateService: XmrRateService,
private readonly discountService: StorefrontDiscountService
) {}
@@ -66,8 +63,6 @@ export class StorefrontCartService {
discounts: [],
cartDiscountTotal: 0,
cartTotalPrice: 0,
- cartTotalXmr: null,
- fiatPerXmr: null,
hasManualLines: false,
hasAutoLines: false,
cartTotalIssueMessage: null,
@@ -77,8 +72,6 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended);
- const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
- 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);
@@ -91,8 +84,6 @@ export class StorefrontCartService {
cartExtended,
cartSubtotal,
...discountState,
- cartTotalXmr,
- fiatPerXmr,
hasManualLines,
hasAutoLines,
cartTotalIssueMessage,
diff --git a/backend/src/modules/storefrontCart/types/CookieCartSummary.ts b/backend/src/modules/storefrontCart/types/CookieCartSummary.ts
index f162516..5106ff5 100644
--- a/backend/src/modules/storefrontCart/types/CookieCartSummary.ts
+++ b/backend/src/modules/storefrontCart/types/CookieCartSummary.ts
@@ -4,8 +4,6 @@ import type { CookieCartExtended } from './CookieCartExtended';
export type CookieCartSummary = {
cartExtended: CookieCartExtended;
cartSubtotal: number;
- cartTotalXmr: string | null;
- fiatPerXmr: number | null;
hasManualLines: boolean;
hasAutoLines: boolean;
cartTotalIssueMessage: string | null;
diff --git a/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts b/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts
index 0056244..f59d748 100644
--- a/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts
+++ b/backend/src/modules/storefrontCheckout/controllers/StorefrontCheckoutController.ts
@@ -41,7 +41,7 @@ export class StorefrontCheckoutController {
@Post('shop/checkout/pay')
@Throttle(throttleProfiles.checkoutPay)
- async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise {
+ async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha, paymentMethod }: PayCheckoutDto): Promise {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (sessionId) {
@@ -64,7 +64,7 @@ export class StorefrontCheckoutController {
const summary = await this.cartService.getCartSummary(cart, discountCodes);
- const session = await this.checkoutSessionService.createFromCartSummary(summary);
+ const session = await this.checkoutSessionService.createFromCartSummary(summary, paymentMethod);
this.checkoutSessionCookieService.setSessionId(req, res, session.id);
diff --git a/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts b/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts
index 265ce0a..0e612c5 100644
--- a/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts
+++ b/backend/src/modules/storefrontCheckout/dto/PayCheckoutDto.ts
@@ -1,10 +1,13 @@
-import { IsNotEmpty, IsString, Length } from 'class-validator';
-import { getAppConfig } from '../../../config';
+import { IsEnum, IsIn, IsNotEmpty, IsString, Length } from 'class-validator';
+import { getAppConfig, getShopSettingsConfig } from '../../../config';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
const {
captcha: { length: captchaLength }
} = getAppConfig();
+const { enabledPaymentMethods } = getShopSettingsConfig();
+
export class PayCheckoutDto {
@IsNotEmpty()
@IsString()
@@ -12,4 +15,9 @@ export class PayCheckoutDto {
message: `Captcha should be ${captchaLength} characters long`
})
captcha: string;
+
+ @IsNotEmpty()
+ @IsEnum(PaymentMethod)
+ @IsIn(enabledPaymentMethods, { message: 'Select a valid payment method' })
+ paymentMethod: PaymentMethod;
}
diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts b/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts
index f3e271a..512cc7f 100644
--- a/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts
+++ b/backend/src/modules/storefrontCheckout/services/CheckoutPaymentPollerService.ts
@@ -25,6 +25,7 @@ export class CheckoutPaymentPollerService {
.createQueryBuilder('session')
.innerJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
+ .leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order')
.where('session.cancelledAt IS NULL')
diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts
index 9c21e21..6093b07 100644
--- a/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts
+++ b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.spec.ts
@@ -38,8 +38,6 @@ const buildSummary = (overrides: Partial = {}): CookieCartSum
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
cartDiscountTotal: 1,
cartTotalPrice: 9,
- cartTotalXmr: '0.06000000',
- fiatPerXmr: 150,
hasManualLines: false,
hasAutoLines: true,
cartTotalIssueMessage: null,
@@ -115,15 +113,15 @@ describe('CheckoutSessionService', () => {
describe('createFromCartSummary', () => {
it('rejects an empty cart', async () => {
- await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow(
- new BadRequestException('Your cart is empty')
- );
+ await expect(
+ service.createFromCartSummary(buildSummary({ cartExtended: [] }), PaymentMethod.Xmr)
+ ).rejects.toThrow(new BadRequestException('Your cart is empty'));
});
it('rejects carts that still have unresolved issues', async () => {
- await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow(
- new BadRequestException('Resolve cart issues before paying')
- );
+ await expect(
+ service.createFromCartSummary(buildSummary({ hasIssues: true }), PaymentMethod.Xmr)
+ ).rejects.toThrow(new BadRequestException('Resolve cart issues before paying'));
});
it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => {
@@ -135,7 +133,7 @@ describe('CheckoutSessionService', () => {
]
});
- const session = await service.createFromCartSummary(summary);
+ const session = await service.createFromCartSummary(summary, PaymentMethod.Xmr);
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Xmr,
@@ -180,7 +178,8 @@ describe('CheckoutSessionService', () => {
await service.createFromCartSummary(
buildSummary({
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
- })
+ }),
+ PaymentMethod.Xmr
);
expect(discountRepo.create).not.toHaveBeenCalled();
@@ -190,6 +189,17 @@ describe('CheckoutSessionService', () => {
})
);
});
+
+ it('issues a bitcoin checkout invoice when that payment method is selected', async () => {
+ await service.createFromCartSummary(buildSummary(), PaymentMethod.Btc);
+
+ expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
+ paymentMethod: PaymentMethod.Btc,
+ reason: InvoiceReason.Checkout,
+ contextId: 'session-uuid',
+ amountFiat: 9
+ });
+ });
});
describe('findById', () => {
@@ -200,7 +210,14 @@ describe('CheckoutSessionService', () => {
await expect(service.findById('session-1')).resolves.toBe(session);
expect(sessionRepo.findOne).toHaveBeenCalledWith({
where: { id: 'session-1' },
- relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
+ relations: [
+ 'lines',
+ 'discounts',
+ 'invoice',
+ 'invoice.moneroDetails',
+ 'invoice.btcDetails',
+ 'invoice.payments'
+ ]
});
});
});
diff --git a/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts
index 2a4f983..ba683ae 100644
--- a/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts
+++ b/backend/src/modules/storefrontCheckout/services/CheckoutSessionService.ts
@@ -6,7 +6,7 @@ import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSum
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService';
-import { PaymentMethod } from '../../payment/types/PaymentMethod';
+import type { PaymentMethod } from '../../payment/types/PaymentMethod';
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import { CheckoutSession } from '../entities/CheckoutSession';
@@ -26,11 +26,21 @@ export class CheckoutSessionService {
async findById(id: string): Promise {
return this.sessionRepo.findOne({
where: { id },
- relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments']
+ relations: [
+ 'lines',
+ 'discounts',
+ 'invoice',
+ 'invoice.moneroDetails',
+ 'invoice.btcDetails',
+ 'invoice.payments'
+ ]
});
}
- async createFromCartSummary(summary: CookieCartSummary): Promise {
+ async createFromCartSummary(
+ summary: CookieCartSummary,
+ requestedPaymentMethod: PaymentMethod
+ ): Promise {
if (summary.cartExtended.length === 0) {
throw new BadRequestException('Your cart is empty');
}
@@ -42,7 +52,7 @@ export class CheckoutSessionService {
const sessionId = randomUUID();
const invoice = await this.invoiceService.issueInvoice({
- paymentMethod: PaymentMethod.Xmr,
+ paymentMethod: requestedPaymentMethod,
reason: InvoiceReason.Checkout,
contextId: sessionId,
amountFiat: summary.cartTotalPrice
@@ -84,7 +94,7 @@ export class CheckoutSessionService {
async cancelSession(sessionId: string): Promise {
const session = await this.sessionRepo.findOne({
where: { id: sessionId },
- relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments']
+ relations: ['invoice', 'invoice.moneroDetails', 'invoice.btcDetails', 'invoice.payments']
});
if (!session) {
diff --git a/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts b/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts
index 245c635..7bf57ce 100644
--- a/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts
+++ b/backend/src/modules/storefrontCheckout/services/StorefrontCheckoutViewService.spec.ts
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
let toStorefrontInvoiceViewSpy: jest.SpiedFunction;
const checkoutInvoiceView = {
- cryptoCurrency: 'XMR',
+ paymentLabel: 'XMR',
amountFiat: 9
} as unknown as StorefrontInvoiceView;
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/public/css/storefront.css b/backend/src/modules/storefrontCore/public/css/storefront.css
index 3eb68f9..730e251 100644
--- a/backend/src/modules/storefrontCore/public/css/storefront.css
+++ b/backend/src/modules/storefrontCore/public/css/storefront.css
@@ -1,4 +1,5 @@
:root {
+ color-scheme: light;
--sf-font-sans:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
--sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -52,6 +53,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
+ color-scheme: dark;
--sf-bg: #0a0a0a;
--sf-surface: #141414;
--sf-surface-muted: #262727;
@@ -85,6 +87,7 @@
}
:root[data-theme='dark'] {
+ color-scheme: dark;
--sf-bg: #0a0a0a;
--sf-surface: #141414;
--sf-surface-muted: #262727;
@@ -117,6 +120,7 @@
}
:root[data-theme='light'] {
+ color-scheme: light;
--sf-bg: #ffffff;
--sf-surface: #ffffff;
--sf-surface-muted: #f5f7fa;
@@ -154,10 +158,6 @@
box-sizing: border-box;
}
-html {
- color-scheme: light dark;
-}
-
body.sf-body {
margin: 0;
min-height: 100vh;
@@ -355,9 +355,31 @@ a:hover {
gap: var(--sf-space-1);
}
+.sf-rates {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--sf-space-3);
+ align-items: center;
+}
+
.sf-rate {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--sf-space-1);
font-size: 0.95rem;
color: var(--sf-text-muted);
+ white-space: nowrap;
+}
+
+.sf-crypto-icon {
+ width: 1.25rem;
+ height: 1.25rem;
+ object-fit: contain;
+ flex-shrink: 0;
+}
+
+.sf-btn--pay {
+ gap: var(--sf-space-2);
}
.sf-category-nav {
diff --git a/backend/src/modules/storefrontCore/public/img/btc.png b/backend/src/modules/storefrontCore/public/img/btc.png
new file mode 100644
index 0000000..4e2d591
Binary files /dev/null and b/backend/src/modules/storefrontCore/public/img/btc.png differ
diff --git a/backend/src/modules/storefrontCore/public/img/xmr.png b/backend/src/modules/storefrontCore/public/img/xmr.png
new file mode 100644
index 0000000..95baa7b
Binary files /dev/null and b/backend/src/modules/storefrontCore/public/img/xmr.png differ
diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts
index d3aa91b..52ecc67 100644
--- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts
+++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.spec.ts
@@ -2,12 +2,13 @@ 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';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => {
type ServiceOverrides = {
@@ -21,8 +22,9 @@ describe('StorefrontShopViewService', () => {
simplexLink: string | null;
shippingNote: string | null;
};
- shopSettings?: { shopName: string; shopFiatCurrency: string };
+ shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
+ fiatPerBtc?: number | null;
req?: Partial> & {
host?: string;
};
@@ -45,14 +47,21 @@ describe('StorefrontShopViewService', () => {
simplexLink: null,
shippingNote: null
},
- shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' },
+ shopSettings = {
+ shopName: 'Demo Shop',
+ shopFiatCurrency: 'USD',
+ enabledPaymentMethods: [PaymentMethod.Xmr, PaymentMethod.Btc]
+ },
fiatPerXmr = 150,
+ fiatPerBtc = 60_000,
req: reqOverrides = {}
} = overrides;
- const xmrRateService = {
- getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr)
- } as unknown as XmrRateService;
+ const exchangeRateService = {
+ getLiveFiatPerCrypto: jest.fn((method: PaymentMethod) =>
+ method === PaymentMethod.Btc ? fiatPerBtc : fiatPerXmr
+ )
+ } as unknown as ExchangeRateService;
const configService = {
get: jest.fn().mockReturnValue(shopSettings)
} as unknown as ConfigService;
@@ -73,7 +82,7 @@ describe('StorefrontShopViewService', () => {
} as unknown as StorefrontThemeCookieService;
const service = new StorefrontShopViewService(
- xmrRateService,
+ exchangeRateService,
configService,
shopSettingsService,
cartCookieService,
@@ -124,7 +133,8 @@ describe('StorefrontShopViewService', () => {
simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days'
},
- fiatPerXmr: 200
+ fiatPerXmr: 200,
+ fiatPerBtc: 80_000
});
const locals = await service.buildShopRenderLocals(req, res, defaultPage);
@@ -134,7 +144,10 @@ describe('StorefrontShopViewService', () => {
cartTotalQty: 3,
feedback: { type: 'success', text: 'Added to cart' },
shopFiatCurrency: 'USD',
- fiatPerXmr: 200,
+ cryptoRates: [
+ { paymentLabel: 'XMR', fiatPerCrypto: 200, iconUrl: '/shop/assets/img/xmr.png' },
+ { paymentLabel: 'BTC', fiatPerCrypto: 80_000, iconUrl: '/shop/assets/img/btc.png' }
+ ],
logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico',
simplexLink: 'https://simplex.example',
@@ -264,7 +277,7 @@ describe('StorefrontShopViewService', () => {
it('uses shop fiat currency in product json-ld', async () => {
const { service, req, res } = createService({
- shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR' },
+ shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR', enabledPaymentMethods: [PaymentMethod.Xmr] },
req: {
path: '/shop/products/1/variants/2',
originalUrl: '/shop/products/1/variants/2'
diff --git a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts
index 33e4bd9..08f5d62 100644
--- a/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts
+++ b/backend/src/modules/storefrontCore/services/StorefrontShopViewService.ts
@@ -7,9 +7,13 @@ 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 { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
+import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
+import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
import type { ShopRenderLocals } from '../types/ShopRenderLocals';
+import type { StorefrontCryptoRate } from '../types/StorefrontCryptoRate';
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
@@ -22,7 +26,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,
@@ -38,9 +42,11 @@ export class StorefrontShopViewService {
const cartTotalQty = getTotalCartQtyFromCart(cart);
- const { shopName, shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
+ const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
+ 'shopSettings'
+ ) as Config['shopSettings'];
- const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr();
+ const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
const { logoUrl, faviconUrl, simplexLink, shippingNote } =
await this.shopSettingsService.getStorefrontBranding();
@@ -60,7 +66,7 @@ export class StorefrontShopViewService {
authorizedOrders,
shopNavActive,
shopFiatCurrency,
- fiatPerXmr,
+ cryptoRates,
logoUrl,
faviconUrl,
simplexLink,
@@ -129,4 +135,22 @@ export class StorefrontShopViewService {
return JSON.stringify(data);
}
+
+ private buildCryptoRates(enabledPaymentMethods: PaymentMethod[]): StorefrontCryptoRate[] {
+ return enabledPaymentMethods.flatMap(paymentMethod => {
+ const fiatPerCrypto = this.exchangeRateService.getLiveFiatPerCrypto(paymentMethod);
+
+ if (fiatPerCrypto === null) {
+ return [];
+ }
+
+ return [
+ {
+ paymentLabel: paymentMethodLabel[paymentMethod],
+ fiatPerCrypto,
+ iconUrl: paymentMethodIconUrl[paymentMethod]
+ }
+ ];
+ });
+ }
}
diff --git a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts
index ab1770b..49742f9 100644
--- a/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts
+++ b/backend/src/modules/storefrontCore/types/ShopRenderLocals.ts
@@ -1,6 +1,7 @@
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem';
import type { ShopNavActive } from './ShopNavActive';
+import type { StorefrontCryptoRate } from './StorefrontCryptoRate';
import type { StorefrontFeedback } from './StorefrontFeedback';
import type { StorefrontPageMeta } from './StorefrontPageMeta';
import type { StorefrontThemePreference } from './StorefrontThemePreference';
@@ -13,7 +14,7 @@ export type ShopRenderLocals = {
authorizedOrders: AuthorizedOrderNavItem[];
shopNavActive: ShopNavActive;
shopFiatCurrency: ShopFiatCurrency;
- fiatPerXmr: number | null;
+ cryptoRates: StorefrontCryptoRate[];
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
diff --git a/backend/src/modules/storefrontCore/types/StorefrontCryptoRate.ts b/backend/src/modules/storefrontCore/types/StorefrontCryptoRate.ts
new file mode 100644
index 0000000..a39ab79
--- /dev/null
+++ b/backend/src/modules/storefrontCore/types/StorefrontCryptoRate.ts
@@ -0,0 +1,5 @@
+export type StorefrontCryptoRate = {
+ paymentLabel: string;
+ fiatPerCrypto: number;
+ iconUrl: string;
+};
diff --git a/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts b/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts
index 69e6b68..e1f9fdc 100644
--- a/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts
+++ b/backend/src/modules/storefrontCore/types/StorefrontInvoiceView.ts
@@ -3,7 +3,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant';
export type StorefrontInvoiceView = {
- cryptoCurrency: string;
+ paymentLabel: string;
expectedTotalCrypto: string;
receivedTotalCrypto: string | null;
paymentAddress: string;
diff --git a/backend/src/modules/storefrontCore/views/checkout.hbs b/backend/src/modules/storefrontCore/views/checkout.hbs
index 910fddc..8e6ca82 100644
--- a/backend/src/modules/storefrontCore/views/checkout.hbs
+++ b/backend/src/modules/storefrontCore/views/checkout.hbs
@@ -23,14 +23,14 @@
Payment received
Received:
- {{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.cryptoCurrency}}
+ {{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.paymentLabel}}
Your order is being prepared. This page will redirect automatically once it is ready.
{{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}}
{{else}}
- Pay with {{checkout.checkoutInvoice.cryptoCurrency}}
+ Pay with {{checkout.checkoutInvoice.paymentLabel}}
{{#with checkout.checkoutInvoice}}
{{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}}
{{/with}}
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..bf1519e 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}}