Slave/btc integration #4

Merged
nobswebdev merged 47 commits from slave/btc-integration into master 2026-09-07 11:11:10 +00:00
3 changed files with 162 additions and 24 deletions
Showing only changes of commit 3a72d0521a - Show all commits
@@ -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)
);
});
});
});
@@ -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<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');
@@ -120,12 +199,8 @@ export class ElectrumWalletRpcClient {
}
const serializedTransaction = await this.call<string>('gettransaction', { txid: txHash });
const transaction = await this.call<ElectrumWalletDeserializedTransaction>('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,
@@ -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;
};