Compare commits

..
3 Commits
Author SHA1 Message Date
nobswebdev 6c894d58ba Add Bitcoin wallet admin withdraw and seed reveal API.
Expose JWT-protected withdraw-all and reveal-seed endpoints with address validation and password verification, matching Monero wallet admin behavior.
2026-09-06 23:28:53 +02:00
nobswebdev 3a72d0521a Add sweepAll to Electrum wallet RPC client.
Consolidate payto, deserialize, and broadcast into a single sweep helper for max-payment withdrawals.
2026-09-06 23:28:47 +02:00
nobswebdev 8d9054b119 pass wallet password to electrum config on backend 2026-09-06 21:08:13 +02:00
15 changed files with 401 additions and 28 deletions
+1
View File
@@ -218,6 +218,7 @@ export const getElectrumWalletConfig = (): ElectrumWalletConfig => ({
rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`, rpcUrl: `http://${env('ELECTRUM_DAEMON_HOST')}:${envInt('ELECTRUM_DAEMON_PORT')}`,
username: env('ELECTRUM_DAEMON_RPC_USER'), username: env('ELECTRUM_DAEMON_RPC_USER'),
password: env('ELECTRUM_DAEMON_RPC_PASSWORD'), password: env('ELECTRUM_DAEMON_RPC_PASSWORD'),
walletPassword: env('ELECTRUM_WALLET_PASSWORD'),
rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS') rpcTimeoutMs: envInt('ELECTRUM_DAEMON_RPC_TIMEOUT_MS')
}); });
+4
View File
@@ -374,6 +374,10 @@ class EnvironmentVariables {
@Min(1000) @Min(1000)
ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number; ELECTRUM_DAEMON_RPC_TIMEOUT_MS: number;
@IsNotEmpty()
@IsString()
ELECTRUM_WALLET_PASSWORD: string;
@IsNotEmpty() @IsNotEmpty()
@IsString() @IsString()
SIMPLEX_WS_URL: string; SIMPLEX_WS_URL: string;
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/AuthModule';
import { BitcoinWalletController } from './controllers/BitcoinWalletController'; import { BitcoinWalletController } from './controllers/BitcoinWalletController';
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService'; import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient'; import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService'; import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
@Module({ @Module({
imports: [AuthModule],
controllers: [BitcoinWalletController], controllers: [BitcoinWalletController],
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService], providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
exports: [ElectrumWalletRpcClient] 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 { JwtGuard } from '../../../guards/JwtGuard';
import { throttleProfiles } from '../../../config/throttleProfiles';
import { BitcoinWalletRevealSeedDto } from '../dto/BitcoinWalletRevealSeedDto';
import { BitcoinWalletWithdrawDto } from '../dto/BitcoinWalletWithdrawDto';
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService'; import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
@Controller('bitcoin-wallet') @Controller('bitcoin-wallet')
@@ -11,4 +15,16 @@ export class BitcoinWalletController {
getStatus() { getStatus() {
return this.walletAdminService.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,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;
}
@@ -1,5 +1,6 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import type { AuthService } from '../../auth/services/AuthService';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient'; import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService'; import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
@@ -11,6 +12,11 @@ describe('BitcoinWalletAdminService', () => {
isSynchronized: jest.Mock; isSynchronized: jest.Mock;
getInfo: jest.Mock; getInfo: jest.Mock;
getBalance: jest.Mock; getBalance: jest.Mock;
sweepAll: jest.Mock;
getSeed: jest.Mock;
};
let authService: {
verifyPassword: jest.Mock;
}; };
let configService: { let configService: {
get: jest.Mock; get: jest.Mock;
@@ -27,7 +33,16 @@ describe('BitcoinWalletAdminService', () => {
getBalance: jest.fn().mockResolvedValue({ getBalance: jest.fn().mockResolvedValue({
balanceAtomic: '150000', balanceAtomic: '150000',
confirmedBalanceAtomic: '150000' confirmedBalanceAtomic: '150000'
}) }),
sweepAll: jest.fn().mockResolvedValue({
txHash: 'tx-hash-1',
amountAtomic: '140000'
}),
getSeed: jest.fn().mockResolvedValue('seed words')
};
authService = {
verifyPassword: jest.fn()
}; };
configService = { configService = {
@@ -38,6 +53,7 @@ describe('BitcoinWalletAdminService', () => {
service = new BitcoinWalletAdminService( service = new BitcoinWalletAdminService(
walletRpcClient as unknown as ElectrumWalletRpcClient, walletRpcClient as unknown as ElectrumWalletRpcClient,
authService as unknown as AuthService,
configService as unknown as ConfigService configService as unknown as ConfigService
); );
}); });
@@ -71,4 +87,61 @@ describe('BitcoinWalletAdminService', () => {
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException); 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 { ConfigService } from '@nestjs/config';
import { AuthService } from '../../auth/services/AuthService';
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc'; import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
import type { Config } from '../../../types/Config'; import type { Config } from '../../../types/Config';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { BitcoinWalletRevealSeedResult } from '../types/BitcoinWalletRevealSeedResult';
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView'; import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
import type { BitcoinWalletWithdrawResult } from '../types/BitcoinWalletWithdrawResult';
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient'; import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
@Injectable() @Injectable()
export class BitcoinWalletAdminService { export class BitcoinWalletAdminService {
constructor( constructor(
private readonly walletRpcClient: ElectrumWalletRpcClient, private readonly walletRpcClient: ElectrumWalletRpcClient,
private readonly authService: AuthService,
private readonly configService: ConfigService 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( private resolveSyncStatus(
isSynchronized: boolean, isSynchronized: boolean,
blockHeight: number | null, blockHeight: number | null,
@@ -17,6 +17,7 @@ describe('ElectrumWalletRpcClient', () => {
rpcUrl: 'http://electrum.test:7777', rpcUrl: 'http://electrum.test:7777',
username: 'electrum', username: 'electrum',
password: 'secret', password: 'secret',
walletPassword: 'wallet-secret',
rpcTimeoutMs: 5000 rpcTimeoutMs: 5000
}) })
} as unknown as ConfigService); } as unknown as ConfigService);
@@ -74,10 +75,10 @@ describe('ElectrumWalletRpcClient', () => {
}); });
}); });
describe('sumIncomingOutputValueAtomic', () => { describe('sumOutputValueAtomic', () => {
it('sums outputs paying to the target address', () => { it('sums outputs paying to the target address', () => {
expect( expect(
clientTest.sumIncomingOutputValueAtomic( clientTest.sumOutputValueAtomic(
{ {
outputs: [ outputs: [
{ address: 'bc1qother', value_sats: 10_000 }, { address: 'bc1qother', value_sats: 10_000 },
@@ -92,7 +93,7 @@ describe('ElectrumWalletRpcClient', () => {
it('returns zero when no outputs match the address', () => { it('returns zero when no outputs match the address', () => {
expect( expect(
clientTest.sumIncomingOutputValueAtomic( clientTest.sumOutputValueAtomic(
{ {
outputs: [{ address: 'bc1qother', value_sats: 10_000 }] outputs: [{ address: 'bc1qother', value_sats: 10_000 }]
}, },
@@ -208,4 +209,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> { async createAddress(label?: string): Promise<string> {
const address = await this.call<string>('createnewaddress'); const address = await this.call<string>('createnewaddress');
@@ -120,12 +199,8 @@ export class ElectrumWalletRpcClient {
} }
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash }); const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.deserializeTransaction(serializedTransaction);
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('deserialize', { const amountAtomic = this.sumOutputValueAtomic(transaction, address);
tx: serializedTransaction
});
const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address);
return this.mapIncomingTransfer(entry, amountAtomic, blockHeight); return this.mapIncomingTransfer(entry, amountAtomic, blockHeight);
}) })
@@ -134,20 +209,6 @@ export class ElectrumWalletRpcClient {
return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null); return 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');
}
private mapIncomingTransfer( private mapIncomingTransfer(
entry: ElectrumWalletAddressHistoryEntry, entry: ElectrumWalletAddressHistoryEntry,
amountAtomic: string, amountAtomic: string,
@@ -0,0 +1,3 @@
export interface BitcoinWalletRevealSeedResult {
mnemonic: string;
}
@@ -0,0 +1,4 @@
export interface BitcoinWalletWithdrawResult {
txHash: string;
amountBtc: string;
}
@@ -8,5 +8,5 @@ export type ElectrumWalletRpcClientTest = {
amountAtomic: string, amountAtomic: string,
blockHeight: number | null blockHeight: number | null
) => ElectrumWalletIncomingTransfer | null; ) => ElectrumWalletIncomingTransfer | null;
sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string;
}; };
@@ -6,5 +6,6 @@ export interface ElectrumWalletConfig {
rpcUrl: string; rpcUrl: string;
username: string; username: string;
password: string; password: string;
walletPassword: string;
rpcTimeoutMs: number; rpcTimeoutMs: number;
} }
@@ -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);