Compare commits

...
35 Commits
Author SHA1 Message Date
nobswebdev 155549a2ec Align CMS select dropdowns with their inputs using fit-input-width.
Remove global el-select popper width caps that caused mismatched dropdown sizing on full-width fields.
2026-09-11 21:27:17 +02:00
nobswebdev f1715f7e7f Switch shop nav from Cart to Checkout during an active checkout session.
Show item quantity on both nav states and highlight checkout while the user is on the payment page.
2026-09-11 20:22:22 +02:00
nobswebdev ffc9a0bbe5 Add system theme preference to the storefront.
Default to system when no theme cookie is set so pages follow OS appearance, and expose Light, Dark, and System in the theme switcher.
2026-09-11 19:15:46 +02:00
nobswebdev 77cd686347 Advertise the onion site on clearnet via Onion-Location header. 2026-09-11 15:26:03 +02:00
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
123 changed files with 1911 additions and 362 deletions
+5 -2
View File
@@ -19,6 +19,7 @@ NODE_ENV=development
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
CLEARNET_DOMAIN=localhost
ONION_HOSTNAME=samplexxxxxxxxx.onion
JWT_SECRET=change-me-in-production
JWT_EXPIRES_IN_MS=604800000 # 7 days
@@ -53,6 +54,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
SIGNED_COOKIE_JWT_SECRET=change-me-in-production
@@ -89,7 +91,7 @@ BASE64_ENCRYPTION_KEY="nyRya1KpYSQ+drpO132mkOEMUR+uq6K7tWvpMfppIME=" # Generate
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_NETWORK=stagenet
MONERO_DAEMON_ADDRESS=xmr-lux.boldsuck.org:38081
@@ -103,7 +105,7 @@ MONERO_WALLET_NAME=shop
MONERO_WALLET_PASSWORD=change-me
MONERO_MIN_INCOMING_ATOMIC=10000000 # 0.00001 XMR (~half a USD cent at that moment)
BITCOIN_CONFIRMATION_TIERS='[{"upToTotalFiat":"30","minConfirmations":0},{"upToTotalFiat":"100","minConfirmations":1},{"upToTotalFiat":"300","minConfirmations":3},{"minConfirmations":6}]'
BITCOIN_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)
ELECTRUM_VERSION=4.8.1
@@ -146,4 +148,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
+3 -1
View File
@@ -103,7 +103,8 @@ export const getAppConfig = (): AppConfig => {
digitalStockAttachmentsMax: envInt('VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'),
shippingNoteMinLength: envInt('VALIDATION_SHIPPING_NOTE_MIN_LENGTH'),
shippingNoteMaxLength: envInt('VALIDATION_SHIPPING_NOTE_MAX_LENGTH'),
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH')
orderMessageMaxLength: envInt('VALIDATION_ORDER_MESSAGE_MAX_LENGTH'),
bitcoinWithdrawMaxFeeRateSatVbyte: envInt('VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE')
}
};
};
@@ -218,6 +219,7 @@ export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
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')
});
@@ -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');
+10
View File
@@ -153,6 +153,12 @@ class EnvironmentVariables {
@Max(10000)
VALIDATION_ORDER_MESSAGE_MAX_LENGTH: number;
@IsNotEmpty()
@IsNumber()
@Min(1)
@Max(1000)
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: number;
@IsNotEmpty()
@IsNumber()
@Min(4)
@@ -374,6 +380,10 @@ class EnvironmentVariables {
@Min(1000)
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
@IsNotEmpty()
@IsString()
ELECTRUM_WALLET_PASSWORD: string;
@IsNotEmpty()
@IsString()
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`
};
@@ -0,0 +1 @@
export const STOREFRONT_THEME_PREFERENCES = ['light', 'dark', 'system'] as const;
+13 -3
View File
@@ -5,6 +5,12 @@ import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser';
import hbs from 'hbs';
import path from 'node:path';
import {
getStorefrontCssDir,
getStorefrontImgDir,
storefrontCssUrlPrefix,
storefrontImgUrlPrefix
} from './config/storefrontAssetPaths';
import { getPublicUploadsDir, publicUploadsUrlPrefix } from './config/uploadPaths';
import { AppModule } from './AppModule';
import { registerStorefrontHelpers } from './modules/storefrontCore/utils/registerStorefrontHelpers';
@@ -26,10 +32,14 @@ async function bootstrap() {
immutable: true
});
const storefrontPublicDir = path.join(__dirname, 'modules', 'storefrontCore', 'public');
app.useStaticAssets(getStorefrontImgDir(), {
prefix: storefrontImgUrlPrefix,
maxAge: '1y',
immutable: true
});
app.useStaticAssets(storefrontPublicDir, {
prefix: '/shop/assets'
app.useStaticAssets(getStorefrontCssDir(), {
prefix: storefrontCssUrlPrefix
});
app.use(cookieParser());
@@ -1,10 +1,12 @@
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]
@@ -1,5 +1,9 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
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')
@@ -11,4 +15,16 @@ export class BitcoinWalletController {
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;
}
@@ -1,6 +1,7 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
import type { AuthService } from '../../auth/services/AuthService';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
@@ -11,6 +12,11 @@ describe('BitcoinWalletAdminService', () => {
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;
@@ -27,7 +33,16 @@ describe('BitcoinWalletAdminService', () => {
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 = {
@@ -38,6 +53,7 @@ describe('BitcoinWalletAdminService', () => {
service = new BitcoinWalletAdminService(
walletRpcClient as unknown as ElectrumWalletRpcClient,
authService as unknown as AuthService,
configService as unknown as ConfigService
);
});
@@ -51,7 +67,7 @@ describe('BitcoinWalletAdminService', () => {
rpcVersion: '4.8.1',
blockHeight: 900_000,
serverHeight: 900_000,
syncStatus: BitcoinWalletSyncStatus.Synced,
syncStatus: WalletSyncStatus.Synced,
balanceBtc: '0.00150000',
confirmedBalanceBtc: '0.00150000'
})
@@ -63,7 +79,7 @@ describe('BitcoinWalletAdminService', () => {
const status = await service.getStatus();
expect(status.syncStatus).toBe(BitcoinWalletSyncStatus.Syncing);
expect(status.syncStatus).toBe(WalletSyncStatus.Syncing);
});
it('throws when RPC calls fail', async () => {
@@ -71,4 +87,61 @@ describe('BitcoinWalletAdminService', () => {
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.'
)
);
});
});
@@ -1,15 +1,19 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
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 { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
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
) {}
@@ -43,19 +47,77 @@ export class BitcoinWalletAdminService {
}
}
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
): BitcoinWalletSyncStatus {
): WalletSyncStatus {
if (isSynchronized) {
return BitcoinWalletSyncStatus.Synced;
return WalletSyncStatus.Synced;
}
if (blockHeight === null || serverHeight === null) {
return BitcoinWalletSyncStatus.Unknown;
return WalletSyncStatus.Unknown;
}
return BitcoinWalletSyncStatus.Syncing;
return WalletSyncStatus.Syncing;
}
}
@@ -17,6 +17,7 @@ describe('ElectrumWalletRpcClient', () => {
rpcUrl: 'http://electrum.test:7777',
username: 'electrum',
password: 'secret',
walletPassword: 'wallet-secret',
rpcTimeoutMs: 5000
})
} as unknown as ConfigService);
@@ -33,13 +34,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123',
height: 800_000
},
'50000',
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3
confirmations: 3,
inputOutpoints: []
});
});
@@ -50,13 +55,17 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123',
height: 0
},
'50000',
{
outputs: [{ address: 'bc1qtest', value_sats: 50_000 }]
},
'bc1qtest',
800_002
)
).toEqual({
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 0
confirmations: 0,
inputOutpoints: []
});
});
@@ -67,17 +76,67 @@ describe('ElectrumWalletRpcClient', () => {
tx_hash: 'abc123',
height: 800_000
},
'0',
{
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']
});
});
describe('sumIncomingOutputValueAtomic', () => {
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.sumIncomingOutputValueAtomic(
clientTest.sumOutputValueAtomic(
{
outputs: [
{ address: 'bc1qother', value_sats: 10_000 },
@@ -92,7 +151,7 @@ describe('ElectrumWalletRpcClient', () => {
it('returns zero when no outputs match the address', () => {
expect(
clientTest.sumIncomingOutputValueAtomic(
clientTest.sumOutputValueAtomic(
{
outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
},
@@ -124,6 +183,7 @@ describe('ElectrumWalletRpcClient', () => {
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 }
@@ -136,7 +196,8 @@ describe('ElectrumWalletRpcClient', () => {
{
txHash: 'abc123',
amountAtomic: '50000',
confirmations: 3
confirmations: 3,
inputOutpoints: ['input123:0']
}
]);
@@ -163,6 +224,105 @@ describe('ElectrumWalletRpcClient', () => {
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', () => {
@@ -208,4 +368,80 @@ describe('ElectrumWalletRpcClient', () => {
});
});
});
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)
);
});
});
});
@@ -88,6 +88,85 @@ export class ElectrumWalletRpcClient {
};
}
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');
@@ -120,40 +199,27 @@ export class ElectrumWalletRpcClient {
}
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.deserializeTransaction(serializedTransaction);
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('deserialize', {
tx: serializedTransaction
});
const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address);
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
return this.mapIncomingTransfer(entry, transaction, address, blockHeight);
})
);
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null);
}
const resolvedTransfers = transfers.filter(
(transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null
);
private sumIncomingOutputValueAtomic(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');
return this.filterSupersededBitcoinTransfers(resolvedTransfers);
}
private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string,
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;
@@ -165,7 +231,60 @@ export class ElectrumWalletRpcClient {
return {
txHash,
amountAtomic,
confirmations
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,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -1,12 +1,12 @@
import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
import { BitcoinWalletSyncStatus } from './BitcoinWalletSyncStatus';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
export interface BitcoinWalletStatusView {
network: ElectrumNetwork;
rpcVersion: string;
blockHeight: number | null;
serverHeight: number | null;
syncStatus: BitcoinWalletSyncStatus;
syncStatus: WalletSyncStatus;
balanceBtc: string;
confirmedBalanceBtc: string;
}
@@ -1,5 +0,0 @@
export enum BitcoinWalletSyncStatus {
Synced = 'synced',
Syncing = 'syncing',
Unknown = 'unknown'
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
@@ -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;
};
@@ -1,8 +1,7 @@
export type ElectrumWalletDeserializedOutput = {
address?: string;
value_sats: number;
};
import type { ElectrumWalletDeserializedInput } from './ElectrumWalletDeserializedInput';
import type { ElectrumWalletDeserializedOutput } from './ElectrumWalletDeserializedOutput';
export type ElectrumWalletDeserializedTransaction = {
inputs?: ElectrumWalletDeserializedInput[];
outputs: ElectrumWalletDeserializedOutput[];
};
@@ -2,4 +2,5 @@ export type ElectrumWalletIncomingTransfer = {
txHash: string;
amountAtomic: string;
confirmations: number;
inputOutpoints: string[];
};
@@ -5,8 +5,12 @@ import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTra
export type ElectrumWalletRpcClientTest = {
mapIncomingTransfer: (
entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string,
transaction: ElectrumWalletDeserializedTransaction,
address: string,
blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null;
sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
filterSupersededBitcoinTransfers: (
transfers: ElectrumWalletIncomingTransfer[]
) => ElectrumWalletIncomingTransfer[];
sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
};
@@ -18,8 +18,8 @@ export class MoneroWalletController {
@Post('/withdraw')
@Throttle(throttleProfiles.walletWithdraw)
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, password);
withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, priority, password);
}
@Post('/reveal-seed')
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString } from 'class-validator';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
export class MoneroWalletWithdrawDto {
@@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto {
@IsMoneroStandardAddress()
destinationAddress: string;
@IsNotEmpty()
@IsEnum(MoneroWithdrawPriority)
priority: MoneroWithdrawPriority;
@IsString()
@IsNotEmpty()
password: string;
@@ -2,7 +2,8 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
import type { ConfigService } from '@nestjs/config';
import axios from 'axios';
import type { AuthService } from '../../auth/services/AuthService';
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
@@ -76,7 +77,7 @@ describe('MoneroWalletAdminService', () => {
rpcVersion: '0.18.3.1',
walletHeight: 3_000_000,
daemonHeight: 3_000_000,
syncStatus: MoneroWalletSyncStatus.Synced,
syncStatus: WalletSyncStatus.Synced,
balanceXmr: '2.00000000',
unlockedBalanceXmr: '1.00000000'
})
@@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => {
unlockedBalanceAtomic: '0'
});
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
new BadRequestException('No unlocked balance to withdraw.')
);
await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.'));
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
@@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => {
it('rejects withdrawals while the wallet is still syncing', async () => {
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
new BadRequestException('Wallet is still syncing. Try again after sync completes.')
);
await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(new BadRequestException('Wallet is still syncing. Try again after sync completes.'));
});
it('sweeps unlocked funds when the wallet is synced', async () => {
const result = await service.withdrawAll('4DestinationAddressExample', 'password');
const result = await service.withdrawAll(
'4DestinationAddressExample',
MoneroWithdrawPriority.Fast,
'password'
);
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith(
'4DestinationAddressExample',
MoneroWithdrawPriority.Fast
);
expect(result).toEqual({
txHashes: ['tx-hash-1'],
amountXmr: '1.00000000'
@@ -137,7 +145,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus();
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
expect(status.syncStatus).toBe(WalletSyncStatus.Unknown);
expect(status.daemonHeight).toBeNull();
});
@@ -149,7 +157,7 @@ describe('MoneroWalletAdminService', () => {
const status = await service.getStatus();
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
expect(status.syncStatus).toBe(WalletSyncStatus.Synced);
});
it('throws when reveal seed RPC fails', async () => {
@@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => {
it('throws when sweep all fails after prechecks pass', async () => {
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(
new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
)
@@ -7,7 +7,8 @@ import type { Config } from '../../../types/Config';
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
@@ -49,7 +50,11 @@ export class MoneroWalletAdminService {
}
}
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> {
async withdrawAll(
destinationAddress: string,
priority: MoneroWithdrawPriority,
password: string
): Promise<MoneroWalletWithdrawResult> {
this.authService.verifyPassword(password);
await this.walletRpcClient.tryRefresh();
@@ -59,11 +64,15 @@ export class MoneroWalletAdminService {
let daemonHeight: number | null;
try {
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([
this.walletRpcClient.getBalance(),
this.walletRpcClient.getHeight(),
this.fetchDaemonHeight()
]);
unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic;
walletHeight = walletHeightResult;
daemonHeight = daemonHeightResult;
} catch {
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
}
@@ -72,7 +81,7 @@ export class MoneroWalletAdminService {
throw new BadRequestException('No unlocked balance to withdraw.');
}
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== WalletSyncStatus.Synced) {
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
}
@@ -80,7 +89,10 @@ export class MoneroWalletAdminService {
let amountAtomic: string;
try {
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority);
txHashes = sweepResult.txHashes;
amountAtomic = sweepResult.amountAtomic;
} catch {
throw new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
@@ -125,11 +137,11 @@ export class MoneroWalletAdminService {
}
}
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): WalletSyncStatus {
if (daemonHeight === null) {
return MoneroWalletSyncStatus.Unknown;
return WalletSyncStatus.Unknown;
}
return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
return walletHeight >= daemonHeight - 1 ? WalletSyncStatus.Synced : WalletSyncStatus.Syncing;
}
}
@@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
@@ -222,14 +223,17 @@ export class MoneroWalletRpcClient {
};
}
async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> {
async sweepAll(
destinationAddress: string,
priority: MoneroWithdrawPriority
): Promise<{ txHashes: string[]; amountAtomic: string }> {
const result = await this.call<MoneroWalletRpcSweepAllResult>(
'sweep_all',
{
address: destinationAddress,
account_index: this.accountIndex,
subaddr_indices_all: true,
priority: 1
priority
},
{ timeoutMs: 120_000 }
);
@@ -1,12 +1,12 @@
import { MoneroNetwork } from '../../../types/MoneroNetwork';
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
export interface MoneroWalletStatusView {
network: MoneroNetwork;
rpcVersion: string;
walletHeight: number;
daemonHeight: number | null;
syncStatus: MoneroWalletSyncStatus;
syncStatus: WalletSyncStatus;
balanceXmr: string;
unlockedBalanceXmr: string;
}
@@ -260,10 +260,6 @@ export class OrderService {
amountFiat: deliveryCost
});
if (!shippingInvoice) {
throw new InternalServerErrorException('Failed to issue shipping invoice');
}
await this.orderRepo.update(orderId, {
shippingInvoice: { id: shippingInvoice.id },
quotedAt
@@ -87,6 +87,7 @@ describe('InvoicePaymentService', () => {
};
let paymentRepo: {
update: jest.Mock;
delete: jest.Mock;
createQueryBuilder: jest.Mock;
};
let insertQueryBuilder: {
@@ -133,6 +134,7 @@ describe('InvoicePaymentService', () => {
paymentRepo = {
update: jest.fn().mockResolvedValue(undefined),
delete: jest.fn().mockResolvedValue(undefined),
createQueryBuilder: jest.fn().mockReturnValue(insertQueryBuilder)
};
@@ -384,13 +386,35 @@ describe('InvoicePaymentService', () => {
expect(paymentRepo.update).not.toHaveBeenCalled();
});
it('does nothing when there are no transfers to process', async () => {
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 () => {
@@ -528,5 +552,34 @@ describe('InvoicePaymentService', () => {
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();
});
});
});
@@ -135,8 +135,6 @@ export class InvoicePaymentService {
}
private async processInvoice(invoiceId: string, transfers: InvoiceIncomingTransfer[]): Promise<void> {
const { minByMethod } = this.configService.get('invoice') as Config['invoice'];
await this.dataSource.transaction(async manager => {
const invoiceRepo = manager.getRepository(Invoice);
const paymentRepo = manager.getRepository(InvoicePayment);
@@ -152,6 +150,19 @@ export class InvoicePaymentService {
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 knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
@@ -174,7 +185,7 @@ export class InvoicePaymentService {
.createQueryBuilder()
.insert()
.values({
invoice: { id: invoiceId },
invoice: { id: invoice.id },
txHash: transfer.txHash,
amountAtomic: transfer.amountAtomic,
confirmations: transfer.confirmations
@@ -182,6 +193,19 @@ export class InvoicePaymentService {
.orIgnore()
.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,4 +1,4 @@
import { Logger, ServiceUnavailableException } from '@nestjs/common';
import { Logger, InternalServerErrorException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import type { ElectrumWalletRpcClient } from '../../bitcoinWallet/services/ElectrumWalletRpcClient';
@@ -301,4 +301,15 @@ describe('InvoiceService', () => {
)
);
});
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,4 +1,4 @@
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
import { Injectable, InternalServerErrorException, Logger, ServiceUnavailableException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ConfigService } from '@nestjs/config';
import { Repository } from 'typeorm';
@@ -32,12 +32,14 @@ export class InvoiceService {
private readonly exchangeRateService: ExchangeRateService
) {}
async issueInvoice(data: IssueInvoiceData): Promise<Invoice | undefined> {
async issueInvoice(data: IssueInvoiceData): Promise<Invoice> {
switch (data.paymentMethod) {
case PaymentMethod.Xmr:
return this.issueXmrInvoice(data);
case PaymentMethod.Btc:
return this.issueBtcInvoice(data);
default:
throw new InternalServerErrorException(`Unsupported payment method: ${String(data.paymentMethod)}`);
}
}
@@ -4,6 +4,7 @@ import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { throttleProfiles } from '../../../config/throttleProfiles';
import type { Config } from '../../../types/Config';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { StorefrontExceptionFilter } from '../../storefrontCore/filters/StorefrontExceptionFilter';
import { StorefrontCartCookieService } from '../../storefrontCore/services/StorefrontCartCookieService';
@@ -70,7 +71,8 @@ export class StorefrontCartController {
const paymentMethods = enabledPaymentMethods.map(paymentMethod => ({
paymentMethod,
paymentMethodLabel: paymentMethodLabel[paymentMethod]
paymentMethodLabel: paymentMethodLabel[paymentMethod],
iconUrl: paymentMethodIconUrl[paymentMethod]
}));
return res.render('cart-summary', {
@@ -98,7 +100,7 @@ export class StorefrontCartController {
text: 'Added to cart.'
});
res.redirect(HttpStatus.FOUND, safeInternalShopRedirectPath(req));
res.redirect(HttpStatus.FOUND, this.buildAddToCartRedirectPath(req, variantId));
}
@Post('shop/cart/product/update')
@@ -187,4 +189,18 @@ export class StorefrontCartController {
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}`;
}
}
@@ -56,7 +56,7 @@ describe('StorefrontCheckoutViewService', () => {
let toStorefrontInvoiceViewSpy: jest.SpiedFunction<typeof toStorefrontInvoiceViewModule.toStorefrontInvoiceView>;
const checkoutInvoiceView = {
cryptoCurrency: 'XMR',
paymentLabel: 'XMR',
amountFiat: 9
} as unknown as StorefrontInvoiceView;
@@ -1,8 +1,9 @@
import { IsIn, IsNotEmpty } from 'class-validator';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
export class SetThemePreferenceDto {
@IsNotEmpty()
@IsIn(['light', 'dark'])
@IsIn(STOREFRONT_THEME_PREFERENCES)
theme: StorefrontThemePreference;
}
@@ -49,6 +49,7 @@
--sf-split-sidebar: minmax(220px, 320px);
--sf-product-card-min: 11rem;
--sf-product-card-max: 22rem;
--sf-sticky-header-scroll-padding: 9rem;
}
@media (prefers-color-scheme: dark) {
@@ -158,6 +159,10 @@
box-sizing: border-box;
}
html {
scroll-padding-top: var(--sf-sticky-header-scroll-padding);
}
body.sf-body {
margin: 0;
min-height: 100vh;
@@ -288,9 +293,13 @@ a:hover {
}
.sf-header {
position: sticky;
top: 0;
z-index: 10;
margin-bottom: var(--sf-space-4);
padding: var(--sf-space-3) 0;
border-bottom: 1px solid var(--sf-border);
background: var(--sf-bg);
}
.sf-header__inner {
@@ -355,9 +364,51 @@ a:hover {
gap: var(--sf-space-1);
}
.sf-rates {
display: flex;
flex-wrap: wrap;
gap: var(--sf-space-3);
align-items: center;
}
.sf-rate {
display: inline-flex;
align-items: center;
gap: var(--sf-space-1);
font-size: 0.95rem;
color: var(--sf-text-muted);
white-space: nowrap;
}
@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 {
@@ -488,6 +539,76 @@ a:hover {
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 {
display: inline-flex;
align-items: center;
@@ -767,7 +888,7 @@ a:hover {
margin: 0;
}
.sf-line-item__body > .sf-discount-error {
.sf-line-item__body > .sf-cart-issue {
margin: 0.125rem 0 0;
}
@@ -844,8 +965,8 @@ a:hover {
color: var(--sf-text-muted);
}
.sf-discount-error {
margin: 0 0 var(--sf-space-2);
.sf-cart-issue {
margin: var(--sf-space-2) 0;
font-size: 0.85rem;
color: var(--sf-error);
line-height: 1.35;
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -7,40 +7,24 @@ import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontShopViewServiceOverrides } from '../types/StorefrontShopViewServiceTestTypes';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
describe('StorefrontShopViewService', () => {
type ServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
theme?: 'light' | 'dark';
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
const defaultPage: StorefrontPageMetaInput = {
title: 'Cart',
metaDescription: 'Review your cart.'
};
const createService = (overrides: ServiceOverrides = {}) => {
const createService = (overrides: StorefrontShopViewServiceOverrides = {}) => {
const {
cart = [],
feedback,
authorizedOrderIds = [],
theme,
checkoutSessionId,
theme = 'system',
branding = {
logoUrl: null,
faviconUrl: null,
@@ -80,6 +64,9 @@ describe('StorefrontShopViewService', () => {
const themeCookieService = {
getTheme: jest.fn().mockReturnValue(theme)
} as unknown as StorefrontThemeCookieService;
const checkoutSessionCookieService = {
getSessionId: jest.fn().mockReturnValue(checkoutSessionId)
} as unknown as StorefrontCheckoutSessionCookieService;
const service = new StorefrontShopViewService(
exchangeRateService,
@@ -88,7 +75,8 @@ describe('StorefrontShopViewService', () => {
cartCookieService,
feedbackCookieService,
orderAuthCookieService,
themeCookieService
themeCookieService,
checkoutSessionCookieService
);
const req = {
@@ -117,6 +105,23 @@ describe('StorefrontShopViewService', () => {
ogImageUrl: null,
productJsonLd: null
});
expect(locals.themePreference).toBe('system');
expect(locals.hasActiveCheckout).toBe(false);
});
it('exposes active checkout state from the checkout session cookie', async () => {
const { service, req, res } = createService({
checkoutSessionId: 'session-1',
req: { path: '/shop/checkout', originalUrl: '/shop/checkout' }
});
const locals = await service.buildShopRenderLocals(req, res, {
title: 'Checkout',
metaDescription: 'Complete your purchase at {shopName}.'
});
expect(locals.hasActiveCheckout).toBe(true);
expect(locals.shopNavActive).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
});
it('exposes shop settings, cart qty, feedback, branding, theme, and request context', async () => {
@@ -145,8 +150,8 @@ describe('StorefrontShopViewService', () => {
feedback: { type: 'success', text: 'Added to cart' },
shopFiatCurrency: 'USD',
cryptoRates: [
{ cryptoCurrency: 'XMR', fiatPerCrypto: 200 },
{ cryptoCurrency: 'BTC', fiatPerCrypto: 80_000 }
{ paymentLabel: 'XMR', fiatPerCrypto: 200, iconUrl: '/shop/assets/img/xmr.png' },
{ paymentLabel: 'BTC', fiatPerCrypto: 80_000, iconUrl: '/shop/assets/img/btc.png' }
],
logoUrl: '/uploads/logo.png',
faviconUrl: '/uploads/favicon.ico',
@@ -8,6 +8,7 @@ import { formatShortOrderId } from '../../../utils/order/formatShortOrderId';
import { toAbsoluteUrl } from '../../../utils/toAbsoluteUrl';
import { ShopSettingsService } from '../../shopSettings/services/ShopSettingsService';
import { ExchangeRateService } from '../../exchangeRate/services/ExchangeRateService';
import { paymentMethodIconUrl } from '../../../consts/paymentMethodIconUrl';
import { paymentMethodLabel } from '../../../consts/paymentMethodLabel';
import { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { AuthorizedOrderNavItem } from '../types/AuthorizedOrderNavItem';
@@ -18,6 +19,7 @@ import type { StorefrontPageMetaInput } from '../types/StorefrontPageMetaInput';
import type { StorefrontProductJsonLdInput } from '../types/StorefrontProductJsonLdInput';
import { resolveShopNavActive } from '../utils/resolveShopNavActive';
import { StorefrontCartCookieService } from './StorefrontCartCookieService';
import { StorefrontCheckoutSessionCookieService } from './StorefrontCheckoutSessionCookieService';
import { StorefrontFeedbackCookieService } from './StorefrontFeedbackCookieService';
import { StorefrontOrderAuthCookieService } from './StorefrontOrderAuthCookieService';
import { StorefrontThemeCookieService } from './StorefrontThemeCookieService';
@@ -31,7 +33,8 @@ export class StorefrontShopViewService {
private readonly cartCookieService: StorefrontCartCookieService,
private readonly feedbackCookieService: StorefrontFeedbackCookieService,
private readonly orderAuthCookieService: StorefrontOrderAuthCookieService,
private readonly themeCookieService: StorefrontThemeCookieService
private readonly themeCookieService: StorefrontThemeCookieService,
private readonly checkoutSessionCookieService: StorefrontCheckoutSessionCookieService
) {}
async buildShopRenderLocals(req: Request, res: Response, page: StorefrontPageMetaInput): Promise<ShopRenderLocals> {
@@ -40,6 +43,7 @@ export class StorefrontShopViewService {
const themePreference = this.themeCookieService.getTheme(req, res);
const cartTotalQty = getTotalCartQtyFromCart(cart);
const hasActiveCheckout = Boolean(this.checkoutSessionCookieService.getSessionId(req, res));
const { shopName, shopFiatCurrency, enabledPaymentMethods } = this.configService.get(
'shopSettings'
@@ -61,6 +65,7 @@ export class StorefrontShopViewService {
title: page.title,
shopName,
cartTotalQty,
hasActiveCheckout,
feedback,
authorizedOrders,
shopNavActive,
@@ -145,8 +150,9 @@ export class StorefrontShopViewService {
return [
{
cryptoCurrency: paymentMethodLabel[paymentMethod],
fiatPerCrypto
paymentLabel: paymentMethodLabel[paymentMethod],
fiatPerCrypto,
iconUrl: paymentMethodIconUrl[paymentMethod]
}
];
});
@@ -14,10 +14,16 @@ describe('StorefrontThemeCookieService', () => {
service = new StorefrontThemeCookieService(signedCookies as unknown as StorefrontSignedCookieService);
});
it('returns undefined for invalid theme values', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
it('returns system when no cookie is set', () => {
signedCookies.getSignedCookie.mockReturnValue(undefined);
expect(service.getTheme({} as never, {} as never)).toBeUndefined();
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
it('returns system for invalid theme values', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'sepia' });
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
it('returns a supported theme preference', () => {
@@ -25,4 +31,10 @@ describe('StorefrontThemeCookieService', () => {
expect(service.getTheme({} as never, {} as never)).toBe('dark');
});
it('returns system when stored in the cookie', () => {
signedCookies.getSignedCookie.mockReturnValue({ theme: 'system' });
expect(service.getTheme({} as never, {} as never)).toBe('system');
});
});
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import type { Request, Response } from 'express';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
import type { StorefrontThemePreference } from '../types/StorefrontThemePreference';
import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
@@ -7,15 +8,15 @@ import { StorefrontSignedCookieService } from './StorefrontSignedCookieService';
export class StorefrontThemeCookieService {
constructor(private readonly signedCookies: StorefrontSignedCookieService) {}
getTheme(req: Request, res: Response): StorefrontThemePreference | undefined {
const payload = this.signedCookies.getSignedCookie<{ theme: string }>(req, res, 'theme');
getTheme(req: Request, res: Response): StorefrontThemePreference {
const payload = this.signedCookies.getSignedCookie<{ theme: StorefrontThemePreference }>(req, res, 'theme');
const theme = payload?.theme;
if (theme === 'light' || theme === 'dark') {
if (theme && STOREFRONT_THEME_PREFERENCES.includes(theme)) {
return theme;
}
return undefined;
return 'system';
}
setTheme(req: Request, res: Response, theme: StorefrontThemePreference): void {
@@ -1 +1 @@
export type ShopNavKey = 'shop' | 'cart' | 'check-order';
export type ShopNavKey = 'shop' | 'cart' | 'checkout' | 'check-order';
@@ -10,6 +10,7 @@ export type ShopRenderLocals = {
title: string;
shopName: string;
cartTotalQty: number;
hasActiveCheckout: boolean;
feedback: StorefrontFeedback | undefined;
authorizedOrders: AuthorizedOrderNavItem[];
shopNavActive: ShopNavActive;
@@ -19,6 +20,6 @@ export type ShopRenderLocals = {
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
themePreference: StorefrontThemePreference | undefined;
themePreference: StorefrontThemePreference;
pageMeta: StorefrontPageMeta;
};
@@ -1,4 +1,5 @@
export type StorefrontCryptoRate = {
cryptoCurrency: string;
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';
export type StorefrontInvoiceView = {
cryptoCurrency: string;
paymentLabel: string;
expectedTotalCrypto: string;
receivedTotalCrypto: string | null;
paymentAddress: string;
@@ -0,0 +1,23 @@
import type { Request } from 'express';
import type { PaymentMethod } from '../../payment/types/PaymentMethod';
import type { StorefrontThemePreference } from './StorefrontThemePreference';
export type StorefrontShopViewServiceOverrides = {
cart?: { variantId: string; qty: number }[];
feedback?: { type: 'success'; text: string };
authorizedOrderIds?: string[];
checkoutSessionId?: string;
theme?: StorefrontThemePreference;
branding?: {
logoUrl: string | null;
faviconUrl: string | null;
simplexLink: string | null;
shippingNote: string | null;
};
shopSettings?: { shopName: string; shopFiatCurrency: string; enabledPaymentMethods?: PaymentMethod[] };
fiatPerXmr?: number;
fiatPerBtc?: number | null;
req?: Partial<Pick<Request, 'protocol' | 'path' | 'originalUrl'>> & {
host?: string;
};
};
@@ -1 +1,3 @@
export type StorefrontThemePreference = 'light' | 'dark';
import { STOREFRONT_THEME_PREFERENCES } from '../../../consts/storefrontThemePreferences';
export type StorefrontThemePreference = (typeof STOREFRONT_THEME_PREFERENCES)[number];
@@ -29,6 +29,7 @@ const STOREFRONT_PARTIALS = [
{ name: 'shop-footer', file: 'shop-footer.hbs' },
{ name: 'storefront-image', file: 'storefront-image.hbs' },
{ name: 'theme-switcher', file: 'theme-switcher.hbs' },
{ name: 'html-theme-attributes', file: 'html-theme-attributes.hbs' },
{ name: 'page-back-link', file: 'page-back-link.hbs' },
{ name: 'product-card', file: 'product-card.hbs' },
{ name: 'order-data-retention-notice', file: 'order-data-retention-notice.hbs' },
@@ -13,8 +13,9 @@ describe('resolveShopNavActive', () => {
});
});
it('highlights cart and check-order pages', () => {
it('highlights cart, checkout, and check-order pages', () => {
expect(resolveShopNavActive('/shop/cart')).toEqual({ activeShopNav: 'cart', activeOrderId: null });
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: 'checkout', activeOrderId: null });
expect(resolveShopNavActive('/shop/check-order')).toEqual({
activeShopNav: 'check-order',
activeOrderId: null
@@ -29,6 +30,6 @@ describe('resolveShopNavActive', () => {
});
it('does not highlight nav items on unrelated pages', () => {
expect(resolveShopNavActive('/shop/checkout')).toEqual({ activeShopNav: null, activeOrderId: null });
expect(resolveShopNavActive('/shop/theme')).toEqual({ activeShopNav: null, activeOrderId: null });
});
});
@@ -11,6 +11,10 @@ export const resolveShopNavActive = (path: string): ShopNavActive => {
return { activeShopNav: 'cart', activeOrderId: null };
}
if (first === 'shop' && second === 'checkout') {
return { activeShopNav: 'checkout', activeOrderId: null };
}
if (first === 'shop' && second === 'check-order') {
return { activeShopNav: 'check-order', activeOrderId: null };
}
@@ -23,14 +23,14 @@
<p class='sf-payment-status sf-payment-status--confirmed'>Payment received</p>
<p class='sf-payment-detail'>
Received:
<strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.cryptoCurrency}}</strong>
<strong>{{checkout.checkoutInvoice.receivedTotalCrypto}} {{checkout.checkoutInvoice.paymentLabel}}</strong>
</p>
<p class='sf-payment-detail'>
Your order is being prepared. This page will redirect automatically once it is ready.
</p>
{{> refresh-link href=checkout.refreshHref showAutoRefreshNote=true}}
{{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}}
{{> invoice-payment refreshHref=../checkout.refreshHref showAutoRefreshNote=true}}
{{/with}}
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
{{> html-theme-attributes}}
>
<head>
{{> shop-head}}
@@ -1,8 +1,7 @@
<!DOCTYPE html>
<html
lang='en'
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
{{> html-theme-attributes}}
>
<head>
{{> shop-head}}
@@ -12,10 +11,10 @@
{{> shop-nav}}
{{> category-nav}}
<main class='sf-main'>
{{> feedback}}
{{{body}}}
</main>
{{> shop-footer}}
</div>
{{> feedback}}
</body>
</html>
@@ -28,7 +28,7 @@
{{@root.shopFiatCurrency}}</div>
<div class='sf-line-item__note'>{{> product-delivery-note deliveryMode=deliveryMode}}</div>
{{#if stockIssueMessage}}
<p class='sf-discount-error'>{{stockIssueMessage}}</p>
<p class='sf-cart-issue'>{{stockIssueMessage}}</p>
{{else}}
{{#unless stockForSession}}
<p class='sf-text-subtle'>No more in stock beside your order</p>
@@ -36,7 +36,7 @@
{{/if}}
{{#each @root.discounts}}
{{#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}}
{{/each}}
<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>
</div>
<p class='sf-discount-error'>{{issueMessage}}</p>
<p class='sf-cart-issue'>{{issueMessage}}</p>
{{else if amount}}
<div class='sf-discount-row sf-discount-item'>
{{> dismiss-button
@@ -62,7 +62,7 @@
</form>
{{#if cartTotalIssueMessage}}
<p class='sf-discount-error'>{{cartTotalIssueMessage}}</p>
<p class='sf-cart-issue'>{{cartTotalIssueMessage}}</p>
{{/if}}
<div class='sf-stack sf-mt-4'>
@@ -80,9 +80,16 @@
type='submit'
name='paymentMethod'
value='{{paymentMethod}}'
class='sf-btn sf-btn--primary'
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>
@@ -1,5 +1,5 @@
{{#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}}
</p>
{{/if}}
@@ -0,0 +1,2 @@
{{#ifeq themePreference 'light'}}data-theme='light'{{/ifeq}}
{{#ifeq themePreference 'dark'}}data-theme='dark'{{/ifeq}}
@@ -3,27 +3,27 @@
{{/if}}
{{#if showProminentAmount}}
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{cryptoCurrency}}</p>
<p class='sf-payment-amount'>{{expectedTotalCrypto}} {{paymentLabel}}</p>
{{/if}}
{{#if showReceivedTotal}}
<p class='sf-payment-detail'>
Received:
<strong>{{receivedTotalCrypto}} {{cryptoCurrency}}</strong>
<strong>{{receivedTotalCrypto}} {{paymentLabel}}</strong>
</p>
{{/if}}
{{#if showExpectedTotal}}
<p class='sf-payment-detail'>
Expected total:
<strong>{{expectedTotalCrypto}} {{cryptoCurrency}}</strong>
<strong>{{expectedTotalCrypto}} {{paymentLabel}}</strong>
</p>
{{/if}}
{{#if showInstruction}}
<p class='sf-payment-detail'>
{{instructionPrefix}}
<strong>{{instructionAmountCrypto}} {{cryptoCurrency}}</strong>
<strong>{{instructionAmountCrypto}} {{paymentLabel}}</strong>
{{instructionSuffix}}
{{#if showExpiry}}
Payment expires in
@@ -34,7 +34,7 @@
{{#if showQr}}
<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>
{{/if}}
@@ -48,7 +48,7 @@
<ul class='sf-list-reset sf-tx-list'>
{{#each payments}}
<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-tx-status sf-tx-status--{{confirmationStatusVariant}}'>{{confirmationStatus}}</div>
</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}}
{{#if thumbnailUrl}}
<a class='sf-product-card__media-link' href='{{detailHref}}'>
@@ -2,7 +2,7 @@
<div class='sf-footer__row'>
<div class='sf-footer__start'>
{{#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}}
<a href='https://nullcart.net/' target='_blank' rel='noopener noreferrer'>Powered by NullCart</a>
</div>
@@ -17,7 +17,11 @@
<nav class='sf-nav' aria-label='Shop navigation'>
{{> nav-link active=shopNavActive.activeShopNav key='shop' href='/' label='Shop'}}
{{#if hasActiveCheckout}}
{{> nav-link active=shopNavActive.activeShopNav key='checkout' href='/shop/checkout' label='Checkout' count=cartTotalQty}}
{{else}}
{{> nav-link active=shopNavActive.activeShopNav key='cart' href='/shop/cart' label='Cart' count=cartTotalQty}}
{{/if}}
{{> nav-link active=shopNavActive.activeShopNav key='check-order' href='/shop/check-order' label='Check order'}}
{{#each authorizedOrders}}
@@ -32,7 +36,16 @@
{{#if cryptoRates.length}}
<div class='sf-rates'>
{{#each cryptoRates}}
<div class='sf-rate'>1 {{cryptoCurrency}} = {{fiatPerCrypto}} {{../shopFiatCurrency}}</div>
<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}}
@@ -15,5 +15,12 @@
class='sf-btn sf-btn--sm{{#ifeq themePreference 'dark'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'dark'}}aria-current='true'{{/ifeq}}
>Dark</button>
<button
type='submit'
name='theme'
value='system'
class='sf-btn sf-btn--sm{{#ifeq themePreference 'system'}} sf-btn--primary{{/ifeq}}'
{{#ifeq themePreference 'system'}}aria-current='true'{{/ifeq}}
>System</button>
</form>
</div>
@@ -198,7 +198,7 @@ describe('StorefrontOrderViewService', () => {
expect(view.totals.shippingCostFiat).toBe(5);
expect(view.totals.grandTotalFiat).toBe(15);
expect(view.shippingInvoice).toMatchObject({
cryptoCurrency: 'XMR',
paymentLabel: 'XMR',
paymentAddress: '4shipping'
});
});
+1
View File
@@ -49,6 +49,7 @@ export interface AppConfig {
shippingNoteMinLength: number;
shippingNoteMaxLength: number;
orderMessageMaxLength: number;
bitcoinWithdrawMaxFeeRateSatVbyte: number;
};
}
@@ -6,5 +6,6 @@ export interface ElectrumWalletConfig {
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',
Syncing = 'syncing',
Unknown = 'unknown'
@@ -1,14 +1,14 @@
import { resolveMinConfirmations } from './resolveMinConfirmations';
const tiers = [
{ upToTotalFiat: '25', minConfirmations: 0 },
{ upToTotalFiat: '25', minConfirmations: 1 },
{ upToTotalFiat: '250', minConfirmations: 5 },
{ minConfirmations: 10 }
] as const;
describe('resolveMinConfirmations', () => {
it('returns 0 for small orders (tx-detected tier)', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(0);
it('returns the first tier for small orders', () => {
expect(resolveMinConfirmations(10, [...tiers])).toBe(1);
});
it('returns the middle tier for medium orders', () => {
@@ -35,14 +35,14 @@ describe('formatInvoicePaymentConfirmationStatus', () => {
).toBe('2/10');
});
it('treats zero-confirmation tiers as confirmed', () => {
it('returns compact progress at zero confirmations', () => {
expect(
formatInvoicePaymentConfirmationStatus({
confirmations: 0,
requiredConfirmations: 0,
requiredConfirmations: 3,
format: 'compact'
})
).toBe('Confirmed');
).toBe('0/3');
});
});
@@ -1,9 +1,6 @@
import { formatRelativeTimeAgo } from '../formatRelativeTimeAgo';
import type { InvoicePaymentConfirmationStatusFormat } from './types/InvoicePaymentConfirmationStatusFormat';
const formatRequiredConfirmationsLabel = (requiredConfirmations: number): string =>
requiredConfirmations === 0 ? '0 (tx-detected)' : String(requiredConfirmations);
export const formatInvoicePaymentConfirmationStatus = ({
confirmations,
requiredConfirmations,
@@ -29,5 +26,5 @@ export const formatInvoicePaymentConfirmationStatus = ({
return `${confirmations} / ${requiredConfirmations} confirmations · detected ${detectedAgo}`;
}
return `${confirmations}/${formatRequiredConfirmationsLabel(requiredConfirmations)}`;
return `${confirmations}/${requiredConfirmations}`;
};
@@ -109,7 +109,7 @@ describe('toStorefrontInvoiceView', () => {
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
cryptoCurrency: 'XMR',
paymentLabel: 'XMR',
expectedTotalCrypto: '1.00000000',
receivedTotalCrypto: null,
paymentAddress,
@@ -154,7 +154,7 @@ describe('toStorefrontInvoiceView', () => {
const view = await toStorefrontInvoiceView(invoice);
expect(view).toMatchObject({
cryptoCurrency: 'BTC',
paymentLabel: 'BTC',
expectedTotalCrypto: '1.00000000',
paymentAddress: 'bc1qstorefronttest'
});
@@ -463,19 +463,6 @@ describe('toStorefrontInvoiceView', () => {
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', () => {
@@ -114,7 +114,7 @@ const buildStorefrontInvoiceView = async ({
const statusVariant = resolveInvoiceStatusVariant(invoiceState);
return {
cryptoCurrency: paymentLabel,
paymentLabel,
expectedTotalCrypto,
receivedTotalCrypto,
paymentAddress: invoice.paymentAddress,
@@ -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 =
'[{"upToTotalFiat":"25","minConfirmations":0},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
'[{"upToTotalFiat":"25","minConfirmations":1},{"upToTotalFiat":"250","minConfirmations":5},{"minConfirmations":10}]';
describe('IsConfirmationTiers', () => {
it('accepts valid default tiers', () => {
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', () => {
expect(validateTiers('').length).toBeGreaterThan(0);
});
@@ -39,22 +32,16 @@ describe('IsConfirmationTiers', () => {
expect(validateTiers('[]').length).toBeGreaterThan(0);
});
it('rejects tx-detected (0) more than once', () => {
it('rejects minConfirmations: 0', () => {
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);
});
it('rejects tx-detected (0) on catch-all tier', () => {
const tiers = '[{"upToTotalFiat":"25","minConfirmations":1},{"minConfirmations":0}]';
expect(validateTiers(tiers).length).toBeGreaterThan(0);
});
it('rejects legacy tx-detected string', () => {
it('rejects non-numeric minConfirmations', () => {
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);
});
@@ -14,7 +14,7 @@ const isPositiveDecimalString = (value: string): 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 => {
if (typeof value !== 'object' || value === null) {
@@ -48,22 +48,12 @@ const isValidConfirmationTiersJson = (raw: string): boolean => {
}
const tiers = parsed;
const txDetectedTierCount = tiers.filter(tier => tier.minConfirmations === 0).length;
if (txDetectedTierCount > 1) {
return false;
}
const lastTier = tiers[tiers.length - 1];
if (lastTier.upToTotalFiat !== undefined) {
return false;
}
if (lastTier.minConfirmations === 0) {
return false;
}
for (let index = 0; index < tiers.length - 1; index++) {
const tier = tiers[index];
@@ -86,7 +76,7 @@ class IsConfirmationTiersConstraint implements ValidatorConstraintInterface {
}
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 */
declare module 'vue' {
export interface GlobalComponents {
BitcoinWallet: typeof import('./components/wallet/BitcoinWallet.vue')['default']
CmsListPagination: typeof import('./components/CmsListPagination.vue')['default']
CreateOrEditCategoryModal: typeof import('./components/CreateOrEditCategoryModal.vue')['default']
CreateOrEditDiscountCodeModal: typeof import('./components/CreateOrEditDiscountCodeModal.vue')['default']
@@ -56,9 +57,9 @@ declare module 'vue' {
MoneroWallet: typeof import('./components/wallet/MoneroWallet.vue')['default']
OrderCartPanel: typeof import('./components/OrderCartPanel.vue')['default']
OrderChatPanel: typeof import('./components/OrderChatPanel.vue')['default']
OrderInvoicePaymentPanel: typeof import('./components/OrderInvoicePaymentPanel.vue')['default']
OrderLineAutoFulfillmentModal: typeof import('./components/OrderLineAutoFulfillmentModal.vue')['default']
OrderManualShippingQuotePanel: typeof import('./components/OrderManualShippingQuotePanel.vue')['default']
OrderMoneroPaymentPanel: typeof import('./components/OrderMoneroPaymentPanel.vue')['default']
OrderPaymentPanel: typeof import('./components/OrderPaymentPanel.vue')['default']
OrderSummaryPanel: typeof import('./components/OrderSummaryPanel.vue')['default']
RichTextEditor: typeof import('./components/RichTextEditor.vue')['default']
@@ -17,6 +17,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
class="cms-scope-select"
placeholder="Select categories"
@update:model-value="value => updateScope({ categoryIds: value })"
@@ -42,6 +43,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
class="cms-scope-select"
:remote-method="loadProducts"
:loading="productSearchLoading"
@@ -69,6 +71,7 @@
collapse-tags
:max-collapse-tags="3"
collapse-tags-tooltip
fit-input-width
class="cms-scope-select"
:remote-method="loadVariants"
:loading="variantSearchLoading"
@@ -8,14 +8,12 @@
</el-descriptions-item>
<el-descriptions-item label="Expected total">
<span class="mono"
>{{ invoice.expectedTotalCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
>
<span class="mono">{{ invoice.expectedTotalCrypto }} {{ invoice.paymentLabel }}</span>
</el-descriptions-item>
<el-descriptions-item v-if="fiatPerXmrAtCreation !== undefined" :label="`Rate at ${rateLabel}`">
{{ formatFiatPrice(fiatPerXmrAtCreation, config.shopFiatCurrency) }} /
{{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}
<el-descriptions-item v-if="fiatPerCryptoAtCreation !== undefined" :label="`Rate at ${rateLabel}`">
{{ formatFiatPrice(fiatPerCryptoAtCreation, config.shopFiatCurrency) }} /
{{ invoice.paymentLabel }}
</el-descriptions-item>
</el-descriptions>
@@ -37,9 +35,7 @@
<el-table-column label="Amount" width="140">
<template #default="{ row }">
<span class="mono"
>{{ row.amountCrypto }} {{ paymentMethodCryptoCurrency[invoice.paymentMethod] }}</span
>
<span class="mono">{{ row.amountCrypto }} {{ invoice.paymentLabel }}</span>
</template>
</el-table-column>
@@ -69,7 +65,7 @@
import { computed, type PropType } from 'vue';
import { config } from '@/config';
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 { formatFiatPrice } from '@/utils/formatFiatPrice';
import { resolveInvoiceStatusTagType } from '@/utils/order/resolveInvoiceStatusTagType';
@@ -85,13 +81,25 @@ const props = defineProps({
},
emptyText: {
type: String,
default: 'No Monero payment session.'
default: 'No payment session.'
}
});
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>
<style scoped>
@@ -53,10 +53,9 @@
</el-descriptions>
<template v-if="order.shippingInvoice">
<order-monero-payment-panel
<order-invoice-payment-panel
:invoice="order.shippingInvoice"
rate-label="quote"
empty-text="Shipping payment session not created yet."
/>
</template>
+1 -1
View File
@@ -4,7 +4,7 @@
<span>Order payment</span>
</template>
<order-monero-payment-panel
<order-invoice-payment-panel
:invoice="order.checkoutInvoice"
rate-label="checkout"
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>
+51 -33
View File
@@ -3,7 +3,7 @@
<el-empty v-if="!loading && loadError" description="Failed to load wallet status" />
<template v-if="!loading && !loadError && walletStatus">
<div v-if="walletStatus.syncStatus !== MoneroWalletSyncStatus.Synced" class="mb-16">
<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>
@@ -23,8 +23,8 @@
{{ walletStatus.daemonHeight ?? 'Unavailable' }}
</el-descriptions-item>
<el-descriptions-item label="Sync">
<el-tag :type="resolveMoneroWalletSyncStatusTagType(walletStatus.syncStatus)" size="small">
{{ resolveMoneroWalletSyncStatusLabel(walletStatus.syncStatus) }}
<el-tag :type="resolveWalletSyncStatusTagType(walletStatus.syncStatus)" size="small">
{{ resolveWalletSyncStatusLabel(walletStatus.syncStatus) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Total balance">{{ walletStatus.balanceXmr }} XMR</el-descriptions-item>
@@ -49,6 +49,7 @@
<el-form-item label="Destination address" prop="destinationAddress">
<el-input
v-model="withdrawForm.destinationAddress"
class="withdraw-address-input"
autocomplete="off"
:placeholder="`${walletStatus.network} Monero address`"
:disabled="withdrawing"
@@ -56,6 +57,22 @@
/>
</el-form-item>
<el-form-item label="Fee priority" prop="priority">
<el-select
v-model="withdrawForm.priority"
class="fee-priority-select"
fit-input-width
:disabled="withdrawing"
>
<el-option
v-for="option in moneroWithdrawPriorityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="withdrawing">
Withdraw all unlocked funds
</el-button>
@@ -90,10 +107,15 @@
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { computed, onBeforeMount, reactive, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { MoneroWalletSyncStatus } from '@/types/moneroWallet/MoneroWalletSyncStatus';
import { moneroWithdrawPriorityOptions } from '@/consts/moneroWallet/moneroWithdrawPriorityOptions';
import { MoneroWithdrawPriority } from '@/types/moneroWallet/MoneroWithdrawPriority';
import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus';
import { useMoneroWalletStore } from '@/stores/moneroWallet';
import { isMoneroStandardAddress } from '@/utils/monero/isMoneroStandardAddress';
import { resolveMoneroWithdrawPriorityLabel } from '@/utils/monero/resolveMoneroWithdrawPriorityLabel';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel';
import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType';
const moneroWalletStore = useMoneroWalletStore();
@@ -108,7 +130,8 @@ const withdrawing = ref(false);
const revealingSeed = ref(false);
const withdrawFormRef = ref<FormInstance>();
const withdrawForm = reactive({
destinationAddress: ''
destinationAddress: '',
priority: MoneroWithdrawPriority.Automatic
});
const seedDialogVisible = ref(false);
const revealedMnemonic = ref('');
@@ -125,7 +148,7 @@ const withdrawFormRules = computed<FormRules>(() => ({
destinationAddress: [
{
validator: (_rule, value, callback) => {
if (walletStatus.value?.syncStatus !== MoneroWalletSyncStatus.Synced) {
if (walletStatus.value?.syncStatus !== WalletSyncStatus.Synced) {
callback(new Error('Wait until the wallet finishes syncing before withdrawing'));
return;
@@ -207,11 +230,17 @@ const onWithdrawSubmit = async (): Promise<void> => {
const trimmedAddress = withdrawForm.destinationAddress.trim();
try {
await ElMessageBox.confirm(`Withdraw all unlocked funds to:\n${trimmedAddress}`, 'Confirm withdrawal', {
const priorityLabel = resolveMoneroWithdrawPriorityLabel(withdrawForm.priority);
await ElMessageBox.confirm(
`Withdraw all unlocked funds at ${priorityLabel} fee to:\n${trimmedAddress}`,
'Confirm withdrawal',
{
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
type: 'warning'
});
}
);
} catch {
return;
}
@@ -227,6 +256,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
try {
const result = await withdrawAll({
destinationAddress: trimmedAddress,
priority: withdrawForm.priority,
password
});
@@ -238,8 +268,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
});
}
withdrawForm.destinationAddress = '';
withdrawFormRef.value?.clearValidate();
withdrawFormRef.value?.resetFields();
await loadWalletStatus();
} catch (error) {
@@ -252,7 +281,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
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 or enter it on untrusted sites.',
'The recovery seed grants full control over this shop wallet. Store it offline. Never share it with anyone.',
'Reveal recovery seed?',
{
confirmButtonText: 'I understand',
@@ -287,26 +316,15 @@ const onRevealSeedClick = async (): Promise<void> => {
const clearSeed = (): void => {
revealedMnemonic.value = '';
};
const resolveMoneroWalletSyncStatusLabel = (syncStatus: MoneroWalletSyncStatus): string => {
switch (syncStatus) {
case MoneroWalletSyncStatus.Synced:
return 'Synced';
case MoneroWalletSyncStatus.Syncing:
return 'Syncing';
default:
return 'Unknown';
}
};
const resolveMoneroWalletSyncStatusTagType = (syncStatus: MoneroWalletSyncStatus): 'success' | 'warning' | 'info' => {
switch (syncStatus) {
case MoneroWalletSyncStatus.Synced:
return 'success';
case MoneroWalletSyncStatus.Syncing:
return 'warning';
default:
return 'info';
}
};
</script>
<style scoped>
.withdraw-address-input {
width: 100%;
max-width: min(560px, 100%);
}
.fee-priority-select {
width: 200px;
}
</style>
+2 -1
View File
@@ -42,7 +42,8 @@ export const config = {
digitalStockAttachmentsMax: parseInt(env('VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX'), 10),
shippingNoteMinLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH'), 10),
shippingNoteMaxLength: parseInt(env('VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH'), 10),
orderMessageMaxLength: parseInt(env('VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH'), 10)
orderMessageMaxLength: parseInt(env('VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH'), 10),
bitcoinWithdrawMaxFeeRateSatVbyte: parseInt(env('VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE'), 10)
},
orders: {
@@ -0,0 +1,13 @@
import { MoneroWithdrawPriority } from '@/types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWithdrawPriorityLabel } from '@/types/moneroWallet/MoneroWithdrawPriorityLabel';
export const moneroWithdrawPriorityOptions = [
{ value: MoneroWithdrawPriority.Automatic, label: 'Automatic' },
{ value: MoneroWithdrawPriority.Slow, label: 'Slow (0.2×)' },
{ value: MoneroWithdrawPriority.Normal, label: 'Normal (1×)' },
{ value: MoneroWithdrawPriority.Fast, label: 'Fast (5×)' },
{ value: MoneroWithdrawPriority.Fastest, label: 'Fastest (200×)' }
] as const satisfies ReadonlyArray<{
value: MoneroWithdrawPriority;
label: MoneroWithdrawPriorityLabel;
}>;
+2 -1
View File
@@ -7,7 +7,8 @@ export const ROUTE_NAMES = {
DiscountCodes: 'DiscountCodes',
ShopSettings: 'ShopSettings',
Notifications: 'Notifications',
Wallet: 'Wallet',
MoneroWallet: 'MoneroWallet',
BitcoinWallet: 'BitcoinWallet',
Orders: 'Orders',
OrderDetail: 'OrderDetail'
} as const;
+18 -3
View File
@@ -57,9 +57,10 @@ const router = createRouter({
path: '/settings',
component: () => import('../views/CmsSettingsLayout.vue'),
meta: { requiresAuth: true, title: 'CMS - Settings', activeMenu: '/settings' },
redirect: { name: ROUTE_NAMES.ShopSettings },
children: [
{
path: '',
path: 'shop',
name: ROUTE_NAMES.ShopSettings,
component: () => import('../views/CmsShopSettingsView.vue'),
meta: { requiresAuth: true, title: 'CMS - Shop settings', activeMenu: '/settings' }
@@ -74,9 +75,23 @@ const router = createRouter({
},
{
path: '/wallet',
name: ROUTE_NAMES.Wallet,
component: () => import('../views/CmsWalletView.vue'),
meta: { requiresAuth: true, title: 'CMS - Wallet' }
meta: { requiresAuth: true, title: 'CMS - Wallet', activeMenu: '/wallet' },
redirect: { name: ROUTE_NAMES.MoneroWallet },
children: [
{
path: 'monero',
name: ROUTE_NAMES.MoneroWallet,
component: () => import('../components/wallet/MoneroWallet.vue'),
meta: { requiresAuth: true, title: 'CMS - Monero wallet', activeMenu: '/wallet' }
},
{
path: 'bitcoin',
name: ROUTE_NAMES.BitcoinWallet,
component: () => import('../components/wallet/BitcoinWallet.vue'),
meta: { requiresAuth: true, title: 'CMS - Bitcoin wallet', activeMenu: '/wallet' }
}
]
},
{
path: '/:pathMatch(.*)*',
+39
View File
@@ -0,0 +1,39 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { api } from '@/plugins/axios';
import type { BitcoinWalletRevealSeedPayload } from '@/types/bitcoinWallet/BitcoinWalletRevealSeedPayload';
import type { BitcoinWalletRevealSeedResult } from '@/types/bitcoinWallet/BitcoinWalletRevealSeedResult';
import type { BitcoinWalletStatus } from '@/types/bitcoinWallet/BitcoinWalletStatus';
import type { BitcoinWalletWithdrawPayload } from '@/types/bitcoinWallet/BitcoinWalletWithdrawPayload';
import type { BitcoinWalletWithdrawResult } from '@/types/bitcoinWallet/BitcoinWalletWithdrawResult';
export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => {
const status = ref<BitcoinWalletStatus | null>(null);
const fetchStatus = async (): Promise<BitcoinWalletStatus> => {
const { data } = await api.get<BitcoinWalletStatus>('/bitcoin-wallet');
status.value = data;
return data;
};
const withdrawAll = async (payload: BitcoinWalletWithdrawPayload): Promise<BitcoinWalletWithdrawResult> => {
const { data } = await api.post<BitcoinWalletWithdrawResult>('/bitcoin-wallet/withdraw', payload);
return data;
};
const revealSeed = async (payload: BitcoinWalletRevealSeedPayload): Promise<BitcoinWalletRevealSeedResult> => {
const { data } = await api.post<BitcoinWalletRevealSeedResult>('/bitcoin-wallet/reveal-seed', payload);
return data;
};
return {
status,
fetchStatus,
withdrawAll,
revealSeed
};
});
-14
View File
@@ -16,16 +16,6 @@
padding: 24px;
}
.el-select__popper {
max-width: min(560px, calc(100vw - 32px));
}
.el-select-dropdown__item {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: $cms-bp-tablet) {
html,
body,
@@ -114,10 +104,6 @@
width: min(180px, calc(100vw - 10px)) !important;
}
.el-select__popper {
max-width: calc(100vw - 10px);
}
.cms-gate {
padding: 12px 5px;
}
@@ -0,0 +1 @@
export type BitcoinNetwork = 'mainnet' | 'testnet4';
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedPayload {
password: string;
}
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -0,0 +1,12 @@
import type { BitcoinNetwork } from './BitcoinNetwork';
import type { WalletSyncStatus } from '../wallet/WalletSyncStatus';
export interface BitcoinWalletStatus {
network: BitcoinNetwork;
rpcVersion: string;
blockHeight: number | null;
serverHeight: number | null;
syncStatus: WalletSyncStatus;
balanceBtc: string;
confirmedBalanceBtc: string;
}
@@ -0,0 +1,5 @@
export interface BitcoinWalletWithdrawPayload {
destinationAddress: string;
feeRateSatVbyte: number;
password: string;
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
@@ -1,12 +1,12 @@
import type { MoneroNetwork } from './MoneroNetwork';
import type { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
import type { WalletSyncStatus } from '../wallet/WalletSyncStatus';
export interface MoneroWalletStatus {
network: MoneroNetwork;
rpcVersion: string;
walletHeight: number;
daemonHeight: number | null;
syncStatus: MoneroWalletSyncStatus;
syncStatus: WalletSyncStatus;
balanceXmr: string;
unlockedBalanceXmr: string;
}
@@ -1,4 +1,7 @@
import { MoneroWithdrawPriority } from './MoneroWithdrawPriority';
export interface MoneroWalletWithdrawPayload {
destinationAddress: string;
priority: MoneroWithdrawPriority;
password: string;
}

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