Compare commits
25
Commits
564b016f47
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08a0bdcdb8 | ||
|
|
93c132b0df | ||
|
|
771d95d1cd | ||
|
|
254736356b | ||
|
|
738e406725 | ||
|
|
de4630635d | ||
|
|
6b358011b0 | ||
|
|
8729098f20 | ||
|
|
deb5f8c2c3 | ||
|
|
b3e683cd71 | ||
|
|
4458edb0d1 | ||
|
|
dcd499664a | ||
|
|
0dcffb394e | ||
|
|
e6c21e58bb | ||
|
|
8639176af4 | ||
|
|
68503ea01d | ||
|
|
dd234421a4 | ||
|
|
2d8a508898 | ||
|
|
de52534271 | ||
|
|
626f2ea4f0 | ||
|
|
6c894d58ba | ||
|
|
3a72d0521a | ||
|
|
8d9054b119 | ||
|
|
c1e28c7545 | ||
|
|
83cb437f19 |
+4
-2
@@ -53,6 +53,7 @@ VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
|
||||
VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
|
||||
VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
|
||||
VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
|
||||
VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
|
||||
|
||||
SIGNED_COOKIE_JWT_SECRET=change-me-in-production
|
||||
|
||||
@@ -89,7 +90,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 +104,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 +147,5 @@ VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX=5
|
||||
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=20
|
||||
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=4000
|
||||
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=2000
|
||||
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=500
|
||||
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=5000
|
||||
|
||||
@@ -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');
|
||||
@@ -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`
|
||||
};
|
||||
+13
-3
@@ -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,5 +1,6 @@
|
||||
import { ServiceUnavailableException } from '@nestjs/common';
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { AuthService } from '../../auth/services/AuthService';
|
||||
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
|
||||
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
@@ -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 { 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,6 +47,64 @@ 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,
|
||||
|
||||
@@ -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']
|
||||
});
|
||||
});
|
||||
|
||||
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('sumIncomingOutputValueAtomic', () => {
|
||||
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;
|
||||
}
|
||||
@@ -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,6 +2,7 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type { AuthService } from '../../auth/services/AuthService';
|
||||
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
|
||||
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
|
||||
@@ -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'
|
||||
@@ -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.'
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResu
|
||||
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
|
||||
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
|
||||
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.');
|
||||
}
|
||||
@@ -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.'
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
|
||||
@@ -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,36 +150,62 @@ export class InvoicePaymentService {
|
||||
return;
|
||||
}
|
||||
|
||||
const minIncomingAtomic = minByMethod[invoice.paymentMethod];
|
||||
const knownByTxHash = new Map((invoice.payments ?? []).map(payment => [payment.txHash, payment]));
|
||||
await this.upsertIncomingPayments(paymentRepo, invoice, transfers);
|
||||
|
||||
for (const transfer of transfers) {
|
||||
const existing = knownByTxHash.get(transfer.txHash);
|
||||
|
||||
if (existing) {
|
||||
if (existing.confirmations !== transfer.confirmations) {
|
||||
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await paymentRepo
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({
|
||||
invoice: { id: invoiceId },
|
||||
txHash: transfer.txHash,
|
||||
amountAtomic: transfer.amountAtomic,
|
||||
confirmations: transfer.confirmations
|
||||
})
|
||||
.orIgnore()
|
||||
.execute();
|
||||
}
|
||||
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]));
|
||||
|
||||
for (const transfer of transfers) {
|
||||
const existing = knownByTxHash.get(transfer.txHash);
|
||||
|
||||
if (existing) {
|
||||
if (existing.confirmations !== transfer.confirmations) {
|
||||
await paymentRepo.update(existing.id, { confirmations: transfer.confirmations });
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isAtomicGte(transfer.amountAtomic, minIncomingAtomic)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await paymentRepo
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.values({
|
||||
invoice: { id: invoice.id },
|
||||
txHash: transfer.txHash,
|
||||
amountAtomic: transfer.amountAtomic,
|
||||
confirmations: transfer.confirmations
|
||||
})
|
||||
.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}`;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
|
||||
@@ -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 |
@@ -145,8 +145,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';
|
||||
@@ -145,8 +146,9 @@ export class StorefrontShopViewService {
|
||||
|
||||
return [
|
||||
{
|
||||
cryptoCurrency: paymentMethodLabel[paymentMethod],
|
||||
fiatPerCrypto
|
||||
paymentLabel: paymentMethodLabel[paymentMethod],
|
||||
fiatPerCrypto,
|
||||
iconUrl: paymentMethodIconUrl[paymentMethod]
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -12,10 +12,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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,7 +32,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}}
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (0–4).
|
||||
*
|
||||
* Absolute fee multipliers (fee algorithm 4, current mainnet) are 1, 5, 25, 1000 for priorities 1–4.
|
||||
* 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,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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<el-alert type="warning" :closable="false" show-icon title="Wallet is syncing. Please wait." />
|
||||
</div>
|
||||
|
||||
<el-card shadow="never">
|
||||
<el-card class="mb-24" shadow="never">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-16">
|
||||
<span>Status</span>
|
||||
@@ -35,29 +35,105 @@
|
||||
</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 } from 'element-plus';
|
||||
import { onBeforeMount, ref } from 'vue';
|
||||
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 } = 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;
|
||||
@@ -67,6 +143,53 @@ onBeforeMount(async () => {
|
||||
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;
|
||||
|
||||
@@ -90,4 +213,131 @@ const refreshStatus = async (): Promise<void> => {
|
||||
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>
|
||||
|
||||
@@ -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,17 @@
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Fee priority" prop="priority">
|
||||
<el-select v-model="withdrawForm.priority" class="fee-priority-select" :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,9 +102,12 @@
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { computed, onBeforeMount, reactive, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
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';
|
||||
@@ -110,7 +125,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('');
|
||||
@@ -209,11 +225,17 @@ const onWithdrawSubmit = async (): Promise<void> => {
|
||||
const trimmedAddress = withdrawForm.destinationAddress.trim();
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`Withdraw all unlocked funds to:\n${trimmedAddress}`, 'Confirm withdrawal', {
|
||||
confirmButtonText: 'Continue',
|
||||
cancelButtonText: 'Cancel',
|
||||
type: 'warning'
|
||||
});
|
||||
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;
|
||||
}
|
||||
@@ -229,6 +251,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await withdrawAll({
|
||||
destinationAddress: trimmedAddress,
|
||||
priority: withdrawForm.priority,
|
||||
password
|
||||
});
|
||||
|
||||
@@ -240,8 +263,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
|
||||
});
|
||||
}
|
||||
|
||||
withdrawForm.destinationAddress = '';
|
||||
withdrawFormRef.value?.clearValidate();
|
||||
withdrawFormRef.value?.resetFields();
|
||||
|
||||
await loadWalletStatus();
|
||||
} catch (error) {
|
||||
@@ -254,7 +276,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',
|
||||
@@ -290,3 +312,14 @@ const clearSeed = (): void => {
|
||||
revealedMnemonic.value = '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.withdraw-address-input {
|
||||
width: 100%;
|
||||
max-width: min(560px, 100%);
|
||||
}
|
||||
|
||||
.fee-priority-select {
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
@@ -1,7 +1,11 @@
|
||||
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);
|
||||
@@ -14,8 +18,22 @@ export const useBitcoinWalletStore = defineStore('bitcoinWallet', () => {
|
||||
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
|
||||
fetchStatus,
|
||||
withdrawAll,
|
||||
revealSeed
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface BitcoinWalletRevealSeedPayload {
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface BitcoinWalletRevealSeedResult {
|
||||
mnemonic: 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,4 +1,7 @@
|
||||
import { MoneroWithdrawPriority } from './MoneroWithdrawPriority';
|
||||
|
||||
export interface MoneroWalletWithdrawPayload {
|
||||
destinationAddress: string;
|
||||
priority: MoneroWithdrawPriority;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum MoneroWithdrawPriority {
|
||||
Automatic = 0,
|
||||
Slow = 1,
|
||||
Normal = 2,
|
||||
Fast = 3,
|
||||
Fastest = 4
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWithdrawPriorityLabel =
|
||||
| 'Automatic'
|
||||
| 'Slow (0.2×)'
|
||||
| 'Normal (1×)'
|
||||
| 'Fast (5×)'
|
||||
| 'Fastest (200×)';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BitcoinNetwork } from '@/types/bitcoinWallet/BitcoinNetwork';
|
||||
|
||||
// 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<BitcoinNetwork, RegExp[]> = {
|
||||
mainnet: [
|
||||
new RegExp(`^1${BASE58}{25,34}$`),
|
||||
new RegExp(`^3${BASE58}{25,34}$`),
|
||||
/^bc1[a-z0-9]{25,87}$/
|
||||
],
|
||||
testnet4: [
|
||||
new RegExp(`^[mn]${BASE58}{25,34}$`),
|
||||
new RegExp(`^2${BASE58}{25,34}$`),
|
||||
/^(?:tb1|bcrt1)[a-z0-9]{25,87}$/
|
||||
]
|
||||
};
|
||||
|
||||
export const isBitcoinAddress = (value: unknown, network: BitcoinNetwork): boolean => {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
|
||||
return NETWORK_ADDRESS_PATTERNS[network].some(pattern => pattern.test(trimmed));
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { moneroWithdrawPriorityOptions } from '@/consts/moneroWallet/moneroWithdrawPriorityOptions';
|
||||
import { MoneroWithdrawPriority } from '@/types/moneroWallet/MoneroWithdrawPriority';
|
||||
import type { MoneroWithdrawPriorityLabel } from '@/types/moneroWallet/MoneroWithdrawPriorityLabel';
|
||||
|
||||
export const resolveMoneroWithdrawPriorityLabel = (priority: MoneroWithdrawPriority): MoneroWithdrawPriorityLabel => {
|
||||
const option = moneroWithdrawPriorityOptions.find(({ value }) => value === priority);
|
||||
|
||||
if (!option) {
|
||||
throw new Error(`Invalid Monero withdraw priority: ${priority}`);
|
||||
}
|
||||
|
||||
return option.label;
|
||||
};
|
||||
@@ -468,7 +468,7 @@ const submitShippingNote = async (): Promise<void> => {
|
||||
};
|
||||
|
||||
const formatConfirmationTier = (tier: ConfirmationTier, currency: string): string => {
|
||||
const requirement = tier.minConfirmations === 0 ? '0 (tx-detected)' : `${tier.minConfirmations} confirmations`;
|
||||
const requirement = `${tier.minConfirmations} confirmations`;
|
||||
|
||||
if (tier.upToTotalFiat === undefined) {
|
||||
return `Above previous tiers → ${requirement}`;
|
||||
|
||||
Vendored
+1
@@ -29,6 +29,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH?: string;
|
||||
readonly VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH?: string;
|
||||
readonly VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH?: string;
|
||||
readonly VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE?: string;
|
||||
readonly VITE_SHOP_FIAT_CURRENCY?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## 1. Requirements
|
||||
|
||||
- Ubuntu 22.04+ or similar Linux with Docker Engine and the Compose plugin — follow [Install Docker Engine on Ubuntu](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository). Tested and recommended on Ubuntu 22.04 LTS.
|
||||
- A domain name pointing at your server (A record for clearnet HTTPS)
|
||||
- A domain name pointing at your server (A records for apex and `www`)
|
||||
|
||||
## 2. Server setup
|
||||
|
||||
@@ -61,20 +61,20 @@ Edit `.env.prod`. Mandatory configuration:
|
||||
| `VITE_API_BASE_URL` | `/api` |
|
||||
| `VITE_SHOP_FIAT_CURRENCY` | same as `SHOP_FIAT_CURRENCY` |
|
||||
|
||||
Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. At most one tier may use `minConfirmations: 0` (accept unconfirmed / mempool); that tier cannot be the catch-all.
|
||||
Optional — adjust per-method payment confirmation rules (`MONERO_CONFIRMATION_TIERS`, `BITCOIN_CONFIRMATION_TIERS`). Each is a JSON array with the same shape. For each order, the shop uses `minConfirmations` from the first tier where the order total (in `SHOP_FIAT_CURRENCY`) is `<= upToTotalFiat`. The last tier is a catch-all and must omit `upToTotalFiat`. Every tier must use `minConfirmations` >= 1.
|
||||
|
||||
Monero example (default in `.env.example`):
|
||||
|
||||
```json
|
||||
[
|
||||
{ "upToTotalFiat": "30", "minConfirmations": 0 },
|
||||
{ "upToTotalFiat": "30", "minConfirmations": 1 },
|
||||
{ "upToTotalFiat": "100", "minConfirmations": 3 },
|
||||
{ "upToTotalFiat": "300", "minConfirmations": 5 },
|
||||
{ "minConfirmations": 10 }
|
||||
]
|
||||
```
|
||||
|
||||
Orders up to 30 → 0 confirmations; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings.
|
||||
Orders up to 30 → 1 confirmation; up to 100 → 3; up to 300 → 5; above 300 → 10. Tiers are shown read-only in CMS shop settings.
|
||||
|
||||
## 5. Bootstrap TLS certificates
|
||||
|
||||
@@ -106,7 +106,7 @@ Remove the temporary bootstrap certificates under `deploy/certs/live/` (Certbot
|
||||
rm -rf deploy/certs/live/*
|
||||
```
|
||||
|
||||
Request the real certificate:
|
||||
Request the real certificate (apex + www):
|
||||
|
||||
```bash
|
||||
./deploy/scripts/issue-certs.sh --email you@example.com
|
||||
|
||||
@@ -9,9 +9,9 @@ usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") --email you@example.com
|
||||
|
||||
Obtain or renew Let's Encrypt certificates for CLEARNET_DOMAIN using the webroot
|
||||
challenge. Nginx must be running and serving /.well-known/acme-challenge/ from
|
||||
deploy/certbot/www.
|
||||
Obtain Let's Encrypt certificates for CLEARNET_DOMAIN and www.CLEARNET_DOMAIN using
|
||||
the webroot challenge. Nginx must be running and serving /.well-known/acme-challenge/
|
||||
from deploy/certbot/www.
|
||||
|
||||
Environment is read from .env.prod (CLEARNET_DOMAIN).
|
||||
EOF
|
||||
@@ -65,6 +65,7 @@ docker run --rm \
|
||||
--webroot \
|
||||
-w /var/www/certbot \
|
||||
-d "$CLEARNET_DOMAIN" \
|
||||
-d "www.${CLEARNET_DOMAIN}" \
|
||||
--email "$CERTBOT_EMAIL" \
|
||||
--agree-tos \
|
||||
--non-interactive
|
||||
|
||||
@@ -140,6 +140,7 @@ services:
|
||||
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH}
|
||||
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH: ${VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH}
|
||||
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH: ${VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH}
|
||||
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE: ${VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE}
|
||||
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS: ${VITE_ORDERS_DETAIL_POLL_INTERVAL_MS}
|
||||
container_name: ${COMPOSE_PROJECT_NAME}_nginx
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -25,6 +25,7 @@ ARG VITE_VALIDATION_DIGITAL_STOCK_ATTACHMENTS_MAX
|
||||
ARG VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH
|
||||
ARG VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH
|
||||
ARG VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH
|
||||
ARG VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE
|
||||
ARG VITE_ORDERS_DETAIL_POLL_INTERVAL_MS
|
||||
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||
@@ -45,6 +46,7 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||
VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MIN_LENGTH \
|
||||
VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH=$VITE_VALIDATION_SHIPPING_NOTE_MAX_LENGTH \
|
||||
VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH=$VITE_VALIDATION_ORDER_MESSAGE_MAX_LENGTH \
|
||||
VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE=$VITE_VALIDATION_BITCOIN_WITHDRAW_MAX_FEE_RATE_SAT_VBYTE \
|
||||
VITE_ORDERS_DETAIL_POLL_INTERVAL_MS=$VITE_ORDERS_DETAIL_POLL_INTERVAL_MS
|
||||
|
||||
RUN npm run build
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${CLEARNET_DOMAIN};
|
||||
server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
|
||||
|
||||
location /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
@@ -13,7 +13,7 @@ server {
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ${CLEARNET_DOMAIN};
|
||||
server_name ${CLEARNET_DOMAIN} www.${CLEARNET_DOMAIN};
|
||||
|
||||
ssl_certificate /etc/nginx/certs/live/${CLEARNET_DOMAIN}/fullchain.pem;
|
||||
ssl_certificate_key /etc/nginx/certs/live/${CLEARNET_DOMAIN}/privkey.pem;
|
||||
|
||||
Reference in New Issue
Block a user