From 8c0749875ef59d627728070f7c8af66feb6449f8 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Fri, 4 Sep 2026 11:32:12 +0200 Subject: [PATCH] add electrum wallet rpc client --- .../services/ElectrumWalletRpcClient.spec.ts | 211 ++++++++++++++++++ .../services/ElectrumWalletRpcClient.ts | 171 ++++++++++++++ .../ElectrumWalletRpcConnectionService.ts | 20 ++ .../ElectrumWalletAddressHistoryEntry.ts | 4 + .../ElectrumWalletDeserializedTransaction.ts | 8 + .../types/ElectrumWalletGetBalanceResult.ts | 4 + .../types/ElectrumWalletGetInfoResult.ts | 5 + .../types/ElectrumWalletIncomingTransfer.ts | 5 + .../types/ElectrumWalletRpcClientTest.ts | 12 + .../types/ElectrumWalletRpcResponse.ts | 9 + 10 files changed, 449 insertions(+) create mode 100644 backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts create mode 100644 backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts create mode 100644 backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts create mode 100644 backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts new file mode 100644 index 0000000..ae4fe00 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts @@ -0,0 +1,211 @@ +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import type { ElectrumWalletRpcClientTest } from '../types/ElectrumWalletRpcClientTest'; +import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('ElectrumWalletRpcClient', () => { + let client: ElectrumWalletRpcClient; + let clientTest: ElectrumWalletRpcClientTest; + + beforeEach(() => { + client = new ElectrumWalletRpcClient({ + get: jest.fn().mockReturnValue({ + rpcUrl: 'http://electrum.test:7777', + username: 'electrum', + password: 'secret', + rpcTimeoutMs: 5000 + }) + } as unknown as ConfigService); + + clientTest = client as unknown as ElectrumWalletRpcClientTest; + mockedAxios.post.mockReset(); + }); + + describe('mapIncomingTransfer', () => { + it('maps confirmed transfers with confirmations derived from block height', () => { + expect( + clientTest.mapIncomingTransfer( + { + tx_hash: 'abc123', + height: 800_000 + }, + '50000', + 800_002 + ) + ).toEqual({ + txHash: 'abc123', + amountAtomic: '50000', + confirmations: 3 + }); + }); + + it('returns zero confirmations for unconfirmed transfers', () => { + expect( + clientTest.mapIncomingTransfer( + { + tx_hash: 'abc123', + height: 0 + }, + '50000', + 800_002 + ) + ).toEqual({ + txHash: 'abc123', + amountAtomic: '50000', + confirmations: 0 + }); + }); + + it('returns null for non-positive amounts', () => { + expect( + clientTest.mapIncomingTransfer( + { + tx_hash: 'abc123', + height: 800_000 + }, + '0', + 800_002 + ) + ).toBeNull(); + }); + }); + + describe('sumIncomingOutputValueAtomic', () => { + it('sums outputs paying to the target address', () => { + expect( + clientTest.sumIncomingOutputValueAtomic( + { + outputs: [ + { address: 'bc1qother', value_sats: 10_000 }, + { address: 'bc1qtest', value_sats: 50_000 }, + { address: 'bc1qtest', value_sats: 25_000 } + ] + }, + 'bc1qtest' + ) + ).toBe('75000'); + }); + + it('returns zero when no outputs match the address', () => { + expect( + clientTest.sumIncomingOutputValueAtomic( + { + outputs: [{ address: 'bc1qother', value_sats: 10_000 }] + }, + 'bc1qtest' + ) + ).toBe('0'); + }); + }); + + describe('getIncomingTransfers', () => { + it('resolves incoming amounts from transaction outputs', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: [{ tx_hash: 'abc123', height: 800_000 }] + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: '01000000' + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: { + outputs: [ + { address: 'bc1qother', value_sats: 10_000 }, + { address: 'bc1qtest', value_sats: 50_000 } + ] + } + } + }); + + await expect(client.getIncomingTransfers('bc1qtest', 800_002)).resolves.toEqual([ + { + txHash: 'abc123', + amountAtomic: '50000', + confirmations: 3 + } + ]); + + expect(mockedAxios.post).toHaveBeenNthCalledWith( + 2, + 'http://electrum.test:7777', + { + jsonrpc: '2.0', + id: 'nullcart', + method: 'gettransaction', + params: { txid: 'abc123' } + }, + expect.any(Object) + ); + expect(mockedAxios.post).toHaveBeenNthCalledWith( + 3, + 'http://electrum.test:7777', + { + jsonrpc: '2.0', + id: 'nullcart', + method: 'deserialize', + params: { tx: '01000000' } + }, + expect.any(Object) + ); + }); + }); + + describe('createAddress', () => { + it('creates an address and sets a label when provided', async () => { + mockedAxios.post + .mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: 'bc1qtest' } }) + .mockResolvedValueOnce({ data: { jsonrpc: '2.0', id: 'nullcart', result: true } }); + + await expect(client.createAddress('checkout - order-1')).resolves.toBe('bc1qtest'); + + expect(mockedAxios.post).toHaveBeenNthCalledWith( + 2, + 'http://electrum.test:7777', + { + jsonrpc: '2.0', + id: 'nullcart', + method: 'setlabel', + params: { key: 'bc1qtest', label: 'checkout - order-1' } + }, + expect.objectContaining({ + auth: { username: 'electrum', password: 'secret' } + }) + ); + }); + }); + + describe('getBalance', () => { + it('returns confirmed and total balances in atomic units', async () => { + mockedAxios.post.mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: { + confirmed: '0.00025', + unconfirmed: '0.0001' + } + } + }); + + await expect(client.getBalance()).resolves.toEqual({ + balanceAtomic: '35000', + confirmedBalanceAtomic: '25000' + }); + }); + }); +}); diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts new file mode 100644 index 0000000..f2c4159 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts @@ -0,0 +1,171 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import type { Config } from '../../../types/Config'; +import { addAtomic } from '../../../utils/atomic/addAtomic'; +import { convertBtcToBtcAtomic } from '../../../utils/bitcoin/convertBtcToBtcAtomic'; +import type { ElectrumWalletAddressHistoryEntry } from '../types/ElectrumWalletAddressHistoryEntry'; +import type { ElectrumWalletDeserializedTransaction } from '../types/ElectrumWalletDeserializedTransaction'; +import type { ElectrumWalletGetBalanceResult } from '../types/ElectrumWalletGetBalanceResult'; +import type { ElectrumWalletGetInfoResult } from '../types/ElectrumWalletGetInfoResult'; +import type { ElectrumWalletIncomingTransfer } from '../types/ElectrumWalletIncomingTransfer'; +import type { ElectrumWalletRpcResponse } from '../types/ElectrumWalletRpcResponse'; + +@Injectable() +export class ElectrumWalletRpcClient { + constructor(private readonly configService: ConfigService) {} + + private async call( + method: string, + params: Record | unknown[] = {}, + options: { timeoutMs?: number } = {} + ): Promise { + const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get( + 'electrumWallet' + ) as Config['electrumWallet']; + + const { data } = await axios.post>( + rpcUrl, + { + jsonrpc: '2.0', + id: 'nullcart', + method, + params + }, + { + timeout: options.timeoutMs ?? rpcTimeoutMs, + auth: { + username, + password + } + } + ); + + if (data.error) { + throw new Error(data.error.message); + } + + if (data.result === undefined) { + throw new Error(`Electrum wallet RPC ${method} returned no result`); + } + + return data.result; + } + + async getVersion(): Promise { + const version = await this.call('version'); + + if (!version) { + throw new Error('Electrum wallet RPC version returned no version'); + } + + return version; + } + + async isSynchronized(): Promise { + return this.call('is_synchronized'); + } + + async getInfo(): Promise { + return this.call('getinfo'); + } + + async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> { + const { confirmed, unconfirmed } = await this.call('getbalance'); + + if (confirmed === undefined) { + throw new Error('Electrum wallet RPC getbalance returned incomplete result'); + } + + const confirmedAtomic = convertBtcToBtcAtomic(confirmed); + + const unconfirmedAtomic = + unconfirmed !== undefined && unconfirmed !== '0' ? convertBtcToBtcAtomic(unconfirmed) : '0'; + + return { + balanceAtomic: addAtomic(confirmedAtomic, unconfirmedAtomic), + confirmedBalanceAtomic: confirmedAtomic + }; + } + + async createAddress(label?: string): Promise { + const address = await this.call('createnewaddress'); + + if (!address) { + throw new Error('Electrum wallet RPC createnewaddress returned no address'); + } + + if (label) { + await this.call('setlabel', { key: address, label }); + } + + return address; + } + + async getIncomingTransfers(address: string, blockHeight: number | null): Promise { + const history = await this.call('getaddresshistory', { + address + }); + + if (!Array.isArray(history)) { + throw new Error('Electrum wallet RPC getaddresshistory returned invalid result'); + } + + const transfers = await Promise.all( + history.map(async entry => { + const txHash = entry.tx_hash; + + if (!txHash) { + return null; + } + + const serializedTransaction = await this.call('gettransaction', { txid: txHash }); + + const transaction = await this.call('deserialize', { + tx: serializedTransaction + }); + + const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address); + + return this.mapIncomingTransfer(entry, amountAtomic, blockHeight); + }) + ); + + return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null); + } + + private sumIncomingOutputValueAtomic(transaction: ElectrumWalletDeserializedTransaction, address: string): string { + if (!Array.isArray(transaction.outputs)) { + return '0'; + } + + return transaction.outputs.reduce((sum, output) => { + if (output.address !== address || !Number.isFinite(output.value_sats) || output.value_sats <= 0) { + return sum; + } + + return addAtomic(sum, String(output.value_sats)); + }, '0'); + } + + private mapIncomingTransfer( + entry: ElectrumWalletAddressHistoryEntry, + amountAtomic: string, + blockHeight: number | null + ): ElectrumWalletIncomingTransfer | null { + const txHash = entry.tx_hash; + + if (!txHash || amountAtomic === '0') { + return null; + } + + const confirmations = + entry.height > 0 && blockHeight !== null ? Math.max(blockHeight - entry.height + 1, 0) : 0; + + return { + txHash, + amountAtomic, + confirmations + }; + } +} diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts new file mode 100644 index 0000000..be68408 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcConnectionService.ts @@ -0,0 +1,20 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { getErrorMessage } from '../../../utils/getErrorMessage'; +import { ElectrumWalletRpcClient } from './ElectrumWalletRpcClient'; + +@Injectable() +export class ElectrumWalletRpcConnectionService implements OnModuleInit { + private readonly logger = new Logger(ElectrumWalletRpcConnectionService.name); + + constructor(private readonly walletRpcClient: ElectrumWalletRpcClient) {} + + async onModuleInit(): Promise { + try { + const version = await this.walletRpcClient.getVersion(); + + this.logger.log(`Connected to electrum-daemon (version ${version})`); + } catch (error) { + this.logger.error(`Failed to reach electrum-daemon at startup: ${getErrorMessage(error)}`); + } + } +} diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts new file mode 100644 index 0000000..ed3004b --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletAddressHistoryEntry.ts @@ -0,0 +1,4 @@ +export type ElectrumWalletAddressHistoryEntry = { + tx_hash: string; + height: number; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts new file mode 100644 index 0000000..bb31f5b --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletDeserializedTransaction.ts @@ -0,0 +1,8 @@ +export type ElectrumWalletDeserializedOutput = { + address?: string; + value_sats: number; +}; + +export type ElectrumWalletDeserializedTransaction = { + outputs: ElectrumWalletDeserializedOutput[]; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts new file mode 100644 index 0000000..c860d9e --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetBalanceResult.ts @@ -0,0 +1,4 @@ +export type ElectrumWalletGetBalanceResult = { + confirmed: string; + unconfirmed?: string; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts new file mode 100644 index 0000000..1bcc8b5 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletGetInfoResult.ts @@ -0,0 +1,5 @@ +export type ElectrumWalletGetInfoResult = { + blockchain_height?: number; + server?: string; + server_height?: number; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts new file mode 100644 index 0000000..57de350 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletIncomingTransfer.ts @@ -0,0 +1,5 @@ +export type ElectrumWalletIncomingTransfer = { + txHash: string; + amountAtomic: string; + confirmations: number; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts new file mode 100644 index 0000000..ec84e95 --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts @@ -0,0 +1,12 @@ +import type { ElectrumWalletAddressHistoryEntry } from './ElectrumWalletAddressHistoryEntry'; +import type { ElectrumWalletDeserializedTransaction } from './ElectrumWalletDeserializedTransaction'; +import type { ElectrumWalletIncomingTransfer } from './ElectrumWalletIncomingTransfer'; + +export type ElectrumWalletRpcClientTest = { + mapIncomingTransfer: ( + entry: ElectrumWalletAddressHistoryEntry, + amountAtomic: string, + blockHeight: number | null + ) => ElectrumWalletIncomingTransfer | null; + sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; +}; diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts new file mode 100644 index 0000000..03647ce --- /dev/null +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcResponse.ts @@ -0,0 +1,9 @@ +export type ElectrumWalletRpcResponse = { + id: string | number; + jsonrpc: string; + result?: T; + error?: { + code: number; + message: string; + }; +};