Slave/btc integration #4

Merged
nobswebdev merged 47 commits from slave/btc-integration into master 2026-09-07 11:11:10 +00:00
180 changed files with 3885 additions and 1019 deletions
+23 -5
View File
@@ -53,6 +53,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
SIGNED_COOKIE_JWT_SECRET=change-me-in-production 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 SIGNED_COOKIE_THEME_EXPIRES_IN_MS=31536000000 # 365 days
COINGECKO_API_BASE_URL=https://api.coingecko.com/api/v3 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 COINPAPRIKA_API_BASE_URL=https://api.coinpaprika.com/v1
KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS=5000 COINPAPRIKA_RATE_FETCH_TIMEOUT_MS=5000
BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate with: openssl rand -base64 32 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_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":10}]'
MONERO_VERSION=0.18.3.4 MONERO_VERSION=0.18.3.4
MONERO_NETWORK=stagenet MONERO_NETWORK=stagenet
@@ -99,6 +102,22 @@ MONERO_WALLET_RPC_TIMEOUT_MS=10000
MONERO_WALLET_DIR=./monero-wallet-rpc/wallet MONERO_WALLET_DIR=./monero-wallet-rpc/wallet
MONERO_WALLET_NAME=shop MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me 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_CHAT_VERSION=v6.5.6
SIMPLEX_WS_URL=ws://simplex-cli:5225 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_SHIPPING_PAYMENT_VALIDITY_MS=259200000 # 72 hours
ORDER_DATA_RETENTION_DAYS=30 ORDER_DATA_RETENTION_DAYS=30
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR
VITE_API_BASE_URL=http://localhost:3000/api VITE_API_BASE_URL=http://localhost:3000/api
VITE_SHOP_FIAT_CURRENCY=USD VITE_SHOP_FIAT_CURRENCY=USD
VITE_PRODUCT_THUMB_ALLOWED_MIMES=image/jpeg,image/png 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_MIN_LENGTH=20
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000 VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
+1
View File
@@ -5,6 +5,7 @@
/deploy/certbot/www /deploy/certbot/www
/monero-wallet-rpc/wallet /monero-wallet-rpc/wallet
/electrum-daemon/wallet
/backend/node_modules /backend/node_modules
/backend/dist /backend/dist
+1 -4
View File
@@ -1,6 +1,6 @@
# NullCart # 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). 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 # Create and edit .env.dev as needed
cp .env.example .env.dev 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 # 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 build --no-cache
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
+8 -4
View File
@@ -9,10 +9,11 @@ import {
getCoingeckoConfig, getCoingeckoConfig,
getEncryptionConfig, getEncryptionConfig,
getJwtConfig, getJwtConfig,
getKrakenConfig, getCoinPaprikaConfig,
getOrderConfig, getOrderConfig,
getInvoiceConfig, getInvoiceConfig,
getMoneroWalletConfig, getMoneroWalletConfig,
getElectrumWalletConfig,
getPostgresConfig, getPostgresConfig,
getShopSettingsConfig, getShopSettingsConfig,
getSimplexConfig getSimplexConfig
@@ -22,6 +23,7 @@ import { AuthModule } from './modules/auth/AuthModule';
import { EncryptionModule } from './modules/encryption/EncryptionModule'; import { EncryptionModule } from './modules/encryption/EncryptionModule';
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
import { BitcoinWalletModule } from './modules/bitcoinWallet/BitcoinWalletModule';
import { SimplexModule } from './modules/simplex/SimplexModule'; import { SimplexModule } from './modules/simplex/SimplexModule';
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule'; import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
import { DataWipeModule } from './modules/dataWipe/DataWipeModule'; import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
@@ -35,7 +37,7 @@ import { StorefrontCheckoutModule } from './modules/storefrontCheckout/Storefron
import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule'; import { StorefrontOrderModule } from './modules/storefrontOrder/StorefrontOrderModule';
import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule'; import { StorefrontCoreModule } from './modules/storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule'; import { StorefrontProductModule } from './modules/storefrontProduct/StorefrontProductModule';
import { XmrRateModule } from './modules/xmrRate/XmrRateModule'; import { ExchangeRateModule } from './modules/exchangeRate/ExchangeRateModule';
import { Config } from './types/Config'; import { Config } from './types/Config';
@Module({ @Module({
@@ -49,12 +51,13 @@ import { Config } from './types/Config';
registerAs('app', getAppConfig), registerAs('app', getAppConfig),
registerAs('jwt', getJwtConfig), registerAs('jwt', getJwtConfig),
registerAs('coingecko', getCoingeckoConfig), registerAs('coingecko', getCoingeckoConfig),
registerAs('kraken', getKrakenConfig), registerAs('coinPaprika', getCoinPaprikaConfig),
registerAs('encryption', getEncryptionConfig), registerAs('encryption', getEncryptionConfig),
registerAs('shopSettings', getShopSettingsConfig), registerAs('shopSettings', getShopSettingsConfig),
registerAs('order', getOrderConfig), registerAs('order', getOrderConfig),
registerAs('invoice', getInvoiceConfig), registerAs('invoice', getInvoiceConfig),
registerAs('moneroWallet', getMoneroWalletConfig), registerAs('moneroWallet', getMoneroWalletConfig),
registerAs('electrumWallet', getElectrumWalletConfig),
registerAs('simplex', getSimplexConfig) registerAs('simplex', getSimplexConfig)
] ]
}), }),
@@ -74,7 +77,7 @@ import { Config } from './types/Config';
}; };
} }
}), }),
XmrRateModule, ExchangeRateModule,
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'], useFactory: (configService: ConfigService) => configService.get('postgres') as Config['postgres'],
inject: [ConfigService] inject: [ConfigService]
@@ -91,6 +94,7 @@ import { Config } from './types/Config';
PaymentModule, PaymentModule,
DataWipeModule, DataWipeModule,
MoneroWalletModule, MoneroWalletModule,
BitcoinWalletModule,
StorefrontCoreModule, StorefrontCoreModule,
StorefrontProductModule, StorefrontProductModule,
StorefrontCartModule, StorefrontCartModule,
+30 -10
View File
@@ -4,19 +4,22 @@ import {
EncryptionConfig, EncryptionConfig,
InvoiceConfig, InvoiceConfig,
JwtConfig, JwtConfig,
KrakenConfig, CoinPaprikaConfig,
MulterConfig, MulterConfig,
OrderConfig, OrderConfig,
PostgresConfig, PostgresConfig,
ShopSettingsConfig ShopSettingsConfig
} from '../types/Config'; } from '../types/Config';
import { ElectrumNetwork } from '../types/ElectrumNetwork';
import { ElectrumWalletConfig } from '../types/ElectrumWalletConfig';
import { MoneroNetwork } from '../types/MoneroNetwork'; import { MoneroNetwork } from '../types/MoneroNetwork';
import { MoneroWalletConfig } from '../types/MoneroWalletConfig'; import { MoneroWalletConfig } from '../types/MoneroWalletConfig';
import { MoneroConfirmationTier } from '../types/MoneroConfirmationTier'; import { ConfirmationTier } from '../types/ConfirmationTier';
import { NodeEnv } from '../types/NodeEnv'; import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency'; import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod'; import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
import { SimplexConfig } from '../types/SimplexConfig'; import { SimplexConfig } from '../types/SimplexConfig';
import { parseEnabledPaymentMethods } from '../utils/payment/parseEnabledPaymentMethods';
const env = (key: string): string => process.env[key] || ''; const env = (key: string): string => process.env[key] || '';
@@ -100,7 +103,8 @@ export const getAppConfig = (): AppConfig => {
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_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 => { export const getCoingeckoConfig = (): CoingeckoConfig => {
return { return {
apiBaseUrl: env('COINGECKO_API_BASE_URL'), 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 { return {
apiBaseUrl: env('KRAKEN_API_BASE_URL'), apiBaseUrl: env('COINPAPRIKA_API_BASE_URL'),
xmrRateFetchTimeoutMs: envInt('KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS') rateFetchTimeoutMs: envInt('COINPAPRIKA_RATE_FETCH_TIMEOUT_MS')
}; };
}; };
@@ -177,8 +181,12 @@ export const getEncryptionConfig = (): EncryptionConfig => ({
export const getShopSettingsConfig = (): ShopSettingsConfig => ({ export const getShopSettingsConfig = (): ShopSettingsConfig => ({
shopName: env('SHOP_NAME'), shopName: env('SHOP_NAME'),
shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency, shopFiatCurrency: env('SHOP_FIAT_CURRENCY') as ShopFiatCurrency,
enabledPaymentMethods: parseEnabledPaymentMethods(env('PAYMENT_METHODS_ENABLED')),
monero: { 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 => ({ export const getInvoiceConfig = (): InvoiceConfig => ({
minByMethod: { 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') 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 => ({ export const getSimplexConfig = (): SimplexConfig => ({
wsUrl: env('SIMPLEX_WS_URL'), wsUrl: env('SIMPLEX_WS_URL'),
botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME') botDisplayName: env('SIMPLEX_BOT_DISPLAY_NAME')
@@ -214,11 +233,12 @@ export default () => ({
app: getAppConfig(), app: getAppConfig(),
jwt: getJwtConfig(), jwt: getJwtConfig(),
coingecko: getCoingeckoConfig(), coingecko: getCoingeckoConfig(),
kraken: getKrakenConfig(), coinPaprika: getCoinPaprikaConfig(),
encryption: getEncryptionConfig(), encryption: getEncryptionConfig(),
shopSettings: getShopSettingsConfig(), shopSettings: getShopSettingsConfig(),
order: getOrderConfig(), order: getOrderConfig(),
invoice: getInvoiceConfig(), invoice: getInvoiceConfig(),
moneroWallet: getMoneroWalletConfig(), moneroWallet: getMoneroWalletConfig(),
electrumWallet: getElectrumWalletConfig(),
simplex: getSimplexConfig() simplex: getSimplexConfig()
}); });
+64 -5
View File
@@ -3,7 +3,9 @@ import { IsBoolean, IsEnum, IsNotEmpty, IsNumber, IsString, Max, Min, validateSy
import { NodeEnv } from '../types/NodeEnv'; import { NodeEnv } from '../types/NodeEnv';
import { ShopFiatCurrency } from '../types/ShopFiatCurrency'; import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { IsBase64 } from '../validation/decorators/isBase64'; 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'; import { MoneroNetwork } from '../types/MoneroNetwork';
class EnvironmentVariables { class EnvironmentVariables {
@@ -151,6 +153,12 @@ class EnvironmentVariables {
@Max(10000) @Max(10000)
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number; VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(1000)
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: number;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(4) @Min(4)
@@ -246,17 +254,17 @@ class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
KRAKEN_API_BASE_URL: string; COINPAPRIKA_API_BASE_URL: string;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
KRAKEN_XMR_RATE_FETCH_TIMEOUT_MS: number; COINPAPRIKA_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(1) @Min(1)
COINGECKO_XMR_RATE_FETCH_TIMEOUT_MS: number; COINGECKO_RATE_FETCH_TIMEOUT_MS: number;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@@ -265,9 +273,19 @@ class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@IsMoneroConfirmationTiers() @IsConfirmationTiers()
MONERO_CONFIRMATION_TIERS: string; MONERO_CONFIRMATION_TIERS: string;
@IsNotEmpty()
@IsString()
@IsConfirmationTiers()
BITCOIN_CONFIRMATION_TIERS: string;
@IsNotEmpty()
@IsString()
@IsEnabledPaymentMethods()
PAYMENT_METHODS_ENABLED: string;
@IsNotEmpty() @IsNotEmpty()
@IsNumber() @IsNumber()
@Min(60000) @Min(60000)
@@ -294,6 +312,12 @@ class EnvironmentVariables {
@Max(Number.MAX_SAFE_INTEGER) @Max(Number.MAX_SAFE_INTEGER)
MONERO_MIN_INCOMING_ATOMIC: number; MONERO_MIN_INCOMING_ATOMIC: number;
@IsNotEmpty()
@IsNumber()
@Min(0)
@Max(Number.MAX_SAFE_INTEGER)
BITCOIN_MIN_INCOMING_ATOMIC: number;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
MONERO_DAEMON_ADDRESS: string; MONERO_DAEMON_ADDRESS: string;
@@ -325,6 +349,41 @@ class EnvironmentVariables {
@Min(1000) @Min(1000)
MONERO_WALLET_RPC_TIMEOUT_MS: number; 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() @IsNotEmpty()
@IsString() @IsString()
SIMPLEX_WS_URL: string; SIMPLEX_WS_URL: string;
+3
View File
@@ -0,0 +1,3 @@
import Decimal from 'decimal.js';
export const BTC_ATOMIC_PER_BTC = new Decimal(100_000_000);
@@ -0,0 +1,6 @@
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodIconUrl: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: '/shop/assets/img/xmr.png',
[PaymentMethod.Btc]: '/shop/assets/img/btc.png'
};
+6
View File
@@ -0,0 +1,6 @@
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodLabel: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: 'XMR',
[PaymentMethod.Btc]: 'BTC'
};
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddBtcInvoiceDetails1784800000000 implements MigrationInterface {
name = 'AddBtcInvoiceDetails1784800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.query(`ALTER TABLE "invoice_btc_details" DROP CONSTRAINT "FK_d307f6e0ecc770f47c0bc320d3d"`);
await queryRunner.query(`DROP TABLE "invoice_btc_details"`);
}
}
+1 -1
View File
@@ -61,7 +61,7 @@ async function bootstrap() {
{ path: '/', method: RequestMethod.GET }, { path: '/', method: RequestMethod.GET },
{ path: 'shop/categories/:id', method: RequestMethod.GET }, { path: 'shop/categories/:id', method: RequestMethod.GET },
{ path: 'shop/products/:id/variants/:variantId', 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/preferences/theme', method: RequestMethod.POST },
{ path: 'shop/error', method: RequestMethod.GET }, { path: 'shop/error', method: RequestMethod.GET },
{ path: 'shop/cart', method: RequestMethod.GET }, { path: 'shop/cart', method: RequestMethod.GET },
@@ -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 {}
@@ -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);
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class BitcoinWalletRevealSeedDto {
@IsString()
@IsNotEmpty()
password: string;
}
@@ -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;
}
@@ -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.'
)
);
});
});
@@ -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<BitcoinWalletStatusView> {
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<BitcoinWalletWithdrawResult> {
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<BitcoinWalletRevealSeedResult> {
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;
}
}
@@ -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<typeof axios>;
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)
);
});
});
});
@@ -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<T>(
method: string,
params: Record<string, unknown> | unknown[] = {},
options: { timeoutMs?: number } = {}
): Promise<T> {
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
'electrumWallet'
) as Config['electrumWallet'];
const { data } = await axios.post<ElectrumWalletRpcResponse<T>>(
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<string> {
const version = await this.call<string>('version');
if (!version) {
throw new Error('Electrum wallet RPC version returned no version');
}
return version;
}
async isSynchronized(): Promise<boolean> {
return this.call<boolean>('is_synchronized');
}
async getInfo(): Promise<ElectrumWalletGetInfoResult> {
return this.call<ElectrumWalletGetInfoResult>('getinfo');
}
async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> {
const { confirmed, unconfirmed } = await this.call<ElectrumWalletGetBalanceResult>('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<string> {
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
const signedTransaction = await this.call<string>(
'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<string> {
const txHash = await this.call<string>('broadcast', { tx: signedTransaction });
if (!txHash) {
throw new Error('Electrum wallet RPC broadcast returned no transaction hash');
}
return txHash;
}
private async deserializeTransaction(signedTransaction: string): Promise<ElectrumWalletDeserializedTransaction> {
return this.call<ElectrumWalletDeserializedTransaction>('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<string> {
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
const mnemonic = await this.call<string>('getseed', { password: walletPassword });
if (!mnemonic) {
throw new Error('Electrum wallet RPC getseed returned no mnemonic');
}
return mnemonic;
}
async createAddress(label?: string): Promise<string> {
const address = await this.call<string>('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<ElectrumWalletIncomingTransfer[]> {
const history = await this.call<ElectrumWalletAddressHistoryEntry[]>('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<string>('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
};
}
}
@@ -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<void> {
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)}`);
}
}
}
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -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;
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
@@ -0,0 +1,4 @@
export type ElectrumWalletAddressHistoryEntry = {
tx_hash: string;
height: number;
};
@@ -0,0 +1,8 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
export type ElectrumWalletDeserializedTransaction = {
outputs: ElectrumWalletDeserializedOutput[];
};
@@ -0,0 +1,4 @@
export type ElectrumWalletGetBalanceResult = {
confirmed: string;
unconfirmed?: string;
};
@@ -0,0 +1,5 @@
export type ElectrumWalletGetInfoResult = {
blockchain_height?: number;
server?: string;
server_height?: number;
};
@@ -0,0 +1,5 @@
export type ElectrumWalletIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
};
@@ -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;
};
@@ -0,0 +1,9 @@
export type ElectrumWalletRpcResponse<T> = {
id: string | number;
jsonrpc: string;
result?: T;
error?: {
code: number;
message: string;
};
};
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ExchangeRateService } from './services/ExchangeRateService';
@Module({
providers: [ExchangeRateService],
exports: [ExchangeRateService]
})
export class ExchangeRateModule {}
@@ -0,0 +1,7 @@
import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
export const COINPAPRIKA_ID_BY_PAYMENT_METHOD = {
[PaymentMethod.Xmr]: 'xmr-monero',
[PaymentMethod.Btc]: 'btc-bitcoin'
} as const satisfies Record<ExchangeRatePaymentMethod, string>;
@@ -0,0 +1,7 @@
import type { ExchangeRatePaymentMethod } from './exchangeRatePaymentMethods';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
export const COINGECKO_ID_BY_PAYMENT_METHOD = {
[PaymentMethod.Xmr]: 'monero',
[PaymentMethod.Btc]: 'bitcoin'
} as const satisfies Record<ExchangeRatePaymentMethod, string>;
@@ -0,0 +1,5 @@
import { PaymentMethod } from '../../payment/types/PaymentMethod';
export const EXCHANGE_RATE_PAYMENT_METHODS = [PaymentMethod.Xmr, PaymentMethod.Btc] as const;
export type ExchangeRatePaymentMethod = (typeof EXCHANGE_RATE_PAYMENT_METHODS)[number];
@@ -0,0 +1,43 @@
import {
IsEnum,
IsNotEmpty,
Validate,
ValidatorConstraint,
type ValidationArguments,
type ValidatorConstraintInterface
} from 'class-validator';
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
@ValidatorConstraint({ name: 'coinPaprikaResponse' })
class CoinPaprikaResponseConstraint implements ValidatorConstraintInterface {
validate(response: unknown, args: ValidationArguments): boolean {
const { shopFiatCurrency } = args.object as CoinPaprikaTickerResponseDto;
if (!shopFiatCurrency || typeof response !== 'object' || response === null) {
return false;
}
const quotes = (response as { quotes?: unknown }).quotes;
if (typeof quotes !== 'object' || quotes === null) {
return false;
}
const quote = (quotes as Record<string, { price?: number }>)[shopFiatCurrency];
return typeof quote?.price === 'number' && Number.isFinite(quote.price) && quote.price > 0;
}
defaultMessage(): string {
return 'CoinPaprika fiat quote price must be a positive number';
}
}
export class CoinPaprikaTickerResponseDto {
@IsEnum(ShopFiatCurrency)
shopFiatCurrency: ShopFiatCurrency;
@IsNotEmpty()
@Validate(CoinPaprikaResponseConstraint)
response: { quotes: Record<string, { price: number }> };
}
@@ -0,0 +1,55 @@
import {
IsArray,
IsEnum,
IsNotEmpty,
IsObject,
IsString,
Validate,
ValidatorConstraint,
type ValidationArguments,
type ValidatorConstraintInterface
} from 'class-validator';
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
@ValidatorConstraint({ name: 'coingeckoResponse' })
class CoingeckoResponseConstraint implements ValidatorConstraintInterface {
validate(response: Record<string, unknown>, args: ValidationArguments): boolean {
const { shopFiatCurrency, coinIds } = args.object as CoingeckoSimplePriceResponseDto;
if (!shopFiatCurrency || coinIds.length === 0) {
return false;
}
const fiatKey = shopFiatCurrency.toLowerCase();
return coinIds.every(coinId => {
const coin = response[coinId];
if (typeof coin !== 'object' || coin === null) {
return false;
}
const rate = coin[fiatKey];
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
});
}
defaultMessage(): string {
return 'CoinGecko coin fiat rate must be a positive number';
}
}
export class CoingeckoSimplePriceResponseDto {
@IsEnum(ShopFiatCurrency)
shopFiatCurrency: ShopFiatCurrency;
@IsArray()
@IsString({ each: true })
coinIds: string[];
@IsNotEmpty()
@IsObject()
@Validate(CoingeckoResponseConstraint)
response: Record<string, Record<string, number>>;
}
@@ -0,0 +1,245 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { ExchangeRateService } from './ExchangeRateService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
const buildCoinPaprikaTicker = (price: number, fiat = 'USD') => ({
quotes: { [fiat]: { price } }
});
const buildCoinPaprikaAxiosMock =
(xmrPrice: number, btcPrice: number, fiat = 'USD') =>
(url: string) => {
if (url === `https://coinpaprika.test/v1/tickers/xmr-monero?quotes=${fiat}`) {
return Promise.resolve({ data: buildCoinPaprikaTicker(xmrPrice, fiat) });
}
if (url === `https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=${fiat}`) {
return Promise.resolve({ data: buildCoinPaprikaTicker(btcPrice, fiat) });
}
return Promise.reject(new Error(`unexpected url: ${url}`));
};
describe('ExchangeRateService', () => {
let service: ExchangeRateService;
let configService: {
get: jest.Mock;
};
let warnLogSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
beforeEach(() => {
warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
configService = {
get: jest.fn((key: string) => {
if (key === 'shopSettings') {
return { shopFiatCurrency: 'USD' };
}
if (key === 'coingecko') {
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
}
if (key === 'coinPaprika') {
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
}
return undefined;
})
};
service = new ExchangeRateService(configService as unknown as ConfigService);
mockedAxios.get.mockReset();
});
afterEach(() => {
warnLogSpy.mockRestore();
errorLogSpy.mockRestore();
logSpy.mockRestore();
});
it('stores batched CoinGecko rates when the fetch succeeds', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { usd: 152.3456 },
bitcoin: { usd: 95_432.1987 }
}
});
await service.fetchLiveRates();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=usd',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(152.35);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_432.2);
});
it('falls back to CoinPaprika when CoinGecko fails', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockImplementation(buildCoinPaprikaAxiosMock(149.876, 94_999.5));
await service.fetchLiveRates();
expect(warnLogSpy).toHaveBeenCalled();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD',
{ timeout: 5000 }
);
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.88);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_999.5);
});
it('leaves cached rates null when both providers fail on the first fetch', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockRejectedValue(new Error('coinpaprika down'));
await service.fetchLiveRates();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
});
it('falls back to CoinPaprika when CoinGecko returns an invalid payload', async () => {
mockedAxios.get
.mockResolvedValueOnce({ data: { monero: { usd: -1 }, bitcoin: { usd: 95_000 } } })
.mockImplementation(buildCoinPaprikaAxiosMock(151.11, 95_111.11));
await service.fetchLiveRates();
expect(warnLogSpy).toHaveBeenCalled();
expect(logSpy).toHaveBeenCalledWith('Successfully fetched live rates from CoinPaprika for USD');
expect(mockedAxios.get).toHaveBeenCalledTimes(3);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(151.11);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(95_111.11);
});
it('falls back to CoinPaprika when CoinGecko omits a coin', async () => {
mockedAxios.get
.mockResolvedValueOnce({ data: { monero: { usd: 150 } } })
.mockImplementation(buildCoinPaprikaAxiosMock(149.5, 94_000));
await service.fetchLiveRates();
expect(warnLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(149.5);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(94_000);
});
it('leaves cached rates null when CoinPaprika returns an invalid payload for one coin', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
}
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
}
return Promise.reject(new Error(`unexpected url: ${url}`));
});
await service.fetchLiveRates();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBeNull();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBeNull();
});
it('keeps the previous live rates when a refresh fails after a successful fetch', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { usd: 140 },
bitcoin: { usd: 90_000 }
}
});
await service.fetchLiveRates();
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockRejectedValue(new Error('coinpaprika down'));
await service.fetchLiveRates();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
});
it('keeps the previous live rates when CoinPaprika returns an invalid payload for one coin on refresh', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { usd: 140 },
bitcoin: { usd: 90_000 }
}
});
await service.fetchLiveRates();
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockImplementation((url: string) => {
if (url === 'https://coinpaprika.test/v1/tickers/xmr-monero?quotes=USD') {
return Promise.resolve({ data: buildCoinPaprikaTicker(151.11) });
}
if (url === 'https://coinpaprika.test/v1/tickers/btc-bitcoin?quotes=USD') {
return Promise.resolve({ data: { quotes: { USD: { price: -1 } } } });
}
return Promise.reject(new Error(`unexpected url: ${url}`));
});
await service.fetchLiveRates();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(140);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(90_000);
});
it('uses the shop fiat currency when building provider URLs', async () => {
configService.get.mockImplementation((key: string) => {
if (key === 'shopSettings') {
return { shopFiatCurrency: 'EUR' };
}
if (key === 'coingecko') {
return { apiBaseUrl: 'https://coingecko.test', rateFetchTimeoutMs: 5000 };
}
if (key === 'coinPaprika') {
return { apiBaseUrl: 'https://coinpaprika.test/v1', rateFetchTimeoutMs: 5000 };
}
return undefined;
});
mockedAxios.get.mockResolvedValueOnce({
data: {
monero: { eur: 130.5 },
bitcoin: { eur: 88_000.4 }
}
});
await service.fetchLiveRates();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coingecko.test/simple/price?ids=monero,bitcoin&vs_currencies=eur',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Xmr)).toBe(130.5);
expect(service.getLiveFiatPerCrypto(PaymentMethod.Btc)).toBe(88_000.4);
});
});
@@ -0,0 +1,156 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import axios from 'axios';
import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { COINGECKO_ID_BY_PAYMENT_METHOD } from '../const/coingeckoIds';
import { COINPAPRIKA_ID_BY_PAYMENT_METHOD } from '../const/coinPaprikaIds';
import { EXCHANGE_RATE_PAYMENT_METHODS, type ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
import { CoinPaprikaTickerResponseDto } from '../dto/CoinPaprikaTickerResponseDto';
import type { FiatPerCryptoRates } from '../types/FiatPerCryptoRates';
import { extractCoingeckoSimplePrice } from '../utils/extractCoingeckoSimplePrice';
import { extractCoinPaprikaTickerPrice } from '../utils/extractCoinPaprikaTickerPrice';
@Injectable()
export class ExchangeRateService implements OnModuleInit {
private readonly logger = new Logger(ExchangeRateService.name);
private liveFiatPerCrypto = Object.fromEntries(
EXCHANGE_RATE_PAYMENT_METHODS.map(method => [method, null])
) as Record<ExchangeRatePaymentMethod, number | null>;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
await this.fetchLiveRates();
}
getLiveFiatPerCrypto(method: ExchangeRatePaymentMethod): number | null {
return this.liveFiatPerCrypto[method];
}
@Cron(CronExpression.EVERY_5_MINUTES)
async fetchLiveRates(): Promise<void> {
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
try {
const rates = await this.fetchRatesFromCoingecko();
this.applyRates(rates);
} catch {
this.logger.warn(`CoinGecko rate fetch failed for ${shopFiatCurrency}, trying CoinPaprika`);
try {
const rates = await this.fetchRatesFromCoinPaprika();
this.applyRates(rates);
this.logger.log(`Successfully fetched live rates from CoinPaprika for ${shopFiatCurrency}`);
} catch (error) {
this.logger.error(
`Failed to fetch live rates from CoinGecko and CoinPaprika for ${shopFiatCurrency}: ${getErrorMessage(error)}`
);
}
}
}
private async fetchRatesFromCoingecko(): Promise<FiatPerCryptoRates> {
const { rateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const url = this.buildCoingeckoRateUrl();
const { data } = await axios.get<unknown>(url, {
timeout: rateFetchTimeoutMs
});
const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]);
const dto = plainToInstance(CoingeckoSimplePriceResponseDto, {
response: data,
shopFiatCurrency,
coinIds
});
const errors = validateSync(dto);
if (errors.length > 0) {
throw new Error('CoinGecko response validation failed');
}
const rates = {} as FiatPerCryptoRates;
for (const method of EXCHANGE_RATE_PAYMENT_METHODS) {
const coinId = COINGECKO_ID_BY_PAYMENT_METHOD[method];
const price = extractCoingeckoSimplePrice(dto.response, coinId, shopFiatCurrency);
if (price === null) {
throw new Error(`CoinGecko response validation failed for coin ${coinId}`);
}
rates[method] = price;
}
return rates;
}
private async fetchRatesFromCoinPaprika(): Promise<FiatPerCryptoRates> {
const { apiBaseUrl, rateFetchTimeoutMs } = this.configService.get('coinPaprika') as Config['coinPaprika'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const rates = {} as FiatPerCryptoRates;
await Promise.all(
EXCHANGE_RATE_PAYMENT_METHODS.map(async method => {
const coinId = COINPAPRIKA_ID_BY_PAYMENT_METHOD[method];
const url = `${apiBaseUrl}/tickers/${coinId}?quotes=${shopFiatCurrency}`;
const { data } = await axios.get<unknown>(url, {
timeout: rateFetchTimeoutMs
});
const dto = plainToInstance(CoinPaprikaTickerResponseDto, {
response: data,
shopFiatCurrency
});
const errors = validateSync(dto);
if (errors.length > 0) {
throw new Error(`CoinPaprika response validation failed for coin ${coinId}`);
}
const price = extractCoinPaprikaTickerPrice(dto.response, shopFiatCurrency);
if (price === null) {
throw new Error(`CoinPaprika response validation failed for coin ${coinId}`);
}
rates[method] = price;
})
);
return rates;
}
private buildCoingeckoRateUrl(): string {
const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const coinIds = EXCHANGE_RATE_PAYMENT_METHODS.map(method => COINGECKO_ID_BY_PAYMENT_METHOD[method]).join(',');
const vs = shopFiatCurrency.toLowerCase();
return `${apiBaseUrl}/simple/price?ids=${coinIds}&vs_currencies=${vs}`;
}
private applyRates(rates: FiatPerCryptoRates): void {
for (const method of EXCHANGE_RATE_PAYMENT_METHODS) {
this.liveFiatPerCrypto[method] = rates[method];
}
}
}
@@ -0,0 +1,3 @@
import type { ExchangeRatePaymentMethod } from '../const/exchangeRatePaymentMethods';
export type FiatPerCryptoRates = Record<ExchangeRatePaymentMethod, number>;
@@ -0,0 +1,25 @@
import Decimal from 'decimal.js';
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
export const extractCoinPaprikaTickerPrice = (
response: { quotes: Record<string, { price: number }> },
shopFiatCurrency: ShopFiatCurrency
): number | null => {
const quote = response.quotes[shopFiatCurrency];
if (!quote) {
return null;
}
const rate = quote.price;
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
return null;
}
try {
return new Decimal(rate).toDecimalPlaces(2).toNumber();
} catch {
return null;
}
};
@@ -0,0 +1,28 @@
import Decimal from 'decimal.js';
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
export const extractCoingeckoSimplePrice = (
coins: Record<string, Record<string, number>>,
coinId: string,
shopFiatCurrency: ShopFiatCurrency
): number | null => {
const coin = coins[coinId];
if (!coin) {
return null;
}
const fiatKey = shopFiatCurrency.toLowerCase();
const rate = coin[fiatKey];
if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) {
return null;
}
try {
return new Decimal(rate).toDecimalPlaces(2).toNumber();
} catch {
return null;
}
};
@@ -18,8 +18,8 @@ export class MoneroWalletController {
@Post('/withdraw') @Post('/withdraw')
@Throttle(throttleProfiles.walletWithdraw) @Throttle(throttleProfiles.walletWithdraw)
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) { withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, password); return this.walletAdminService.withdrawAll(destinationAddress, priority, password);
} }
@Post('/reveal-seed') @Post('/reveal-seed')
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer'; 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'; import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
export class MoneroWalletWithdrawDto { export class MoneroWalletWithdrawDto {
@@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto {
@IsMoneroStandardAddress() @IsMoneroStandardAddress()
destinationAddress: string; destinationAddress: string;
@IsNotEmpty()
@IsEnum(MoneroWithdrawPriority)
priority: MoneroWithdrawPriority;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
@@ -2,7 +2,8 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import type { AuthService } from '../../auth/services/AuthService'; 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 type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
import { MoneroWalletAdminService } from './MoneroWalletAdminService'; import { MoneroWalletAdminService } from './MoneroWalletAdminService';
@@ -76,7 +77,7 @@ describe('MoneroWalletAdminService', () => {
rpcVersion: '0.18.3.1', rpcVersion: '0.18.3.1',
walletHeight: 3_000_000, walletHeight: 3_000_000,
daemonHeight: 3_000_000, daemonHeight: 3_000_000,
syncStatus: MoneroWalletSyncStatus.Synced, syncStatus: WalletSyncStatus.Synced,
balanceXmr: '2.00000000', balanceXmr: '2.00000000',
unlockedBalanceXmr: '1.00000000' unlockedBalanceXmr: '1.00000000'
}) })
@@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => {
unlockedBalanceAtomic: '0' unlockedBalanceAtomic: '0'
}); });
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( await expect(
new BadRequestException('No unlocked balance to withdraw.') service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
); ).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.'));
expect(authService.verifyPassword).toHaveBeenCalledWith('password'); expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled(); expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
@@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => {
it('rejects withdrawals while the wallet is still syncing', async () => { it('rejects withdrawals while the wallet is still syncing', async () => {
walletRpcClient.getHeight.mockResolvedValue(2_999_000); walletRpcClient.getHeight.mockResolvedValue(2_999_000);
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( await expect(
new BadRequestException('Wallet is still syncing. Try again after sync completes.') 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 () => { 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({ expect(result).toEqual({
txHashes: ['tx-hash-1'], txHashes: ['tx-hash-1'],
amountXmr: '1.00000000' amountXmr: '1.00000000'
@@ -137,7 +145,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus(); const status = await service.getStatus();
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown); expect(status.syncStatus).toBe(WalletSyncStatus.Unknown);
expect(status.daemonHeight).toBeNull(); expect(status.daemonHeight).toBeNull();
}); });
@@ -149,7 +157,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus(); 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 () => { it('throws when reveal seed RPC fails', async () => {
@@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => {
it('throws when sweep all fails after prechecks pass', async () => { it('throws when sweep all fails after prechecks pass', async () => {
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed')); 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( new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
) )
@@ -7,7 +7,8 @@ import type { Config } from '../../../types/Config';
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult'; import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult'; import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView'; 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 type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
@@ -49,7 +50,11 @@ export class MoneroWalletAdminService {
} }
} }
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> { async withdrawAll(
destinationAddress: string,
priority: MoneroWithdrawPriority,
password: string
): Promise<MoneroWalletWithdrawResult> {
this.authService.verifyPassword(password); this.authService.verifyPassword(password);
await this.walletRpcClient.tryRefresh(); await this.walletRpcClient.tryRefresh();
@@ -59,11 +64,15 @@ export class MoneroWalletAdminService {
let daemonHeight: number | null; let daemonHeight: number | null;
try { try {
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([ const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([
this.walletRpcClient.getBalance(), this.walletRpcClient.getBalance(),
this.walletRpcClient.getHeight(), this.walletRpcClient.getHeight(),
this.fetchDaemonHeight() this.fetchDaemonHeight()
]); ]);
unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic;
walletHeight = walletHeightResult;
daemonHeight = daemonHeightResult;
} catch { } catch {
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.'); 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.'); 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.'); throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
} }
@@ -80,7 +89,10 @@ export class MoneroWalletAdminService {
let amountAtomic: string; let amountAtomic: string;
try { try {
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress)); const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority);
txHashes = sweepResult.txHashes;
amountAtomic = sweepResult.amountAtomic;
} catch { } catch {
throw new ServiceUnavailableException( 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.' '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) { 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;
} }
} }
@@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult'; import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult'; import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult'; import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult'; import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge'; import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse'; 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<MoneroWalletRpcSweepAllResult>( const result = await this.call<MoneroWalletRpcSweepAllResult>(
'sweep_all', 'sweep_all',
{ {
address: destinationAddress, address: destinationAddress,
account_index: this.accountIndex, account_index: this.accountIndex,
subaddr_indices_all: true, subaddr_indices_all: true,
priority: 1 priority
}, },
{ timeoutMs: 120_000 } { timeoutMs: 120_000 }
); );
@@ -1,12 +1,12 @@
import { MoneroNetwork } from '../../../types/MoneroNetwork'; import { MoneroNetwork } from '../../../types/MoneroNetwork';
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
export interface MoneroWalletStatusView { export interface MoneroWalletStatusView {
network: MoneroNetwork; network: MoneroNetwork;
rpcVersion: string; rpcVersion: string;
walletHeight: number; walletHeight: number;
daemonHeight: number | null; daemonHeight: number | null;
syncStatus: MoneroWalletSyncStatus; syncStatus: WalletSyncStatus;
balanceXmr: string; balanceXmr: string;
unlockedBalanceXmr: string; unlockedBalanceXmr: string;
} }
@@ -36,6 +36,7 @@ export class OrderCreationService {
.leftJoinAndSelect('session.discounts', 'discount') .leftJoinAndSelect('session.discounts', 'discount')
.leftJoinAndSelect('session.invoice', 'invoice') .leftJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails') .leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment') .leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order') .leftJoin('session.order', 'order')
.where('session.id = :sessionId', { sessionId }) .where('session.id = :sessionId', { sessionId })
@@ -210,11 +210,45 @@ describe('OrderService', () => {
accessToken: 'plain-token', accessToken: 'plain-token',
checkoutInvoice: expect.objectContaining({ checkoutInvoice: expect.objectContaining({
statusLabel: 'Payment confirmed', statusLabel: 'Payment confirmed',
paymentLabel: 'XMR',
expectedTotalCrypto: '0.10000000' 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', () => { describe('setDeliveryCost', () => {
@@ -222,7 +256,7 @@ describe('OrderService', () => {
({ ({
id: 'order-1', id: 'order-1',
lines: [{ deliveryMode: DeliveryMode.Manual }], lines: [{ deliveryMode: DeliveryMode.Manual }],
checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD' } checkoutInvoice: { id: 'checkout-invoice-1', fiatCurrency: 'USD', paymentMethod: PaymentMethod.Xmr }
}) as Order; }) as Order;
it('throws when the order cannot be quoted', async () => { it('throws when the order cannot be quoted', async () => {
@@ -300,6 +334,26 @@ describe('OrderService', () => {
}); });
expect(result).toBe(orderExtended); 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', () => { describe('fulfillManualLine', () => {
@@ -8,14 +8,15 @@ import { formatInvoicePaymentConfirmationStatus } from '../../../utils/invoice/f
import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage'; import { resolveInvoiceStatusMessage } from '../../../utils/invoice/resolveInvoiceStatusMessage';
import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations'; import { resolveInvoiceRequiredConfirmations } from '../../../utils/invoice/resolveInvoiceRequiredConfirmations';
import type { InvoiceState } from '../../../utils/invoice/types/InvoiceState'; 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 { Invoice } from '../../payment/entities/Invoice';
import type { InvoicePayment } from '../../payment/entities/InvoicePayment'; import type { InvoicePayment } from '../../payment/entities/InvoicePayment';
import type { InvoiceExtended } from '../../payment/types/InvoiceExtended'; import type { InvoiceExtended } from '../../payment/types/InvoiceExtended';
import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended'; import type { InvoicePaymentExtended } from '../../payment/types/InvoicePaymentExtended';
import { InvoiceReason } from '../../payment/types/InvoiceReason'; import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService'; import { InvoiceService } from '../../payment/services/InvoiceService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import { DeliveryMode } from '../../product/types/DeliveryMode'; import { DeliveryMode } from '../../product/types/DeliveryMode';
import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto'; import { SetDeliveryCostDto } from '../dto/SetDeliveryCostDto';
import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto'; import type { ListOrdersQueryDto } from '../dto/ListOrdersQueryDto';
@@ -99,9 +100,11 @@ export class OrderService {
'checkoutInvoice', 'checkoutInvoice',
'checkoutInvoice.payments', 'checkoutInvoice.payments',
'checkoutInvoice.moneroDetails', 'checkoutInvoice.moneroDetails',
'checkoutInvoice.btcDetails',
'shippingInvoice', 'shippingInvoice',
'shippingInvoice.payments', 'shippingInvoice.payments',
'shippingInvoice.moneroDetails', 'shippingInvoice.moneroDetails',
'shippingInvoice.btcDetails',
'lines', 'lines',
'lines.manualFulfillment', 'lines.manualFulfillment',
'discounts', 'discounts',
@@ -181,24 +184,31 @@ export class OrderService {
const statusLabel = invoiceState ? resolveInvoiceStatusMessage(invoiceState) : null; 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 => const payments = (invoice.payments ?? []).map(payment =>
this.toInvoicePaymentExtended(payment, requiredConfirmations) this.toInvoicePaymentExtended(payment, requiredConfirmations, convertAtomicToCrypto)
); );
return { return {
...invoice, ...invoice,
statusLabel, statusLabel,
paymentLabel: paymentMethodLabel[invoice.paymentMethod],
expectedTotalCrypto, expectedTotalCrypto,
payments payments
}; };
} }
private toInvoicePaymentExtended(payment: InvoicePayment, requiredConfirmations: number): InvoicePaymentExtended { private toInvoicePaymentExtended(
payment: InvoicePayment,
requiredConfirmations: number,
convertAtomicToCrypto: CryptoAtomicConverter
): InvoicePaymentExtended {
const isConfirmed = payment.confirmations >= requiredConfirmations; const isConfirmed = payment.confirmations >= requiredConfirmations;
const amountCrypto = convertXmrAtomicToXmr(payment.amountAtomic); const amountCrypto = convertAtomicToCrypto(payment.amountAtomic);
const confirmationsLabel = formatInvoicePaymentConfirmationStatus({ const confirmationsLabel = formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations, confirmations: payment.confirmations,
@@ -244,7 +254,7 @@ export class OrderService {
} }
const shippingInvoice = await this.invoiceService.issueInvoice({ const shippingInvoice = await this.invoiceService.issueInvoice({
paymentMethod: PaymentMethod.Xmr, paymentMethod: order.checkoutInvoice.paymentMethod,
reason: InvoiceReason.Shipping, reason: InvoiceReason.Shipping,
contextId: orderId, contextId: orderId,
amountFiat: deliveryCost amountFiat: deliveryCost
+6 -3
View File
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { BitcoinWalletModule } from '../bitcoinWallet/BitcoinWalletModule';
import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule'; import { MoneroWalletModule } from '../moneroWallet/MoneroWalletModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice'; import { Invoice } from './entities/Invoice';
import { InvoiceBtcDetails } from './entities/InvoiceBtcDetails';
import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails'; import { InvoiceMoneroDetails } from './entities/InvoiceMoneroDetails';
import { InvoicePayment } from './entities/InvoicePayment'; import { InvoicePayment } from './entities/InvoicePayment';
import { InvoicePaymentService } from './services/InvoicePaymentService'; import { InvoicePaymentService } from './services/InvoicePaymentService';
@@ -10,9 +12,10 @@ import { InvoiceService } from './services/InvoiceService';
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails]), TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule, MoneroWalletModule,
XmrRateModule BitcoinWalletModule,
ExchangeRateModule
], ],
providers: [InvoicePaymentService, InvoiceService], providers: [InvoicePaymentService, InvoiceService],
exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])] exports: [InvoiceService, TypeOrmModule.forFeature([Invoice])]
@@ -3,6 +3,7 @@ import { ColumnBigIntTransformer } from '../../../utils/ColumnBigIntTransformer'
import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer'; import { ColumnNumericTransformer } from '../../../utils/ColumnNumericTransformer';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
import { InvoiceReason } from '../types/InvoiceReason'; import { InvoiceReason } from '../types/InvoiceReason';
import { InvoiceBtcDetails } from './InvoiceBtcDetails';
import { InvoiceMoneroDetails } from './InvoiceMoneroDetails'; import { InvoiceMoneroDetails } from './InvoiceMoneroDetails';
import { InvoicePayment } from './InvoicePayment'; import { InvoicePayment } from './InvoicePayment';
@@ -43,6 +44,9 @@ export class Invoice {
@OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true }) @OneToOne(() => InvoiceMoneroDetails, moneroDetails => moneroDetails.invoice, { cascade: true })
moneroDetails: InvoiceMoneroDetails | null; moneroDetails: InvoiceMoneroDetails | null;
@OneToOne(() => InvoiceBtcDetails, btcDetails => btcDetails.invoice, { cascade: true })
btcDetails: InvoiceBtcDetails | null;
@CreateDateColumn() @CreateDateColumn()
createdAt: Date; createdAt: Date;
} }
@@ -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;
}
@@ -1,17 +1,22 @@
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import type { DataSource, EntityManager, Repository } from 'typeorm'; import type { DataSource, EntityManager, Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment'; import { InvoicePayment } from '../entities/InvoicePayment';
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest'; import type { InvoicePaymentServiceTest } from '../types/InvoicePaymentServiceTest';
import { InvoicePaymentService } from './InvoicePaymentService'; 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> = {} overrides: Partial<MoneroWalletRpcIncomingTransfer> = {}
): MoneroWalletRpcIncomingTransfer => ({ ): MoneroWalletRpcIncomingTransfer => ({
txHash: 'tx-hash-1', txHash: 'tx-hash-1',
@@ -21,7 +26,14 @@ const buildTransfer = (
...overrides ...overrides
}); });
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice => const buildBtcTransfer = (overrides: Partial<InvoiceIncomingTransfer> = {}): InvoiceIncomingTransfer => ({
txHash: 'tx-hash-1',
amountAtomic: '200000000',
confirmations: 1,
...overrides
});
const buildXmrInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({ ({
id: 'invoice-1', id: 'invoice-1',
paymentMethod: PaymentMethod.Xmr, paymentMethod: PaymentMethod.Xmr,
@@ -30,6 +42,16 @@ const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
...overrides ...overrides
}) as Invoice; }) as Invoice;
const buildBtcInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({
id: 'invoice-btc-1',
paymentMethod: PaymentMethod.Btc,
paymentAddress: 'bc1qtest',
btcDetails: { requiredConfirmations: 1 },
payments: [],
...overrides
}) as Invoice;
describe('InvoicePaymentService', () => { describe('InvoicePaymentService', () => {
let service: InvoicePaymentServiceTest; let service: InvoicePaymentServiceTest;
let invoiceRepo: { let invoiceRepo: {
@@ -41,7 +63,11 @@ describe('InvoicePaymentService', () => {
andWhere: jest.Mock; andWhere: jest.Mock;
getMany: jest.Mock; getMany: jest.Mock;
}; };
let walletRpcClient: { let moneroWalletRpcClient: {
getIncomingTransfers: jest.Mock;
};
let bitcoinWalletRpcClient: {
getInfo: jest.Mock;
getIncomingTransfers: jest.Mock; getIncomingTransfers: jest.Mock;
}; };
let configService: { 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([]) getIncomingTransfers: jest.fn().mockResolvedValue([])
}; };
configService = { configService = {
get: jest.fn().mockReturnValue({ get: jest.fn().mockReturnValue({
minByMethod: { minByMethod: {
[PaymentMethod.Xmr]: minIncomingAtomic [PaymentMethod.Xmr]: minXmrIncomingAtomic,
[PaymentMethod.Btc]: minBtcIncomingAtomic
} }
}) })
}; };
@@ -145,7 +177,8 @@ describe('InvoicePaymentService', () => {
service = new InvoicePaymentService( service = new InvoicePaymentService(
invoiceRepo as unknown as Repository<Invoice>, invoiceRepo as unknown as Repository<Invoice>,
dataSource as unknown as DataSource, 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 configService as unknown as ConfigService
) as unknown as InvoicePaymentServiceTest; ) as unknown as InvoicePaymentServiceTest;
@@ -157,87 +190,184 @@ describe('InvoicePaymentService', () => {
errorLogSpy.mockRestore(); errorLogSpy.mockRestore();
}); });
describe('pollInvoices', () => { describe('pollMoneroInvoices', () => {
it('returns early when there are no open invoices', async () => { it('returns early when there are no open invoices', async () => {
pollQueryBuilder.getMany.mockResolvedValue([]); pollQueryBuilder.getMany.mockResolvedValue([]);
await service.pollInvoices(); await service.pollMoneroInvoices();
expect(walletRpcClient.getIncomingTransfers).not.toHaveBeenCalled(); expect(moneroWalletRpcClient.getIncomingTransfers).not.toHaveBeenCalled();
expect(processInvoiceSpy).not.toHaveBeenCalled(); expect(processInvoiceSpy).not.toHaveBeenCalled();
}); });
it('returns early when incoming transfers cannot be fetched', async () => { it('returns early when incoming transfers cannot be fetched', async () => {
pollQueryBuilder.getMany.mockResolvedValue([buildInvoice()]); pollQueryBuilder.getMany.mockResolvedValue([buildXmrInvoice()]);
walletRpcClient.getIncomingTransfers.mockRejectedValue(new Error('rpc down')); 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(); expect(processInvoiceSpy).not.toHaveBeenCalled();
}); });
it('routes transfers to each invoice by subaddress index', async () => { it('routes transfers to each invoice by subaddress index', async () => {
pollQueryBuilder.getMany.mockResolvedValue([ pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ buildXmrInvoice({
id: 'invoice-1', id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}), }),
buildInvoice({ buildXmrInvoice({
id: 'invoice-2', id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails'] moneroDetails: { paymentAddressIndex: 7, requiredConfirmations: 1 } as Invoice['moneroDetails']
}) })
]); ]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([ moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([
buildTransfer({ subaddrIndex: 3, txHash: 'tx-a' }), buildXmrTransfer({ subaddrIndex: 3, txHash: 'tx-a' }),
buildTransfer({ subaddrIndex: 7, txHash: 'tx-b' }) buildXmrTransfer({ subaddrIndex: 7, txHash: 'tx-b' })
]); ]);
await service.pollInvoices(); await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenNthCalledWith(1, 'invoice-1', [ 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(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 () => { it('continues processing other invoices when one invoice fails', async () => {
pollQueryBuilder.getMany.mockResolvedValue([ pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ id: 'invoice-1' }), buildXmrInvoice({ id: 'invoice-1' }),
buildInvoice({ id: 'invoice-2' }) buildXmrInvoice({ id: 'invoice-2' })
]); ]);
walletRpcClient.getIncomingTransfers.mockResolvedValue([buildTransfer()]); moneroWalletRpcClient.getIncomingTransfers.mockResolvedValue([buildXmrTransfer()]);
processInvoiceSpy.mockRestore(); processInvoiceSpy.mockRestore();
processInvoiceSpy = jest processInvoiceSpy = jest
.spyOn(service, 'processInvoice') .spyOn(service, 'processInvoice')
.mockRejectedValueOnce(new Error('invoice-1 failed')) .mockRejectedValueOnce(new Error('invoice-1 failed'))
.mockResolvedValueOnce(undefined); .mockResolvedValueOnce(undefined);
await service.pollInvoices(); await service.pollMoneroInvoices();
expect(processInvoiceSpy).toHaveBeenCalledTimes(2); expect(processInvoiceSpy).toHaveBeenCalledTimes(2);
}); });
it('deduplicates subaddress indices when fetching incoming transfers', async () => { it('deduplicates subaddress indices when fetching incoming transfers', async () => {
pollQueryBuilder.getMany.mockResolvedValue([ pollQueryBuilder.getMany.mockResolvedValue([
buildInvoice({ buildXmrInvoice({
id: 'invoice-1', id: 'invoice-1',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails']
}), }),
buildInvoice({ buildXmrInvoice({
id: 'invoice-2', id: 'invoice-2',
moneroDetails: { paymentAddressIndex: 3, requiredConfirmations: 1 } as Invoice['moneroDetails'] 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); 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', () => { describe('processInvoice', () => {
@@ -248,39 +378,48 @@ describe('InvoicePaymentService', () => {
it('does nothing when the invoice is missing inside the transaction', async () => { it('does nothing when the invoice is missing inside the transaction', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(null); 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.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('skips transfers below the configured minimum', async () => { it('skips transfers below the configured minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', [ await service.processInvoice('invoice-1', [
buildTransfer({ amountAtomic: '99999999', txHash: 'dust-tx' }) buildXmrTransfer({ amountAtomic: xmrBelowMinIncomingAtomic, txHash: 'dust-tx' })
]); ]);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
}); });
it('inserts a payment when the transfer amount equals the configured minimum', async () => { 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', [ 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({ expect(insertQueryBuilder.values).toHaveBeenCalledWith({
invoice: { id: 'invoice-1' }, invoice: { id: 'invoice-1' },
txHash: 'min-tx', txHash: 'min-tx',
amountAtomic: minIncomingAtomic, amountAtomic: minXmrIncomingAtomic,
confirmations: 1 confirmations: 1
}); });
}); });
it('processes a mixed batch of dust, new, and existing transfers', async () => { it('processes a mixed batch of dust, new, and existing transfers', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({ buildXmrInvoice({
payments: [ payments: [
{ {
id: 'payment-1', id: 'payment-1',
@@ -293,9 +432,9 @@ describe('InvoicePaymentService', () => {
); );
await service.processInvoice('invoice-1', [ await service.processInvoice('invoice-1', [
buildTransfer({ txHash: 'dust-tx', amountAtomic: '99999999' }), buildXmrTransfer({ txHash: 'dust-tx', amountAtomic: xmrBelowMinIncomingAtomic }),
buildTransfer({ txHash: 'known-tx', confirmations: 4 }), buildXmrTransfer({ txHash: 'known-tx', confirmations: 4 }),
buildTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 }) buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '300000000', confirmations: 2 })
]); ]);
expect(paymentRepo.update).toHaveBeenCalledWith('payment-1', { confirmations: 4 }); 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 () => { it('inserts a new payment for transfers at or above the minimum', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildInvoice()); transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
const transfer = buildTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 }); const transfer = buildXmrTransfer({ txHash: 'new-tx', amountAtomic: '200000000', confirmations: 2 });
await service.processInvoice('invoice-1', [transfer]); await service.processInvoice('invoice-1', [transfer]);
@@ -327,7 +466,7 @@ describe('InvoicePaymentService', () => {
it('updates confirmations for an existing payment when they change', async () => { it('updates confirmations for an existing payment when they change', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({ buildXmrInvoice({
payments: [ payments: [
{ {
id: 'payment-1', 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.update).toHaveBeenCalledWith('payment-1', { confirmations: 5 });
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled(); expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
@@ -347,7 +486,7 @@ describe('InvoicePaymentService', () => {
it('does not update an existing payment when confirmations are unchanged', async () => { it('does not update an existing payment when confirmations are unchanged', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue( transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildInvoice({ buildXmrInvoice({
payments: [ payments: [
{ {
id: 'payment-1', 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.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).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
});
});
}); });
}); });
@@ -7,11 +7,13 @@ import type { Config } from '../../../types/Config';
import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex'; import { groupIncomingMoneroTransfersBySubaddrIndex } from '../../../utils/monero/groupIncomingMoneroTransfersBySubaddrIndex';
import { isAtomicGte } from '../../../utils/atomic/isAtomicGte'; import { isAtomicGte } from '../../../utils/atomic/isAtomicGte';
import { getErrorMessage } from '../../../utils/getErrorMessage'; import { getErrorMessage } from '../../../utils/getErrorMessage';
import { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; import { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
import { InvoicePayment } from '../entities/InvoicePayment'; import { InvoicePayment } from '../entities/InvoicePayment';
import type { InvoiceIncomingTransfer } from '../types/InvoiceIncomingTransfer';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
import { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer';
@Injectable() @Injectable()
export class InvoicePaymentService { export class InvoicePaymentService {
@@ -21,12 +23,17 @@ export class InvoicePaymentService {
@InjectRepository(Invoice) @InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>, private readonly invoiceRepo: Repository<Invoice>,
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
private readonly walletRpcClient: MoneroWalletRpcClient, private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly configService: ConfigService private readonly configService: ConfigService
) {} ) {}
@Cron(CronExpression.EVERY_10_SECONDS) @Cron(CronExpression.EVERY_10_SECONDS)
private async pollInvoices(): Promise<void> { private async pollInvoices(): Promise<void> {
await Promise.all([this.pollMoneroInvoices(), this.pollBitcoinInvoices()]);
}
private async pollMoneroInvoices(): Promise<void> {
const now = new Date(); const now = new Date();
const invoices = await this.invoiceRepo const invoices = await this.invoiceRepo
@@ -55,7 +62,7 @@ export class InvoicePaymentService {
let transfers: MoneroWalletRpcIncomingTransfer[]; let transfers: MoneroWalletRpcIncomingTransfer[];
try { try {
transfers = await this.walletRpcClient.getIncomingTransfers(subaddrIndices); transfers = await this.moneroWalletRpcClient.getIncomingTransfers(subaddrIndices);
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch incoming Monero transfers: ${getErrorMessage(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<void> { private async pollBitcoinInvoices(): Promise<void> {
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<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice']; const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => { await this.dataSource.transaction(async manager => {
@@ -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 { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
import type { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient'; 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 { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason'; import { InvoiceReason } from '../types/InvoiceReason';
import { PaymentMethod } from '../types/PaymentMethod'; import { PaymentMethod } from '../types/PaymentMethod';
@@ -19,11 +20,14 @@ describe('InvoiceService', () => {
let configService: { let configService: {
get: jest.Mock; get: jest.Mock;
}; };
let walletRpcClient: { let moneroWalletRpcClient: {
createAddress: jest.Mock; createAddress: jest.Mock;
}; };
let xmrRateService: { let bitcoinWalletRpcClient: {
getLiveFiatPerXmr: jest.Mock; createAddress: jest.Mock;
};
let exchangeRateService: {
getLiveFiatPerCrypto: jest.Mock;
}; };
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>; let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
@@ -45,6 +49,10 @@ describe('InvoiceService', () => {
return { confirmationTiers }; return { confirmationTiers };
} }
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') { if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 };
} }
@@ -53,22 +61,27 @@ describe('InvoiceService', () => {
}) })
}; };
walletRpcClient = { moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({ createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample', address: '4MoneroPaymentAddressExample',
address_index: 12 address_index: 12
}) })
}; };
xmrRateService = { bitcoinWalletRpcClient = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(150) createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
};
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
}; };
service = new InvoiceService( service = new InvoiceService(
invoiceRepo as unknown as Repository<Invoice>, invoiceRepo as unknown as Repository<Invoice>,
configService as unknown as ConfigService, configService as unknown as ConfigService,
walletRpcClient as unknown as MoneroWalletRpcClient, moneroWalletRpcClient as unknown as MoneroWalletRpcClient,
xmrRateService as unknown as XmrRateService bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
exchangeRateService as unknown as ExchangeRateService
); );
}); });
@@ -76,26 +89,26 @@ describe('InvoiceService', () => {
errorLogSpy.mockRestore(); errorLogSpy.mockRestore();
}); });
const issueCheckoutInvoice = () => const issueCheckoutInvoice = (paymentMethod: PaymentMethod = PaymentMethod.Xmr) =>
service.issueInvoice({ service.issueInvoice({
paymentMethod: PaymentMethod.Xmr, paymentMethod,
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
contextId: 'session-uuid', contextId: 'session-uuid',
amountFiat: 15 amountFiat: 15
}); });
it('throws when the live XMR rate is unavailable for checkout invoices', async () => { 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( await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't show a price right now. Please try again in a few minutes.") 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 () => { 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( await expect(issueCheckoutInvoice()).rejects.toThrow(
new ServiceUnavailableException("We can't take payments right now. Please try again in a few minutes.") 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 () => { it('creates a checkout invoice with converted totals and monero details', async () => {
const invoice = await issueCheckoutInvoice(); const invoice = await issueCheckoutInvoice();
expect(walletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid'); expect(moneroWalletRpcClient.createAddress).toHaveBeenCalledWith('checkout - session-uuid');
expect(invoiceRepo.create).toHaveBeenCalledWith( expect(invoiceRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
@@ -145,6 +158,10 @@ describe('InvoiceService', () => {
}; };
} }
if (key === 'shopSettings.bitcoin') {
return { confirmationTiers };
}
if (key === 'order') { if (key === 'order') {
return { checkoutValidityMs: 3_600_000, shippingPaymentValidityMs: 7_200_000 }; 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 () => { it('uses shipping-specific messages and address labels for shipping invoices', async () => {
xmrRateService.getLiveFiatPerXmr.mockReturnValue(null); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(null);
await expect( await expect(
service.issueInvoice({ service.issueInvoice({
@@ -184,8 +201,8 @@ describe('InvoiceService', () => {
) )
); );
xmrRateService.getLiveFiatPerXmr.mockReturnValue(150); exchangeRateService.getLiveFiatPerCrypto.mockReturnValue(150);
walletRpcClient.createAddress.mockRejectedValue(new Error('rpc down')); moneroWalletRpcClient.createAddress.mockRejectedValue(new Error('rpc down'));
await expect( await expect(
service.issueInvoice({ service.issueInvoice({
@@ -200,7 +217,7 @@ describe('InvoiceService', () => {
) )
); );
walletRpcClient.createAddress.mockResolvedValue({ moneroWalletRpcClient.createAddress.mockResolvedValue({
address: '4ShippingPaymentAddressExample', address: '4ShippingPaymentAddressExample',
address_index: 3 address_index: 3
}); });
@@ -212,6 +229,87 @@ describe('InvoiceService', () => {
amountFiat: 5 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'));
}); });
}); });
@@ -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 { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import dayjs from '../../../plugins/dayjs'; import dayjs from '../../../plugins/dayjs';
import type { Config } from '../../../types/Config'; import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage'; import { getErrorMessage } from '../../../utils/getErrorMessage';
import { convertFiatToBtc } from '../../../utils/bitcoin/convertFiatToBtc';
import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr'; import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { convertXmrToXmrAtomic } from '../../../utils/monero/convertXmrToXmrAtomic'; 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 { MoneroWalletRpcClient } from '../../moneroWallet/services/MoneroWalletRpcClient';
import { XmrRateService } from '../../xmrRate/services/XmrRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
import { InvoiceReason } from '../types/InvoiceReason'; import { InvoiceReason } from '../types/InvoiceReason';
import type { IssueInvoiceData } from '../types/IssueInvoiceData'; import type { IssueInvoiceData } from '../types/IssueInvoiceData';
@@ -24,14 +27,19 @@ export class InvoiceService {
@InjectRepository(Invoice) @InjectRepository(Invoice)
private readonly invoiceRepo: Repository<Invoice>, private readonly invoiceRepo: Repository<Invoice>,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly walletRpcClient: MoneroWalletRpcClient, private readonly moneroWalletRpcClient: MoneroWalletRpcClient,
private readonly xmrRateService: XmrRateService private readonly bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly exchangeRateService: ExchangeRateService
) {} ) {}
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> { async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
switch (data.paymentMethod) { switch (data.paymentMethod) {
case PaymentMethod.Xmr: case PaymentMethod.Xmr:
return this.issueXmrInvoice(data); 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( const { rateUnavailableMessage, unavailableMessage, addressLabel, validityMs } = this.resolveReasonData(
reason, reason,
contextId contextId,
PaymentMethod.Xmr
); );
const fiatPerXmr = this.xmrRateService.getLiveFiatPerXmr(); const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
if (fiatPerXmr === null) { if (fiatPerXmr === null) {
throw new ServiceUnavailableException(rateUnavailableMessage); throw new ServiceUnavailableException(rateUnavailableMessage);
@@ -54,7 +63,7 @@ export class InvoiceService {
let paymentAddressIndex: number; let paymentAddressIndex: number;
try { try {
const { address, address_index } = await this.walletRpcClient.createAddress(addressLabel); const { address, address_index } = await this.moneroWalletRpcClient.createAddress(addressLabel);
paymentAddress = address; paymentAddress = address;
paymentAddressIndex = address_index; paymentAddressIndex = address_index;
@@ -89,8 +98,63 @@ export class InvoiceService {
return this.invoiceRepo.save(invoice); return this.invoiceRepo.save(invoice);
} }
private resolveReasonData(reason: InvoiceReason, contextId: string): InvoiceReasonData { private async issueBtcInvoice({ reason, contextId, amountFiat }: IssueInvoiceData): Promise<Invoice> {
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 { checkoutValidityMs, shippingPaymentValidityMs } = this.configService.get('order') as Config['order'];
const cryptoLabel = paymentMethod === PaymentMethod.Btc ? 'BTC' : 'XMR';
switch (reason) { switch (reason) {
case InvoiceReason.Checkout: case InvoiceReason.Checkout:
@@ -105,8 +169,7 @@ export class InvoiceService {
addressLabel: `order-shipping - ${contextId}`, addressLabel: `order-shipping - ${contextId}`,
validityMs: shippingPaymentValidityMs, validityMs: shippingPaymentValidityMs,
unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.", unavailableMessage: "We can't take shipping payments right now. Please try again in a few minutes.",
rateUnavailableMessage: rateUnavailableMessage: `We can't quote shipping in ${cryptoLabel} right now. Please try again in a few minutes.`
"We can't quote shipping in XMR right now. Please try again in a few minutes."
}; };
} }
} }
@@ -4,6 +4,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
export type InvoiceExtended = Omit<Invoice, 'payments'> & { export type InvoiceExtended = Omit<Invoice, 'payments'> & {
statusLabel: InvoiceStatusLabel | null; statusLabel: InvoiceStatusLabel | null;
paymentLabel: string;
expectedTotalCrypto: string; expectedTotalCrypto: string;
payments: InvoicePaymentExtended[]; payments: InvoicePaymentExtended[];
}; };
@@ -0,0 +1,5 @@
export type InvoiceIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
};
@@ -1,6 +1,8 @@
import type { MoneroWalletRpcIncomingTransfer } from '../../moneroWallet/types/MoneroWalletRpcIncomingTransfer'; import type { InvoiceIncomingTransfer } from './InvoiceIncomingTransfer';
export type InvoicePaymentServiceTest = { export type InvoicePaymentServiceTest = {
pollInvoices: () => Promise<void>; pollInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]) => Promise<void>; pollMoneroInvoices: () => Promise<void>;
pollBitcoinInvoices: () => Promise<void>;
processInvoice: (invoiceId: string, transfers: InvoiceIncomingTransfer[]) => Promise<void>;
}; };
@@ -1,3 +1,4 @@
export enum PaymentMethod { export enum PaymentMethod {
Xmr = 'xmr' Xmr = 'xmr',
Btc = 'btc'
} }
@@ -196,7 +196,9 @@ export class ShopSettingsService {
}: ShopSettings): ShopSettingsView { }: ShopSettings): ShopSettingsView {
const setupChecklist = this.buildSetupChecklist({ logoStorageKey, faviconStorageKey, simplexLink, shippingNote }); 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 logoUrl = logoStorageKey ? getShopBrandingPublicUrl(logoStorageKey) : null;
const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null; const faviconUrl = faviconStorageKey ? getShopBrandingPublicUrl(faviconStorageKey) : null;
@@ -208,6 +210,7 @@ export class ShopSettingsService {
shopName, shopName,
shopFiatCurrency, shopFiatCurrency,
monero, monero,
bitcoin,
logoUrl, logoUrl,
faviconUrl, faviconUrl,
simplexLink, simplexLink,
@@ -0,0 +1,5 @@
import { ConfirmationTier } from '../../../types/ConfirmationTier';
export interface ShopSettingsBitcoinView {
confirmationTiers: ConfirmationTier[];
}
@@ -1,5 +1,5 @@
import { MoneroConfirmationTier } from '../../../types/MoneroConfirmationTier'; import { ConfirmationTier } from '../../../types/ConfirmationTier';
export interface ShopSettingsMoneroView { export interface ShopSettingsMoneroView {
confirmationTiers: MoneroConfirmationTier[]; confirmationTiers: ConfirmationTier[];
} }
@@ -1,12 +1,14 @@
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
import { SetupChecklist } from './SetupChecklist'; import { SetupChecklist } from './SetupChecklist';
import { ShopSettingsMoneroView } from './ShopSettingsMoneroView'; import { ShopSettingsMoneroView } from './ShopSettingsMoneroView';
import { ShopSettingsBitcoinView } from './ShopSettingsBitcoinView';
export interface ShopSettingsView { export interface ShopSettingsView {
id: string | null; id: string | null;
shopName: string; shopName: string;
shopFiatCurrency: ShopFiatCurrency; shopFiatCurrency: ShopFiatCurrency;
monero: ShopSettingsMoneroView; monero: ShopSettingsMoneroView;
bitcoin: ShopSettingsBitcoinView;
logoUrl: string | null; logoUrl: string | null;
faviconUrl: string | null; faviconUrl: string | null;
simplexLink: string | null; simplexLink: string | null;
@@ -3,14 +3,13 @@ import { DiscountCodesModule } from '../discountCode/DiscountCodesModule';
import { ProductsModule } from '../product/ProductsModule'; import { ProductsModule } from '../product/ProductsModule';
import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule'; import { StorefrontCoreModule } from '../storefrontCore/StorefrontCoreModule';
import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule'; import { StorefrontProductModule } from '../storefrontProduct/StorefrontProductModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule';
import { StorefrontCartController } from './controllers/StorefrontCartController'; import { StorefrontCartController } from './controllers/StorefrontCartController';
import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver'; import { StorefrontCartDiscountResolver } from './services/StorefrontCartDiscountResolver';
import { StorefrontCartService } from './services/StorefrontCartService'; import { StorefrontCartService } from './services/StorefrontCartService';
import { StorefrontDiscountService } from './services/StorefrontDiscountService'; import { StorefrontDiscountService } from './services/StorefrontDiscountService';
@Module({ @Module({
imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule, XmrRateModule], imports: [StorefrontCoreModule, StorefrontProductModule, ProductsModule, DiscountCodesModule],
controllers: [StorefrontCartController], controllers: [StorefrontCartController],
providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver], providers: [StorefrontCartService, StorefrontDiscountService, StorefrontCartDiscountResolver],
exports: [StorefrontCartService] exports: [StorefrontCartService]
@@ -1,7 +1,11 @@
import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common'; import { Body, Controller, Get, HttpStatus, Post, Req, Res, UseFilters } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express'; import type { Request, Response } from 'express';
import { throttleProfiles } from '../../../config/throttleProfiles'; 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 { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService'; import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService'; import { StorefrontCheckoutSessionCookieService } from '../../storefrontCore/services/StorefrontCheckoutSessionCookieService';
@@ -33,7 +37,8 @@ export class StorefrontCartController {
private readonly feedbackCookieService: StorefrontFeedbackCookieService, private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService, private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService,
private readonly captchaService: StorefrontCaptchaService, private readonly captchaService: StorefrontCaptchaService,
private readonly captchaCookieService: StorefrontCaptchaCookieService private readonly captchaCookieService: StorefrontCaptchaCookieService,
private readonly configService: ConfigService
) {} ) {}
@Get('shop/cart') @Get('shop/cart')
@@ -62,9 +67,18 @@ export class StorefrontCartController {
this.captchaCookieService.setAnswer(req, res, encryptedAnswer); 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', { return res.render('cart-summary', {
...summary, ...summary,
...shopLocals, ...shopLocals,
paymentMethods,
captchaSvg captchaSvg
}); });
} }
@@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DeliveryMode } from '../../product/types/DeliveryMode'; import { DeliveryMode } from '../../product/types/DeliveryMode';
import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService'; import type { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
import type { XmrRateService } from '../../xmrRate/services/XmrRateService';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended'; import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
import type { StorefrontDiscountService } from './StorefrontDiscountService'; import type { StorefrontDiscountService } from './StorefrontDiscountService';
import { StorefrontCartService } from './StorefrontCartService'; import { StorefrontCartService } from './StorefrontCartService';
@@ -29,9 +28,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock; getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock; getStorefrontVariant: jest.Mock;
}; };
let xmrRateService: {
getLiveFiatPerXmr: jest.Mock;
};
let discountService: { let discountService: {
getDiscountStateForCart: jest.Mock; getDiscountStateForCart: jest.Mock;
applyDiscountCode: jest.Mock; applyDiscountCode: jest.Mock;
@@ -43,10 +39,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant()) getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
}; };
xmrRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(150)
};
discountService = { discountService = {
getDiscountStateForCart: jest.fn().mockResolvedValue({ getDiscountStateForCart: jest.fn().mockResolvedValue({
discounts: [], discounts: [],
@@ -58,7 +50,6 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService( service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService, productsService as unknown as StorefrontProductsService,
xmrRateService as unknown as XmrRateService,
discountService as unknown as StorefrontDiscountService discountService as unknown as StorefrontDiscountService
); );
}); });
@@ -98,8 +89,6 @@ describe('StorefrontCartService', () => {
discounts: [], discounts: [],
cartDiscountTotal: 0, cartDiscountTotal: 0,
cartTotalPrice: 0, cartTotalPrice: 0,
cartTotalXmr: null,
fiatPerXmr: null,
hasManualLines: false, hasManualLines: false,
hasAutoLines: false, hasAutoLines: false,
cartTotalIssueMessage: null, 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 }], []); const summary = await service.getCartSummary([{ variantId, qty: 1 }], []);
expect(summary.cartSubtotal).toBe(10); expect(summary.cartSubtotal).toBe(10);
expect(summary.cartTotalPrice).toBe(10); expect(summary.cartTotalPrice).toBe(10);
expect(summary.fiatPerXmr).toBe(150);
expect(summary.cartTotalXmr).toBe('0.06666667');
expect(summary.hasAutoLines).toBe(true); expect(summary.hasAutoLines).toBe(true);
}); expect(summary.hasIssues).toBe(false);
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();
}); });
it('flags manual and auto delivery lines separately', async () => { it('flags manual and auto delivery lines separately', async () => {
@@ -7,8 +7,6 @@ import type { CookieCart } from '../../storefrontCore/types/cart/CookieCart';
import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart'; import { getQtyByVariantIdFromCart } from '../../../utils/cart/getQtyByVariantIdFromCart';
import { DeliveryMode } from '../../product/types/DeliveryMode'; import { DeliveryMode } from '../../product/types/DeliveryMode';
import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService'; import { StorefrontProductsService } from '../../storefrontProduct/services/StorefrontProductsService';
import { XmrRateService } from '../../xmrRate/services/XmrRateService';
import { convertFiatToXmr } from '../../../utils/monero/convertFiatToXmr';
import { CookieCartLineDto } from '../dto/CookieCartLineDto'; import { CookieCartLineDto } from '../dto/CookieCartLineDto';
import type { CookieCartExtended } from '../types/CookieCartExtended'; import type { CookieCartExtended } from '../types/CookieCartExtended';
import type { CookieCartLineExtended } from '../types/CookieCartLineExtended'; import type { CookieCartLineExtended } from '../types/CookieCartLineExtended';
@@ -22,7 +20,6 @@ import { StorefrontDiscountService } from './StorefrontDiscountService';
export class StorefrontCartService { export class StorefrontCartService {
constructor( constructor(
private readonly productsService: StorefrontProductsService, private readonly productsService: StorefrontProductsService,
private readonly xmrRateService: XmrRateService,
private readonly discountService: StorefrontDiscountService private readonly discountService: StorefrontDiscountService
) {} ) {}
@@ -66,8 +63,6 @@ export class StorefrontCartService {
discounts: [], discounts: [],
cartDiscountTotal: 0, cartDiscountTotal: 0,
cartTotalPrice: 0, cartTotalPrice: 0,
cartTotalXmr: null,
fiatPerXmr: null,
hasManualLines: false, hasManualLines: false,
hasAutoLines: false, hasAutoLines: false,
cartTotalIssueMessage: null, cartTotalIssueMessage: null,
@@ -77,8 +72,6 @@ export class StorefrontCartService {
const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal'); const cartSubtotal = sumByKey(cartExtended, 'lineSubtotal');
const discountState = await this.discountService.getDiscountStateForCart(discountCodes, cartExtended); 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 hasManualLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Manual);
const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto); const hasAutoLines = cartExtended.some(line => line.deliveryMode === DeliveryMode.Auto);
const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0); const cartTotalIssueMessage = getZeroCartTotalIssue(discountState.cartTotalPrice, cartExtended.length > 0);
@@ -91,8 +84,6 @@ export class StorefrontCartService {
cartExtended, cartExtended,
cartSubtotal, cartSubtotal,
...discountState, ...discountState,
cartTotalXmr,
fiatPerXmr,
hasManualLines, hasManualLines,
hasAutoLines, hasAutoLines,
cartTotalIssueMessage, cartTotalIssueMessage,
@@ -4,8 +4,6 @@ import type { CookieCartExtended } from './CookieCartExtended';
export type CookieCartSummary = { export type CookieCartSummary = {
cartExtended: CookieCartExtended; cartExtended: CookieCartExtended;
cartSubtotal: number; cartSubtotal: number;
cartTotalXmr: string | null;
fiatPerXmr: number | null;
hasManualLines: boolean; hasManualLines: boolean;
hasAutoLines: boolean; hasAutoLines: boolean;
cartTotalIssueMessage: string | null; cartTotalIssueMessage: string | null;
@@ -41,7 +41,7 @@ export class StorefrontCheckoutController {
@Post('shop/checkout/pay') @Post('shop/checkout/pay')
@Throttle(throttleProfiles.checkoutPay) @Throttle(throttleProfiles.checkoutPay)
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise<void> { async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha, paymentMethod }: PayCheckoutDto): Promise<void> {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res); const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (sessionId) { if (sessionId) {
@@ -64,7 +64,7 @@ export class StorefrontCheckoutController {
const summary = await this.cartService.getCartSummary(cart, discountCodes); 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); this.checkoutSessionCookieService.setSessionId(req, res, session.id);
@@ -1,10 +1,13 @@
import { IsNotEmpty, IsString, Length } from 'class-validator'; import { IsEnum, IsIn, IsNotEmpty, IsString, Length } from 'class-validator';
import { getAppConfig } from '../../../config'; import { getAppConfig, getShopSettingsConfig } from '../../../config';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
const { const {
captcha: { length: captchaLength } captcha: { length: captchaLength }
} = getAppConfig(); } = getAppConfig();
const { enabledPaymentMethods } = getShopSettingsConfig();
export class PayCheckoutDto { export class PayCheckoutDto {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@@ -12,4 +15,9 @@ export class PayCheckoutDto {
message: `Captcha should be ${captchaLength} characters long` message: `Captcha should be ${captchaLength} characters long`
}) })
captcha: string; captcha: string;
@IsNotEmpty()
@IsEnum(PaymentMethod)
@IsIn(enabledPaymentMethods, { message: 'Select a valid payment method' })
paymentMethod: PaymentMethod;
} }
@@ -25,6 +25,7 @@ export class CheckoutPaymentPollerService {
.createQueryBuilder('session') .createQueryBuilder('session')
.innerJoinAndSelect('session.invoice', 'invoice') .innerJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails') .leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment') .leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order') .leftJoin('session.order', 'order')
.where('session.cancelledAt IS NULL') .where('session.cancelledAt IS NULL')
@@ -38,8 +38,6 @@ const buildSummary = (overrides: Partial<CookieCartSummary> = {}): CookieCartSum
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }], discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
cartDiscountTotal: 1, cartDiscountTotal: 1,
cartTotalPrice: 9, cartTotalPrice: 9,
cartTotalXmr: '0.06000000',
fiatPerXmr: 150,
hasManualLines: false, hasManualLines: false,
hasAutoLines: true, hasAutoLines: true,
cartTotalIssueMessage: null, cartTotalIssueMessage: null,
@@ -115,15 +113,15 @@ describe('CheckoutSessionService', () => {
describe('createFromCartSummary', () => { describe('createFromCartSummary', () => {
it('rejects an empty cart', async () => { it('rejects an empty cart', async () => {
await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow( await expect(
new BadRequestException('Your cart is empty') service.createFromCartSummary(buildSummary({ cartExtended: [] }), PaymentMethod.Xmr)
); ).rejects.toThrow(new BadRequestException('Your cart is empty'));
}); });
it('rejects carts that still have unresolved issues', async () => { it('rejects carts that still have unresolved issues', async () => {
await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow( await expect(
new BadRequestException('Resolve cart issues before paying') 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 () => { 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({ expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Xmr, paymentMethod: PaymentMethod.Xmr,
@@ -180,7 +178,8 @@ describe('CheckoutSessionService', () => {
await service.createFromCartSummary( await service.createFromCartSummary(
buildSummary({ buildSummary({
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }] discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
}) }),
PaymentMethod.Xmr
); );
expect(discountRepo.create).not.toHaveBeenCalled(); 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', () => { describe('findById', () => {
@@ -200,7 +210,14 @@ describe('CheckoutSessionService', () => {
await expect(service.findById('session-1')).resolves.toBe(session); await expect(service.findById('session-1')).resolves.toBe(session);
expect(sessionRepo.findOne).toHaveBeenCalledWith({ expect(sessionRepo.findOne).toHaveBeenCalledWith({
where: { id: 'session-1' }, where: { id: 'session-1' },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments'] relations: [
'lines',
'discounts',
'invoice',
'invoice.moneroDetails',
'invoice.btcDetails',
'invoice.payments'
]
}); });
}); });
}); });
@@ -6,7 +6,7 @@ import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSum
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState'; import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
import { InvoiceReason } from '../../payment/types/InvoiceReason'; import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService'; 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 { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine'; import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import { CheckoutSession } from '../entities/CheckoutSession'; import { CheckoutSession } from '../entities/CheckoutSession';
@@ -26,11 +26,21 @@ export class CheckoutSessionService {
async findById(id: string): Promise<CheckoutSession | null> { async findById(id: string): Promise<CheckoutSession | null> {
return this.sessionRepo.findOne({ return this.sessionRepo.findOne({
where: { id }, 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<CheckoutSession> { async createFromCartSummary(
summary: CookieCartSummary,
requestedPaymentMethod: PaymentMethod
): Promise<CheckoutSession> {
if (summary.cartExtended.length === 0) { if (summary.cartExtended.length === 0) {
throw new BadRequestException('Your cart is empty'); throw new BadRequestException('Your cart is empty');
} }
@@ -42,7 +52,7 @@ export class CheckoutSessionService {
const sessionId = randomUUID(); const sessionId = randomUUID();
const invoice = await this.invoiceService.issueInvoice({ const invoice = await this.invoiceService.issueInvoice({
paymentMethod: PaymentMethod.Xmr, paymentMethod: requestedPaymentMethod,
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
contextId: sessionId, contextId: sessionId,
amountFiat: summary.cartTotalPrice amountFiat: summary.cartTotalPrice
@@ -84,7 +94,7 @@ export class CheckoutSessionService {
async cancelSession(sessionId: string): Promise<void> { async cancelSession(sessionId: string): Promise<void> {
const session = await this.sessionRepo.findOne({ const session = await this.sessionRepo.findOne({
where: { id: sessionId }, where: { id: sessionId },
relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments'] relations: ['invoice', 'invoice.moneroDetails', 'invoice.btcDetails', 'invoice.payments']
}); });
if (!session) { if (!session) {
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>; let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
const checkoutInvoiceView = { const checkoutInvoiceView = {
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
amountFiat: 9 amountFiat: 9
} as unknown as StorefrontInvoiceView; } as unknown as StorefrontInvoiceView;
@@ -1,7 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { EncryptionModule } from '../encryption/EncryptionModule'; import { EncryptionModule } from '../encryption/EncryptionModule';
import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule'; import { ShopSettingsModule } from '../shopSettings/ShopSettingsModule';
import { XmrRateModule } from '../xmrRate/XmrRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { StorefrontErrorController } from './controllers/StorefrontErrorController'; import { StorefrontErrorController } from './controllers/StorefrontErrorController';
import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController'; import { StorefrontPreferencesController } from './controllers/StorefrontPreferencesController';
import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter'; import { StorefrontExceptionFilter } from './filters/StorefrontExceptionFilter';
@@ -18,7 +18,7 @@ import { StorefrontSignedCookieService } from './services/StorefrontSignedCookie
import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService'; import { StorefrontThemeCookieService } from './services/StorefrontThemeCookieService';
@Module({ @Module({
imports: [ShopSettingsModule, XmrRateModule, EncryptionModule], imports: [ShopSettingsModule, ExchangeRateModule, EncryptionModule],
controllers: [StorefrontErrorController, StorefrontPreferencesController], controllers: [StorefrontErrorController, StorefrontPreferencesController],
providers: [ providers: [
StorefrontSignedCookieService, StorefrontSignedCookieService,
@@ -1,4 +1,5 @@
:root { :root {
color-scheme: light;
--sf-font-sans: --sf-font-sans:
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif; 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', Arial, sans-serif;
--sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; --sf-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
@@ -52,6 +53,7 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { :root:not([data-theme='light']) {
color-scheme: dark;
--sf-bg: #0a0a0a; --sf-bg: #0a0a0a;
--sf-surface: #141414; --sf-surface: #141414;
--sf-surface-muted: #262727; --sf-surface-muted: #262727;
@@ -85,6 +87,7 @@
} }
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark;
--sf-bg: #0a0a0a; --sf-bg: #0a0a0a;
--sf-surface: #141414; --sf-surface: #141414;
--sf-surface-muted: #262727; --sf-surface-muted: #262727;
@@ -117,6 +120,7 @@
} }
:root[data-theme='light'] { :root[data-theme='light'] {
color-scheme: light;
--sf-bg: #ffffff; --sf-bg: #ffffff;
--sf-surface: #ffffff; --sf-surface: #ffffff;
--sf-surface-muted: #f5f7fa; --sf-surface-muted: #f5f7fa;
@@ -154,10 +158,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
color-scheme: light dark;
}
body.sf-body { body.sf-body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
@@ -355,9 +355,31 @@ a:hover {
gap: var(--sf-space-1); gap: var(--sf-space-1);
} }
.sf-rates {
display: flex;
flex-wrap: wrap;
gap: var(--sf-space-3);
align-items: center;
}
.sf-rate { .sf-rate {
display: inline-flex;
align-items: center;
gap: var(--sf-space-1);
font-size: 0.95rem; font-size: 0.95rem;
color: var(--sf-text-muted); 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 { .sf-category-nav {
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -2,12 +2,13 @@ import type { Request, Response } from 'express';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { StorefrontShopViewService } from './StorefrontShopViewService'; import { StorefrontShopViewService } from './StorefrontShopViewService';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { XmrRateService } from '../../xmrRate/services/XmrRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { StorefrontCartCookieService } from './StorefrontCartCookieService'; import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService'; import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService'; import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService'; import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput'; import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => { describe('StorefrontShopViewService', () => {
type ServiceOverrides = { type ServiceOverrides = {
@@ -21,8 +22,9 @@ describe('StorefrontShopViewService', () => {
simplexLink: string | null; simplexLink: string | null;
shippingNote: string | null; shippingNote: string | null;
}; };
shopSettings?: { shopName: string; shopFiatCurrency: string }; shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number; fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & { req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string; host?: string;
}; };
@@ -45,14 +47,21 @@ describe('StorefrontShopViewService', () => {
simplexLink: null, simplexLink: null,
shippingNote: null shippingNote: null
}, },
shopSettings = { shopName: 'Demo Shop', shopFiatCurrency: 'USD' }, shopSettings = {
shopName: 'Demo Shop',
shopFiatCurrency: 'USD',
enabledPaymentMethods: [PaymentMethod.Xmr, PaymentMethod.Btc]
},
fiatPerXmr = 150, fiatPerXmr = 150,
fiatPerBtc = 60_000,
req: reqOverrides = {} req: reqOverrides = {}
} = overrides; } = overrides;
const xmrRateService = { const exchangeRateService = {
getLiveFiatPerXmr: jest.fn().mockReturnValue(fiatPerXmr) getLiveFiatPerCrypto: jest.fn((method: PaymentMethod) =>
} as unknown as XmrRateService; method === PaymentMethod.Btc ? fiatPerBtc : fiatPerXmr
)
} as unknown as ExchangeRateService;
const configService = { const configService = {
get: jest.fn().mockReturnValue(shopSettings) get: jest.fn().mockReturnValue(shopSettings)
} as unknown as ConfigService; } as unknown as ConfigService;
@@ -73,7 +82,7 @@ describe('StorefrontShopViewService', () => {
} as unknown as StorefrontThemeCookieService; } as unknown as StorefrontThemeCookieService;
const service = new StorefrontShopViewService( const service = new StorefrontShopViewService(
xmrRateService, exchangeRateService,
configService, configService,
shopSettingsService, shopSettingsService,
cartCookieService, cartCookieService,
@@ -124,7 +133,8 @@ describe('StorefrontShopViewService', () => {
simplexLink: 'https://simplex.example', simplexLink: 'https://simplex.example',
shippingNote: 'Ships in 3 days' shippingNote: 'Ships in 3 days'
}, },
fiatPerXmr: 200 fiatPerXmr: 200,
fiatPerBtc: 80_000
}); });
const locals = await service.buildShopRenderLocals(req, res, defaultPage); const locals = await service.buildShopRenderLocals(req, res, defaultPage);
@@ -134,7 +144,10 @@ describe('StorefrontShopViewService', () => {
cartTotalQty: 3, cartTotalQty: 3,
feedback: { type: 'success', text: 'Added to cart' }, feedback: { type: 'success', text: 'Added to cart' },
shopFiatCurrency: 'USD', 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', logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico', faviconUrl: '/uploads/favicon.ico',
simplexLink: 'https://simplex.example', simplexLink: 'https://simplex.example',
@@ -264,7 +277,7 @@ describe('StorefrontShopViewService', () => {
it('uses shop fiat currency in product json-ld', async () => { it('uses shop fiat currency in product json-ld', async () => {
const { service, req, res } = createService({ const { service, req, res } = createService({
shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR' }, shopSettings: { shopName: 'Euro Shop', shopFiatCurrency: 'EUR', enabledPaymentMethods: [PaymentMethod.Xmr] },
req: { req: {
path: '/shop/products/1/variants/2', path: '/shop/products/1/variants/2',
originalUrl: '/shop/products/1/variants/2' originalUrl: '/shop/products/1/variants/2'
@@ -7,9 +7,13 @@ import { getTotalCartQtyFromCart } from '../../../utils/cart/getTotalCartQtyFrom
import { formatShortOrderId } from '../../../utils/order/formatShortOrderId'; import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl'; import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService'; 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 { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
import type { ShopRenderLocals } from '../types/ShopRenderLocals'; import type { ShopRenderLocals } from '../types/ShopRenderLocals';
import type { StorefrontCryptoRate } from '../types/StorefrontCryptoRate';
import type { StorefrontPageMeta } from '../types/StorefrontPageMeta'; import type { StorefrontPageMeta } from '../types/StorefrontPageMeta';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput'; import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput'; import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
@@ -22,7 +26,7 @@ import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@Injectable() @Injectable()
export class StorefrontShopViewService { export class StorefrontShopViewService {
constructor( constructor(
private readonly xmrRateService: XmrRateService, private readonly exchangeRateService: ExchangeRateService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly shopSettingsService: ShopSettingsService, private readonly shopSettingsService: ShopSettingsService,
private readonly cartCookieService: StorefrontCartCookieService, private readonly cartCookieService: StorefrontCartCookieService,
@@ -38,9 +42,11 @@ export class StorefrontShopViewService {
const cartTotalQty = getTotalCartQtyFromCart(cart); 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 } = const { logoUrl, faviconUrl, simplexLink, shippingNote } =
await this.shopSettingsService.getStorefrontBranding(); await this.shopSettingsService.getStorefrontBranding();
@@ -60,7 +66,7 @@ export class StorefrontShopViewService {
authorizedOrders, authorizedOrders,
shopNavActive, shopNavActive,
shopFiatCurrency, shopFiatCurrency,
fiatPerXmr, cryptoRates,
logoUrl, logoUrl,
faviconUrl, faviconUrl,
simplexLink, simplexLink,
@@ -129,4 +135,22 @@ export class StorefrontShopViewService {
return JSON.stringify(data); 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]
}
];
});
}
} }
@@ -1,6 +1,7 @@
import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency'; import type { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem'; import type { AuthorizedOrderNavItem } from './AuthorizedOrderNavItem';
import type { ShopNavActive } from './ShopNavActive'; import type { ShopNavActive } from './ShopNavActive';
import type { StorefrontCryptoRate } from './StorefrontCryptoRate';
import type { StorefrontFeedback } from './StorefrontFeedback'; import type { StorefrontFeedback } from './StorefrontFeedback';
import type { StorefrontPageMeta } from './StorefrontPageMeta'; import type { StorefrontPageMeta } from './StorefrontPageMeta';
import type { StorefrontThemePreference } from './StorefrontThemePreference'; import type { StorefrontThemePreference } from './StorefrontThemePreference';
@@ -13,7 +14,7 @@ export type ShopRenderLocals = {
authorizedOrders: AuthorizedOrderNavItem[]; authorizedOrders: AuthorizedOrderNavItem[];
shopNavActive: ShopNavActive; shopNavActive: ShopNavActive;
shopFiatCurrency: ShopFiatCurrency; shopFiatCurrency: ShopFiatCurrency;
fiatPerXmr: number | null; cryptoRates: StorefrontCryptoRate[];
logoUrl: string | null; logoUrl: string | null;
faviconUrl: string | null; faviconUrl: string | null;
simplexLink: string | null; simplexLink: string | null;
@@ -0,0 +1,5 @@
export type StorefrontCryptoRate = {
paymentLabel: string;
fiatPerCrypto: number;
iconUrl: string;
};
@@ -3,7 +3,7 @@ import type { InvoiceStatusLabel } from '../../../utils/invoice/types/InvoiceSta
import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant'; import type { InvoiceStatusVariant } from '../../../utils/invoice/types/InvoiceStatusVariant';
export type StorefrontInvoiceView = { export type StorefrontInvoiceView = {
cryptoCurrency: string; paymentLabel: string;
expectedTotalCrypto: string; expectedTotalCrypto: string;
receivedTotalCrypto: string | null; receivedTotalCrypto: string | null;
paymentAddress: string; paymentAddress: string;
@@ -23,14 +23,14 @@
<p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p> <p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p>
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Received: Received:
<strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.cryptoCurrency}}</strong> <strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.paymentLabel}}</strong>
</p> </p>
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Your order is being prepared. This page will redirect automatically once it is ready. Your order is being prepared. This page will redirect automatically once it is ready.
</p> </p>
{{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}} {{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}}
{{else}} {{else}}
<h2 class='sf-section-title'>Pay with {{checkout.checkoutInvoice.cryptoCurrency}}</h2> <h2 class='sf-section-title'>Pay with {{checkout.checkoutInvoice.paymentLabel}}</h2>
{{#with checkout.checkoutInvoice}} {{#with checkout.checkoutInvoice}}
{{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}} {{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}}
{{/with}} {{/with}}
@@ -52,13 +52,6 @@
{{cartTotalPrice}} {{cartTotalPrice}}
{{shopFiatCurrency}} {{shopFiatCurrency}}
</p> </p>
{{#if cartTotalXmr}}
<p class='sf-text-muted'>= {{cartTotalXmr}} XMR</p>
{{else}}
<p class='sf-text-muted'>
We couldn't determine the XMR rate right now. Please try again later.
</p>
{{/if}}
<form class='sf-form-row' method='post' action='/shop/cart/discount'> <form class='sf-form-row' method='post' action='/shop/cart/discount'>
<label class='sf-field'> <label class='sf-field'>
@@ -73,22 +66,37 @@
{{/if}} {{/if}}
<div class='sf-stack sf-mt-4'> <div class='sf-stack sf-mt-4'>
{{#if cartTotalXmr}} {{#unless hasIssues}}
{{#unless hasIssues}} <form class='sf-form-col' method='post' action='/shop/checkout/pay'>
<form class='sf-form-col' method='post' action='/shop/checkout/pay'> <div>{{{captchaSvg}}}</div>
<div>{{{captchaSvg}}}</div> <label class='sf-field'>
<label class='sf-field'> <span class='sf-field__label'>Enter the code above</span>
<span class='sf-field__label'>Enter the code above</span> <input class='sf-input' type='text' name='captcha' autocomplete='off' required />
<input class='sf-input' type='text' name='captcha' autocomplete='off' required /> </label>
</label>
<button type='submit' class='sf-btn sf-btn--primary'>Pay with XMR</button> <div class='sf-stack'>
</form> {{#each paymentMethods}}
{{else}} <button
<button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Pay with XMR</button> type='submit'
{{/unless}} name='paymentMethod'
value='{{paymentMethod}}'
class='sf-btn sf-btn--primary sf-btn--pay'
>
Pay with {{paymentMethodLabel}}
{{> storefront-image
class='sf-crypto-icon'
src=iconUrl
alt=''
width='20'
height='20'
}}
</button>
{{/each}}
</div>
</form>
{{else}} {{else}}
<button type='button' class='sf-btn sf-btn--primary' disabled title='XMR rate unavailable'>Pay with XMR</button> <button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Continue to payment</button>
{{/if}} {{/unless}}
{{> manual-shipping-info}} {{> manual-shipping-info}}
{{> auto-delivery-info}} {{> auto-delivery-info}}
@@ -3,27 +3,27 @@
{{/if}} {{/if}}
{{#if showProminentAmount}} {{#if showProminentAmount}}
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{cryptoCurrency}}</p> <p class='sf-payment-amount'>{{expectedTotalCrypto}} {{paymentLabel}}</p>
{{/if}} {{/if}}
{{#if showReceivedTotal}} {{#if showReceivedTotal}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Received: Received:
<strong>{{receivedTotalCrypto}} {{cryptoCurrency}}</strong> <strong>{{receivedTotalCrypto}} {{paymentLabel}}</strong>
</p> </p>
{{/if}} {{/if}}
{{#if showExpectedTotal}} {{#if showExpectedTotal}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
Expected total: Expected total:
<strong>{{expectedTotalCrypto}} {{cryptoCurrency}}</strong> <strong>{{expectedTotalCrypto}} {{paymentLabel}}</strong>
</p> </p>
{{/if}} {{/if}}
{{#if showInstruction}} {{#if showInstruction}}
<p class='sf-payment-detail'> <p class='sf-payment-detail'>
{{instructionPrefix}} {{instructionPrefix}}
<strong>{{instructionAmountCrypto}} {{cryptoCurrency}}</strong> <strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
{{instructionSuffix}} {{instructionSuffix}}
{{#if showExpiry}} {{#if showExpiry}}
Payment expires in Payment expires in
@@ -34,7 +34,7 @@
{{#if showQr}} {{#if showQr}}
<div class='sf-qr-wrap'> <div class='sf-qr-wrap'>
<img src='{{qrCodeUrl}}' width='220' height='220' alt='{{cryptoCurrency}} payment QR code' /> <img src='{{qrCodeUrl}}' width='220' height='220' alt='{{paymentLabel}} payment QR code' />
</div> </div>
{{/if}} {{/if}}
@@ -48,7 +48,7 @@
<ul class='sf-list-reset sf-tx-list'> <ul class='sf-list-reset sf-tx-list'>
{{#each payments}} {{#each payments}}
<li class='sf-tx-item'> <li class='sf-tx-item'>
<div><strong>{{amountCrypto}} {{../cryptoCurrency}}</strong></div> <div><strong>{{amountCrypto}} {{../paymentLabel}}</strong></div>
<div class='sf-mono sf-text-subtle'>{{txHash}}</div> <div class='sf-mono sf-text-subtle'>{{txHash}}</div>
<div class='sf-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div> <div class='sf-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div>
</li> </li>
@@ -29,8 +29,21 @@
</nav> </nav>
</div> </div>
{{#if fiatPerXmr}} {{#if cryptoRates.length}}
<div class='sf-rate'>1 XMR = {{fiatPerXmr}} {{shopFiatCurrency}}</div> <div class='sf-rates'>
{{#each cryptoRates}}
<span class='sf-rate'>
{{> storefront-image
class='sf-crypto-icon'
src=iconUrl
alt=paymentLabel
width='20'
height='20'
}}
<span>{{fiatPerCrypto}} {{../shopFiatCurrency}}</span>
</span>
{{/each}}
</div>
{{/if}} {{/if}}
</div> </div>
</header> </header>
@@ -68,6 +68,7 @@ export class StorefrontOrderService {
.createQueryBuilder('order') .createQueryBuilder('order')
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice') .leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'moneroDetails') .leftJoinAndSelect('checkoutInvoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('checkoutInvoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('checkoutInvoice.payments', 'payment') .leftJoinAndSelect('checkoutInvoice.payments', 'payment')
.where('order.id = :orderId', { orderId }) .where('order.id = :orderId', { orderId })
.getOne(); .getOne();
@@ -198,7 +198,7 @@ describe('StorefrontOrderViewService', () => {
expect(view.totals.shippingCostFiat).toBe(5); expect(view.totals.shippingCostFiat).toBe(5);
expect(view.totals.grandTotalFiat).toBe(15); expect(view.totals.grandTotalFiat).toBe(15);
expect(view.shippingInvoice).toMatchObject({ expect(view.shippingInvoice).toMatchObject({
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
paymentAddress: '4shipping' paymentAddress: '4shipping'
}); });
}); });
@@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { XmrRateService } from './services/XmrRateService';
@Module({
providers: [XmrRateService],
exports: [XmrRateService]
})
export class XmrRateModule {}
@@ -1,39 +0,0 @@
import {
IsEnum,
IsNotEmpty,
IsObject,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface,
type ValidationArguments
} from 'class-validator';
import { ShopFiatCurrency } from '../../../types/ShopFiatCurrency';
@ValidatorConstraint({ name: 'coingeckoMoneroFiat' })
class CoingeckoMoneroFiatConstraint implements ValidatorConstraintInterface {
validate(monero: Record<string, number>, args: ValidationArguments): boolean {
const { shopFiatCurrency } = args.object as CoingeckoSimplePriceResponseDto;
if (!shopFiatCurrency) {
return false;
}
const rate = monero[shopFiatCurrency.toLowerCase()];
return typeof rate === 'number' && Number.isFinite(rate) && rate > 0;
}
defaultMessage(): string {
return 'CoinGecko monero fiat rate must be a positive number';
}
}
export class CoingeckoSimplePriceResponseDto {
@IsEnum(ShopFiatCurrency)
shopFiatCurrency: ShopFiatCurrency;
@IsNotEmpty()
@IsObject()
@Validate(CoingeckoMoneroFiatConstraint)
monero: Record<string, number>;
}
@@ -1,56 +0,0 @@
import {
ArrayMaxSize,
IsArray,
IsNotEmpty,
IsString,
Validate,
ValidatorConstraint,
type ValidatorConstraintInterface
} from 'class-validator';
import Decimal from 'decimal.js';
@ValidatorConstraint({ name: 'krakenResult' })
class KrakenResultConstraint implements ValidatorConstraintInterface {
validate(result: unknown): boolean {
if (typeof result !== 'object' || result === null) {
return false;
}
const pair = Object.values(result as Record<string, unknown>)[0];
if (typeof pair !== 'object' || pair === null) {
return false;
}
if (!('c' in pair)) {
return false;
}
if (!Array.isArray(pair.c) || pair.c.length < 1 || typeof pair.c[0] !== 'string') {
return false;
}
try {
const price = new Decimal(pair.c[0]);
return price.isFinite() && price.gt(0);
} catch {
return false;
}
}
defaultMessage(): string {
return 'Kraken ticker last price (c[0]) must be a positive number';
}
}
export class KrakenTickerResponseDto {
@IsArray()
@ArrayMaxSize(0)
@IsString({ each: true })
error: string[];
@IsNotEmpty()
@Validate(KrakenResultConstraint)
result: Record<string, { c: string[] }>;
}
@@ -1,10 +0,0 @@
import { ShopFiatCurrency } from '../../types/ShopFiatCurrency';
export const KRAKEN_XMR_PAIR_BY_FIAT: Record<ShopFiatCurrency, string> = {
[ShopFiatCurrency.Usd]: 'XMRUSD',
[ShopFiatCurrency.Eur]: 'XMREUR',
[ShopFiatCurrency.Gbp]: 'XMRGBP',
[ShopFiatCurrency.Cad]: 'XMRCAD',
[ShopFiatCurrency.Aud]: 'XMRAUD',
[ShopFiatCurrency.Chf]: 'XMRCHF'
};
@@ -1,127 +0,0 @@
import { Logger } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { XmrRateService } from './XmrRateService';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('XmrRateService', () => {
let service: XmrRateService;
let configService: {
get: jest.Mock;
};
let warnLogSpy: jest.SpiedFunction<typeof Logger.prototype.warn>;
let errorLogSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
beforeEach(() => {
warnLogSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
errorLogSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
configService = {
get: jest.fn((key: string) => {
if (key === 'shopSettings') {
return { shopFiatCurrency: 'USD' };
}
if (key === 'coingecko') {
return { apiBaseUrl: 'https://coingecko.test', xmrRateFetchTimeoutMs: 5000 };
}
if (key === 'kraken') {
return { apiBaseUrl: 'https://kraken.test', xmrRateFetchTimeoutMs: 5000 };
}
return undefined;
})
};
service = new XmrRateService(configService as unknown as ConfigService);
mockedAxios.get.mockReset();
});
afterEach(() => {
warnLogSpy.mockRestore();
errorLogSpy.mockRestore();
logSpy.mockRestore();
});
it('stores the CoinGecko rate when the fetch succeeds', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { monero: { usd: 152.3456 } }
});
await service.fetchFiatPerXmrRate();
expect(mockedAxios.get).toHaveBeenCalledWith(
'https://coingecko.test/simple/price?ids=monero&vs_currencies=usd',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerXmr()).toBe(152.35);
});
it('falls back to Kraken when CoinGecko fails', async () => {
mockedAxios.get
.mockRejectedValueOnce(new Error('coingecko down'))
.mockResolvedValueOnce({
data: {
error: [],
result: {
XMRUSD: { c: ['149.876'] }
}
}
});
await service.fetchFiatPerXmrRate();
expect(warnLogSpy).toHaveBeenCalled();
expect(mockedAxios.get).toHaveBeenNthCalledWith(
2,
'https://kraken.test/Ticker?pair=XMRUSD',
{ timeout: 5000 }
);
expect(service.getLiveFiatPerXmr()).toBe(149.88);
});
it('leaves the cached rate null when both providers fail', async () => {
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
await service.fetchFiatPerXmrRate();
expect(errorLogSpy).toHaveBeenCalled();
expect(service.getLiveFiatPerXmr()).toBeNull();
});
it('falls back to Kraken when CoinGecko returns an invalid payload', async () => {
mockedAxios.get
.mockResolvedValueOnce({ data: { monero: { usd: -1 } } })
.mockResolvedValueOnce({
data: {
error: [],
result: {
XMRUSD: { c: ['151.11'] }
}
}
});
await service.fetchFiatPerXmrRate();
expect(mockedAxios.get).toHaveBeenCalledTimes(2);
expect(service.getLiveFiatPerXmr()).toBe(151.11);
});
it('keeps the previous live rate when a refresh fails after a successful fetch', async () => {
mockedAxios.get.mockResolvedValueOnce({
data: { monero: { usd: 140 } }
});
await service.fetchFiatPerXmrRate();
mockedAxios.get.mockRejectedValueOnce(new Error('coingecko down')).mockRejectedValueOnce(new Error('kraken down'));
await service.fetchFiatPerXmrRate();
expect(service.getLiveFiatPerXmr()).toBe(140);
});
});
@@ -1,105 +0,0 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron, CronExpression } from '@nestjs/schedule';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import axios from 'axios';
import Decimal from 'decimal.js';
import type { Config } from '../../../types/Config';
import { getErrorMessage } from '../../../utils/getErrorMessage';
import { CoingeckoSimplePriceResponseDto } from '../dto/CoingeckoSimplePriceResponseDto';
import { KrakenTickerResponseDto } from '../dto/KrakenTickerResponseDto';
import { KRAKEN_XMR_PAIR_BY_FIAT } from '../krakenXmrPairs';
@Injectable()
export class XmrRateService implements OnModuleInit {
private readonly logger = new Logger(XmrRateService.name);
private fiatPerXmr: number | null = null;
constructor(private readonly configService: ConfigService) {}
async onModuleInit(): Promise<void> {
await this.fetchFiatPerXmrRate();
}
@Cron(CronExpression.EVERY_30_SECONDS)
async fetchFiatPerXmrRate(): Promise<void> {
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
try {
this.fiatPerXmr = await this.fetchFiatPerXmrFromCoingecko();
} catch {
this.logger.warn(`CoinGecko XMR/${shopFiatCurrency} rate fetch failed, trying Kraken`);
try {
this.fiatPerXmr = await this.fetchFiatPerXmrFromKraken();
this.logger.log(`Successfully fetched XMR/${shopFiatCurrency} rate from Kraken`);
} catch (error) {
this.logger.error(
`Failed to fetch XMR/${shopFiatCurrency} rate from CoinGecko and Kraken: ${getErrorMessage(error)}`
);
}
}
}
getLiveFiatPerXmr(): number | null {
return this.fiatPerXmr;
}
private buildCoingeckoRateUrl(): string {
const { apiBaseUrl } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const vs = shopFiatCurrency.toLowerCase();
return `${apiBaseUrl}/simple/price?ids=monero&vs_currencies=${vs}`;
}
private buildKrakenRateUrl(): string {
const { apiBaseUrl } = this.configService.get('kraken') as Config['kraken'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const pair = KRAKEN_XMR_PAIR_BY_FIAT[shopFiatCurrency];
return `${apiBaseUrl}/Ticker?pair=${pair}`;
}
private async fetchFiatPerXmrFromCoingecko(): Promise<number> {
const { xmrRateFetchTimeoutMs } = this.configService.get('coingecko') as Config['coingecko'];
const { shopFiatCurrency } = this.configService.get('shopSettings') as Config['shopSettings'];
const { data } = await axios.get<unknown>(this.buildCoingeckoRateUrl(), {
timeout: xmrRateFetchTimeoutMs
});
const body = plainToInstance(CoingeckoSimplePriceResponseDto, data);
body.shopFiatCurrency = shopFiatCurrency;
const errors = validateSync(body);
if (errors.length > 0) {
throw new Error('CoinGecko response validation failed');
}
return new Decimal(body.monero[shopFiatCurrency.toLowerCase()]).toDecimalPlaces(2).toNumber();
}
private async fetchFiatPerXmrFromKraken(): Promise<number> {
const { xmrRateFetchTimeoutMs } = this.configService.get('kraken') as Config['kraken'];
const { data } = await axios.get<unknown>(this.buildKrakenRateUrl(), {
timeout: xmrRateFetchTimeoutMs
});
const body = plainToInstance(KrakenTickerResponseDto, data);
const errors = validateSync(body);
if (errors.length > 0) {
throw new Error('Kraken response validation failed');
}
const pair = Object.values(body.result)[0];
return new Decimal(pair.c[0]).toDecimalPlaces(2).toNumber();
}
}
+13 -6
View File
@@ -1,4 +1,5 @@
import { MoneroConfirmationTier } from './MoneroConfirmationTier'; import { ConfirmationTier } from './ConfirmationTier';
import { ElectrumWalletConfig } from './ElectrumWalletConfig';
import { MoneroWalletConfig } from './MoneroWalletConfig'; import { MoneroWalletConfig } from './MoneroWalletConfig';
import { NodeEnv } from './NodeEnv'; import { NodeEnv } from './NodeEnv';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod'; import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
@@ -48,14 +49,19 @@ export interface AppConfig {
shippingNoteMinLength: number; shippingNoteMinLength: number;
shippingNoteMaxLength: number; shippingNoteMaxLength: number;
orderMessageMaxLength: number; orderMessageMaxLength: number;
bitcoinWithdrawMaxFeeRateSatVbyte: number;
}; };
} }
export interface ShopSettingsConfig { export interface ShopSettingsConfig {
shopName: string; shopName: string;
shopFiatCurrency: ShopFiatCurrency; shopFiatCurrency: ShopFiatCurrency;
enabledPaymentMethods: PaymentMethod[];
monero: { monero: {
confirmationTiers: MoneroConfirmationTier[]; confirmationTiers: ConfirmationTier[];
};
bitcoin: {
confirmationTiers: ConfirmationTier[];
}; };
} }
@@ -84,12 +90,12 @@ export interface MulterConfig {
export interface CoingeckoConfig { export interface CoingeckoConfig {
apiBaseUrl: string; apiBaseUrl: string;
xmrRateFetchTimeoutMs: number; rateFetchTimeoutMs: number;
} }
export interface KrakenConfig { export interface CoinPaprikaConfig {
apiBaseUrl: string; apiBaseUrl: string;
xmrRateFetchTimeoutMs: number; rateFetchTimeoutMs: number;
} }
export interface EncryptionConfig { export interface EncryptionConfig {
@@ -112,11 +118,12 @@ export interface Config {
postgres: PostgresConfig; postgres: PostgresConfig;
jwt: JwtConfig; jwt: JwtConfig;
coingecko: CoingeckoConfig; coingecko: CoingeckoConfig;
kraken: KrakenConfig; coinPaprika: CoinPaprikaConfig;
encryption: EncryptionConfig; encryption: EncryptionConfig;
shopSettings: ShopSettingsConfig; shopSettings: ShopSettingsConfig;
order: OrderConfig; order: OrderConfig;
invoice: InvoiceConfig; invoice: InvoiceConfig;
moneroWallet: MoneroWalletConfig; moneroWallet: MoneroWalletConfig;
electrumWallet: ElectrumWalletConfig;
simplex: SimplexConfig; simplex: SimplexConfig;
} }
@@ -1,4 +1,4 @@
export interface MoneroConfirmationTier { export interface ConfirmationTier {
upToTotalFiat?: string; upToTotalFiat?: string;
minConfirmations: number; minConfirmations: number;
} }
@@ -0,0 +1,3 @@
import type Decimal from 'decimal.js';
export type CryptoAtomicConverter = (amountAtomic: string, rounding?: Decimal.Rounding) => string;

Some files were not shown because too many files have changed in this diff Show More