Pass sweep_all priority through the withdraw endpoint so admins can choose transaction fee speed.
183 lines
6.6 KiB
TypeScript
183 lines
6.6 KiB
TypeScript
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';
|
|
|
|
jest.mock('axios');
|
|
|
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
|
|
|
describe('MoneroWalletAdminService', () => {
|
|
let service: MoneroWalletAdminService;
|
|
let walletRpcClient: {
|
|
tryRefresh: jest.Mock;
|
|
getVersion: jest.Mock;
|
|
getHeight: jest.Mock;
|
|
getBalance: jest.Mock;
|
|
sweepAll: jest.Mock;
|
|
queryMnemonic: jest.Mock;
|
|
};
|
|
let authService: {
|
|
verifyPassword: jest.Mock;
|
|
};
|
|
let configService: {
|
|
get: jest.Mock;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
walletRpcClient = {
|
|
tryRefresh: jest.fn().mockResolvedValue(undefined),
|
|
getVersion: jest.fn().mockResolvedValue('0.18.3.1'),
|
|
getHeight: jest.fn().mockResolvedValue(3_000_000),
|
|
getBalance: jest.fn().mockResolvedValue({
|
|
balanceAtomic: '2000000000000',
|
|
unlockedBalanceAtomic: '1000000000000'
|
|
}),
|
|
sweepAll: jest.fn().mockResolvedValue({
|
|
txHashes: ['tx-hash-1'],
|
|
amountAtomic: '1000000000000'
|
|
}),
|
|
queryMnemonic: jest.fn().mockResolvedValue('seed words')
|
|
};
|
|
|
|
authService = {
|
|
verifyPassword: jest.fn()
|
|
};
|
|
|
|
configService = {
|
|
get: jest.fn().mockReturnValue({
|
|
network: 'mainnet',
|
|
daemonRpcUrl: 'http://daemon.test/json_rpc',
|
|
rpcTimeoutMs: 5000
|
|
})
|
|
};
|
|
|
|
mockedAxios.post.mockResolvedValue({
|
|
data: { result: { height: 3_000_000 } }
|
|
});
|
|
|
|
service = new MoneroWalletAdminService(
|
|
walletRpcClient as unknown as MoneroWalletRpcClient,
|
|
authService as unknown as AuthService,
|
|
configService as unknown as ConfigService
|
|
);
|
|
});
|
|
|
|
it('returns wallet status when RPC and daemon calls succeed', async () => {
|
|
const status = await service.getStatus();
|
|
|
|
expect(walletRpcClient.tryRefresh).toHaveBeenCalled();
|
|
expect(status).toEqual(
|
|
expect.objectContaining({
|
|
network: 'mainnet',
|
|
rpcVersion: '0.18.3.1',
|
|
walletHeight: 3_000_000,
|
|
daemonHeight: 3_000_000,
|
|
syncStatus: WalletSyncStatus.Synced,
|
|
balanceXmr: '2.00000000',
|
|
unlockedBalanceXmr: '1.00000000'
|
|
})
|
|
);
|
|
});
|
|
|
|
it('throws when wallet status cannot be loaded', async () => {
|
|
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
|
|
|
|
await expect(service.getStatus()).rejects.toThrow(
|
|
new ServiceUnavailableException(
|
|
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
|
)
|
|
);
|
|
});
|
|
|
|
it('rejects withdrawals when there is no unlocked balance', async () => {
|
|
walletRpcClient.getBalance.mockResolvedValue({
|
|
balanceAtomic: '0',
|
|
unlockedBalanceAtomic: '0'
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
it('rejects withdrawals while the wallet is still syncing', async () => {
|
|
walletRpcClient.getHeight.mockResolvedValue(2_999_000);
|
|
|
|
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',
|
|
MoneroWithdrawPriority.Fast,
|
|
'password'
|
|
);
|
|
|
|
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith(
|
|
'4DestinationAddressExample',
|
|
MoneroWithdrawPriority.Fast
|
|
);
|
|
expect(result).toEqual({
|
|
txHashes: ['tx-hash-1'],
|
|
amountXmr: '1.00000000'
|
|
});
|
|
});
|
|
|
|
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.queryMnemonic).toHaveBeenCalled();
|
|
});
|
|
|
|
it('reports unknown sync status when the daemon height cannot be fetched', async () => {
|
|
mockedAxios.post.mockRejectedValue(new Error('daemon down'));
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.syncStatus).toBe(WalletSyncStatus.Unknown);
|
|
expect(status.daemonHeight).toBeNull();
|
|
});
|
|
|
|
it('treats the wallet as synced when it is one block behind the daemon', async () => {
|
|
walletRpcClient.getHeight.mockResolvedValue(2_999_999);
|
|
mockedAxios.post.mockResolvedValue({
|
|
data: { result: { height: 3_000_000 } }
|
|
});
|
|
|
|
const status = await service.getStatus();
|
|
|
|
expect(status.syncStatus).toBe(WalletSyncStatus.Synced);
|
|
});
|
|
|
|
it('throws when reveal seed RPC fails', async () => {
|
|
walletRpcClient.queryMnemonic.mockRejectedValue(new Error('rpc down'));
|
|
|
|
await expect(service.revealSeed('password')).rejects.toThrow(
|
|
new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.')
|
|
);
|
|
});
|
|
|
|
it('throws when sweep all fails after prechecks pass', async () => {
|
|
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
|
|
|
|
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.'
|
|
)
|
|
);
|
|
});
|
|
});
|