Slave/btc integration #4
+23
-5
@@ -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
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
||||||
|
};
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
+1
-1
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
Reference in New Issue
Block a user