init
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
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<T>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
options: { timeoutMs?: number } = {}
|
||||
): Promise<T> {
|
||||
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<MoneroWalletRpcResponse<T>>(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<string, unknown> }): Promise<string> {
|
||||
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<string> {
|
||||
const { version, release } = await this.call<MoneroWalletRpcGetVersionResult>('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<MoneroCreateAddressResult>('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<MoneroWalletRpcIncomingTransfer[]> {
|
||||
if (subaddrIndices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await this.call<MoneroWalletRpcGetTransfersResult>('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<void> {
|
||||
try {
|
||||
await this.call('refresh');
|
||||
} catch (error) {
|
||||
this.logger.warn(`Monero wallet refresh failed: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getHeight(): Promise<number> {
|
||||
const { height } = await this.call<MoneroWalletRpcGetHeightResult>('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<MoneroWalletRpcGetBalanceResult>('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<MoneroWalletRpcSweepAllResult>(
|
||||
'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<string> {
|
||||
const { key } = await this.call<MoneroWalletRpcQueryKeyResult>('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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user