From 83cb437f196e0580937025ad165460d041ab0cf0 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 6 Sep 2026 18:21:07 +0200 Subject: [PATCH] add fee priority to Monero wallet withdrawal API. Pass sweep_all priority through the withdraw endpoint so admins can choose transaction fee speed. --- .../controllers/MoneroWalletController.ts | 4 +-- .../dto/MoneroWalletWithdrawDto.ts | 7 ++++- .../services/MoneroWalletAdminService.spec.ts | 28 +++++++++++++------ .../services/MoneroWalletAdminService.ts | 18 ++++++++++-- .../services/MoneroWalletRpcClient.ts | 8 ++++-- .../moneroWallet/MoneroWithdrawPriority.ts | 15 ++++++++++ 6 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 backend/src/types/moneroWallet/MoneroWithdrawPriority.ts diff --git a/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts b/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts index 8202632..860a3bf 100644 --- a/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts +++ b/backend/src/modules/moneroWallet/controllers/MoneroWalletController.ts @@ -18,8 +18,8 @@ export class MoneroWalletController { @Post('/withdraw') @Throttle(throttleProfiles.walletWithdraw) - withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) { - return this.walletAdminService.withdrawAll(destinationAddress, password); + withdraw(@Body() { destinationAddress, priority, password }: MoneroWalletWithdrawDto) { + return this.walletAdminService.withdrawAll(destinationAddress, priority, password); } @Post('/reveal-seed') diff --git a/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts b/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts index 942c1c5..4eca8bd 100644 --- a/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts +++ b/backend/src/modules/moneroWallet/dto/MoneroWalletWithdrawDto.ts @@ -1,5 +1,6 @@ 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'; export class MoneroWalletWithdrawDto { @@ -9,6 +10,10 @@ export class MoneroWalletWithdrawDto { @IsMoneroStandardAddress() destinationAddress: string; + @IsNotEmpty() + @IsEnum(MoneroWithdrawPriority) + priority: MoneroWithdrawPriority; + @IsString() @IsNotEmpty() password: string; diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts index 365dd14..162c7c7 100644 --- a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts +++ b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.spec.ts @@ -2,6 +2,7 @@ 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'; @@ -99,9 +100,9 @@ describe('MoneroWalletAdminService', () => { unlockedBalanceAtomic: '0' }); - await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( - new BadRequestException('No unlocked balance to withdraw.') - ); + 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(); @@ -110,15 +111,22 @@ describe('MoneroWalletAdminService', () => { it('rejects withdrawals while the wallet is still syncing', async () => { walletRpcClient.getHeight.mockResolvedValue(2_999_000); - await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow( - new BadRequestException('Wallet is still syncing. Try again after sync completes.') - ); + 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', '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({ txHashes: ['tx-hash-1'], amountXmr: '1.00000000' @@ -163,7 +171,9 @@ describe('MoneroWalletAdminService', () => { it('throws when sweep all fails after prechecks pass', async () => { 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( 'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.' ) diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts index b146367..e396f80 100644 --- a/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts +++ b/backend/src/modules/moneroWallet/services/MoneroWalletAdminService.ts @@ -8,6 +8,7 @@ import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResu import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult'; import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView'; import { WalletSyncStatus } from '../../../types/wallet/WalletSyncStatus'; +import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority'; import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult'; import { MoneroWalletRpcClient } from './MoneroWalletRpcClient'; @@ -49,7 +50,11 @@ export class MoneroWalletAdminService { } } - async withdrawAll(destinationAddress: string, password: string): Promise { + async withdrawAll( + destinationAddress: string, + priority: MoneroWithdrawPriority, + password: string + ): Promise { this.authService.verifyPassword(password); await this.walletRpcClient.tryRefresh(); @@ -59,11 +64,15 @@ export class MoneroWalletAdminService { let daemonHeight: number | null; try { - [{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([ + const [balanceResult, walletHeightResult, daemonHeightResult] = await Promise.all([ this.walletRpcClient.getBalance(), this.walletRpcClient.getHeight(), this.fetchDaemonHeight() ]); + + unlockedBalanceAtomic = balanceResult.unlockedBalanceAtomic; + walletHeight = walletHeightResult; + daemonHeight = daemonHeightResult; } catch { throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.'); } @@ -80,7 +89,10 @@ export class MoneroWalletAdminService { let amountAtomic: string; try { - ({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress)); + const sweepResult = await this.walletRpcClient.sweepAll(destinationAddress, priority); + + txHashes = sweepResult.txHashes; + amountAtomic = sweepResult.amountAtomic; } catch { 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.' diff --git a/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts index f7748fd..e9713f3 100644 --- a/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts +++ b/backend/src/modules/moneroWallet/services/MoneroWalletRpcClient.ts @@ -12,6 +12,7 @@ import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGe import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult'; import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult'; import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult'; +import type { MoneroWithdrawPriority } from '../../../types/moneroWallet/MoneroWithdrawPriority'; import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult'; import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge'; 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( 'sweep_all', { address: destinationAddress, account_index: this.accountIndex, subaddr_indices_all: true, - priority: 1 + priority }, { timeoutMs: 120_000 } ); diff --git a/backend/src/types/moneroWallet/MoneroWithdrawPriority.ts b/backend/src/types/moneroWallet/MoneroWithdrawPriority.ts new file mode 100644 index 0000000..b04a386 --- /dev/null +++ b/backend/src/types/moneroWallet/MoneroWithdrawPriority.ts @@ -0,0 +1,15 @@ +/** + * Maps to monero-wallet-rpc `sweep_all` / `transfer` priority (0–4). + * + * Absolute fee multipliers (fee algorithm 4, current mainnet) are 1, 5, 25, 1000 for priorities 1–4. + * 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 +}