init
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/AuthModule';
|
||||
import { MoneroWalletController } from './controllers/MoneroWalletController';
|
||||
import { MoneroWalletAdminService } from './services/MoneroWalletAdminService';
|
||||
import { MoneroWalletRpcClient } from './services/MoneroWalletRpcClient';
|
||||
import { MoneroWalletRpcConnectionService } from './services/MoneroWalletRpcConnectionService';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [MoneroWalletController],
|
||||
providers: [MoneroWalletRpcClient, MoneroWalletRpcConnectionService, MoneroWalletAdminService],
|
||||
exports: [MoneroWalletRpcClient]
|
||||
})
|
||||
export class MoneroWalletModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { JwtGuard } from '../../../guards/JwtGuard';
|
||||
import { throttleProfiles } from '../../../config/throttleProfiles';
|
||||
import { MoneroWalletRevealSeedDto } from '../dto/MoneroWalletRevealSeedDto';
|
||||
import { MoneroWalletWithdrawDto } from '../dto/MoneroWalletWithdrawDto';
|
||||
import { MoneroWalletAdminService } from '../services/MoneroWalletAdminService';
|
||||
|
||||
@Controller('monero-wallet')
|
||||
@UseGuards(JwtGuard)
|
||||
export class MoneroWalletController {
|
||||
constructor(private readonly walletAdminService: MoneroWalletAdminService) {}
|
||||
|
||||
@Get('/')
|
||||
getStatus() {
|
||||
return this.walletAdminService.getStatus();
|
||||
}
|
||||
|
||||
@Post('/withdraw')
|
||||
@Throttle(throttleProfiles.walletWithdraw)
|
||||
withdraw(@Body() { destinationAddress, password }: MoneroWalletWithdrawDto) {
|
||||
return this.walletAdminService.withdrawAll(destinationAddress, password);
|
||||
}
|
||||
|
||||
@Post('/reveal-seed')
|
||||
@Throttle(throttleProfiles.walletRevealSeed)
|
||||
revealSeed(@Body() { password }: MoneroWalletRevealSeedDto) {
|
||||
return this.walletAdminService.revealSeed(password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class MoneroWalletRevealSeedDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { IsMoneroStandardAddress } from '../../../validation/decorators/isMoneroStandardAddress';
|
||||
|
||||
export class MoneroWalletWithdrawDto {
|
||||
@Transform(({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value))
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsMoneroStandardAddress()
|
||||
destinationAddress: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type { AuthService } from '../../auth/services/AuthService';
|
||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletAdminService } from './MoneroWalletAdminService';
|
||||
|
||||
jest.mock('axios');
|
||||
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('MoneroWalletAdminService', () => {
|
||||
let service: MoneroWalletAdminService;
|
||||
let walletRpcClient: {
|
||||
tryRefresh: jest.Mock;
|
||||
getVersion: jest.Mock;
|
||||
getHeight: jest.Mock;
|
||||
getBalance: jest.Mock;
|
||||
sweepAll: jest.Mock;
|
||||
queryMnemonic: jest.Mock;
|
||||
};
|
||||
let authService: {
|
||||
verifyPassword: jest.Mock;
|
||||
};
|
||||
let configService: {
|
||||
get: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
walletRpcClient = {
|
||||
tryRefresh: jest.fn().mockResolvedValue(undefined),
|
||||
getVersion: jest.fn().mockResolvedValue('0.18.3.1'),
|
||||
getHeight: jest.fn().mockResolvedValue(3_000_000),
|
||||
getBalance: jest.fn().mockResolvedValue({
|
||||
balanceAtomic: '2000000000000',
|
||||
unlockedBalanceAtomic: '1000000000000'
|
||||
}),
|
||||
sweepAll: jest.fn().mockResolvedValue({
|
||||
txHashes: ['tx-hash-1'],
|
||||
amountAtomic: '1000000000000'
|
||||
}),
|
||||
queryMnemonic: jest.fn().mockResolvedValue('seed words')
|
||||
};
|
||||
|
||||
authService = {
|
||||
verifyPassword: jest.fn()
|
||||
};
|
||||
|
||||
configService = {
|
||||
get: jest.fn().mockReturnValue({
|
||||
network: 'mainnet',
|
||||
daemonRpcUrl: 'http://daemon.test/json_rpc',
|
||||
rpcTimeoutMs: 5000
|
||||
})
|
||||
};
|
||||
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { result: { height: 3_000_000 } }
|
||||
});
|
||||
|
||||
service = new MoneroWalletAdminService(
|
||||
walletRpcClient as unknown as MoneroWalletRpcClient,
|
||||
authService as unknown as AuthService,
|
||||
configService as unknown as ConfigService
|
||||
);
|
||||
});
|
||||
|
||||
it('returns wallet status when RPC and daemon calls succeed', async () => {
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(walletRpcClient.tryRefresh).toHaveBeenCalled();
|
||||
expect(status).toEqual(
|
||||
expect.objectContaining({
|
||||
network: 'mainnet',
|
||||
rpcVersion: '0.18.3.1',
|
||||
walletHeight: 3_000_000,
|
||||
daemonHeight: 3_000_000,
|
||||
syncStatus: MoneroWalletSyncStatus.Synced,
|
||||
balanceXmr: '2.00000000',
|
||||
unlockedBalanceXmr: '1.00000000'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when wallet status cannot be loaded', async () => {
|
||||
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(service.getStatus()).rejects.toThrow(
|
||||
new ServiceUnavailableException(
|
||||
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects withdrawals when there is no unlocked balance', async () => {
|
||||
walletRpcClient.getBalance.mockResolvedValue({
|
||||
balanceAtomic: '0',
|
||||
unlockedBalanceAtomic: '0'
|
||||
});
|
||||
|
||||
await expect(service.withdrawAll('4DestinationAddressExample', 'password')).rejects.toThrow(
|
||||
new BadRequestException('No unlocked balance to withdraw.')
|
||||
);
|
||||
|
||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||
expect(walletRpcClient.sweepAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.')
|
||||
);
|
||||
});
|
||||
|
||||
it('sweeps unlocked funds when the wallet is synced', async () => {
|
||||
const result = await service.withdrawAll('4DestinationAddressExample', 'password');
|
||||
|
||||
expect(walletRpcClient.sweepAll).toHaveBeenCalledWith('4DestinationAddressExample');
|
||||
expect(result).toEqual({
|
||||
txHashes: ['tx-hash-1'],
|
||||
amountXmr: '1.00000000'
|
||||
});
|
||||
});
|
||||
|
||||
it('reveals the wallet seed after password verification', async () => {
|
||||
await expect(service.revealSeed('password')).resolves.toEqual({ mnemonic: 'seed words' });
|
||||
|
||||
expect(authService.verifyPassword).toHaveBeenCalledWith('password');
|
||||
expect(walletRpcClient.queryMnemonic).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reports unknown sync status when the daemon height cannot be fetched', async () => {
|
||||
mockedAxios.post.mockRejectedValue(new Error('daemon down'));
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Unknown);
|
||||
expect(status.daemonHeight).toBeNull();
|
||||
});
|
||||
|
||||
it('treats the wallet as synced when it is one block behind the daemon', async () => {
|
||||
walletRpcClient.getHeight.mockResolvedValue(2_999_999);
|
||||
mockedAxios.post.mockResolvedValue({
|
||||
data: { result: { height: 3_000_000 } }
|
||||
});
|
||||
|
||||
const status = await service.getStatus();
|
||||
|
||||
expect(status.syncStatus).toBe(MoneroWalletSyncStatus.Synced);
|
||||
});
|
||||
|
||||
it('throws when reveal seed RPC fails', async () => {
|
||||
walletRpcClient.queryMnemonic.mockRejectedValue(new Error('rpc down'));
|
||||
|
||||
await expect(service.revealSeed('password')).rejects.toThrow(
|
||||
new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.')
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
new ServiceUnavailableException(
|
||||
'Withdrawal failed. Funds may be unspendable dust, still locked, or the wallet may be out of sync. Refresh status and try again.'
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException, Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { AuthService } from '../../auth/services/AuthService';
|
||||
import { convertXmrAtomicToXmr } from '../../../utils/monero/convertXmrAtomicToXmr';
|
||||
import type { Config } from '../../../types/Config';
|
||||
import type { MoneroDaemonGetInfoResult } from '../types/MoneroDaemonGetInfoResult';
|
||||
import type { MoneroWalletRevealSeedResult } from '../types/MoneroWalletRevealSeedResult';
|
||||
import type { MoneroWalletStatusView } from '../types/MoneroWalletStatusView';
|
||||
import { MoneroWalletSyncStatus } from '../types/MoneroWalletSyncStatus';
|
||||
import type { MoneroWalletWithdrawResult } from '../types/MoneroWalletWithdrawResult';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletAdminService {
|
||||
constructor(
|
||||
private readonly walletRpcClient: MoneroWalletRpcClient,
|
||||
private readonly authService: AuthService,
|
||||
private readonly configService: ConfigService
|
||||
) {}
|
||||
|
||||
async getStatus(): Promise<MoneroWalletStatusView> {
|
||||
const { network } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
await this.walletRpcClient.tryRefresh();
|
||||
|
||||
try {
|
||||
const [rpcVersion, daemonHeight, walletHeight, { balanceAtomic, unlockedBalanceAtomic }] =
|
||||
await Promise.all([
|
||||
this.walletRpcClient.getVersion(),
|
||||
this.fetchDaemonHeight(),
|
||||
this.walletRpcClient.getHeight(),
|
||||
this.walletRpcClient.getBalance()
|
||||
]);
|
||||
|
||||
return {
|
||||
network,
|
||||
rpcVersion,
|
||||
walletHeight,
|
||||
daemonHeight,
|
||||
syncStatus: this.resolveSyncStatus(walletHeight, daemonHeight),
|
||||
balanceXmr: convertXmrAtomicToXmr(balanceAtomic),
|
||||
unlockedBalanceXmr: convertXmrAtomicToXmr(unlockedBalanceAtomic)
|
||||
};
|
||||
} catch {
|
||||
throw new ServiceUnavailableException(
|
||||
'Could not load wallet status. The Monero wallet may be busy or unavailable.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async withdrawAll(destinationAddress: string, password: string): Promise<MoneroWalletWithdrawResult> {
|
||||
this.authService.verifyPassword(password);
|
||||
|
||||
await this.walletRpcClient.tryRefresh();
|
||||
|
||||
let unlockedBalanceAtomic: string;
|
||||
let walletHeight: number;
|
||||
let daemonHeight: number | null;
|
||||
|
||||
try {
|
||||
[{ unlockedBalanceAtomic }, walletHeight, daemonHeight] = await Promise.all([
|
||||
this.walletRpcClient.getBalance(),
|
||||
this.walletRpcClient.getHeight(),
|
||||
this.fetchDaemonHeight()
|
||||
]);
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||
}
|
||||
|
||||
if (unlockedBalanceAtomic === '0') {
|
||||
throw new BadRequestException('No unlocked balance to withdraw.');
|
||||
}
|
||||
|
||||
if (this.resolveSyncStatus(walletHeight, daemonHeight) !== MoneroWalletSyncStatus.Synced) {
|
||||
throw new BadRequestException('Wallet is still syncing. Try again after sync completes.');
|
||||
}
|
||||
|
||||
let txHashes: string[];
|
||||
let amountAtomic: string;
|
||||
|
||||
try {
|
||||
({ txHashes, amountAtomic } = await this.walletRpcClient.sweepAll(destinationAddress));
|
||||
} 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.'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
txHashes,
|
||||
amountXmr: convertXmrAtomicToXmr(amountAtomic)
|
||||
};
|
||||
}
|
||||
|
||||
async revealSeed(password: string): Promise<MoneroWalletRevealSeedResult> {
|
||||
this.authService.verifyPassword(password);
|
||||
|
||||
try {
|
||||
const mnemonic = await this.walletRpcClient.queryMnemonic();
|
||||
|
||||
return { mnemonic };
|
||||
} catch {
|
||||
throw new ServiceUnavailableException('Could not reach the Monero wallet. Try again in a moment.');
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchDaemonHeight(): Promise<number | null> {
|
||||
const { daemonRpcUrl, rpcTimeoutMs } = this.configService.get('moneroWallet') as Config['moneroWallet'];
|
||||
|
||||
try {
|
||||
const { data } = await axios.post<{ result?: MoneroDaemonGetInfoResult }>(
|
||||
daemonRpcUrl,
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: '0',
|
||||
method: 'get_info'
|
||||
},
|
||||
{ timeout: rpcTimeoutMs }
|
||||
);
|
||||
|
||||
return data.result?.height ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveSyncStatus(walletHeight: number, daemonHeight: number | null): MoneroWalletSyncStatus {
|
||||
if (daemonHeight === null) {
|
||||
return MoneroWalletSyncStatus.Unknown;
|
||||
}
|
||||
|
||||
return walletHeight >= daemonHeight - 1 ? MoneroWalletSyncStatus.Synced : MoneroWalletSyncStatus.Syncing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { MoneroWalletRpcClientTest } from '../types/MoneroWalletRpcClientTest';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
jest.mock('node:crypto', () => {
|
||||
const actual = jest.requireActual<typeof import('node:crypto')>('node:crypto');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
randomBytes: jest.fn(() => Buffer.from('0123456789abcdef', 'hex'))
|
||||
};
|
||||
});
|
||||
|
||||
describe('MoneroWalletRpcClient', () => {
|
||||
let client: MoneroWalletRpcClientTest;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new MoneroWalletRpcClient({
|
||||
get: jest.fn()
|
||||
} as unknown as ConfigService) as unknown as MoneroWalletRpcClientTest;
|
||||
});
|
||||
|
||||
describe('formatRpcVersion', () => {
|
||||
it('formats the packed RPC version integer from get_version', () => {
|
||||
expect(client.formatRpcVersion(65539)).toBe('1.3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDigestChallenge', () => {
|
||||
it('parses a full digest challenge header', () => {
|
||||
const header = 'Digest realm="monero-rpc", nonce="abc123", opaque="opaque-value", qop="auth"';
|
||||
|
||||
expect(client.parseDigestChallenge(header)).toEqual({
|
||||
realm: 'monero-rpc',
|
||||
nonce: 'abc123',
|
||||
opaque: 'opaque-value',
|
||||
qop: 'auth'
|
||||
});
|
||||
});
|
||||
|
||||
it('parses headers with a lowercase digest prefix', () => {
|
||||
const header = 'digest realm="monero-rpc", nonce="abc123"';
|
||||
|
||||
expect(client.parseDigestChallenge(header)).toEqual({
|
||||
realm: 'monero-rpc',
|
||||
nonce: 'abc123',
|
||||
opaque: undefined,
|
||||
qop: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when realm is missing', () => {
|
||||
expect(() => client.parseDigestChallenge('Digest nonce="abc123"')).toThrow(
|
||||
'Invalid Monero wallet RPC digest challenge'
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when nonce is missing', () => {
|
||||
expect(() => client.parseDigestChallenge('Digest realm="monero-rpc"')).toThrow(
|
||||
'Invalid Monero wallet RPC digest challenge'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncomingTransfers', () => {
|
||||
let rpcClient: MoneroWalletRpcClient;
|
||||
let callSpy: jest.SpiedFunction<(method: string, params?: Record<string, unknown>) => Promise<unknown>>;
|
||||
|
||||
beforeEach(() => {
|
||||
rpcClient = new MoneroWalletRpcClient({
|
||||
get: jest.fn()
|
||||
} as unknown as ConfigService);
|
||||
|
||||
callSpy = jest.spyOn(
|
||||
MoneroWalletRpcClient.prototype as unknown as {
|
||||
call: (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
},
|
||||
'call'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
callSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('returns an empty array when the RPC omits in and pool', async () => {
|
||||
callSpy.mockResolvedValue({});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('maps confirmed and pending transfers when present', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{
|
||||
txid: 'confirmed-tx',
|
||||
amount: 1000000000000,
|
||||
confirmations: 2,
|
||||
subaddr_index: { major: 0, minor: 3 }
|
||||
}
|
||||
],
|
||||
pool: [
|
||||
{
|
||||
txid: 'pending-tx',
|
||||
amount: 500000000000,
|
||||
confirmations: 0,
|
||||
subaddr_index: { major: 0, minor: 4 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3, 4])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'confirmed-tx',
|
||||
amountAtomic: '1000000000000',
|
||||
confirmations: 2,
|
||||
subaddrIndex: 3
|
||||
},
|
||||
{
|
||||
txHash: 'pending-tx',
|
||||
amountAtomic: '500000000000',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 4
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults missing confirmations on confirmed transfers to zero', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{
|
||||
txid: 'confirmed-tx',
|
||||
amount: 1000000000000,
|
||||
subaddr_index: { major: 0, minor: 3 }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([3])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'confirmed-tx',
|
||||
amountAtomic: '1000000000000',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 3
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips malformed transfer entries', async () => {
|
||||
callSpy.mockResolvedValue({
|
||||
in: [
|
||||
{ amount: 1, subaddr_index: { major: 0, minor: 1 } },
|
||||
{ txid: 'valid-tx', amount: 2, subaddr_index: { major: 0, minor: 2 } }
|
||||
]
|
||||
});
|
||||
|
||||
await expect(rpcClient.getIncomingTransfers([1, 2])).resolves.toEqual([
|
||||
{
|
||||
txHash: 'valid-tx',
|
||||
amountAtomic: '2',
|
||||
confirmations: 0,
|
||||
subaddrIndex: 2
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDigestAuthorization', () => {
|
||||
it('builds a digest authorization header from the challenge', () => {
|
||||
const username = 'rpcuser';
|
||||
const password = 'rpcpass';
|
||||
const uri = '/json_rpc';
|
||||
const realm = 'monero-rpc';
|
||||
const nonce = 'server-nonce';
|
||||
const qop = 'auth';
|
||||
const nc = '00000001';
|
||||
const cnonce = '0123456789abcdef';
|
||||
const digestHeader = `Digest realm="${realm}", nonce="${nonce}", qop="${qop}"`;
|
||||
const ha1 = createHash('md5').update(`${username}:${realm}:${password}`).digest('hex');
|
||||
const ha2 = createHash('md5').update(`POST:${uri}`).digest('hex');
|
||||
const response = createHash('md5').update(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`).digest('hex');
|
||||
|
||||
expect(client.buildDigestAuthorization(uri, username, password, digestHeader)).toBe(
|
||||
`Digest username="${username}", realm="${realm}", nonce="${nonce}", uri="${uri}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${response}"`
|
||||
);
|
||||
});
|
||||
|
||||
it('includes opaque when the challenge provides it', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", opaque="opaque-token", qop="auth"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('opaque="opaque-token"');
|
||||
});
|
||||
|
||||
it('defaults qop to auth when the challenge omits it', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('qop=auth');
|
||||
});
|
||||
|
||||
it('uses the first qop option when several are offered', () => {
|
||||
const digestHeader = 'Digest realm="monero-rpc", nonce="server-nonce", qop="auth, auth-int"';
|
||||
|
||||
const authorization = client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', digestHeader);
|
||||
|
||||
expect(authorization).toContain('qop=auth');
|
||||
expect(authorization).not.toContain('auth-int');
|
||||
});
|
||||
|
||||
it('throws when the challenge header is invalid', () => {
|
||||
expect(() =>
|
||||
client.buildDigestAuthorization('/json_rpc', 'rpcuser', 'rpcpass', 'Digest qop="auth"')
|
||||
).toThrow('Invalid Monero wallet RPC digest challenge');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
import { MoneroWalletRpcConnectionService } from './MoneroWalletRpcConnectionService';
|
||||
|
||||
describe('MoneroWalletRpcConnectionService', () => {
|
||||
let service: MoneroWalletRpcConnectionService;
|
||||
let walletRpcClient: {
|
||||
getVersion: jest.Mock;
|
||||
};
|
||||
let logSpy: jest.SpiedFunction<typeof Logger.prototype.log>;
|
||||
let errorSpy: jest.SpiedFunction<typeof Logger.prototype.error>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
errorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
walletRpcClient = {
|
||||
getVersion: jest.fn().mockResolvedValue('0.18.3.1')
|
||||
};
|
||||
|
||||
service = new MoneroWalletRpcConnectionService(walletRpcClient as unknown as MoneroWalletRpcClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
logSpy.mockRestore();
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('logs a successful wallet rpc connection on module init', async () => {
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(walletRpcClient.getVersion).toHaveBeenCalled();
|
||||
expect(logSpy).toHaveBeenCalledWith('Connected to monero-wallet-rpc (version 0.18.3.1)');
|
||||
});
|
||||
|
||||
it('logs an error when wallet rpc is unreachable at startup', async () => {
|
||||
walletRpcClient.getVersion.mockRejectedValue(new Error('connection refused'));
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith('Failed to reach monero-wallet-rpc at startup: connection refused');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { getErrorMessage } from '../../../utils/getErrorMessage';
|
||||
import { MoneroWalletRpcClient } from './MoneroWalletRpcClient';
|
||||
|
||||
@Injectable()
|
||||
export class MoneroWalletRpcConnectionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(MoneroWalletRpcConnectionService.name);
|
||||
|
||||
constructor(private readonly walletRpcClient: MoneroWalletRpcClient) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
try {
|
||||
const version = await this.walletRpcClient.getVersion();
|
||||
|
||||
this.logger.log(`Connected to monero-wallet-rpc (version ${version})`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to reach monero-wallet-rpc at startup: ${getErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroCreateAddressResult = {
|
||||
address?: string;
|
||||
address_index?: number;
|
||||
address_indices?: number[];
|
||||
addresses?: string[];
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroDaemonGetInfoResult {
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRevealSeedResult {
|
||||
mnemonic: string;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { MoneroWalletRpcDigestChallenge } from './MoneroWalletRpcDigestChallenge';
|
||||
|
||||
export type MoneroWalletRpcClientTest = {
|
||||
formatRpcVersion(version: number): string;
|
||||
parseDigestChallenge(header: string): MoneroWalletRpcDigestChallenge;
|
||||
buildDigestAuthorization(uri: string, username: string, password: string, digestHeader: string): string;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWalletRpcDigestChallenge = {
|
||||
realm: string;
|
||||
nonce: string;
|
||||
opaque?: string;
|
||||
qop?: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcError = {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletRpcGetBalanceResult {
|
||||
balance?: number;
|
||||
unlocked_balance?: number;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRpcGetHeightResult {
|
||||
height?: number;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MoneroWalletRpcTransferEntry } from './MoneroWalletRpcTransferEntry';
|
||||
|
||||
export type MoneroWalletRpcGetTransfersResult = {
|
||||
in?: MoneroWalletRpcTransferEntry[];
|
||||
out?: MoneroWalletRpcTransferEntry[];
|
||||
pending?: MoneroWalletRpcTransferEntry[];
|
||||
failed?: MoneroWalletRpcTransferEntry[];
|
||||
pool?: MoneroWalletRpcTransferEntry[];
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcGetVersionResult = {
|
||||
version?: number;
|
||||
release?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type MoneroWalletRpcIncomingTransfer = {
|
||||
txHash: string;
|
||||
amountAtomic: string;
|
||||
confirmations: number;
|
||||
subaddrIndex: number;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface MoneroWalletRpcQueryKeyResult {
|
||||
key?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { MoneroWalletRpcError } from './MoneroWalletRpcError';
|
||||
|
||||
export type MoneroWalletRpcResponse<T> = {
|
||||
id: string;
|
||||
jsonrpc: string;
|
||||
result?: T;
|
||||
error?: MoneroWalletRpcError;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export type MoneroWalletRpcSubaddrIndex = {
|
||||
major?: number;
|
||||
minor?: number;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletRpcSweepAllResult {
|
||||
tx_hash_list?: string[];
|
||||
amount_list?: number[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MoneroWalletRpcSubaddrIndex } from './MoneroWalletRpcSubaddrIndex';
|
||||
|
||||
export type MoneroWalletRpcTransferEntry = {
|
||||
txid?: string;
|
||||
amount?: number;
|
||||
confirmations?: number;
|
||||
subaddr_index?: MoneroWalletRpcSubaddrIndex;
|
||||
address?: string;
|
||||
height?: number;
|
||||
timestamp?: number;
|
||||
type?: string;
|
||||
locked?: boolean;
|
||||
amounts?: number[];
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { MoneroNetwork } from '../../../types/MoneroNetwork';
|
||||
import { MoneroWalletSyncStatus } from './MoneroWalletSyncStatus';
|
||||
|
||||
export interface MoneroWalletStatusView {
|
||||
network: MoneroNetwork;
|
||||
rpcVersion: string;
|
||||
walletHeight: number;
|
||||
daemonHeight: number | null;
|
||||
syncStatus: MoneroWalletSyncStatus;
|
||||
balanceXmr: string;
|
||||
unlockedBalanceXmr: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum MoneroWalletSyncStatus {
|
||||
Synced = 'synced',
|
||||
Syncing = 'syncing',
|
||||
Unknown = 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface MoneroWalletWithdrawResult {
|
||||
txHashes: string[];
|
||||
amountXmr: string;
|
||||
}
|
||||
Reference in New Issue
Block a user