Compare commits

..
2 Commits
Author SHA1 Message Date
nobswebdev c1e28c7545 add fee priority selector to Monero wallet withdraw form.
Expose GUI-style priority options in the CMS and include the chosen priority in withdrawal confirmation.
2026-09-06 18:21:13 +02:00
nobswebdev 83cb437f19 add fee priority to Monero wallet withdrawal API.
Pass sweep_all priority through the withdraw endpoint so admins can choose transaction fee speed.
2026-09-06 18:21:07 +02:00
12 changed files with 145 additions and 23 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
}
+40 -6
View File
@@ -49,6 +49,7 @@
<el-form-item label="Destination address" prop="destinationAddress"> <el-form-item label="Destination address" prop="destinationAddress">
<el-input <el-input
v-model="withdrawForm.destinationAddress" v-model="withdrawForm.destinationAddress"
class="withdraw-address-input"
autocomplete="off" autocomplete="off"
:placeholder="`${walletStatus.network} Monero address`" :placeholder="`${walletStatus.network} Monero address`"
:disabled="withdrawing" :disabled="withdrawing"
@@ -56,6 +57,17 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="Fee priority" prop="priority">
<el-select v-model="withdrawForm.priority" class="fee-priority-select" :disabled="withdrawing">
<el-option
v-for="option in moneroWithdrawPriorityOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</el-form-item>
<el-button type="primary" native-type="submit" :loading="withdrawing"> <el-button type="primary" native-type="submit" :loading="withdrawing">
Withdraw all unlocked funds Withdraw all unlocked funds
</el-button> </el-button>
@@ -90,9 +102,12 @@
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'; import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { computed, onBeforeMount, reactive, ref } from 'vue'; import { computed, onBeforeMount, reactive, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { moneroWithdrawPriorityOptions } from '@/consts/moneroWallet/moneroWithdrawPriorityOptions';
import { MoneroWithdrawPriority } from '@/types/moneroWallet/MoneroWithdrawPriority';
import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus'; import { WalletSyncStatus } from '@/types/wallet/WalletSyncStatus';
import { useMoneroWalletStore } from '@/stores/moneroWallet'; import { useMoneroWalletStore } from '@/stores/moneroWallet';
import { isMoneroStandardAddress } from '@/utils/monero/isMoneroStandardAddress'; import { isMoneroStandardAddress } from '@/utils/monero/isMoneroStandardAddress';
import { resolveMoneroWithdrawPriorityLabel } from '@/utils/monero/resolveMoneroWithdrawPriorityLabel';
import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage'; import { resolveAxiosErrorMessage } from '@/utils/resolveAxiosErrorMessage';
import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel'; import { resolveWalletSyncStatusLabel } from '@/utils/wallet/resolveWalletSyncStatusLabel';
import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType'; import { resolveWalletSyncStatusTagType } from '@/utils/wallet/resolveWalletSyncStatusTagType';
@@ -110,7 +125,8 @@ const withdrawing = ref(false);
const revealingSeed = ref(false); const revealingSeed = ref(false);
const withdrawFormRef = ref<FormInstance>(); const withdrawFormRef = ref<FormInstance>();
const withdrawForm = reactive({ const withdrawForm = reactive({
destinationAddress: '' destinationAddress: '',
priority: MoneroWithdrawPriority.Automatic
}); });
const seedDialogVisible = ref(false); const seedDialogVisible = ref(false);
const revealedMnemonic = ref(''); const revealedMnemonic = ref('');
@@ -209,11 +225,17 @@ const onWithdrawSubmit = async (): Promise<void> => {
const trimmedAddress = withdrawForm.destinationAddress.trim(); const trimmedAddress = withdrawForm.destinationAddress.trim();
try { try {
await ElMessageBox.confirm(`Withdraw all unlocked funds to:\n${trimmedAddress}`, 'Confirm withdrawal', { const priorityLabel = resolveMoneroWithdrawPriorityLabel(withdrawForm.priority);
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel', await ElMessageBox.confirm(
type: 'warning' `Withdraw all unlocked funds at ${priorityLabel} fee to:\n${trimmedAddress}`,
}); 'Confirm withdrawal',
{
confirmButtonText: 'Continue',
cancelButtonText: 'Cancel',
type: 'warning'
}
);
} catch { } catch {
return; return;
} }
@@ -229,6 +251,7 @@ const onWithdrawSubmit = async (): Promise<void> => {
try { try {
const result = await withdrawAll({ const result = await withdrawAll({
destinationAddress: trimmedAddress, destinationAddress: trimmedAddress,
priority: withdrawForm.priority,
password password
}); });
@@ -290,3 +313,14 @@ const clearSeed = (): void => {
revealedMnemonic.value = ''; revealedMnemonic.value = '';
}; };
</script> </script>
<style scoped>
.withdraw-address-input {
width: 100%;
max-width: min(560px, 100%);
}
.fee-priority-select {
width: 200px;
}
</style>
@@ -0,0 +1,13 @@
import { MoneroWithdrawPriority } from '@/types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWithdrawPriorityLabel } from '@/types/moneroWallet/MoneroWithdrawPriorityLabel';
export const moneroWithdrawPriorityOptions = [
{ value: MoneroWithdrawPriority.Automatic, label: 'Automatic' },
{ value: MoneroWithdrawPriority.Slow, label: 'Slow (0.2×)' },
{ value: MoneroWithdrawPriority.Normal, label: 'Normal (1×)' },
{ value: MoneroWithdrawPriority.Fast, label: 'Fast (5×)' },
{ value: MoneroWithdrawPriority.Fastest, label: 'Fastest (200×)' }
] as const satisfies ReadonlyArray<{
value: MoneroWithdrawPriority;
label: MoneroWithdrawPriorityLabel;
}>;
@@ -1,4 +1,7 @@
import { MoneroWithdrawPriority } from './MoneroWithdrawPriority';
export interface MoneroWalletWithdrawPayload { export interface MoneroWalletWithdrawPayload {
destinationAddress: string; destinationAddress: string;
priority: MoneroWithdrawPriority;
password: string; password: string;
} }
@@ -0,0 +1,7 @@
export enum MoneroWithdrawPriority {
Automatic = 0,
Slow = 1,
Normal = 2,
Fast = 3,
Fastest = 4
}
@@ -0,0 +1,6 @@
export type MoneroWithdrawPriorityLabel =
| 'Automatic'
| 'Slow (0.2×)'
| 'Normal (1×)'
| 'Fast (5×)'
| 'Fastest (200×)';
@@ -0,0 +1,13 @@
import { moneroWithdrawPriorityOptions } from '@/consts/moneroWallet/moneroWithdrawPriorityOptions';
import { MoneroWithdrawPriority } from '@/types/moneroWallet/MoneroWithdrawPriority';
import type { MoneroWithdrawPriorityLabel } from '@/types/moneroWallet/MoneroWithdrawPriorityLabel';
export const resolveMoneroWithdrawPriorityLabel = (priority: MoneroWithdrawPriority): MoneroWithdrawPriorityLabel => {
const option = moneroWithdrawPriorityOptions.find(({ value }) => value === priority);
if (!option) {
throw new Error(`Invalid Monero withdraw priority: ${priority}`);
}
return option.label;
};