import { HttpStatus, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import axios from 'axios'; import { createHash, randomBytes } from 'node:crypto'; import type { Config } from '../../../types/Config'; import { getErrorMessage } from '../../../utils/getErrorMessage'; import type { MoneroCreateAddressResult } from '../types/MoneroCreateAddressResult'; import type { MoneroWalletRpcIncomingTransfer } from '../types/MoneroWalletRpcIncomingTransfer'; import type { MoneroWalletRpcGetTransfersResult } from '../types/MoneroWalletRpcGetTransfersResult'; import type { MoneroWalletRpcTransferEntry } from '../types/MoneroWalletRpcTransferEntry'; import type { MoneroWalletRpcGetBalanceResult } from '../types/MoneroWalletRpcGetBalanceResult'; import type { MoneroWalletRpcGetHeightResult } from '../types/MoneroWalletRpcGetHeightResult'; import type { MoneroWalletRpcGetVersionResult } from '../types/MoneroWalletRpcGetVersionResult'; import type { MoneroWalletRpcQueryKeyResult } from '../types/MoneroWalletRpcQueryKeyResult'; import type { MoneroWalletRpcSweepAllResult } from '../types/MoneroWalletRpcSweepAllResult'; import type { MoneroWalletRpcDigestChallenge } from '../types/MoneroWalletRpcDigestChallenge'; import type { MoneroWalletRpcResponse } from '../types/MoneroWalletRpcResponse'; @Injectable() export class MoneroWalletRpcClient { private readonly logger = new Logger(MoneroWalletRpcClient.name); private readonly accountIndex = 0; constructor(private readonly configService: ConfigService) {} private async call( method: string, params: Record = {}, options: { timeoutMs?: number } = {} ): Promise { const { rpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet']; const timeoutMs = options.timeoutMs ?? rpcTimeoutMs; const body = { method, params }; const authorization = await this.doDigestAuthorization(body); const { data } = await axios.post>(rpcUrl, body, { timeout: timeoutMs, headers: { Authorization: authorization } }); if (data.error) { throw new Error(data.error.message); } if (data.result === undefined) { throw new Error(`Monero wallet RPC ${method} returned no result`); } return data.result; } private async doDigestAuthorization(body: { method: string; params: Record }): Promise { const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get( 'moneroWallet' ) as Config['moneroWallet']; const rpcUri = new URL(rpcUrl); const requestPath = `${rpcUri.pathname}${rpcUri.search}`; const challengeResponse = await axios.post(rpcUrl, body, { timeout: rpcTimeoutMs, validateStatus: (status: HttpStatus) => status === HttpStatus.UNAUTHORIZED }); const digestHeader = challengeResponse.headers['www-authenticate'] as unknown; if (!digestHeader || typeof digestHeader !== 'string') { throw new Error(`Monero wallet RPC ${body.method} did not return a digest auth challenge`); } return this.buildDigestAuthorization(requestPath, username, password, digestHeader); } private buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string { const challenge = this.parseDigestChallenge(digestHeader); const ha1 = createHash('md5').update(`${username}:${challenge.realm}:${password}`).digest('hex'); const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex'); const nc = '00000001'; const cnonce = randomBytes(8).toString('hex'); const qop = challenge.qop?.split(',')[0]?.trim() || 'auth'; const response = createHash('md5') .update(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${qop}:${ha2}`) .digest('hex'); const parts = [ `username="${username}"`, `realm="${challenge.realm}"`, `nonce="${challenge.nonce}"`, `uri="${uri}"`, `qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`, `response="${response}"` ]; if (challenge.opaque) { parts.push(`opaque="${challenge.opaque}"`); } return `Digest ${parts.join(', ')}`; } private parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge { const params = Object.fromEntries( header .replace(/^Digest\s+/i, '') .split(',') .map(part => { const [key, ...valueParts] = part.trim().split('='); return [key, valueParts.join('=').replace(/^"|"$/g, '')]; }) ); if (!params.realm || !params.nonce) { throw new Error('Invalid Monero wallet RPC digest challenge'); } return { realm: params.realm, nonce: params.nonce, opaque: params.opaque, qop: params.qop }; } async getVersion(): Promise { const { version, release } = await this.call('get_version'); if (version === undefined) { throw new Error('Monero wallet RPC get_version returned no version'); } const formatted = this.formatRpcVersion(version); if (release === false) { return `${formatted} (non-release)`; } return formatted; } private formatRpcVersion(version: number): string { const major = version >>> 16; const minor = version & 0xffff; return `${major}.${minor}`; } async createAddress(label?: string): Promise<{ address: string; address_index: number }> { const params: { account_index: number; label?: string } = { account_index: this.accountIndex }; if (label) { params.label = label; } const result = await this.call('create_address', params); if (!result.address || result.address_index === undefined) { throw new Error('Monero wallet RPC create_address returned incomplete result'); } return { address: result.address, address_index: result.address_index }; } async getIncomingTransfers(subaddrIndices: number[]): Promise { if (subaddrIndices.length === 0) { return []; } const result = await this.call('get_transfers', { in: true, pool: true, account_index: this.accountIndex, subaddr_indices: subaddrIndices }); const confirmed = (result.in ?? []) .map(transfer => this.mapIncomingTransfer(transfer, transfer.confirmations ?? 0)) .filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null); const pending = (result.pool ?? []) .map(transfer => this.mapIncomingTransfer(transfer, 0)) .filter((transfer): transfer is MoneroWalletRpcIncomingTransfer => transfer !== null); return [...confirmed, ...pending]; } async tryRefresh(): Promise { try { await this.call('refresh'); } catch (error) { this.logger.warn(`Monero wallet refresh failed: ${getErrorMessage(error)}`); } } async getHeight(): Promise { const { height } = await this.call('get_height'); if (height === undefined) { throw new Error('Monero wallet RPC get_height returned no height'); } return height; } async getBalance(): Promise<{ balanceAtomic: string; unlockedBalanceAtomic: string }> { const { balance, unlocked_balance } = await this.call('get_balance', { account_index: this.accountIndex }); if (balance === undefined || unlocked_balance === undefined) { throw new Error('Monero wallet RPC get_balance returned incomplete result'); } return { balanceAtomic: String(balance), unlockedBalanceAtomic: String(unlocked_balance) }; } async sweepAll(destinationAddress: string): Promise<{ txHashes: string[]; amountAtomic: string }> { const result = await this.call( 'sweep_all', { address: destinationAddress, account_index: this.accountIndex, subaddr_indices_all: true, priority: 1 }, { timeoutMs: 120_000 } ); const txHashes = result.tx_hash_list; if (!txHashes?.length) { throw new Error('Monero wallet RPC sweep_all returned no transaction hashes'); } const sweptAmount = (result.amount_list ?? []).reduce((sum, amount) => sum + amount, 0); return { txHashes, amountAtomic: String(sweptAmount) }; } async queryMnemonic(): Promise { const { key } = await this.call('query_key', { key_type: 'mnemonic' }); const trimmedKey = key?.trim(); if (!trimmedKey) { throw new Error('Monero wallet RPC query_key returned no mnemonic'); } return trimmedKey; } private mapIncomingTransfer( transfer: MoneroWalletRpcTransferEntry, confirmations: number ): MoneroWalletRpcIncomingTransfer | null { const txHash = transfer.txid?.trim(); if (!txHash || transfer.amount === undefined || transfer.subaddr_index?.minor === undefined) { return null; } return { txHash, amountAtomic: String(transfer.amount), confirmations, subaddrIndex: transfer.subaddr_index.minor }; } }