Compare commits

..
51 Commits
Author SHA1 Message Date
nobswebdev 08a0bdcdb8 Include www subdomain in clearnet TLS issuance.
Request apex and www from Let's Encrypt and serve both names in nginx.
2026-09-10 16:50:26 +02:00
nobswebdev 93c132b0df Fix SimpleX branding in shop footer link label. 2026-09-09 16:33:04 +02:00
nobswebdev 771d95d1cd Stack and center the shop header on mobile.
Column-layout logo, nav, and crypto rates with full-width wrapping for many order links.
2026-09-09 16:27:58 +02:00
nobswebdev 254736356b Show storefront feedback as fixed top-center toasts.
Use CSS-only dismiss animations, render the partial at the end of body, and add top margin to cart issue messages.
2026-09-09 16:07:58 +02:00
nobswebdev 738e406725 Rename sf-discount-error to sf-cart-issue for cart validation messages.
The class is used for stock, discount, and cart-total issues—not only discounts.
2026-09-09 15:38:15 +02:00
nobswebdev de4630635d Merge pull request 'restore scroll position after adding to cart' (#7) from slave/storefront-products-index-add-to-cart-ux-improvement into master
Reviewed-on: #7
2026-09-09 11:45:50 +00:00
nobswebdev 6b358011b0 restore scroll position after adding to cart 2026-09-09 13:44:25 +02:00
nobswebdev 8729098f20 Merge pull request 'Require min 1 confirmation in payment tier config.' (#6) from slave/get-rid-of-mempool-conf-tier into master
Reviewed-on: #6
2026-09-08 21:33:26 +00:00
nobswebdev deb5f8c2c3 Require min 1 confirmation in payment tier config.
Disallow minConfirmations: 0 for BTC and XMR tiers at startup validation, update env examples and deploy docs, and drop tx-detected UI copy and legacy tests.
2026-09-08 23:31:23 +02:00
nobswebdev b3e683cd71 Merge pull request 'Slave/btc rbf edge case fix' (#5) from slave/btc-rbf-edge-case-fix into master
Reviewed-on: #5
2026-09-08 17:02:47 +00:00
nobswebdev 4458edb0d1 Prune stale unconfirmed invoice payments during wallet polling.
Refactor processInvoice into upsert and prune helpers so absent 0-conf payments are removed when the wallet no longer reports them.
2026-09-08 18:30:16 +02:00
nobswebdev dcd499664a Filter superseded Bitcoin transfers in Electrum wallet client.
Detect RBF conflicts via shared input outpoints so invoice polling no longer ingests both the original and replacement mempool transactions.
2026-09-08 18:30:06 +02:00
nobswebdev 0dcffb394e Cache storefront images aggressively while keeping CSS revalidatable.
Split static asset mounts by type and centralize paths in storefrontAssetPaths so icon URLs stay aligned with the server.
2026-09-07 14:06:44 +02:00
nobswebdev e6c21e58bb Merge pull request 'Slave/btc integration' (#4) from slave/btc-integration into master
Reviewed-on: #4
2026-09-07 11:11:09 +00:00
nobswebdev 8639176af4 Throw on unsupported payment methods in issueInvoice.
Prevent checkout sessions from being created without an invoice when
the payment method switch has no matching case.
2026-09-07 12:39:31 +02:00
nobswebdev 68503ea01d Add payment method icons to storefront navbar and cart.
Show BTC/XMR icons beside exchange rates and pay buttons.
2026-09-07 12:28:14 +02:00
nobswebdev dd234421a4 Rename cryptoCurrency to paymentLabel in storefront invoice views.
Align invoice view naming with InvoiceExtended and CMS.
2026-09-07 12:28:09 +02:00
nobswebdev 2d8a508898 Fix Monero withdraw form reset after successful withdrawal.
Use resetFields so validation state clears without re-triggering required rules on an empty address.
2026-09-07 02:08:22 +02:00
nobswebdev de52534271 Add Bitcoin wallet withdraw and seed reveal UI in CMS.
Wire admin actions to the backend API with client-side address validation and configurable max fee rate.
2026-09-07 02:08:17 +02:00
nobswebdev 626f2ea4f0 add bitcoin withdraw max fee validation config 2026-09-07 01:35:57 +02:00
nobswebdev 6c894d58ba Add Bitcoin wallet admin withdraw and seed reveal API.
Expose JWT-protected withdraw-all and reveal-seed endpoints with address validation and password verification, matching Monero wallet admin behavior.
2026-09-06 23:28:53 +02:00
nobswebdev 3a72d0521a Add sweepAll to Electrum wallet RPC client.
Consolidate payto, deserialize, and broadcast into a single sweep helper for max-payment withdrawals.
2026-09-06 23:28:47 +02:00
nobswebdev 8d9054b119 pass wallet password to electrum config on backend 2026-09-06 21:08:13 +02:00
nobswebdev c1e28c7545 add fee priority selector to Monero wallet withdraw form.
Expose GUI-style priority options in the CMS and include the chosen priority in withdrawal confirmation.
2026-09-06 18:21:13 +02:00
nobswebdev 83cb437f19 add fee priority to Monero wallet withdrawal API.
Pass sweep_all priority through the withdraw endpoint so admins can choose transaction fee speed.
2026-09-06 18:21:07 +02:00
nobswebdev 564b016f47 use explicit nested routes for settings and wallet CMS tabs. 2026-09-06 16:08:02 +02:00
nobswebdev 82932ce7c8 add Bitcoin wallet status tab in CMS. 2026-09-06 16:07:56 +02:00
nobswebdev 33600d5c1c consolidate confirmation tiers and show Bitcoin tiers in shop settings. 2026-09-06 16:07:49 +02:00
nobswebdev 7640f5d74d generalize CMS order invoice payment panel for multi-crypto invoices. 2026-09-06 16:07:44 +02:00
nobswebdev 35052661f8 consolidate monero wallet sync status into shared WalletSyncStatus type. 2026-09-06 15:27:55 +02:00
nobswebdev 5f1c8bee4b consolidate wallet sync status into shared WalletSyncStatus type. 2026-09-06 15:21:13 +02:00
nobswebdev 9e776df79f expose payment label and multi-crypto amounts in admin order invoice view. 2026-09-06 14:19:37 +02:00
nobswebdev e7c42fcaca add resolveAtomicToCryptoConverter for payment method amount formatting. 2026-09-06 14:19:32 +02:00
nobswebdev 665362eb32 use path-to-regexp named wildcard for shop assets route exclude
Silence Nest 11 LegacyRouteConverter warning by replacing shop/assets/(.*) with shop/assets/{*path}.
2026-09-06 11:40:58 +02:00
nobswebdevandCursor c7ac66a5cd set explicit color-scheme on storefront theme roots
Align native browser controls with the active light or dark theme instead of using light dark on html.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:42:10 +02:00
nobswebdevandCursor 500fe6cf94 use checkout payment method for shipping invoices
Load btc details on admin order queries and issue shipping invoices in the same crypto the customer used at checkout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:42:05 +02:00
nobswebdevandCursor 917e86b3de load btc invoice details in checkout session and storefront order queries
Ensure bitcoin invoice metadata is available when rendering checkout sessions and customer order pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:42:02 +02:00
nobswebdevandCursor cf92212226 add bitcoin support to storefront invoice view
Refactor invoice view building to share logic across XMR and BTC, including BIP21 QR codes and payment detail validation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 00:41:57 +02:00
nobswebdev ced92ead37 load btc invoice details in checkout and order queries 2026-09-05 22:42:01 +02:00
nobswebdev b728c34357 wire checkout pay to selected payment method 2026-09-05 22:07:02 +02:00
nobswebdev 7222d0c9be show multi-crypto rates in shop nav 2026-09-05 20:50:49 +02:00
nobswebdev 302c3729c4 add multi-crypto payment buttons to cart 2026-09-05 18:55:58 +02:00
nobswebdev 3b6323658e remove crypto rate display from cart 2026-09-05 14:48:01 +02:00
nobswebdev 7b62bc41ad default dev electrum to testnet4 2026-09-04 17:26:32 +02:00
nobswebdev b4618cf3b1 wire bitcoin invoice creation and payment polling 2026-09-04 14:11:54 +02:00
nobswebdev cae5b4fe50 add bitcoin wallet admin status endpoint 2026-09-04 11:59:06 +02:00
nobswebdev 8c0749875e add electrum wallet rpc client 2026-09-04 11:32:12 +02:00
nobswebdev 1143b8873c update deployment guide for bitcoin payments 2026-09-03 23:44:54 +02:00
nobswebdev bfb7b013f9 add electrum wallet daemon with container entrypoint 2026-09-03 23:13:20 +02:00
nobswebdev cde7fab90c move monero wallet creation into container entrypoint 2026-09-03 23:03:49 +02:00
nobswebdev 9241474d48 add electrum config 2026-09-03 13:30:58 +02:00
141 changed files with 3368 additions and 697 deletions
+16 -2
View File
@@ -53,6 +53,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
SIGNED_COOKIE_JWT_SECRET=change-me-in-production SIGNED_COOKIE_JWT_SECRET=change-me-in-production
@@ -89,7 +90,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
PAYMENT_METHODS_ENABLED=xmr,btc 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":1},{"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
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081 MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
@@ -103,9 +104,21 @@ 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) 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_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":1},{"upToTotalFiat":"100","minConfirmations":3},{"upToTotalFiat":"300","minConfirmations":5},{"minConfirmations":6}]'
BITCOIN_MIN_INCOMING_ATOMIC=7 # 0.00000007 BTC (~half a USD cent at that moment) 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
SIMPLEX_BOT_DISPLAY_NAME=NullCartBot SIMPLEX_BOT_DISPLAY_NAME=NullCartBot
@@ -134,4 +147,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20 VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000 VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000 VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000 VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
+1
View File
@@ -5,6 +5,7 @@
/deploy/certbot/www /deploy/certbot/www
/monero-wallet-rpc/wallet /monero-wallet-rpc/wallet
/electrum-daemon/wallet
/backend/node_modules /backend/node_modules
/backend/dist /backend/dist
+1 -4
View File
@@ -1,6 +1,6 @@
# NullCart # NullCart
Self-hosted Monero shop with clearnet (HTTPS) and Tor onion hosting. Self-hosted crypto shop with clearnet (HTTPS) and Tor onion hosting.
For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md). For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md).
@@ -12,9 +12,6 @@ For production deployment, see the [deployment guide](deploy/DEPLOYMENT_GUIDE.md
# Create and edit .env.dev as needed # Create and edit .env.dev as needed
cp .env.example .env.dev cp .env.example .env.dev
# Create monero wallet used by shop
./monero-wallet-rpc/setup-monero-wallet.sh --env-file .env.dev
# Build and start docker containers # Build and start docker containers
docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache docker compose --env-file .env.dev -f docker-compose.dev.yml build --no-cache
docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate docker compose --env-file .env.dev -f docker-compose.dev.yml up --force-recreate
+4
View File
@@ -13,6 +13,7 @@ import {
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';
@@ -55,6 +57,7 @@ import { Config } from './types/Config';
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)
] ]
}), }),
@@ -91,6 +94,7 @@ import { Config } from './types/Config';
PaymentModule, PaymentModule,
DataWipeModule, DataWipeModule,
MoneroWalletModule, MoneroWalletModule,
BitcoinWalletModule,
StorefrontCoreModule, StorefrontCoreModule,
StorefrontProductModule, StorefrontProductModule,
StorefrontCartModule, StorefrontCartModule,
+15 -1
View File
@@ -10,6 +10,8 @@ import {
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 { ConfirmationTier } from '../types/ConfirmationTier'; import { ConfirmationTier } from '../types/ConfirmationTier';
@@ -101,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')
} }
}; };
}; };
@@ -210,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')
@@ -226,5 +239,6 @@ export default () => ({
order: getOrderConfig(), order: getOrderConfig(),
invoice: getInvoiceConfig(), invoice: getInvoiceConfig(),
moneroWallet: getMoneroWalletConfig(), moneroWallet: getMoneroWalletConfig(),
electrumWallet: getElectrumWalletConfig(),
simplex: getSimplexConfig() simplex: getSimplexConfig()
}); });
@@ -0,0 +1,12 @@
import path from 'node:path';
export const storefrontImgUrlPrefix = '/shop/assets/img';
export const storefrontCssUrlPrefix = '/shop/assets/css';
export const getStorefrontPublicDir = (): string =>
path.join(__dirname, '..', 'modules', 'storefrontCore', 'public');
export const getStorefrontImgDir = (): string => path.join(getStorefrontPublicDir(), 'img');
export const getStorefrontCssDir = (): string => path.join(getStorefrontPublicDir(), 'css');
+42
View File
@@ -5,6 +5,7 @@ import { ShopFiatCurrency } from '../types/ShopFiatCurrency';
import { IsBase64 } from '../validation/decorators/isBase64'; import { IsBase64 } from '../validation/decorators/isBase64';
import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers'; import { IsConfirmationTiers } from '../validation/decorators/isConfirmationTiers';
import { IsEnabledPaymentMethods } from '../validation/decorators/isEnabledPaymentMethods'; 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 {
@@ -152,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)
@@ -342,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,7 @@
import { storefrontImgUrlPrefix } from '../config/storefrontAssetPaths';
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodIconUrl: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: `${storefrontImgUrlPrefix}/xmr.png`,
[PaymentMethod.Btc]: `${storefrontImgUrlPrefix}/btc.png`
};
+6
View File
@@ -0,0 +1,6 @@
import { PaymentMethod } from '../modules/payment/types/PaymentMethod';
export const paymentMethodLabel: Record<PaymentMethod, string> = {
[PaymentMethod.Xmr]: 'XMR',
[PaymentMethod.Btc]: 'BTC'
};
+14 -4
View File
@@ -5,6 +5,12 @@ import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import hbs from 'hbs'; import hbs from 'hbs';
import path from 'node:path'; import path from 'node:path';
import {
getStorefrontCssDir,
getStorefrontImgDir,
storefrontCssUrlPrefix,
storefrontImgUrlPrefix
} from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths'; import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule'; import { AppModule } from './AppModule';
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers'; import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
@@ -26,10 +32,14 @@ async function bootstrap() {
immutable: true immutable: true
}); });
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public'); app.useStaticAssets(getStorefrontImgDir(), {
prefix: storefrontImgUrlPrefix,
maxAge: '1y',
immutable: true
});
app.useStaticAssets(storefrontPublicDir, { app.useStaticAssets(getStorefrontCssDir(), {
prefix: '/shop/assets' prefix: storefrontCssUrlPrefix
}); });
app.use(cookieParser()); app.use(cookieParser());
@@ -61,7 +71,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,447 @@
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
},
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: []
});
});
it('returns zero confirmations for unconfirmed transfers', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 0
},
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 0,
inputOutpoints: []
});
});
it('returns null for non-positive amounts', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
'bc1qtest',
800_002
)
).toBeNull();
});
it('maps input outpoints from transaction inputs', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [
{ prevout_hash: 'abc123', prevout_n: 0 },
{ prevout_hash: 'def456', prevout_n: 2 }
],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['abc123:0', 'def456:2']
});
});
it('skips coinbase-like inputs without prevout data', () => {
expect(
clientTest.mapIncomingTransfer(
{
tx_hash: 'abc123',
height: 800_000
},
{
inputs: [{}, { prevout_hash: '', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: []
});
});
});
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: {
inputs: [{ prevout_hash: 'input123', prevout_n: 0 }],
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,
inputOutpoints: ['input123:0']
}
]);
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)
);
});
it('drops superseded unconfirmed transfers that share inputs with a confirmed replacement', async () => {
mockedAxios.post
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: [
{ tx_hash: 'original', height: 0 },
{ tx_hash: 'replacement', height: 800_000 }
]
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '01000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: '02000000'
}
})
.mockResolvedValueOnce({
data: {
jsonrpc: '2.0',
id: 'nullcart',
result: {
inputs: [{ prevout_hash: 'shared-input', prevout_n: 0 }],
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
}
}
});
await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([
{
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3,
inputOutpoints: ['shared-input:0']
}
]);
});
});
describe('filterSupersededBitcoinTransfers', () => {
it('keeps unrelated transfers unchanged', () => {
const transfers = [
{ txHash: 'a', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'b', amountAtomic: '1', confirmations: 3, inputOutpoints: ['in2:1'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
it('drops an unconfirmed transfer superseded by a confirmed replacement', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 2, inputOutpoints: ['in1:0'] }
]);
});
it('keeps the later unconfirmed transfer when both conflict before confirmation', () => {
const transfers = [
{ txHash: 'original', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] },
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual([
{ txHash: 'replacement', amountAtomic: '1', confirmations: 0, inputOutpoints: ['in1:0'] }
]);
});
it('keeps transfers without input outpoints', () => {
const transfers = [
{ txHash: 'coinbase', amountAtomic: '1', confirmations: 0, inputOutpoints: [] },
{ txHash: 'payment', amountAtomic: '1', confirmations: 1, inputOutpoints: ['in1:0'] }
];
expect(clientTest.filterSupersededBitcoinTransfers(transfers)).toEqual(transfers);
});
});
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,290 @@
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);
return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
})
);
const resolvedTransfers = transfers.filter(
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
);
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
}
private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
): ElectrumWalletIncomingTransfer | null {
const txHash = entry.tx_hash;
const amountAtomic = this.sumOutputValueAtomic(transaction, address);
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,
inputOutpoints: this.extractInputOutpoints(transaction)
};
}
private filterSupersededBitcoinTransfers(
transfers: ElectrumWalletIncomingTransfer[]
): ElectrumWalletIncomingTransfer[] {
return transfers.filter((transfer, index) => {
const isSuperseded = transfers.some((other, otherIndex) => {
if (otherIndex === index) {
return false;
}
if (!this.sharesInputOutpoint(transfer.inputOutpoints, other.inputOutpoints)) {
return false;
}
if (other.confirmations > transfer.confirmations) {
return true;
}
return other.confirmations === transfer.confirmations && otherIndex > index;
});
return !isSuperseded;
});
}
private extractInputOutpoints(transaction: ElectrumWalletDeserializedTransaction): string[] {
if (!Array.isArray(transaction.inputs)) {
return [];
}
return transaction.inputs.flatMap(input => {
if (typeof input.prevout_hash !== 'string' || input.prevout_hash.length === 0) {
return [];
}
if (typeof input.prevout_n !== 'number') {
return [];
}
return [`${input.prevout_hash}:${input.prevout_n}`];
});
}
private sharesInputOutpoint(left: readonly string[], right: readonly string[]): boolean {
if (left.length === 0 || right.length === 0) {
return false;
}
const rightOutpoints = new Set(right);
return left.some(outpoint => rightOutpoints.has(outpoint));
}
}
@@ -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,4 @@
export type ElectrumWalletDeserializedInput = {
prevout_hash?: string;
prevout_n?: number;
};
@@ -0,0 +1,4 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
@@ -0,0 +1,7 @@
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
export type ElectrumWalletDeserializedTransaction = {
inputs?: ElectrumWalletDeserializedInput[];
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,6 @@
export type ElectrumWalletIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
inputOutpoints: string[];
};
@@ -0,0 +1,16 @@
import type { ElectrumWalletAddressHistoryEntry } from './ElectrumWalletAddressHistoryEntry';
import type { ElectrumWalletDeserializedTransaction } from './ElectrumWalletDeserializedTransaction';
import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTransfer';
export type ElectrumWalletRpcClientTest = {
mapIncomingTransfer: (
entry: ElectrumWalletAddressHistoryEntry,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null;
filterSupersededBitcoinTransfers: (
transfers: ElectrumWalletIncomingTransfer[]
) => ElectrumWalletIncomingTransfer[];
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;
};
};
@@ -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,16 +254,12 @@ 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
}); });
if (!shippingInvoice) {
throw new InternalServerErrorException('Failed to issue shipping invoice');
}
await this.orderRepo.update(orderId, { await this.orderRepo.update(orderId, {
shippingInvoice: { id: shippingInvoice.id }, shippingInvoice: { id: shippingInvoice.id },
quotedAt quotedAt
@@ -1,5 +1,6 @@
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 { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule'; import { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
import { Invoice } from './entities/Invoice'; import { Invoice } from './entities/Invoice';
@@ -13,6 +14,7 @@ import { InvoiceService } from './services/InvoiceService';
imports: [ imports: [
TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]), TypeOrmModule.forFeature([Invoice, InvoicePayment, InvoiceMoneroDetails, InvoiceBtcDetails]),
MoneroWalletModule, MoneroWalletModule,
BitcoinWalletModule,
ExchangeRateModule ExchangeRateModule
], ],
providers: [InvoicePaymentService, InvoiceService], providers: [InvoicePaymentService, InvoiceService],
@@ -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: {
@@ -61,6 +87,7 @@ describe('InvoicePaymentService', () => {
}; };
let paymentRepo: { let paymentRepo: {
update: jest.Mock; update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock; createQueryBuilder: jest.Mock;
}; };
let insertQueryBuilder: { let insertQueryBuilder: {
@@ -107,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = { paymentRepo = {
update: jest.fn().mockResolvedValue(undefined), update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder) createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
}; };
@@ -130,14 +158,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 +179,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 +192,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 +380,70 @@ 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.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled(); expect(paymentRepo.update).not.toHaveBeenCalled();
}); });
it('does not mutate payments when there are no transfers and no existing payments', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(buildXmrInvoice());
await service.processInvoice('invoice-1', []);
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.delete).not.toHaveBeenCalled();
});
it('removes unconfirmed payments that are no longer reported when transfers are empty', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-ghost',
txHash: 'ghost',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', []);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-ghost');
expect(paymentRepo.createQueryBuilder).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 +456,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 +473,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 +490,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 +502,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 +510,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 +522,64 @@ 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
});
});
it('removes unconfirmed payments that are no longer reported', async () => {
transactionalInvoiceQueryBuilder.getOne.mockResolvedValue(
buildBtcInvoice({
payments: [
{
id: 'payment-original',
txHash: 'original',
amountAtomic: '50000',
confirmations: 0
} as InvoicePayment,
{
id: 'payment-replacement',
txHash: 'replacement',
amountAtomic: '50000',
confirmations: 3
} as InvoicePayment
]
})
);
await service.processInvoice('invoice-btc-1', [
buildBtcTransfer({ txHash: 'replacement', amountAtomic: '50000', confirmations: 3 })
]);
expect(paymentRepo.delete).toHaveBeenCalledWith('payment-original');
expect(paymentRepo.update).not.toHaveBeenCalled();
expect(paymentRepo.createQueryBuilder).not.toHaveBeenCalled();
});
}); });
}); });
@@ -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,9 +84,57 @@ export class InvoicePaymentService {
} }
} }
private async processInvoice(invoiceId: string, transfers: MoneroWalletRpcIncomingTransfer[]): Promise<void> { private async pollBitcoinInvoices(): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice']; 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> {
await this.dataSource.transaction(async manager => { await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice); const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment); const paymentRepo = manager.getRepository(InvoicePayment);
@@ -95,6 +150,19 @@ export class InvoicePaymentService {
return; return;
} }
await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
await this.pruneAbsentUnconfirmedPayments(paymentRepo, invoice, transfers);
});
}
private async upsertIncomingPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
const minIncomingAtomic = minByMethod[invoice.paymentMethod]; const minIncomingAtomic = minByMethod[invoice.paymentMethod];
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment])); const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
@@ -117,7 +185,7 @@ export class InvoicePaymentService {
.createQueryBuilder() .createQueryBuilder()
.insert() .insert()
.values({ .values({
invoice: { id: invoiceId }, invoice: { id: invoice.id },
txHash: transfer.txHash, txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic, amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations confirmations: transfer.confirmations
@@ -125,6 +193,19 @@ export class InvoicePaymentService {
.orIgnore() .orIgnore()
.execute(); .execute();
} }
}); }
private async pruneAbsentUnconfirmedPayments(
paymentRepo: Repository<InvoicePayment>,
invoice: Invoice,
transfers: InvoiceIncomingTransfer[]
): Promise<void> {
const activeTxHashes = new Set(transfers.map(transfer => transfer.txHash));
for (const payment of invoice.payments ?? []) {
if (payment.confirmations === 0 && !activeTxHashes.has(payment.txHash)) {
await paymentRepo.delete(payment.id);
}
}
} }
} }
@@ -1,6 +1,7 @@
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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import type { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
@@ -19,7 +20,10 @@ describe('InvoiceService', () => {
let configService: { let configService: {
get: jest.Mock; get: jest.Mock;
}; };
let walletRpcClient: { let moneroWalletRpcClient: {
createAddress: jest.Mock;
};
let bitcoinWalletRpcClient: {
createAddress: jest.Mock; createAddress: jest.Mock;
}; };
let exchangeRateService: { let exchangeRateService: {
@@ -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,13 +61,17 @@ describe('InvoiceService', () => {
}) })
}; };
walletRpcClient = { moneroWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue({ createAddress: jest.fn().mockResolvedValue({
address: '4MoneroPaymentAddressExample', address: '4MoneroPaymentAddressExample',
address_index: 12 address_index: 12
}) })
}; };
bitcoinWalletRpcClient = {
createAddress: jest.fn().mockResolvedValue('bc1qtestpaymentaddress')
};
exchangeRateService = { exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150) getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
}; };
@@ -67,7 +79,8 @@ describe('InvoiceService', () => {
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,
bitcoinWalletRpcClient as unknown as ElectrumWalletRpcClient,
exchangeRateService as unknown as ExchangeRateService exchangeRateService as unknown as ExchangeRateService
); );
}); });
@@ -76,9 +89,9 @@ 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
@@ -91,11 +104,11 @@ describe('InvoiceService', () => {
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 };
} }
@@ -185,7 +202,7 @@ describe('InvoiceService', () => {
); );
exchangeRateService.getLiveFiatPerCrypto.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,13 +1,16 @@
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/confirmation/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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { Invoice } from '../entities/Invoice'; import { Invoice } from '../entities/Invoice';
@@ -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 bitcoinWalletRpcClient: ElectrumWalletRpcClient,
private readonly exchangeRateService: ExchangeRateService private readonly exchangeRateService: ExchangeRateService
) {} ) {}
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> { 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,7 +49,8 @@ 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.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr); const fiatPerXmr = this.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
@@ -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>;
}; };
@@ -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 { ExchangeRateModule } from '../exchangeRate/ExchangeRateModule';
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, ExchangeRateModule], 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
}); });
} }
@@ -86,7 +100,7 @@ export class StorefrontCartController {
text: 'Added to cart.' text: 'Added to cart.'
}); });
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req)); res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
} }
@Post('shop/cart/product/update') @Post('shop/cart/product/update')
@@ -175,4 +189,18 @@ export class StorefrontCartController {
this.cartCookieService.setCart(req, res, cartMutation); this.cartCookieService.setCart(req, res, cartMutation);
} }
} }
private buildAddToCartRedirectPath(req: Request, variantId: string): string {
const redirectPath = safeInternalShopRedirectPath(req);
const pathname = redirectPath.split('?')[0] ?? redirectPath;
const isProductsIndexPath = pathname === '/' || pathname.startsWith('/shop/categories/');
if (!isProductsIndexPath) {
return redirectPath;
}
const anchor = `product-card-${variantId}`;
return `${redirectPath}#${anchor}`;
}
} }
@@ -1,8 +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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
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';
@@ -30,9 +28,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariantsByIds: jest.Mock; getStorefrontVariantsByIds: jest.Mock;
getStorefrontVariant: jest.Mock; getStorefrontVariant: jest.Mock;
}; };
let exchangeRateService: {
getLiveFiatPerCrypto: jest.Mock;
};
let discountService: { let discountService: {
getDiscountStateForCart: jest.Mock; getDiscountStateForCart: jest.Mock;
applyDiscountCode: jest.Mock; applyDiscountCode: jest.Mock;
@@ -44,10 +39,6 @@ describe('StorefrontCartService', () => {
getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant()) getStorefrontVariant: jest.fn().mockResolvedValue(buildVariant())
}; };
exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(150)
};
discountService = { discountService = {
getDiscountStateForCart: jest.fn().mockResolvedValue({ getDiscountStateForCart: jest.fn().mockResolvedValue({
discounts: [], discounts: [],
@@ -59,7 +50,6 @@ describe('StorefrontCartService', () => {
service = new StorefrontCartService( service = new StorefrontCartService(
productsService as unknown as StorefrontProductsService, productsService as unknown as StorefrontProductsService,
exchangeRateService as unknown as ExchangeRateService,
discountService as unknown as StorefrontDiscountService discountService as unknown as StorefrontDiscountService
); );
}); });
@@ -99,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,
@@ -108,23 +96,13 @@ describe('StorefrontCartService', () => {
}); });
}); });
it('converts the discounted total to XMR when a live rate is available', async () => { it('returns cart totals and delivery flags', async () => {
const summary = await service.getCartSummary([{ variantId, qty: 1 }], []); 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 () => {
exchangeRateService.getLiveFiatPerCrypto.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,9 +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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
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';
@@ -23,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 exchangeRateService: ExchangeRateService,
private readonly discountService: StorefrontDiscountService private readonly discountService: StorefrontDiscountService
) {} ) {}
@@ -67,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,
@@ -78,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.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr);
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);
@@ -92,8 +84,6 @@ export class StorefrontCartService {
cartExtended, cartExtended,
cartSubtotal, cartSubtotal,
...discountState, ...discountState,
cartTotalXmr,
fiatPerXmr,
hasManualLines, hasManualLines,
hasAutoLines, hasAutoLines,
cartTotalIssueMessage, cartTotalIssueMessage,
@@ -4,8 +4,6 @@ import type { CookieCartExtended } from './CookieCartExtended';
export type CookieCartSummary = { export type CookieCartSummary = {
cartExtended: CookieCartExtended; cartExtended: CookieCartExtended;
cartSubtotal: number; cartSubtotal: number;
cartTotalXmr: string | null;
fiatPerXmr: number | null;
hasManualLines: boolean; hasManualLines: boolean;
hasAutoLines: boolean; hasAutoLines: boolean;
cartTotalIssueMessage: string | null; cartTotalIssueMessage: string | null;
@@ -41,7 +41,7 @@ export class StorefrontCheckoutController {
@Post('shop/checkout/pay') @Post('shop/checkout/pay')
@Throttle(throttleProfiles.checkoutPay) @Throttle(throttleProfiles.checkoutPay)
async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha }: PayCheckoutDto): Promise<void> { async pay(@Req() req: Request, @Res() res: Response, @Body() { captcha, paymentMethod }: PayCheckoutDto): Promise<void> {
const sessionId = this.checkoutSessionCookieService.getSessionId(req, res); const sessionId = this.checkoutSessionCookieService.getSessionId(req, res);
if (sessionId) { if (sessionId) {
@@ -64,7 +64,7 @@ export class StorefrontCheckoutController {
const summary = await this.cartService.getCartSummary(cart, discountCodes); const summary = await this.cartService.getCartSummary(cart, discountCodes);
const session = await this.checkoutSessionService.createFromCartSummary(summary); const session = await this.checkoutSessionService.createFromCartSummary(summary, paymentMethod);
this.checkoutSessionCookieService.setSessionId(req, res, session.id); this.checkoutSessionCookieService.setSessionId(req, res, session.id);
@@ -1,10 +1,13 @@
import { IsNotEmpty, IsString, Length } from 'class-validator'; import { IsEnum, IsIn, IsNotEmpty, IsString, Length } from 'class-validator';
import { getAppConfig } from '../../../config'; import { getAppConfig, getShopSettingsConfig } from '../../../config';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
const { const {
captcha: { length: captchaLength } captcha: { length: captchaLength }
} = getAppConfig(); } = getAppConfig();
const { enabledPaymentMethods } = getShopSettingsConfig();
export class PayCheckoutDto { export class PayCheckoutDto {
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
@@ -12,4 +15,9 @@ export class PayCheckoutDto {
message: `Captcha should be ${captchaLength} characters long` message: `Captcha should be ${captchaLength} characters long`
}) })
captcha: string; captcha: string;
@IsNotEmpty()
@IsEnum(PaymentMethod)
@IsIn(enabledPaymentMethods, { message: 'Select a valid payment method' })
paymentMethod: PaymentMethod;
} }
@@ -25,6 +25,7 @@ export class CheckoutPaymentPollerService {
.createQueryBuilder('session') .createQueryBuilder('session')
.innerJoinAndSelect('session.invoice', 'invoice') .innerJoinAndSelect('session.invoice', 'invoice')
.leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails') .leftJoinAndSelect('invoice.moneroDetails', 'moneroDetails')
.leftJoinAndSelect('invoice.btcDetails', 'btcDetails')
.leftJoinAndSelect('invoice.payments', 'payment') .leftJoinAndSelect('invoice.payments', 'payment')
.leftJoin('session.order', 'order') .leftJoin('session.order', 'order')
.where('session.cancelledAt IS NULL') .where('session.cancelledAt IS NULL')
@@ -38,8 +38,6 @@ const buildSummary = (overrides: Partial<CookieCartSummary> = {}): CookieCartSum
discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }], discounts: [{ code: 'SAVE1', amount: 1, issueMessage: null }],
cartDiscountTotal: 1, cartDiscountTotal: 1,
cartTotalPrice: 9, cartTotalPrice: 9,
cartTotalXmr: '0.06000000',
fiatPerXmr: 150,
hasManualLines: false, hasManualLines: false,
hasAutoLines: true, hasAutoLines: true,
cartTotalIssueMessage: null, cartTotalIssueMessage: null,
@@ -115,15 +113,15 @@ describe('CheckoutSessionService', () => {
describe('createFromCartSummary', () => { describe('createFromCartSummary', () => {
it('rejects an empty cart', async () => { it('rejects an empty cart', async () => {
await expect(service.createFromCartSummary(buildSummary({ cartExtended: [] }))).rejects.toThrow( await expect(
new BadRequestException('Your cart is empty') service.createFromCartSummary(buildSummary({ cartExtended: [] }), PaymentMethod.Xmr)
); ).rejects.toThrow(new BadRequestException('Your cart is empty'));
}); });
it('rejects carts that still have unresolved issues', async () => { it('rejects carts that still have unresolved issues', async () => {
await expect(service.createFromCartSummary(buildSummary({ hasIssues: true }))).rejects.toThrow( await expect(
new BadRequestException('Resolve cart issues before paying') service.createFromCartSummary(buildSummary({ hasIssues: true }), PaymentMethod.Xmr)
); ).rejects.toThrow(new BadRequestException('Resolve cart issues before paying'));
}); });
it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => { it('creates a session, invoice, lines, and valid discounts from the cart summary', async () => {
@@ -135,7 +133,7 @@ describe('CheckoutSessionService', () => {
] ]
}); });
const session = await service.createFromCartSummary(summary); const session = await service.createFromCartSummary(summary, PaymentMethod.Xmr);
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({ expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Xmr, paymentMethod: PaymentMethod.Xmr,
@@ -180,7 +178,8 @@ describe('CheckoutSessionService', () => {
await service.createFromCartSummary( await service.createFromCartSummary(
buildSummary({ buildSummary({
discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }] discounts: [{ code: 'BAD', amount: null, issueMessage: 'Invalid discount code' }]
}) }),
PaymentMethod.Xmr
); );
expect(discountRepo.create).not.toHaveBeenCalled(); expect(discountRepo.create).not.toHaveBeenCalled();
@@ -190,6 +189,17 @@ describe('CheckoutSessionService', () => {
}) })
); );
}); });
it('issues a bitcoin checkout invoice when that payment method is selected', async () => {
await service.createFromCartSummary(buildSummary(), PaymentMethod.Btc);
expect(invoiceService.issueInvoice).toHaveBeenCalledWith({
paymentMethod: PaymentMethod.Btc,
reason: InvoiceReason.Checkout,
contextId: 'session-uuid',
amountFiat: 9
});
});
}); });
describe('findById', () => { describe('findById', () => {
@@ -200,7 +210,14 @@ describe('CheckoutSessionService', () => {
await expect(service.findById('session-1')).resolves.toBe(session); await expect(service.findById('session-1')).resolves.toBe(session);
expect(sessionRepo.findOne).toHaveBeenCalledWith({ expect(sessionRepo.findOne).toHaveBeenCalledWith({
where: { id: 'session-1' }, where: { id: 'session-1' },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments'] relations: [
'lines',
'discounts',
'invoice',
'invoice.moneroDetails',
'invoice.btcDetails',
'invoice.payments'
]
}); });
}); });
}); });
@@ -6,7 +6,7 @@ import type { CookieCartSummary } from '../../storefrontCart/types/CookieCartSum
import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState'; import { deriveCheckoutSessionState } from '../../../utils/checkout/deriveCheckoutSessionState';
import { InvoiceReason } from '../../payment/types/InvoiceReason'; import { InvoiceReason } from '../../payment/types/InvoiceReason';
import { InvoiceService } from '../../payment/services/InvoiceService'; import { InvoiceService } from '../../payment/services/InvoiceService';
import { PaymentMethod } from '../../payment/types/PaymentMethod'; import type { PaymentMethod } from '../../payment/types/PaymentMethod';
import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount'; import { CheckoutSessionDiscount } from '../entities/CheckoutSessionDiscount';
import { CheckoutSessionLine } from '../entities/CheckoutSessionLine'; import { CheckoutSessionLine } from '../entities/CheckoutSessionLine';
import { CheckoutSession } from '../entities/CheckoutSession'; import { CheckoutSession } from '../entities/CheckoutSession';
@@ -26,11 +26,21 @@ export class CheckoutSessionService {
async findById(id: string): Promise<CheckoutSession | null> { async findById(id: string): Promise<CheckoutSession | null> {
return this.sessionRepo.findOne({ return this.sessionRepo.findOne({
where: { id }, where: { id },
relations: ['lines', 'discounts', 'invoice', 'invoice.moneroDetails', 'invoice.payments'] relations: [
'lines',
'discounts',
'invoice',
'invoice.moneroDetails',
'invoice.btcDetails',
'invoice.payments'
]
}); });
} }
async createFromCartSummary(summary: CookieCartSummary): Promise<CheckoutSession> { async createFromCartSummary(
summary: CookieCartSummary,
requestedPaymentMethod: PaymentMethod
): Promise<CheckoutSession> {
if (summary.cartExtended.length === 0) { if (summary.cartExtended.length === 0) {
throw new BadRequestException('Your cart is empty'); throw new BadRequestException('Your cart is empty');
} }
@@ -42,7 +52,7 @@ export class CheckoutSessionService {
const sessionId = randomUUID(); const sessionId = randomUUID();
const invoice = await this.invoiceService.issueInvoice({ const invoice = await this.invoiceService.issueInvoice({
paymentMethod: PaymentMethod.Xmr, paymentMethod: requestedPaymentMethod,
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
contextId: sessionId, contextId: sessionId,
amountFiat: summary.cartTotalPrice amountFiat: summary.cartTotalPrice
@@ -84,7 +94,7 @@ export class CheckoutSessionService {
async cancelSession(sessionId: string): Promise<void> { async cancelSession(sessionId: string): Promise<void> {
const session = await this.sessionRepo.findOne({ const session = await this.sessionRepo.findOne({
where: { id: sessionId }, where: { id: sessionId },
relations: ['invoice', 'invoice.moneroDetails', 'invoice.payments'] relations: ['invoice', 'invoice.moneroDetails', 'invoice.btcDetails', 'invoice.payments']
}); });
if (!session) { if (!session) {
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>; let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
const checkoutInvoiceView = { const checkoutInvoiceView = {
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
amountFiat: 9 amountFiat: 9
} as unknown as StorefrontInvoiceView; } as unknown as StorefrontInvoiceView;
@@ -1,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;
@@ -48,10 +49,12 @@
--sf-split-sidebar: minmax(220px, 320px); --sf-split-sidebar: minmax(220px, 320px);
--sf-product-card-min: 11rem; --sf-product-card-min: 11rem;
--sf-product-card-max: 22rem; --sf-product-card-max: 22rem;
--sf-sticky-header-scroll-padding: 9rem;
} }
@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 +88,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 +121,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;
@@ -155,7 +160,7 @@
} }
html { html {
color-scheme: light dark; scroll-padding-top: var(--sf-sticky-header-scroll-padding);
} }
body.sf-body { body.sf-body {
@@ -288,9 +293,13 @@ a:hover {
} }
.sf-header { .sf-header {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--sf-space-4); margin-bottom: var(--sf-space-4);
padding: var(--sf-space-3) 0; padding: var(--sf-space-3) 0;
border-bottom: 1px solid var(--sf-border); border-bottom: 1px solid var(--sf-border);
background: var(--sf-bg);
} }
.sf-header__inner { .sf-header__inner {
@@ -355,9 +364,51 @@ 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;
}
@media (max-width: 768px) {
.sf-header__inner {
flex-direction: column;
align-items: center;
justify-content: center;
}
.sf-header__brand-group {
flex-direction: column;
align-items: center;
}
.sf-nav,
.sf-rates {
width: 100%;
max-width: 100%;
justify-content: center;
}
}
.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 {
@@ -488,6 +539,76 @@ a:hover {
border-color: var(--sf-warning-border); border-color: var(--sf-warning-border);
} }
.sf-alert--toast {
position: fixed;
top: var(--sf-space-5);
left: 50%;
z-index: 20;
display: inline-flex;
align-items: center;
gap: var(--sf-space-2);
width: max-content;
max-width: min(24rem, calc(100% - 2 * var(--sf-space-4)));
margin: 0;
padding: 0.6875rem 0.9375rem;
font-size: 0.875rem;
line-height: 1.25;
box-shadow: 0 2px 12px rgb(0 0 0 / 10%);
pointer-events: none;
transform: translateX(-50%);
}
.sf-alert--toast.sf-alert--success {
animation: sf-toast-dismiss 3s ease forwards;
}
.sf-alert--toast.sf-alert--error {
animation: sf-toast-dismiss 6s ease forwards;
}
.sf-alert--toast::before {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: var(--sf-radius-full);
color: var(--sf-on-accent);
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
}
.sf-alert--toast.sf-alert--success::before {
content: '✓';
background: var(--sf-success);
}
.sf-alert--toast.sf-alert--error::before {
content: '×';
background: var(--sf-error);
}
@keyframes sf-toast-dismiss {
0%,
70% {
opacity: 1;
visibility: visible;
}
100% {
opacity: 0;
visibility: hidden;
}
}
@media (prefers-reduced-motion: reduce) {
.sf-alert--toast {
animation: none;
}
}
.sf-btn { .sf-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -767,7 +888,7 @@ a:hover {
margin: 0; margin: 0;
} }
.sf-line-item__body > .sf-discount-error { .sf-line-item__body > .sf-cart-issue {
margin: 0.125rem 0 0; margin: 0.125rem 0 0;
} }
@@ -844,8 +965,8 @@ a:hover {
color: var(--sf-text-muted); color: var(--sf-text-muted);
} }
.sf-discount-error { .sf-cart-issue {
margin: 0 0 var(--sf-space-2); margin: var(--sf-space-2) 0;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--sf-error); color: var(--sf-error);
line-height: 1.35; line-height: 1.35;
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -8,6 +8,7 @@ import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieServi
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,13 +47,20 @@ 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 exchangeRateService = { const exchangeRateService = {
getLiveFiatPerCrypto: jest.fn().mockReturnValue(fiatPerXmr) getLiveFiatPerCrypto: jest.fn((method: PaymentMethod) =>
method === PaymentMethod.Btc ? fiatPerBtc : fiatPerXmr
)
} as unknown as ExchangeRateService; } as unknown as ExchangeRateService;
const configService = { const configService = {
get: jest.fn().mockReturnValue(shopSettings) get: jest.fn().mockReturnValue(shopSettings)
@@ -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'
@@ -8,9 +8,12 @@ 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 { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService'; import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { PaymentMethod } from '../../payment/types/PaymentMethod'; 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';
@@ -39,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.exchangeRateService.getLiveFiatPerCrypto(PaymentMethod.Xmr); const cryptoRates = this.buildCryptoRates(enabledPaymentMethods);
const { logoUrl, faviconUrl, simplexLink, shippingNote } = const { logoUrl, faviconUrl, simplexLink, shippingNote } =
await this.shopSettingsService.getStorefrontBranding(); await this.shopSettingsService.getStorefrontBranding();
@@ -61,7 +66,7 @@ export class StorefrontShopViewService {
authorizedOrders, authorizedOrders,
shopNavActive, shopNavActive,
shopFiatCurrency, shopFiatCurrency,
fiatPerXmr, cryptoRates,
logoUrl, logoUrl,
faviconUrl, faviconUrl,
simplexLink, simplexLink,
@@ -130,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}}
@@ -12,10 +12,10 @@
{{> shop-nav}} {{> shop-nav}}
{{> category-nav}} {{> category-nav}}
<main class='sf-main'> <main class='sf-main'>
{{> feedback}}
{{{body}}} {{{body}}}
</main> </main>
{{> shop-footer}} {{> shop-footer}}
</div> </div>
{{> feedback}}
</body> </body>
</html> </html>
@@ -28,7 +28,7 @@
{{@root.shopFiatCurrency}}</div> {{@root.shopFiatCurrency}}</div>
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div> <div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
{{#if stockIssueMessage}} {{#if stockIssueMessage}}
<p class='sf-discount-error'>{{stockIssueMessage}}</p> <p class='sf-cart-issue'>{{stockIssueMessage}}</p>
{{else}} {{else}}
{{#unless stockForSession}} {{#unless stockForSession}}
<p class='sf-text-subtle'>No more in stock beside your order</p> <p class='sf-text-subtle'>No more in stock beside your order</p>
@@ -36,7 +36,7 @@
{{/if}} {{/if}}
{{#each @root.discounts}} {{#each @root.discounts}}
{{#if (includes ineligibleVariantIds ../id)}} {{#if (includes ineligibleVariantIds ../id)}}
<p class='sf-discount-error'>Not eligible for code {{code}}</p> <p class='sf-cart-issue'>Not eligible for code {{code}}</p>
{{/if}} {{/if}}
{{/each}} {{/each}}
<form class='sf-form-row' method='post' action='/shop/cart/product/update'> <form class='sf-form-row' method='post' action='/shop/cart/product/update'>
@@ -23,7 +23,7 @@
}} }}
<span class='sf-discount-row__code sf-text-error'>{{code}}</span> <span class='sf-discount-row__code sf-text-error'>{{code}}</span>
</div> </div>
<p class='sf-discount-error'>{{issueMessage}}</p> <p class='sf-cart-issue'>{{issueMessage}}</p>
{{else if amount}} {{else if amount}}
<div class='sf-discount-row sf-discount-item'> <div class='sf-discount-row sf-discount-item'>
{{> dismiss-button {{> dismiss-button
@@ -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'>
@@ -69,11 +62,10 @@
</form> </form>
{{#if cartTotalIssueMessage}} {{#if cartTotalIssueMessage}}
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p> <p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
{{/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>
@@ -81,14 +73,30 @@
<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'>
{{#each paymentMethods}}
<button
type='submit'
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> </form>
{{else}} {{else}}
<button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Pay with XMR</button> <button type='button' class='sf-btn sf-btn--primary' disabled title='Resolve cart issues before paying'>Continue to payment</button>
{{/unless}} {{/unless}}
{{else}}
<button type='button' class='sf-btn sf-btn--primary' disabled title='XMR rate unavailable'>Pay with XMR</button>
{{/if}}
{{> manual-shipping-info}} {{> manual-shipping-info}}
{{> auto-delivery-info}} {{> auto-delivery-info}}
@@ -1,5 +1,5 @@
{{#if feedback}} {{#if feedback}}
<p class='sf-alert sf-alert--{{feedback.type}}' role='status'> <p class='sf-alert sf-alert--{{feedback.type}} sf-alert--toast' role='status'>
{{feedback.text}} {{feedback.text}}
</p> </p>
{{/if}} {{/if}}
@@ -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>
@@ -1,4 +1,4 @@
<li class='sf-card sf-product-card'> <li class='sf-card sf-product-card' id='product-card-{{selectedVariant.id}}'>
{{#with selectedVariant}} {{#with selectedVariant}}
{{#if thumbnailUrl}} {{#if thumbnailUrl}}
<a class='sf-product-card__media-link' href='{{detailHref}}'> <a class='sf-product-card__media-link' href='{{detailHref}}'>
@@ -2,7 +2,7 @@
<div class='sf-footer__row'> <div class='sf-footer__row'>
<div class='sf-footer__start'> <div class='sf-footer__start'>
{{#if simplexLink}} {{#if simplexLink}}
<a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via Simplex</a> <a href='{{simplexLink}}' target='_blank' rel='noopener noreferrer'>Contact via SimpleX</a>
{{/if}} {{/if}}
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a> <a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
</div> </div>
@@ -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'
}); });
}); });
+3
View File
@@ -1,4 +1,5 @@
import { ConfirmationTier } from './ConfirmationTier'; 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,6 +49,7 @@ export interface AppConfig {
shippingNoteMinLength: number; shippingNoteMinLength: number;
shippingNoteMaxLength: number; shippingNoteMaxLength: number;
orderMessageMaxLength: number; orderMessageMaxLength: number;
bitcoinWithdrawMaxFeeRateSatVbyte: number;
}; };
} }
@@ -122,5 +124,6 @@ export interface Config {
order: OrderConfig; order: OrderConfig;
invoice: InvoiceConfig; invoice: InvoiceConfig;
moneroWallet: MoneroWalletConfig; moneroWallet: MoneroWalletConfig;
electrumWallet: ElectrumWalletConfig;
simplex: SimplexConfig; simplex: SimplexConfig;
} }
@@ -0,0 +1,3 @@
import type Decimal from 'decimal.js';
export type CryptoAtomicConverter = (amountAtomic: string, rounding?: Decimal.Rounding) => string;
+4
View File
@@ -0,0 +1,4 @@
export enum ElectrumNetwork {
Mainnet = 'mainnet',
Testnet4 = 'testnet4'
}
+11
View File
@@ -0,0 +1,11 @@
import { ElectrumNetwork } from './ElectrumNetwork';
export interface ElectrumWalletConfig {
network: ElectrumNetwork;
server: string;
rpcUrl: string;
username: string;
password: string;
walletPassword: string;
rpcTimeoutMs: number;
}
@@ -0,0 +1,15 @@
/**
* Maps to monero-wallet-rpc `sweep_all` / `transfer` priority (04).
*
* Absolute fee multipliers (fee algorithm 4, current mainnet) are 1, 5, 25, 1000 for priorities 14.
* Official GUI labels are scaled relative to Normal (priority 2): 0.2×, 1×, 5×, 200×.
*
* @see https://www.getmonero.org/resources/developer-guides/wallet-rpc.html#sweep_all
*/
export enum MoneroWithdrawPriority {
Automatic = 0,
Slow = 1,
Normal = 2,
Fast = 3,
Fastest = 4
}
@@ -1,4 +1,4 @@
export enum MoneroWalletSyncStatus { export enum WalletSyncStatus {
Synced = 'synced', Synced = 'synced',
Syncing = 'syncing', Syncing = 'syncing',
Unknown = 'unknown' Unknown = 'unknown'
@@ -1,14 +1,14 @@
import { resolveMinConfirmations } from './resolveMinConfirmations'; import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [ const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 }, { upToTotalFiat: '25', minConfirmations: 1 },
{ upToTotalFiat: '250', minConfirmations: 5 }, { upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 } { minConfirmations: 10 }
] as const; ] as const;
describe('resolveMinConfirmations', () => { describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => { it('returns the first tier for small orders', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0); expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
}); });
it('returns the middle tier for medium orders', () => { it('returns the middle tier for medium orders', () => {
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
).toBe('2/10'); ).toBe('2/10');
}); });
it('treats zero-confirmation tiers as confirmed', () => { it('returns compact progress at zero confirmations', () => {
expect( expect(
formatInvoicePaymentConfirmationStatus({ formatInvoicePaymentConfirmationStatus({
confirmations: 0, confirmations: 0,
requiredConfirmations: 0, requiredConfirmations: 3,
format: 'compact' format: 'compact'
}) })
).toBe('Confirmed'); ).toBe('0/3');
}); });
}); });
@@ -1,9 +1,6 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo'; import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat'; import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({ export const formatInvoicePaymentConfirmationStatus = ({
confirmations, confirmations,
requiredConfirmations, requiredConfirmations,
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`; return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
} }
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`; return `${confirmations}/${requiredConfirmations}`;
}; };
@@ -1,15 +1,19 @@
import { InternalServerErrorException } from '@nestjs/common'; import { InternalServerErrorException } from '@nestjs/common';
import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr'; import { XMR_ATOMIC_PER_XMR } from '../../consts/xmrAtomicPerXmr';
import { BTC_ATOMIC_PER_BTC } from '../../consts/btcAtomicPerBtc';
import type { Invoice } from '../../modules/payment/entities/Invoice'; import type { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoiceBtcDetails } from '../../modules/payment/entities/InvoiceBtcDetails';
import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails'; import type { InvoiceMoneroDetails } from '../../modules/payment/entities/InvoiceMoneroDetails';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import { InvoiceReason } from '../../modules/payment/types/InvoiceReason'; import { InvoiceReason } from '../../modules/payment/types/InvoiceReason';
import type { BtcDetailsOverrides, MoneroDetailsOverrides } from './types/ToStorefrontInvoiceViewTestTypes';
import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo'; import * as formatRelativeTimeAgoModule from '../formatRelativeTimeAgo';
import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl'; import * as generateQrCodeDataUrlModule from '../generateQrCodeDataUrl';
import { toStorefrontInvoiceView } from './toStorefrontInvoiceView'; import { toStorefrontInvoiceView } from './toStorefrontInvoiceView';
const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString(); const oneXmrAtomic = XMR_ATOMIC_PER_XMR.toString();
const oneBtcAtomic = BTC_ATOMIC_PER_BTC.toString();
const paymentAddress = '4StorefrontInvoiceViewTestAddress'; const paymentAddress = '4StorefrontInvoiceViewTestAddress';
const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment => const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =>
@@ -21,10 +25,6 @@ const buildPayment = (overrides: Partial<InvoicePayment> = {}): InvoicePayment =
...overrides ...overrides
}) as InvoicePayment; }) as InvoicePayment;
type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails => const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMoneroDetails =>
({ ({
paymentAddressIndex: 1, paymentAddressIndex: 1,
@@ -33,6 +33,13 @@ const buildMoneroDetails = (overrides: MoneroDetailsOverrides = {}): InvoiceMone
...overrides ...overrides
}) as InvoiceMoneroDetails; }) as InvoiceMoneroDetails;
const buildBtcDetails = (overrides: BtcDetailsOverrides = {}): InvoiceBtcDetails =>
({
fiatPerBtcAtCreation: 60_000,
requiredConfirmations: 1,
...overrides
}) as InvoiceBtcDetails;
const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice => const buildInvoice = (overrides: Partial<Invoice> = {}): Invoice =>
({ ({
reason: InvoiceReason.Checkout, reason: InvoiceReason.Checkout,
@@ -77,10 +84,21 @@ describe('toStorefrontInvoiceView', () => {
await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException); await expect(toStorefrontInvoiceView(invoice)).rejects.toBeInstanceOf(InternalServerErrorException);
}); });
it('throws for unsupported payment methods', async () => { it('throws when bitcoin details are missing', async () => {
const invoice = buildInvoice({ paymentMethod: 'btc' as PaymentMethod }); const invoice = buildInvoice({
paymentMethod: PaymentMethod.Btc,
moneroDetails: null,
btcDetails: null,
expectedTotalAtomic: oneBtcAtomic
});
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: btc'); await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Invoice is missing Bitcoin payment details');
});
it('throws for unsupported payment methods', async () => {
const invoice = buildInvoice({ paymentMethod: 'eth' as PaymentMethod, moneroDetails: null });
await expect(toStorefrontInvoiceView(invoice)).rejects.toThrow('Unsupported payment method: eth');
}); });
}); });
@@ -91,7 +109,7 @@ describe('toStorefrontInvoiceView', () => {
const view = await toStorefrontInvoiceView(invoice); const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({ expect(view).toMatchObject({
cryptoCurrency: 'XMR', paymentLabel: 'XMR',
expectedTotalCrypto: '1.00000000', expectedTotalCrypto: '1.00000000',
receivedTotalCrypto: null, receivedTotalCrypto: null,
paymentAddress, paymentAddress,
@@ -124,6 +142,27 @@ describe('toStorefrontInvoiceView', () => {
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`); expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(`monero:${paymentAddress}?tx_amount=1.00000000`);
}); });
it('maps bitcoin invoices and builds a bip21 qr code', async () => {
const invoice = buildInvoice({
paymentMethod: PaymentMethod.Btc,
moneroDetails: null,
btcDetails: buildBtcDetails(),
expectedTotalAtomic: oneBtcAtomic,
paymentAddress: 'bc1qstorefronttest'
});
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
paymentLabel: 'BTC',
expectedTotalCrypto: '1.00000000',
paymentAddress: 'bc1qstorefronttest'
});
expect(generateQrCodeDataUrlSpy).toHaveBeenCalledWith(
'bitcoin:bc1qstorefronttest?amount=1.00000000'
);
});
it('formats expiry as seconds only when under one minute remains', async () => { it('formats expiry as seconds only when under one minute remains', async () => {
const invoice = buildInvoice({ const invoice = buildInvoice({
expiresAt: new Date('2026-06-01T12:00:45.000Z') expiresAt: new Date('2026-06-01T12:00:45.000Z')
@@ -424,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
statusVariant: 'confirmed' statusVariant: 'confirmed'
}); });
}); });
it('treats tx-detected invoices as confirmed for payment status display', async () => {
const payment = buildPayment({ confirmations: 0 });
const invoice = buildInvoice({
moneroDetails: buildMoneroDetails({ requiredConfirmations: 0 }),
payments: [payment]
});
const view = await toStorefrontInvoiceView(invoice);
expect(view.showRefresh).toBe(false);
expect(view.payments[0].confirmationStatus).toBe('Confirmed');
});
}); });
describe('overpayment', () => { describe('overpayment', () => {
@@ -2,13 +2,16 @@ import Decimal from 'decimal.js';
import { InternalServerErrorException } from '@nestjs/common'; import { InternalServerErrorException } from '@nestjs/common';
import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView'; import type { StorefrontInvoicePaymentView } from '../../modules/storefrontCore/types/StorefrontInvoicePaymentView';
import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView'; import type { StorefrontInvoiceView } from '../../modules/storefrontCore/types/StorefrontInvoiceView';
import type { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter';
import type { Invoice } from '../../modules/payment/entities/Invoice'; import type { Invoice } from '../../modules/payment/entities/Invoice';
import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment'; import type { InvoicePayment } from '../../modules/payment/entities/InvoicePayment';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod'; import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import dayjs from '../../plugins/dayjs'; import dayjs from '../../plugins/dayjs';
import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl'; import { generateQrCodeDataUrl } from '../generateQrCodeDataUrl';
import { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc';
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr'; import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
import { subtractAtomic } from '../atomic/subtractAtomic'; import { subtractAtomic } from '../atomic/subtractAtomic';
import { paymentMethodLabel } from '../../consts/paymentMethodLabel';
import { deriveInvoiceState } from './deriveInvoiceState'; import { deriveInvoiceState } from './deriveInvoiceState';
import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus'; import { formatInvoicePaymentConfirmationStatus } from './formatInvoicePaymentConfirmationStatus';
import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage'; import { resolveInvoiceStatusMessage } from './resolveInvoiceStatusMessage';
@@ -19,17 +22,44 @@ import { sumInvoicePaymentAmountsAtomic } from './sumInvoicePaymentAmountsAtomic
export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => { export const toStorefrontInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => {
switch (invoice.paymentMethod) { switch (invoice.paymentMethod) {
case PaymentMethod.Xmr: case PaymentMethod.Xmr:
return toXmrInvoiceView(invoice); if (!invoice.moneroDetails) {
throw new InternalServerErrorException('Invoice is missing Monero payment details');
}
return buildStorefrontInvoiceView({
invoice,
paymentLabel: paymentMethodLabel[PaymentMethod.Xmr],
convertAtomicToCrypto: convertXmrAtomicToXmr,
buildQrCodePayload: (paymentAddress, amountCrypto) =>
`monero:${paymentAddress}?tx_amount=${amountCrypto}`
});
case PaymentMethod.Btc:
if (!invoice.btcDetails) {
throw new InternalServerErrorException('Invoice is missing Bitcoin payment details');
}
return buildStorefrontInvoiceView({
invoice,
paymentLabel: paymentMethodLabel[PaymentMethod.Btc],
convertAtomicToCrypto: convertBtcAtomicToBtc,
buildQrCodePayload: (paymentAddress, amountCrypto) => `bitcoin:${paymentAddress}?amount=${amountCrypto}`
});
default: default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`); throw new InternalServerErrorException(`Unsupported payment method: ${String(invoice.paymentMethod)}`);
} }
}; };
const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView> => { const buildStorefrontInvoiceView = async ({
if (!invoice.moneroDetails) { invoice,
throw new InternalServerErrorException('Invoice is missing Monero payment details'); paymentLabel,
} convertAtomicToCrypto,
buildQrCodePayload
}: {
invoice: Invoice;
paymentLabel: string;
convertAtomicToCrypto: CryptoAtomicConverter;
buildQrCodePayload: (paymentAddress: string, amountCrypto: string) => string;
}): Promise<StorefrontInvoiceView> => {
const invoiceState = deriveInvoiceState(invoice); const invoiceState = deriveInvoiceState(invoice);
const { const {
@@ -42,22 +72,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
hasPendingConfirmations hasPendingConfirmations
} = invoiceState; } = invoiceState;
const cryptoCurrency = 'XMR'; const expectedTotalCrypto = convertAtomicToCrypto(invoice.expectedTotalAtomic);
const expectedTotalCrypto = convertXmrAtomicToXmr(invoice.expectedTotalAtomic);
const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments); const receivedAtomic = sumInvoicePaymentAmountsAtomic(invoice.payments);
const receivedTotalCrypto = receivedAtomic === '0' ? null : convertXmrAtomicToXmr(receivedAtomic); const receivedTotalCrypto = receivedAtomic === '0' ? null : convertAtomicToCrypto(receivedAtomic);
const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice); const requiredConfirmations = resolveInvoiceRequiredConfirmations(invoice);
const payments = [...(invoice.payments ?? [])] const payments = [...(invoice.payments ?? [])]
.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()) .sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime())
.map(payment => toPaymentView(payment, requiredConfirmations)); .map(payment => toPaymentView(payment, requiredConfirmations, convertAtomicToCrypto));
const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired; const showPaymentCapture = (isAwaitingPayment || isUnderpaid) && !isExpired;
const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null; const expiresInDuration = showPaymentCapture ? formatInvoiceExpiresInDuration(invoice.expiresAt) : null;
let remainingTotalCrypto: string | null = null;
let instructionPrefix: string | null = null; let instructionPrefix: string | null = null;
let instructionAmountCrypto: string | null = null; let instructionAmountCrypto: string | null = null;
let instructionSuffix: string | null = null; let instructionSuffix: string | null = null;
@@ -65,22 +93,20 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
if (showPaymentCapture) { if (showPaymentCapture) {
if (isAwaitingPayment) { if (isAwaitingPayment) {
remainingTotalCrypto = expectedTotalCrypto;
instructionAmountCrypto = expectedTotalCrypto; instructionAmountCrypto = expectedTotalCrypto;
instructionPrefix = 'Send exactly'; instructionPrefix = 'Send exactly';
instructionSuffix = 'to the address below.'; instructionSuffix = 'to the address below.';
} else { } else {
const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic); const remainingTotalCryptoAtomic = subtractAtomic(invoice.expectedTotalAtomic, receivedAtomic);
remainingTotalCrypto = convertXmrAtomicToXmr(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL); instructionAmountCrypto = convertAtomicToCrypto(remainingTotalCryptoAtomic, Decimal.ROUND_CEIL);
instructionAmountCrypto = remainingTotalCrypto;
instructionPrefix = 'Send'; instructionPrefix = 'Send';
instructionSuffix = 'more to the same address below.'; instructionSuffix = 'more to the same address below.';
} }
qrCodeUrl = await generateQrCodeDataUrl( const qrCodePayload = buildQrCodePayload(invoice.paymentAddress, instructionAmountCrypto);
`monero:${invoice.paymentAddress}?tx_amount=${instructionAmountCrypto}`
); qrCodeUrl = await generateQrCodeDataUrl(qrCodePayload);
} }
const statusMessage = resolveInvoiceStatusMessage(invoiceState); const statusMessage = resolveInvoiceStatusMessage(invoiceState);
@@ -88,7 +114,7 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
const statusVariant = resolveInvoiceStatusVariant(invoiceState); const statusVariant = resolveInvoiceStatusVariant(invoiceState);
return { return {
cryptoCurrency, paymentLabel,
expectedTotalCrypto, expectedTotalCrypto,
receivedTotalCrypto, receivedTotalCrypto,
paymentAddress: invoice.paymentAddress, paymentAddress: invoice.paymentAddress,
@@ -122,12 +148,16 @@ const toXmrInvoiceView = async (invoice: Invoice): Promise<StorefrontInvoiceView
}; };
}; };
const toPaymentView = (payment: InvoicePayment, requiredConfirmations: number): StorefrontInvoicePaymentView => { const toPaymentView = (
payment: InvoicePayment,
requiredConfirmations: number,
convertAtomicToCrypto: CryptoAtomicConverter
): StorefrontInvoicePaymentView => {
const isConfirmed = payment.confirmations >= requiredConfirmations; const isConfirmed = payment.confirmations >= requiredConfirmations;
return { return {
txHash: payment.txHash, txHash: payment.txHash,
amountCrypto: convertXmrAtomicToXmr(payment.amountAtomic), amountCrypto: convertAtomicToCrypto(payment.amountAtomic),
confirmationStatus: formatInvoicePaymentConfirmationStatus({ confirmationStatus: formatInvoicePaymentConfirmationStatus({
confirmations: payment.confirmations, confirmations: payment.confirmations,
requiredConfirmations, requiredConfirmations,
@@ -0,0 +1,8 @@
import type { InvoiceBtcDetails } from '../../../modules/payment/entities/InvoiceBtcDetails';
import type { InvoiceMoneroDetails } from '../../../modules/payment/entities/InvoiceMoneroDetails';
export type MoneroDetailsOverrides = Partial<
Pick<InvoiceMoneroDetails, 'paymentAddressIndex' | 'fiatPerXmrAtCreation' | 'requiredConfirmations'>
>;
export type BtcDetailsOverrides = Partial<Pick<InvoiceBtcDetails, 'fiatPerBtcAtCreation' | 'requiredConfirmations'>>;
@@ -11,9 +11,11 @@ export const createOrderDetailQuery = (orderRepo: Repository<Order>, orderId: st
.leftJoinAndSelect('order.discounts', 'orderDiscount') .leftJoinAndSelect('order.discounts', 'orderDiscount')
.leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice') .leftJoinAndSelect('order.checkoutInvoice', 'checkoutInvoice')
.leftJoinAndSelect('checkoutInvoice.moneroDetails', 'checkoutMoneroDetails') .leftJoinAndSelect('checkoutInvoice.moneroDetails', 'checkoutMoneroDetails')
.leftJoinAndSelect('checkoutInvoice.btcDetails', 'checkoutBtcDetails')
.leftJoinAndSelect('checkoutInvoice.payments', 'checkoutPayment') .leftJoinAndSelect('checkoutInvoice.payments', 'checkoutPayment')
.leftJoinAndSelect('order.shippingInvoice', 'shippingInvoice') .leftJoinAndSelect('order.shippingInvoice', 'shippingInvoice')
.leftJoinAndSelect('shippingInvoice.moneroDetails', 'shippingMoneroDetails') .leftJoinAndSelect('shippingInvoice.moneroDetails', 'shippingMoneroDetails')
.leftJoinAndSelect('shippingInvoice.btcDetails', 'shippingBtcDetails')
.leftJoinAndSelect('shippingInvoice.payments', 'shippingPayment') .leftJoinAndSelect('shippingInvoice.payments', 'shippingPayment')
.leftJoinAndSelect('order.messages', 'message') .leftJoinAndSelect('order.messages', 'message')
.addSelect('order.accessToken') .addSelect('order.accessToken')
@@ -0,0 +1,16 @@
import { InternalServerErrorException } from '@nestjs/common';
import { PaymentMethod } from '../../modules/payment/types/PaymentMethod';
import type { CryptoAtomicConverter } from '../../types/CryptoAtomicConverter';
import { convertBtcAtomicToBtc } from '../bitcoin/convertBtcAtomicToBtc';
import { convertXmrAtomicToXmr } from '../monero/convertXmrAtomicToXmr';
export const resolveAtomicToCryptoConverter = (paymentMethod: PaymentMethod): CryptoAtomicConverter => {
switch (paymentMethod) {
case PaymentMethod.Xmr:
return convertXmrAtomicToXmr;
case PaymentMethod.Btc:
return convertBtcAtomicToBtc;
default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(paymentMethod)}`);
}
};
@@ -0,0 +1,42 @@
import { Validate, ValidatorConstraint, type ValidatorConstraintInterface } from 'class-validator';
import { getElectrumWalletConfig } from '../../config';
import { ElectrumNetwork } from '../../types/ElectrumNetwork';
// Soft validation only: prefix/length checks to reject obvious garbage and wrong-network
// addresses early. Checksums and spendability are validated by Electrum on payto.
const BASE58 = '[1-9A-HJ-NP-Za-km-z]';
const NETWORK_ADDRESS_PATTERNS: Record<ElectrumNetwork, RegExp[]> = {
[ElectrumNetwork.Mainnet]: [
new RegExp(`^1${BASE58}{25,34}$`),
new RegExp(`^3${BASE58}{25,34}$`),
/^bc1[a-z0-9]{25,87}$/
],
[ElectrumNetwork.Testnet4]: [
new RegExp(`^[mn]${BASE58}{25,34}$`),
new RegExp(`^2${BASE58}{25,34}$`),
/^(?:tb1|bcrt1)[a-z0-9]{25,87}$/
]
};
@ValidatorConstraint({ name: 'isBitcoinAddress' })
class IsBitcoinAddressConstraint implements ValidatorConstraintInterface {
validate(value: unknown): boolean {
if (typeof value !== 'string') {
return false;
}
const { network } = getElectrumWalletConfig();
const trimmed = value.trim();
return NETWORK_ADDRESS_PATTERNS[network].some(pattern => pattern.test(trimmed));
}
defaultMessage(): string {
const { network } = getElectrumWalletConfig();
return `Enter a valid ${network} Bitcoin address.`;
}
}
export const IsBitcoinAddress = () => Validate(IsBitcoinAddressConstraint);
@@ -13,20 +13,13 @@ const validateTiers = (value: string) => {
}; };
const validTiers = const validTiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
describe('IsConfirmationTiers', () => { describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => { it('accepts valid default tiers', () => {
expect(validateTiers(validTiers)).toHaveLength(0); expect(validateTiers(validTiers)).toHaveLength(0);
}); });
it('accepts numeric-only tiers without tx-detected (0)', () => {
const tiers =
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers)).toHaveLength(0);
});
it('rejects empty string', () => { it('rejects empty string', () => {
expect(validateTiers('').length).toBeGreaterThan(0); expect(validateTiers('').length).toBeGreaterThan(0);
}); });
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
expect(validateTiers('[]').length).toBeGreaterThan(0); expect(validateTiers('[]').length).toBeGreaterThan(0);
}); });
it('rejects tx-detected (0) more than once', () => { it('rejects minConfirmations: 0', () => {
const tiers = const tiers =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":0},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0); expect(validateTiers(tiers).length).toBeGreaterThan(0);
}); });
it('rejects tx-detected (0) on catch-all tier', () => { it('rejects non-numeric minConfirmations', () => {
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects legacy tx-detected string', () => {
const tiers = const tiers =
'[{"upToTotalFiat":"25","minConfirmations":"tx-detected"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]'; '[{"upToTotalFiat":"25","minConfirmations":"foo"},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0); expect(validateTiers(tiers).length).toBeGreaterThan(0);
}); });
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): boolean => {
}; };
const isMinConfirmations = (value: unknown): boolean => const isMinConfirmations = (value: unknown): boolean =>
typeof value === 'number' && Number.isInteger(value) && value >= 0; typeof value === 'number' && Number.isInteger(value) && value >= 1;
const isConfirmationTier = (value: unknown): value is ConfirmationTier => { const isConfirmationTier = (value: unknown): value is ConfirmationTier => {
if (typeof value !== 'object' || value === null) { if (typeof value !== 'object' || value === null) {
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
} }
const tiers = parsed; const tiers = parsed;
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
if (txDetectedTierCount > 1) {
return false;
}
const lastTier = tiers[tiers.length - 1]; const lastTier = tiers[tiers.length - 1];
if (lastTier.upToTotalFiat !== undefined) { if (lastTier.upToTotalFiat !== undefined) {
return false; return false;
} }
if (lastTier.minConfirmations === 0) {
return false;
}
for (let index = 0; index < tiers.length - 1; index++) { for (let index = 0; index < tiers.length - 1; index++) {
const tier = tiers[index]; const tier = tiers[index];
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
} }
defaultMessage(): string { defaultMessage(): string {
return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be 0 (tx-detected) or an integer >= 1, 0 may appear only once and not on the catch-all tier, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat'; return '$property must be a non-empty JSON array of confirmation tiers; minConfirmations must be an integer >= 1, non-final tiers need a positive upToTotalFiat in shop fiat currency, and the last tier must be a catch-all without upToTotalFiat';
} }
} }
+2 -1
View File
@@ -11,6 +11,7 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
BitcoinWallet: typeof import('./components/wallet/BitcoinWallet.vue')['default']
CmsListPagination: typeof import('./components/CmsListPagination.vue')['default'] CmsListPagination: typeof import('./components/CmsListPagination.vue')['default']
CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default'] CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default']
CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default'] CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default']
@@ -56,9 +57,9 @@ declare module 'vue' {
MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default'] MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default']
OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default'] OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default']
OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default'] OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default']
OrderInvoicePaymentPanel: typeof import('./components/OrderInvoicePaymentPanel.vue')['default']
OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default'] OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default']
OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default'] OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default']
OrderMoneroPaymentPanel: typeof import('./components/OrderMoneroPaymentPanel.vue')['default']
OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default'] OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default']
OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default'] OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default']
RichTextEditor: typeof import('./components/RichTextEditor.vue')['default'] RichTextEditor: typeof import('./components/RichTextEditor.vue')['default']
@@ -8,14 +8,12 @@
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="Expected total"> <el-descriptions-item label="Expected total">
<span class="mono" <span class="mono">{{ invoice.expectedTotalCrypto }} {{ invoice.paymentLabel }}</span>
>{{ invoice.expectedTotalCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item v-if="fiatPerXmrAtCreation !== undefined" :label="`Rate at ${rateLabel}`"> <el-descriptions-item v-if="fiatPerCryptoAtCreation !== undefined" :label="`Rate at ${rateLabel}`">
{{ formatFiatPrice(fiatPerXmrAtCreation, config.shopFiatCurrency) }} / {{ formatFiatPrice(fiatPerCryptoAtCreation, config.shopFiatCurrency) }} /
{{ paymentMethodCryptoCurrency[invoice.paymentMethod] }} {{ invoice.paymentLabel }}
</el-descriptions-item> </el-descriptions-item>
</el-descriptions> </el-descriptions>
@@ -37,9 +35,7 @@
<el-table-column label="Amount" width="140"> <el-table-column label="Amount" width="140">
<template #default="{ row }"> <template #default="{ row }">
<span class="mono" <span class="mono">{{ row.amountCrypto }} {{ invoice.paymentLabel }}</span>
>{{ row.amountCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
>
</template> </template>
</el-table-column> </el-table-column>
@@ -69,7 +65,7 @@
import { computed, type PropType } from 'vue'; import { computed, type PropType } from 'vue';
import { config } from '@/config'; import { config } from '@/config';
import type { InvoiceExtended } from '@/types/payment/InvoiceExtended'; import type { InvoiceExtended } from '@/types/payment/InvoiceExtended';
import { paymentMethodCryptoCurrency } from '@/types/payment/PaymentMethod'; import { PaymentMethod } from '@/types/payment/PaymentMethod';
import { formatDate } from '@/utils/formatDate'; import { formatDate } from '@/utils/formatDate';
import { formatFiatPrice } from '@/utils/formatFiatPrice'; import { formatFiatPrice } from '@/utils/formatFiatPrice';
import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType'; import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType';
@@ -85,13 +81,25 @@ const props = defineProps({
}, },
emptyText: { emptyText: {
type: String, type: String,
default: 'No Monero payment session.' default: 'No payment session.'
} }
}); });
const payments = computed(() => props.invoice?.payments ?? []); const payments = computed(() => props.invoice?.payments ?? []);
const fiatPerXmrAtCreation = computed(() => props.invoice?.moneroDetails?.fiatPerXmrAtCreation); const fiatPerCryptoAtCreation = computed(() => {
const invoice = props.invoice;
if (!invoice) {
return undefined;
}
if (invoice.paymentMethod === PaymentMethod.Btc) {
return invoice.btcDetails?.fiatPerBtcAtCreation;
}
return invoice.moneroDetails?.fiatPerXmrAtCreation;
});
</script> </script>
<style scoped> <style scoped>
@@ -53,10 +53,9 @@
</el-descriptions> </el-descriptions>
<template v-if="order.shippingInvoice"> <template v-if="order.shippingInvoice">
<order-monero-payment-panel <order-invoice-payment-panel
:invoice="order.shippingInvoice" :invoice="order.shippingInvoice"
rate-label="quote" rate-label="quote"
empty-text="Shipping payment session not created yet."
/> />
</template> </template>
+1 -1
View File
@@ -4,7 +4,7 @@
<span>Order payment</span> <span>Order payment</span>
</template> </template>
<order-monero-payment-panel <order-invoice-payment-panel
:invoice="order.checkoutInvoice" :invoice="order.checkoutInvoice"
rate-label="checkout" rate-label="checkout"
empty-text="No checkout payment session." empty-text="No checkout payment session."
+343
View File
@@ -0,0 +1,343 @@
<template>
<div v-loading="loading" class="detail-loading-host" element-loading-text="Loading wallet…">
<el-empty v-if="!loading && loadError" description="Failed to load wallet status" />
<template v-if="!loading && !loadError && walletStatus">
<div v-if="walletStatus.syncStatus !== WalletSyncStatus.Synced" class="mb-16">
<el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." />
</div>
<el-card class="mb-24" shadow="never">
<template #header>
<div class="flex items-center justify-between gap-16">
<span>Status</span>
<el-button type="default" :loading="refreshing" @click="refreshStatus">Refresh</el-button>
</div>
</template>
<el-descriptions :column="1" border>
<el-descriptions-item label="Network">{{ walletStatus.network }}</el-descriptions-item>
<el-descriptions-item label="RPC version">{{ walletStatus.rpcVersion }}</el-descriptions-item>
<el-descriptions-item label="Block height">
{{ walletStatus.blockHeight ?? 'Unavailable' }}
</el-descriptions-item>
<el-descriptions-item label="Server height">
{{ walletStatus.serverHeight ?? 'Unavailable' }}
</el-descriptions-item>
<el-descriptions-item label="Sync">
<el-tag :type="resolveWalletSyncStatusTagType(walletStatus.syncStatus)" size="small">
{{ resolveWalletSyncStatusLabel(walletStatus.syncStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Total balance">{{ walletStatus.balanceBtc }} BTC</el-descriptions-item>
<el-descriptions-item label="Confirmed balance">
{{ walletStatus.confirmedBalanceBtc }} BTC
</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card class="mb-24" shadow="never">
<template #header>
<span>Withdraw all</span>
</template>
<el-form
ref="withdrawFormRef"
label-position="top"
:model="withdrawForm"
:rules="withdrawFormRules"
@submit.prevent="onWithdrawSubmit"
>
<el-form-item label="Destination address" prop="destinationAddress">
<el-input
v-model="withdrawForm.destinationAddress"
class="withdraw-address-input"
autocomplete="off"
:placeholder="`${walletStatus.network} Bitcoin address`"
:disabled="withdrawing"
@input="withdrawFormRef?.clearValidate('destinationAddress')"
/>
</el-form-item>
<el-form-item label="Fee rate (sat/vbyte)" prop="feeRateSatVbyte">
<el-input-number
v-model="withdrawForm.feeRateSatVbyte"
class="fee-rate-input"
:min="1"
:max="maxBitcoinWithdrawFeeRateSatVbyte"
:step="1"
:disabled="withdrawing"
/>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="withdrawing">
Withdraw all confirmed funds
</el-button>
</el-form>
</el-card>
<el-card shadow="never">
<template #header>
<span>Recovery seed</span>
</template>
<p class="m-0 mb-16 secondary-text">
This shop is non-custodial. You control the wallet seed. Anyone with the seed can spend all funds.
Store it offline and never share it.
</p>
<el-button type="danger" plain :loading="revealingSeed" @click="onRevealSeedClick">
Reveal seed
</el-button>
</el-card>
</template>
<el-dialog v-model="seedDialogVisible" title="Recovery seed" width="560px" destroy-on-close @closed="clearSeed">
<el-alert type="error" :closable="false" show-icon title="Store this offline. Do not share it." />
<el-input v-model="revealedMnemonic" class="mt-16" type="textarea" :rows="4" readonly autocomplete="off" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { computed, onBeforeMount, reactive, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { config } from '@/config';
import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus';
import { useBitcoinWalletStore } from '@/stores/bitcoinWallet';
import { isBitcoinAddress } from '@/utils/bitcoin/isBitcoinAddress';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel';
import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType';
const bitcoinWalletStore = useBitcoinWalletStore();
const {
validation: { bitcoinWithdrawMaxFeeRateSatVbyte: maxBitcoinWithdrawFeeRateSatVbyte }
} = config;
const { status: walletStatus } = storeToRefs(bitcoinWalletStore);
const { fetchStatus, withdrawAll, revealSeed } = bitcoinWalletStore;
const loading = ref(true);
const loadError = ref(false);
const refreshing = ref(false);
const withdrawing = ref(false);
const revealingSeed = ref(false);
const withdrawFormRef = ref<FormInstance>();
const withdrawForm = reactive({
destinationAddress: '',
feeRateSatVbyte: 3
});
const seedDialogVisible = ref(false);
const revealedMnemonic = ref('');
onBeforeMount(async () => {
loading.value = true;
await loadWalletStatus();
loading.value = false;
});
const withdrawFormRules = computed<FormRules>(() => ({
destinationAddress: [
{
validator: (_rule, value, callback) => {
if (walletStatus.value?.syncStatus !== WalletSyncStatus.Synced) {
callback(new Error('Wait until the wallet finishes syncing before withdrawing'));
return;
}
if (typeof value !== 'string' || !value.trim()) {
callback(new Error('Enter a destination address'));
return;
}
const network = walletStatus.value?.network;
if (!network || !isBitcoinAddress(value, network)) {
callback(new Error(`Enter a valid ${network ?? 'Bitcoin'} address.`));
return;
}
callback();
},
trigger: ['blur', 'change']
}
],
feeRateSatVbyte: [
{
validator: (_rule, value, callback) => {
if (!Number.isInteger(value) || value < 1 || value > maxBitcoinWithdrawFeeRateSatVbyte) {
callback(
new Error(`Enter a fee rate between 1 and ${maxBitcoinWithdrawFeeRateSatVbyte} sat/vbyte`)
);
return;
}
callback();
},
trigger: ['blur', 'change']
}
]
}));
const loadWalletStatus = async (): Promise<void> => {
loadError.value = false;
try {
await fetchStatus();
} catch (error) {
loadError.value = true;
ElMessage.error(resolveAxiosErrorMessage(error, 'Failed to load wallet status'));
}
};
const refreshStatus = async (): Promise<void> => {
refreshing.value = true;
try {
await loadWalletStatus();
ElMessage.success('Wallet status refreshed');
} finally {
refreshing.value = false;
}
};
const promptForPassword = async (title: string): Promise<string | null> => {
try {
const { value } = await ElMessageBox.prompt('Enter your CMS password to continue.', title, {
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
inputType: 'password',
inputValidator: value => (value.trim().length > 0 ? true : 'Password is required')
});
return value.trim();
} catch {
return null;
}
};
const onWithdrawSubmit = async (): Promise<void> => {
const formEl = withdrawFormRef.value;
if (!formEl) {
return;
}
try {
await formEl.validate();
} catch {
return;
}
const trimmedAddress = withdrawForm.destinationAddress.trim();
try {
await ElMessageBox.confirm(
`Withdraw all confirmed funds at ${withdrawForm.feeRateSatVbyte} sat/vbyte to:\n${trimmedAddress}`,
'Confirm withdrawal',
{
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
} catch {
return;
}
const password = await promptForPassword('Confirm withdrawal');
if (!password) {
return;
}
withdrawing.value = true;
try {
const result = await withdrawAll({
destinationAddress: trimmedAddress,
feeRateSatVbyte: withdrawForm.feeRateSatVbyte,
password
});
ElMessage.success(`Withdrew ${result.amountBtc} BTC`);
await ElMessageBox.alert(result.txHash, 'Transaction hash', {
confirmButtonText: 'OK'
});
withdrawFormRef.value?.resetFields();
await loadWalletStatus();
} catch (error) {
ElMessage.error({ message: resolveAxiosErrorMessage(error, 'Withdrawal failed'), duration: 5000 });
} finally {
withdrawing.value = false;
}
};
const onRevealSeedClick = async (): Promise<void> => {
try {
await ElMessageBox.confirm(
'The recovery seed grants full control over this shop wallet. Store it offline. Never share it with anyone.',
'Reveal recovery seed?',
{
confirmButtonText: 'I understand',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
} catch {
return;
}
const password = await promptForPassword('Reveal recovery seed');
if (!password) {
return;
}
revealingSeed.value = true;
try {
const result = await revealSeed({ password });
revealedMnemonic.value = result.mnemonic;
seedDialogVisible.value = true;
} catch (error) {
const errorMessage = resolveAxiosErrorMessage(error, 'Could not reveal seed');
ElMessage.error(errorMessage);
} finally {
revealingSeed.value = false;
}
};
const clearSeed = (): void => {
revealedMnemonic.value = '';
};
</script>
<style scoped>
.withdraw-address-input {
width: 100%;
max-width: min(560px, 100%);
}
.fee-rate-input {
width: 120px;
}
</style>

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