From 6c894d58bab1a56a6b1809f4c4654c381f262bef Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 6 Sep 2026 23:28:53 +0200 Subject: [PATCH] Add Bitcoin wallet admin withdraw and seed reveal API. Expose JWT-protected withdraw-all and reveal-seed endpoints with address validation and password verification, matching Monero wallet admin behavior. --- .../bitcoinWallet/BitcoinWalletModule.ts | 2 + .../controllers/BitcoinWalletController.ts | 18 ++++- .../dto/BitcoinWalletRevealSeedDto.ts | 7 ++ .../dto/BitcoinWalletWithdrawDto.ts | 20 +++++ .../BitcoinWalletAdminService.spec.ts | 77 ++++++++++++++++++- .../services/BitcoinWalletAdminService.ts | 64 ++++++++++++++- .../types/BitcoinWalletRevealSeedResult.ts | 3 + .../types/BitcoinWalletWithdrawResult.ts | 4 + .../validation/decorators/isBitcoinAddress.ts | 42 ++++++++++ 9 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts create mode 100644 backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts create mode 100644 backend/src/modules/bitcoinWallet/types/BitcoinWalletRevealSeedResult.ts create mode 100644 backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts create mode 100644 backend/src/validation/decorators/isBitcoinAddress.ts diff --git a/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts index d56c048..5996d3a 100644 --- a/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts +++ b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts @@ -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] diff --git a/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts index e309946..f7a3b7e 100644 --- a/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts +++ b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts @@ -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); + } } diff --git a/backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts new file mode 100644 index 0000000..e38499d --- /dev/null +++ b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletRevealSeedDto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class BitcoinWalletRevealSeedDto { + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts new file mode 100644 index 0000000..1ed6136 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/dto/BitcoinWalletWithdrawDto.ts @@ -0,0 +1,20 @@ +import { Transform } from 'class-transformer'; +import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; +import { IsBitcoinAddress } from '../../../validation/decorators/isBitcoinAddress'; + +export class BitcoinWalletWithdrawDto { + @Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @IsBitcoinAddress() + destinationAddress: string; + + @IsInt() + @Min(1) + @Max(100) + feeRateSatVbyte: number; + + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts index 755d764..9dfd1b3 100644 --- a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts +++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts @@ -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.' + ) + ); + }); }); diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts index d3b31b6..ec314a2 100644 --- a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts +++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts @@ -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 { + 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 { + 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, diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletRevealSeedResult.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletRevealSeedResult.ts new file mode 100644 index 0000000..6ba90ab --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletRevealSeedResult.ts @@ -0,0 +1,3 @@ +export interface BitcoinWalletRevealSeedResult { + mnemonic: string; +} diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts new file mode 100644 index 0000000..63d4b29 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletWithdrawResult.ts @@ -0,0 +1,4 @@ +export interface BitcoinWalletWithdrawResult { + txHash: string; + amountBtc: string; +} diff --git a/backend/src/validation/decorators/isBitcoinAddress.ts b/backend/src/validation/decorators/isBitcoinAddress.ts new file mode 100644 index 0000000..bc55bcb --- /dev/null +++ b/backend/src/validation/decorators/isBitcoinAddress.ts @@ -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.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);