add fee priority to Monero wallet withdrawal API.

Pass sweep_all priority through the withdraw endpoint so admins can choose transaction fee speed.
This commit is contained in:
2026-09-06 18:21:07 +02:00
parent 564b016f47
commit 83cb437f19
6 changed files with 63 additions and 17 deletions
@@ -18,8 +18,8 @@ export class MoneroWalletController {
@Post('/withdraw') @Post('/withdraw')
@Throttle(throttleProfiles.walletWithdraw) @Throttle(throttleProfiles.walletWithdraw)
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) { withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) {
return this.walletAdminService.withdrawAll(destinationAddress, password); return this.walletAdminService.withdrawAll(destinationAddress, priority, password);
} }
@Post('/reveal-seed') @Post('/reveal-seed')
@@ -1,5 +1,6 @@
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString } from 'class-validator'; import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress'; import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
export class MoneroWalletWithdrawDto { export class MoneroWalletWithdrawDto {
@@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto {
@IsMoneroStandardAddress() @IsMoneroStandardAddress()
destinationAddress: string; destinationAddress: string;
@IsNotEmpty()
@IsEnum(MoneroWithdrawPriority)
priority: MoneroWithdrawPriority;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
password: string; password: string;
@@ -2,6 +2,7 @@ import { BadRequestException, ServiceUnavailableException } from '@nestjs/common
import type { ConfigService } from '@nestjs/config'; import type { ConfigService } from '@nestjs/config';
import axios from 'axios'; import axios from 'axios';
import type { AuthService } from '../../auth/services/AuthService'; import type { AuthService } from '../../auth/services/AuthService';
import { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
import { MoneroWalletAdminService } from './MoneroWalletAdminService'; import { MoneroWalletAdminService } from './MoneroWalletAdminService';
@@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => {
unlockedBalanceAtomic: '0' unlockedBalanceAtomic: '0'
}); });
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( await expect(
new BadRequestException('No unlocked balance to withdraw.') service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
); ).rejects.toThrow(new BadRequestException('No unlocked balance to withdraw.'));
expect(authService.verifyPassword).toHaveBeenCalledWith('password'); expect(authService.verifyPassword).toHaveBeenCalledWith('password');
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled(); expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
@@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => {
it('rejects withdrawals while the wallet is still syncing', async () => { it('rejects withdrawals while the wallet is still syncing', async () => {
walletRpcClient.getHeight.mockResolvedValue(2_999_000); walletRpcClient.getHeight.mockResolvedValue(2_999_000);
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( await expect(
new BadRequestException('Wallet is still syncing. Try again after sync completes.') 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 () => { it('sweeps unlocked funds when the wallet is synced', async () => {
const result = await service.withdrawAll('4DestinationAddressExample', 'password'); const result = await service.withdrawAll(
'4DestinationAddressExample',
MoneroWithdrawPriority.Fast,
'password'
);
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample'); expect(walletRpcClient.sweepAll).toHaveBeenCalledWith(
'4DestinationAddressExample',
MoneroWithdrawPriority.Fast
);
expect(result).toEqual({ expect(result).toEqual({
txHashes: ['tx-hash-1'], txHashes: ['tx-hash-1'],
amountXmr: '1.00000000' amountXmr: '1.00000000'
@@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => {
it('throws when sweep all fails after prechecks pass', async () => { it('throws when sweep all fails after prechecks pass', async () => {
walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed')); walletRpcClient.sweepAll.mockRejectedValue(new Error('sweep failed'));
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( await expect(
service.withdrawAll('4DestinationAddressExample', MoneroWithdrawPriority.Normal, 'password')
).rejects.toThrow(
new ServiceUnavailableException( new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
) )
@@ -8,6 +8,7 @@ import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResu
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult'; import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView'; import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult'; import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
@@ -49,7 +50,11 @@ export class MoneroWalletAdminService {
} }
} }
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> { async withdrawAll(
destinationAddress: string,
priority: MoneroWithdrawPriority,
password: string
): Promise<MoneroWalletWithdrawResult> {
this.authService.verifyPassword(password); this.authService.verifyPassword(password);
await this.walletRpcClient.tryRefresh(); await this.walletRpcClient.tryRefresh();
@@ -59,11 +64,15 @@ export class MoneroWalletAdminService {
let daemonHeight: number | null; let daemonHeight: number | null;
try { try {
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([ const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([
this.walletRpcClient.getBalance(), this.walletRpcClient.getBalance(),
this.walletRpcClient.getHeight(), this.walletRpcClient.getHeight(),
this.fetchDaemonHeight() this.fetchDaemonHeight()
]); ]);
unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic;
walletHeight = walletHeightResult;
daemonHeight = daemonHeightResult;
} catch { } catch {
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.'); throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
} }
@@ -80,7 +89,10 @@ export class MoneroWalletAdminService {
let amountAtomic: string; let amountAtomic: string;
try { try {
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress)); const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority);
txHashes = sweepResult.txHashes;
amountAtomic = sweepResult.amountAtomic;
} catch { } catch {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
@@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe
import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult'; import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult';
import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult'; import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult';
import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult'; import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult';
import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult'; import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult';
import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge'; import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge';
import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse'; import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse';
@@ -222,14 +223,17 @@ export class MoneroWalletRpcClient {
}; };
} }
async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> { async sweepAll(
destinationAddress: string,
priority: MoneroWithdrawPriority
): Promise<{ txHashes: string[]; amountAtomic: string }> {
const result = await this.call<MoneroWalletRpcSweepAllResult>( const result = await this.call<MoneroWalletRpcSweepAllResult>(
'sweep_all', 'sweep_all',
{ {
address: destinationAddress, address: destinationAddress,
account_index: this.accountIndex, account_index: this.accountIndex,
subaddr_indices_all: true, subaddr_indices_all: true,
priority: 1 priority
}, },
{ timeoutMs: 120_000 } { timeoutMs: 120_000 }
); );
@@ -0,0 +1,15 @@
/**
* Maps to monero-wallet-rpc `sweep_all` / `transfer` priority (04).
*
* Absolute fee multipliers (fee algorithm 4, current mainnet) are 1, 5, 25, 1000 for priorities 14.
* Official GUI labels are scaled relative to Normal (priority 2): 0.2×, 1×, 5×, 200×.
*
* @see https://www.getmonero.org/resources/developer-guides/wallet-rpc.html#sweep_all
*/
export enum MoneroWithdrawPriority {
Automatic = 0,
Slow = 1,
Normal = 2,
Fast = 3,
Fastest = 4
}