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 sweepAll( destinationAddress: string, feeRateSatVbyte: number ): Promise<{ txHash: string; amountAtomic: string }> { const signedTransaction = await this.payToMax(destinationAddress, feeRateSatVbyte); const transaction = await this.deserializeTransaction(signedTransaction); const amountAtomic = this.sumOutputValueAtomic(transaction, destinationAddress); if (amountAtomic === '0') { throw new Error('Withdrawal transaction has no spendable output to the destination address'); } const txHash = await this.broadcast(signedTransaction); return { txHash, amountAtomic }; } private async payToMax(destinationAddress: string, feeRateSatVbyte: number): Promise { const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet']; const signedTransaction = await this.call( 'payto', { destination: destinationAddress, amount: '!', feerate: String(feeRateSatVbyte), password: walletPassword }, { timeoutMs: 120_000 } ); if (!signedTransaction) { throw new Error('Electrum wallet RPC payto returned no transaction'); } return signedTransaction; } private async broadcast(signedTransaction: string): Promise { const txHash = await this.call('broadcast', { tx: signedTransaction }); if (!txHash) { throw new Error('Electrum wallet RPC broadcast returned no transaction hash'); } return txHash; } private async deserializeTransaction(signedTransaction: string): Promise { return this.call('deserialize', { tx: signedTransaction }); } private sumOutputValueAtomic(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'); } async getSeed(): Promise { const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet']; const mnemonic = await this.call('getseed', { password: walletPassword }); if (!mnemonic) { throw new Error('Electrum wallet RPC getseed returned no mnemonic'); } return mnemonic; } 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.deserializeTransaction(serializedTransaction); const amountAtomic = this.sumOutputValueAtomic(transaction, address); return this.mapIncomingTransfer(entry, amountAtomic, blockHeight); }) ); return transfers.filter((transfer): transfer is ElectrumWalletIncomingTransfer => transfer !== null); } 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 }; } }