add bitcoin wallet admin status endpoint

This commit is contained in:
2026-09-04 11:59:06 +02:00
parent 8c0749875e
commit cae5b4fe50
7 changed files with 182 additions and 0 deletions
+4
View File
@@ -13,6 +13,7 @@ import {
getOrderConfig, getOrderConfig,
getInvoiceConfig, getInvoiceConfig,
getMoneroWalletConfig, getMoneroWalletConfig,
getElectrumWalletConfig,
getPostgresConfig, getPostgresConfig,
getShopSettingsConfig, getShopSettingsConfig,
getSimplexConfig getSimplexConfig
@@ -22,6 +23,7 @@ import { AuthModule } from './modules/auth/AuthModule';
import { EncryptionModule } from './modules/encryption/EncryptionModule'; import { EncryptionModule } from './modules/encryption/EncryptionModule';
import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule';
import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule';
import { BitcoinWalletModule } from './modules/bitcoinWallet/BitcoinWalletModule';
import { SimplexModule } from './modules/simplex/SimplexModule'; import { SimplexModule } from './modules/simplex/SimplexModule';
import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule'; import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule';
import { DataWipeModule } from './modules/dataWipe/DataWipeModule'; import { DataWipeModule } from './modules/dataWipe/DataWipeModule';
@@ -55,6 +57,7 @@ import { Config } from './types/Config';
registerAs('order', getOrderConfig), registerAs('order', getOrderConfig),
registerAs('invoice', getInvoiceConfig), registerAs('invoice', getInvoiceConfig),
registerAs('moneroWallet', getMoneroWalletConfig), registerAs('moneroWallet', getMoneroWalletConfig),
registerAs('electrumWallet', getElectrumWalletConfig),
registerAs('simplex', getSimplexConfig) registerAs('simplex', getSimplexConfig)
] ]
}), }),
@@ -91,6 +94,7 @@ import { Config } from './types/Config';
PaymentModule, PaymentModule,
DataWipeModule, DataWipeModule,
MoneroWalletModule, MoneroWalletModule,
BitcoinWalletModule,
StorefrontCoreModule, StorefrontCoreModule,
StorefrontProductModule, StorefrontProductModule,
StorefrontCartModule, StorefrontCartModule,
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BitcoinWalletController } from './controllers/BitcoinWalletController';
import { BitcoinWalletAdminService } from './services/BitcoinWalletAdminService';
import { ElectrumWalletRpcClient } from './services/ElectrumWalletRpcClient';
import { ElectrumWalletRpcConnectionService } from './services/ElectrumWalletRpcConnectionService';
@Module({
controllers: [BitcoinWalletController],
providers: [ElectrumWalletRpcClient, ElectrumWalletRpcConnectionService, BitcoinWalletAdminService],
exports: [ElectrumWalletRpcClient]
})
export class BitcoinWalletModule {}
@@ -0,0 +1,14 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtGuard } from '../../../guards/JwtGuard';
import { BitcoinWalletAdminService } from '../services/BitcoinWalletAdminService';
@Controller('bitcoin-wallet')
@UseGuards(JwtGuard)
export class BitcoinWalletController {
constructor(private readonly walletAdminService: BitcoinWalletAdminService) {}
@Get('/')
getStatus() {
return this.walletAdminService.getStatus();
}
}
@@ -0,0 +1,74 @@
import { ServiceUnavailableException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
import type { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
import { BitcoinWalletAdminService } from './BitcoinWalletAdminService';
describe('BitcoinWalletAdminService', () => {
let service: BitcoinWalletAdminService;
let walletRpcClient: {
getVersion: jest.Mock;
isSynchronized: jest.Mock;
getInfo: jest.Mock;
getBalance: jest.Mock;
};
let configService: {
get: jest.Mock;
};
beforeEach(() => {
walletRpcClient = {
getVersion: jest.fn().mockResolvedValue('4.8.1'),
isSynchronized: jest.fn().mockResolvedValue(true),
getInfo: jest.fn().mockResolvedValue({
blockchain_height: 900_000,
server_height: 900_000
}),
getBalance: jest.fn().mockResolvedValue({
balanceAtomic: '150000',
confirmedBalanceAtomic: '150000'
})
};
configService = {
get: jest.fn().mockReturnValue({
network: 'testnet'
})
};
service = new BitcoinWalletAdminService(
walletRpcClient as unknown as ElectrumWalletRpcClient,
configService as unknown as ConfigService
);
});
it('returns wallet status when RPC calls succeed', async () => {
const status = await service.getStatus();
expect(status).toEqual(
expect.objectContaining({
network: 'testnet',
rpcVersion: '4.8.1',
blockHeight: 900_000,
serverHeight: 900_000,
syncStatus: BitcoinWalletSyncStatus.Synced,
balanceBtc: '0.00150000',
confirmedBalanceBtc: '0.00150000'
})
);
});
it('reports syncing when the wallet is not synchronized', async () => {
walletRpcClient.isSynchronized.mockResolvedValue(false);
const status = await service.getStatus();
expect(status.syncStatus).toBe(BitcoinWalletSyncStatus.Syncing);
});
it('throws when RPC calls fail', async () => {
walletRpcClient.getBalance.mockRejectedValue(new Error('rpc down'));
await expect(service.getStatus()).rejects.toBeInstanceOf(ServiceUnavailableException);
});
});
@@ -0,0 +1,61 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { convertBtcAtomicToBtc } from '../../../utils/bitcoin/convertBtcAtomicToBtc';
import type { Config } from '../../../types/Config';
import { BitcoinWalletSyncStatus } from '../types/BitcoinWalletSyncStatus';
import type { BitcoinWalletStatusView } from '../types/BitcoinWalletStatusView';
import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient';
@Injectable()
export class BitcoinWalletAdminService {
constructor(
private readonly walletRpcClient: ElectrumWalletRpcClient,
private readonly configService: ConfigService
) {}
async getStatus(): Promise<BitcoinWalletStatusView> {
const { network } = this.configService.get('electrumWallet') as Config['electrumWallet'];
try {
const [rpcVersion, isSynchronized, info, { balanceAtomic, confirmedBalanceAtomic }] = await Promise.all([
this.walletRpcClient.getVersion(),
this.walletRpcClient.isSynchronized(),
this.walletRpcClient.getInfo(),
this.walletRpcClient.getBalance()
]);
const blockHeight = info.blockchain_height ?? null;
const serverHeight = info.server_height ?? null;
return {
network,
rpcVersion,
blockHeight,
serverHeight,
syncStatus: this.resolveSyncStatus(isSynchronized, blockHeight, serverHeight),
balanceBtc: convertBtcAtomicToBtc(balanceAtomic),
confirmedBalanceBtc: convertBtcAtomicToBtc(confirmedBalanceAtomic)
};
} catch {
throw new ServiceUnavailableException(
'Could not load wallet status. The Bitcoin wallet may be busy or unavailable.'
);
}
}
private resolveSyncStatus(
isSynchronized: boolean,
blockHeight: number | null,
serverHeight: number | null
): BitcoinWalletSyncStatus {
if (isSynchronized) {
return BitcoinWalletSyncStatus.Synced;
}
if (blockHeight === null || serverHeight === null) {
return BitcoinWalletSyncStatus.Unknown;
}
return BitcoinWalletSyncStatus.Syncing;
}
}
@@ -0,0 +1,12 @@
import { ElectrumNetwork } from '../../../types/ElectrumNetwork';
import { BitcoinWalletSyncStatus } from './BitcoinWalletSyncStatus';
export interface BitcoinWalletStatusView {
network: ElectrumNetwork;
rpcVersion: string;
blockHeight: number | null;
serverHeight: number | null;
syncStatus: BitcoinWalletSyncStatus;
balanceBtc: string;
confirmedBalanceBtc: string;
}
@@ -0,0 +1,5 @@
export enum BitcoinWalletSyncStatus {
Synced = 'synced',
Syncing = 'syncing',
Unknown = 'unknown'
}