Consolidate payto, deserialize, and broadcast into a single sweep helper for max-payment withdrawals.
233 lines
7.9 KiB
TypeScript
233 lines
7.9 KiB
TypeScript
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 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<string> {
|
|
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
|
|
|
const signedTransaction = await this.call<string>(
|
|
'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<string> {
|
|
const txHash = await this.call<string>('broadcast', { tx: signedTransaction });
|
|
|
|
if (!txHash) {
|
|
throw new Error('Electrum wallet RPC broadcast returned no transaction hash');
|
|
}
|
|
|
|
return txHash;
|
|
}
|
|
|
|
private async deserializeTransaction(signedTransaction: string): Promise<ElectrumWalletDeserializedTransaction> {
|
|
return this.call<ElectrumWalletDeserializedTransaction>('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<string> {
|
|
const { walletPassword } = this.configService.get('electrumWallet') as Config['electrumWallet'];
|
|
|
|
const mnemonic = await this.call<string>('getseed', { password: walletPassword });
|
|
|
|
if (!mnemonic) {
|
|
throw new Error('Electrum wallet RPC getseed returned no mnemonic');
|
|
}
|
|
|
|
return mnemonic;
|
|
}
|
|
|
|
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.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
|
|
};
|
|
}
|
|
}
|