add electrum wallet rpc client

This commit is contained in:
2026-09-04 11:32:12 +02:00
parent 1143b8873c
commit 8c0749875e
10 changed files with 449 additions and 0 deletions
@@ -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<T>(
method: string,
params: Record<string, unknown> | unknown[] = {},
options: { timeoutMs?: number } = {}
): Promise<T> {
const { rpcUrl, username, password, rpcTimeoutMs } = this.configService.get(
'electrumWallet'
) as Config['electrumWallet'];
const { data } = await axios.post<ElectrumWalletRpcResponse<T>>(
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<string> {
const version = await this.call<string>('version');
if (!version) {
throw new Error('Electrum wallet RPC version returned no version');
}
return version;
}
async isSynchronized(): Promise<boolean> {
return this.call<boolean>('is_synchronized');
}
async getInfo(): Promise<ElectrumWalletGetInfoResult> {
return this.call<ElectrumWalletGetInfoResult>('getinfo');
}
async getBalance(): Promise<{ balanceAtomic: string; confirmedBalanceAtomic: string }> {
const { confirmed, unconfirmed } = await this.call<ElectrumWalletGetBalanceResult>('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<string> {
const address = await this.call<string>('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<ElectrumWalletIncomingTransfer[]> {
const history = await this.call<ElectrumWalletAddressHistoryEntry[]>('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<string>('gettransaction', { txid: txHash });
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('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
};
}
}