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.
This commit is contained in:
2026-09-06 23:28:53 +02:00
parent 3a72d0521a
commit 6c894d58ba
9 changed files with 233 additions and 4 deletions
@@ -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.'
)
);
});
});