Expose JWT-protected withdraw-all and reveal-seed endpoints with address validation and password verification, matching Monero wallet admin behavior.
148 lines
5.3 KiB
TypeScript
148 lines
5.3 KiB
TypeScript
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';
|
|
|
|
describe('BitcoinWalletAdminService', () => {
|
|
let service: BitcoinWalletAdminService;
|
|
let walletRpcClient: {
|
|
getVersion: jest.Mock;
|
|
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;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
walletRpcClient = {
|
|
getVersion: jest.fn().mockResolvedValue('4.8.1'),
|
|
isSynchronized: jest.fn().mockResolvedValue(true),
|
|
getInfo: jest.fn().mockResolvedValue({
|
|
blockchain_height: 900_000,
|
|
server_height: 900_000
|
|
}),
|
|
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 = {
|
|
get: jest.fn().mockReturnValue({
|
|
network: 'testnet4'
|
|
})
|
|
};
|
|
|
|
service = new BitcoinWalletAdminService(
|
|
walletRpcClient as unknown as ElectrumWalletRpcClient,
|
|
authService as unknown as AuthService,
|
|
configService as unknown as ConfigService
|
|
);
|
|
});
|
|
|
|
it('returns wallet status when RPC calls succeed', async () => {
|
|
const status = await service.getStatus();
|
|
|
|
expect(status).toEqual(
|
|
expect.objectContaining({
|
|
network: 'testnet4',
|
|
rpcVersion: '4.8.1',
|
|
blockHeight: 900_000,
|
|
serverHeight: 900_000,
|
|
syncStatus: WalletSyncStatus.Synced,
|
|
balanceBtc: '0.00150000',
|
|
confirmedBalanceBtc: '0.00150000'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('reports syncing when the wallet is not synchronized', async () => {
|
|
walletRpcClient.isSynchronized.mockResolvedValue(false);
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.syncStatus).toBe(WalletSyncStatus.Syncing);
|
|
});
|
|
|
|
it('throws when RPC calls fail', async () => {
|
|
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
|
|
|
|
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.'
|
|
)
|
|
);
|
|
});
|
|
});
|