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