diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts index ae4fe00..537a354 100644 --- a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.spec.ts @@ -17,6 +17,7 @@ describe('ElectrumWalletRpcClient', () => { rpcUrl: 'http://electrum.test:7777', username: 'electrum', password: 'secret', + walletPassword: 'wallet-secret', rpcTimeoutMs: 5000 }) } as unknown as ConfigService); @@ -74,10 +75,10 @@ describe('ElectrumWalletRpcClient', () => { }); }); - describe('sumIncomingOutputValueAtomic', () => { + describe('sumOutputValueAtomic', () => { it('sums outputs paying to the target address', () => { expect( - clientTest.sumIncomingOutputValueAtomic( + clientTest.sumOutputValueAtomic( { outputs: [ { address: 'bc1qother', value_sats: 10_000 }, @@ -92,7 +93,7 @@ describe('ElectrumWalletRpcClient', () => { it('returns zero when no outputs match the address', () => { expect( - clientTest.sumIncomingOutputValueAtomic( + clientTest.sumOutputValueAtomic( { outputs: [{ address: 'bc1qother', value_sats: 10_000 }] }, @@ -208,4 +209,80 @@ describe('ElectrumWalletRpcClient', () => { }); }); }); + + describe('sweepAll', () => { + it('creates, signs, and broadcasts a max-payment transaction', async () => { + mockedAxios.post + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: 'signed-tx' + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: { + outputs: [{ address: 'bc1qtest', value_sats: 140_000 }] + } + } + }) + .mockResolvedValueOnce({ + data: { + jsonrpc: '2.0', + id: 'nullcart', + result: 'tx-hash-1' + } + }); + + await expect(client.sweepAll('bc1qtest', 10)).resolves.toEqual({ + txHash: 'tx-hash-1', + amountAtomic: '140000' + }); + + expect(mockedAxios.post).toHaveBeenNthCalledWith( + 1, + 'http://electrum.test:7777', + { + jsonrpc: '2.0', + id: 'nullcart', + method: 'payto', + params: { + destination: 'bc1qtest', + amount: '!', + feerate: '10', + password: 'wallet-secret' + } + }, + expect.objectContaining({ + timeout: 120_000 + }) + ); + expect(mockedAxios.post).toHaveBeenNthCalledWith( + 2, + 'http://electrum.test:7777', + { + jsonrpc: '2.0', + id: 'nullcart', + method: 'deserialize', + params: { tx: 'signed-tx' } + }, + expect.any(Object) + ); + expect(mockedAxios.post).toHaveBeenNthCalledWith( + 3, + 'http://electrum.test:7777', + { + jsonrpc: '2.0', + id: 'nullcart', + method: 'broadcast', + params: { tx: 'signed-tx' } + }, + expect.any(Object) + ); + }); + }); + }); diff --git a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts index f2c4159..40ab1f0 100644 --- a/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts +++ b/backend/src/modules/bitcoinWallet/services/ElectrumWalletRpcClient.ts @@ -88,6 +88,85 @@ export class ElectrumWalletRpcClient { }; } + 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'); @@ -120,12 +199,8 @@ export class ElectrumWalletRpcClient { } const serializedTransaction = await this.call('gettransaction', { txid: txHash }); - - const transaction = await this.call('deserialize', { - tx: serializedTransaction - }); - - const amountAtomic = this.sumIncomingOutputValueAtomic(transaction, address); + const transaction = await this.deserializeTransaction(serializedTransaction); + const amountAtomic = this.sumOutputValueAtomic(transaction, address); return this.mapIncomingTransfer(entry, amountAtomic, blockHeight); }) @@ -134,20 +209,6 @@ export class ElectrumWalletRpcClient { 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, diff --git a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts index ec84e95..d7eb43c 100644 --- a/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts +++ b/backend/src/modules/bitcoinWallet/types/ElectrumWalletRpcClientTest.ts @@ -8,5 +8,5 @@ export type ElectrumWalletRpcClientTest = { amountAtomic: string, blockHeight: number | null ) => ElectrumWalletIncomingTransfer | null; - sumIncomingOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; + sumOutputValueAtomic: (transaction: ElectrumWalletDeserializedTransaction, address: string) => string; };