From cae5b4fe509089e5e397d33d62b757e8782555de Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Fri, 4 Sep 2026 11:59:06 +0200 Subject: [PATCH] add bitcoin wallet admin status endpoint --- backend/src/AppModule.ts | 4 + .../bitcoinWallet/BitcoinWalletModule.ts | 12 +++ .../controllers/BitcoinWalletController.ts | 14 ++++ .../BitcoinWalletAdminService.spec.ts | 74 +++++++++++++++++++ .../services/BitcoinWalletAdminService.ts | 61 +++++++++++++++ .../types/BitcoinWalletStatusView.ts | 12 +++ .../types/BitcoinWalletSyncStatus.ts | 5 ++ 7 files changed, 182 insertions(+) create mode 100644 backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts create mode 100644 backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts create mode 100644 backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts create mode 100644 backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts create mode 100644 backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts create mode 100644 backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts diff --git a/backend/src/AppModule.ts b/backend/src/AppModule.ts index c209f63..3e38a2e 100644 --- a/backend/src/AppModule.ts +++ b/backend/src/AppModule.ts @@ -13,6 +13,7 @@ import { getOrderConfig, getInvoiceConfig, getMoneroWalletConfig, + getElectrumWalletConfig, getPostgresConfig, getShopSettingsConfig, getSimplexConfig @@ -22,6 +23,7 @@ import { AuthModule } from './modules/auth/AuthModule'; import { EncryptionModule } from './modules/encryption/EncryptionModule'; import { HealthCheckModule } from './modules/healthCheck/HealthCheckModule'; import { MoneroWalletModule } from './modules/moneroWallet/MoneroWalletModule'; +import { BitcoinWalletModule } from './modules/bitcoinWallet/BitcoinWalletModule'; import { SimplexModule } from './modules/simplex/SimplexModule'; import { DiscountCodesModule } from './modules/discountCode/DiscountCodesModule'; import { DataWipeModule } from './modules/dataWipe/DataWipeModule'; @@ -55,6 +57,7 @@ import { Config } from './types/Config'; registerAs('order', getOrderConfig), registerAs('invoice', getInvoiceConfig), registerAs('moneroWallet', getMoneroWalletConfig), + registerAs('electrumWallet', getElectrumWalletConfig), registerAs('simplex', getSimplexConfig) ] }), @@ -91,6 +94,7 @@ import { Config } from './types/Config'; PaymentModule, DataWipeModule, MoneroWalletModule, + BitcoinWalletModule, StorefrontCoreModule, StorefrontProductModule, StorefrontCartModule, diff --git a/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts new file mode 100644 index 0000000..d56c048 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/BitcoinWalletModule.ts @@ -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 {} diff --git a/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts new file mode 100644 index 0000000..e309946 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/controllers/BitcoinWalletController.ts @@ -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(); + } +} diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts new file mode 100644 index 0000000..bc32bbd --- /dev/null +++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.spec.ts @@ -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); + }); +}); diff --git a/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts new file mode 100644 index 0000000..a3dc27a --- /dev/null +++ b/backend/src/modules/bitcoinWallet/services/BitcoinWalletAdminService.ts @@ -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 { + 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; + } +} diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts new file mode 100644 index 0000000..ca5d9af --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletStatusView.ts @@ -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; +} diff --git a/backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts b/backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts new file mode 100644 index 0000000..ae39d08 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/BitcoinWalletSyncStatus.ts @@ -0,0 +1,5 @@ +export enum BitcoinWalletSyncStatus { + Synced = 'synced', + Syncing = 'syncing', + Unknown = 'unknown' +}