mirror of
https://github.com/bitcoin/bitcoin.git
synced 2026-05-01 09:19:27 +02:00
Merge bitcoin/bitcoin#30708: rpc: add getdescriptoractivity
37a5c5d836doc: update descriptors.md for getdescriptoractivity (James O'Beirne)ee3ce6a4f4test: rpc: add no address case for getdescriptoractivity (James O'Beirne)811f76f3a5rpc: add getdescriptoractivity (James O'Beirne)25fe087de5rpc: move-only: move ScriptPubKeyDoc to utils (James O'Beirne) Pull request description: The RPC command `scanblocks` provides a useful way to get a set of blockhashes that have activity relevant to a set of descriptors (`relevant_blocks`). However actually extracting the activity from those blocks is left as an exercise to the end user. This process involves not only generating the (potentially ranged) set of scripts for the descriptor set on the client side (maybe via `deriveaddresses`), but then the user must retrieve each block's contents one-by-one using `getblock <hash>`, which is transmitted over a network link. And that's all before they perform the actual search over block content. There's even more work required to incorporate unconfirmed transactions. This PR introduces an RPC `getdescriptoractivity` that [dovetails](https://bitcoin-irc.chaincode.com/bitcoin-core-dev/2024-08-16#1046393;) with `scanblocks` output, handling the process described above. Users specify the blockhashes (perhaps from `relevant_blocks`) and a set of descriptors; they are then given all spend/receive activity in that set of blocks. This is a very useful tool when implementing lightweight wallets that want neither to require a third-party indexer like electrs, nor the overhead of creating and managing watch-only wallets in Core. This allows Core to be more easily used in a "stateless" manner by wallets, with potentially many nodes interchangeably acting as backends. ### Example usage ``` % ./src/bitcoin-cli scanblocks start \ '["addr(bc1p0cp0vyag6snlta2l7c4am3rue7eef9f72l7uhx52m4v27vfydx9s8tfs7t)"]' \ 857263 { "from_height": 857263, "to_height": 858263, "relevant_blocks": [ "00000000000000000002bc5cc78f5b0913a5230a8f4b0d5060bc9a60900a5a88", "00000000000000000001c5291ed6a40c06d3db5c8fb738567654b24a14b24ecb" ], "completed": true } % ./src/bitcoin-cli getdescriptoractivity \ '["00000000000000000002bc5cc78f5b0913a5230a8f4b0d5060bc9a60900a5a88", "00000000000000000001c5291ed6a40c06d3db5c8fb738567654b24a14b24ecb"]' \ '["addr(bc1p0cp0vyag6snlta2l7c4am3rue7eef9f72l7uhx52m4v27vfydx9s8tfs7t)"]' { "activity": [ { "type": "receive", "amount": 0.00002900, "blockhash": "00000000000000000002bc5cc78f5b0913a5230a8f4b0d5060bc9a60900a5a88", "height": 857907, "txid": "c9d34f202c1f66d80cae76f305350f5fdde910b97cf6ae6bf79f5bcf2a337d06", "vout": 254, "output_spk": { "asm": "1 7e02f613a8d427f5f55ff62bddc47ccfb394953e57fdcb9a8add58af3124698b", "desc": "rawtr(7e02f613a8d427f5f55ff62bddc47ccfb394953e57fdcb9a8add58af3124698b)#yewcd80j", "hex": "51207e02f613a8d427f5f55ff62bddc47ccfb394953e57fdcb9a8add58af3124698b", "address": "bc1p0cp0vyag6snlta2l7c4am3rue7eef9f72l7uhx52m4v27vfydx9s8tfs7t", "type": "witness_v1_taproot" } }, { "type": "spend", "amount": 0.00002900, "blockhash": "00000000000000000001c5291ed6a40c06d3db5c8fb738567654b24a14b24ecb", "height": 858260, "spend_txid": "7f61d1b248d4ee46376f9c6df272f63fbb0c17039381fb23ca5d90473b823c36", "spend_vin": 0, "prevout_txid": "c9d34f202c1f66d80cae76f305350f5fdde910b97cf6ae6bf79f5bcf2a337d06", "prevout_vout": 254, "prevout_spk": { "asm": "1 7e02f613a8d427f5f55ff62bddc47ccfb394953e57fdcb9a8add58af3124698b", "desc": "rawtr(7e02f613a8d427f5f55ff62bddc47ccfb394953e57fdcb9a8add58af3124698b)#yewcd80j", "hex": "51207e02f613a8d427f5f55ff62bddc47ccfb394953e57fdcb9a8add58af3124698b", "address": "bc1p0cp0vyag6snlta2l7c4am3rue7eef9f72l7uhx52m4v27vfydx9s8tfs7t", "type": "witness_v1_taproot" } } ] } ``` ACKs for top commit: instagibbs: reACK37a5c5d836achow101: ACK37a5c5d836tdb3: Code review and light retest ACK37a5c5d836rkrux: re-ACK37a5c5d836Tree-SHA512: 04aa51e329c6c2ed72464b9886281d5ebd7511a8a8e184ea81249033a4dad535a12829b1010afc2da79b344ea8b5ab8ed47e426d0bf2eb78ab395d20b1da8dbb
This commit is contained in:
226
test/functional/rpc_getdescriptoractivity.py
Executable file
226
test/functional/rpc_getdescriptoractivity.py
Executable file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2024-present The Bitcoin Core developers
|
||||
# Distributed under the MIT software license, see the accompanying
|
||||
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from test_framework.test_framework import BitcoinTestFramework
|
||||
from test_framework.util import assert_equal, assert_raises_rpc_error
|
||||
from test_framework.messages import COIN
|
||||
from test_framework.wallet import MiniWallet, MiniWalletMode, getnewdestination
|
||||
|
||||
|
||||
class GetBlocksActivityTest(BitcoinTestFramework):
|
||||
def set_test_params(self):
|
||||
self.num_nodes = 1
|
||||
self.setup_clean_chain = True
|
||||
|
||||
def run_test(self):
|
||||
node = self.nodes[0]
|
||||
wallet = MiniWallet(node)
|
||||
node.setmocktime(node.getblockheader(node.getbestblockhash())['time'])
|
||||
wallet.generate(200, invalid_call=False)
|
||||
|
||||
self.test_no_activity(node)
|
||||
self.test_activity_in_block(node, wallet)
|
||||
self.test_no_mempool_inclusion(node, wallet)
|
||||
self.test_multiple_addresses(node, wallet)
|
||||
self.test_invalid_blockhash(node, wallet)
|
||||
self.test_invalid_descriptor(node, wallet)
|
||||
self.test_confirmed_and_unconfirmed(node, wallet)
|
||||
self.test_receive_then_spend(node, wallet)
|
||||
self.test_no_address(node, wallet)
|
||||
|
||||
def test_no_activity(self, node):
|
||||
_, _, addr_1 = getnewdestination()
|
||||
result = node.getdescriptoractivity([], [f"addr({addr_1})"], True)
|
||||
assert_equal(len(result['activity']), 0)
|
||||
|
||||
def test_activity_in_block(self, node, wallet):
|
||||
_, spk_1, addr_1 = getnewdestination(address_type='bech32m')
|
||||
txid = wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN)['txid']
|
||||
blockhash = self.generate(node, 1)[0]
|
||||
|
||||
# Test getdescriptoractivity with the specific blockhash
|
||||
result = node.getdescriptoractivity([blockhash], [f"addr({addr_1})"], True)
|
||||
assert_equal(list(result.keys()), ['activity'])
|
||||
[activity] = result['activity']
|
||||
|
||||
for k, v in {
|
||||
'amount': Decimal('1.00000000'),
|
||||
'blockhash': blockhash,
|
||||
'height': 201,
|
||||
'txid': txid,
|
||||
'type': 'receive',
|
||||
'vout': 1,
|
||||
}.items():
|
||||
assert_equal(activity[k], v)
|
||||
|
||||
outspk = activity['output_spk']
|
||||
|
||||
assert_equal(outspk['asm'][:2], '1 ')
|
||||
assert_equal(outspk['desc'].split('(')[0], 'rawtr')
|
||||
assert_equal(outspk['hex'], spk_1.hex())
|
||||
assert_equal(outspk['address'], addr_1)
|
||||
assert_equal(outspk['type'], 'witness_v1_taproot')
|
||||
|
||||
|
||||
def test_no_mempool_inclusion(self, node, wallet):
|
||||
_, spk_1, addr_1 = getnewdestination()
|
||||
wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN)
|
||||
|
||||
_, spk_2, addr_2 = getnewdestination()
|
||||
wallet.send_to(
|
||||
from_node=node, scriptPubKey=spk_2, amount=1 * COIN)
|
||||
|
||||
# Do not generate a block to keep the transaction in the mempool
|
||||
|
||||
result = node.getdescriptoractivity([], [f"addr({addr_1})", f"addr({addr_2})"], False)
|
||||
|
||||
assert_equal(len(result['activity']), 0)
|
||||
|
||||
def test_multiple_addresses(self, node, wallet):
|
||||
_, spk_1, addr_1 = getnewdestination()
|
||||
_, spk_2, addr_2 = getnewdestination()
|
||||
wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN)
|
||||
wallet.send_to(from_node=node, scriptPubKey=spk_2, amount=2 * COIN)
|
||||
|
||||
blockhash = self.generate(node, 1)[0]
|
||||
|
||||
result = node.getdescriptoractivity([blockhash], [f"addr({addr_1})", f"addr({addr_2})"], True)
|
||||
|
||||
assert_equal(len(result['activity']), 2)
|
||||
|
||||
# Duplicate address specification is fine.
|
||||
assert_equal(
|
||||
result,
|
||||
node.getdescriptoractivity([blockhash], [
|
||||
f"addr({addr_1})", f"addr({addr_1})", f"addr({addr_2})"], True))
|
||||
|
||||
# Flipping descriptor order doesn't affect results.
|
||||
result_flipped = node.getdescriptoractivity(
|
||||
[blockhash], [f"addr({addr_2})", f"addr({addr_1})"], True)
|
||||
assert_equal(result, result_flipped)
|
||||
|
||||
[a1] = [a for a in result['activity'] if a['output_spk']['address'] == addr_1]
|
||||
[a2] = [a for a in result['activity'] if a['output_spk']['address'] == addr_2]
|
||||
|
||||
assert a1['blockhash'] == blockhash
|
||||
assert a1['amount'] == 1.0
|
||||
|
||||
assert a2['blockhash'] == blockhash
|
||||
assert a2['amount'] == 2.0
|
||||
|
||||
def test_invalid_blockhash(self, node, wallet):
|
||||
self.generate(node, 20) # Generate to get more fees
|
||||
|
||||
_, spk_1, addr_1 = getnewdestination()
|
||||
wallet.send_to(from_node=node, scriptPubKey=spk_1, amount=1 * COIN)
|
||||
|
||||
invalid_blockhash = "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
assert_raises_rpc_error(
|
||||
-5, "Block not found",
|
||||
node.getdescriptoractivity, [invalid_blockhash], [f"addr({addr_1})"], True)
|
||||
|
||||
def test_invalid_descriptor(self, node, wallet):
|
||||
blockhash = self.generate(node, 1)[0]
|
||||
_, _, addr_1 = getnewdestination()
|
||||
|
||||
assert_raises_rpc_error(
|
||||
-5, "is not a valid descriptor",
|
||||
node.getdescriptoractivity, [blockhash], [f"addrx({addr_1})"], True)
|
||||
|
||||
def test_confirmed_and_unconfirmed(self, node, wallet):
|
||||
self.generate(node, 20) # Generate to get more fees
|
||||
|
||||
_, spk_1, addr_1 = getnewdestination()
|
||||
txid_1 = wallet.send_to(
|
||||
from_node=node, scriptPubKey=spk_1, amount=1 * COIN)['txid']
|
||||
blockhash = self.generate(node, 1)[0]
|
||||
|
||||
_, spk_2, to_addr = getnewdestination()
|
||||
txid_2 = wallet.send_to(
|
||||
from_node=node, scriptPubKey=spk_2, amount=1 * COIN)['txid']
|
||||
|
||||
result = node.getdescriptoractivity(
|
||||
[blockhash], [f"addr({addr_1})", f"addr({to_addr})"], True)
|
||||
|
||||
activity = result['activity']
|
||||
assert_equal(len(activity), 2)
|
||||
|
||||
[confirmed] = [a for a in activity if a.get('blockhash') == blockhash]
|
||||
assert confirmed['txid'] == txid_1
|
||||
assert confirmed['height'] == node.getblockchaininfo()['blocks']
|
||||
|
||||
[unconfirmed] = [a for a in activity if not a.get('blockhash')]
|
||||
assert 'blockhash' not in unconfirmed
|
||||
assert 'height' not in unconfirmed
|
||||
|
||||
assert any(a['txid'] == txid_2 for a in activity if not a.get('blockhash'))
|
||||
|
||||
def test_receive_then_spend(self, node, wallet):
|
||||
"""Also important because this tests multiple blockhashes."""
|
||||
self.generate(node, 20) # Generate to get more fees
|
||||
|
||||
sent1 = wallet.send_self_transfer(from_node=node)
|
||||
utxo = sent1['new_utxo']
|
||||
blockhash_1 = self.generate(node, 1)[0]
|
||||
|
||||
sent2 = wallet.send_self_transfer(from_node=node, utxo_to_spend=utxo)
|
||||
blockhash_2 = self.generate(node, 1)[0]
|
||||
|
||||
result = node.getdescriptoractivity(
|
||||
[blockhash_1, blockhash_2], [wallet.get_descriptor()], True)
|
||||
|
||||
assert_equal(len(result['activity']), 4)
|
||||
|
||||
assert result['activity'][1]['type'] == 'receive'
|
||||
assert result['activity'][1]['txid'] == sent1['txid']
|
||||
assert result['activity'][1]['blockhash'] == blockhash_1
|
||||
|
||||
assert result['activity'][2]['type'] == 'spend'
|
||||
assert result['activity'][2]['spend_txid'] == sent2['txid']
|
||||
assert result['activity'][2]['prevout_txid'] == sent1['txid']
|
||||
assert result['activity'][2]['blockhash'] == blockhash_2
|
||||
|
||||
# Test that reversing the blockorder yields the same result.
|
||||
assert_equal(result, node.getdescriptoractivity(
|
||||
[blockhash_1, blockhash_2], [wallet.get_descriptor()], True))
|
||||
|
||||
# Test that duplicating a blockhash yields the same result.
|
||||
assert_equal(result, node.getdescriptoractivity(
|
||||
[blockhash_1, blockhash_2, blockhash_2], [wallet.get_descriptor()], True))
|
||||
|
||||
def test_no_address(self, node, wallet):
|
||||
raw_wallet = MiniWallet(self.nodes[0], mode=MiniWalletMode.RAW_P2PK)
|
||||
raw_wallet.generate(100, invalid_call=False)
|
||||
|
||||
no_addr_tx = raw_wallet.send_self_transfer(from_node=node)
|
||||
raw_desc = raw_wallet.get_descriptor()
|
||||
|
||||
blockhash = self.generate(node, 1)[0]
|
||||
|
||||
result = node.getdescriptoractivity([blockhash], [raw_desc], False)
|
||||
|
||||
assert_equal(len(result['activity']), 2)
|
||||
|
||||
a1 = result['activity'][0]
|
||||
a2 = result['activity'][1]
|
||||
|
||||
assert a1['type'] == "spend"
|
||||
assert a1['blockhash'] == blockhash
|
||||
# sPK lacks address.
|
||||
assert_equal(list(a1['prevout_spk'].keys()), ['asm', 'desc', 'hex', 'type'])
|
||||
assert a1['amount'] == no_addr_tx["fee"] + Decimal(no_addr_tx["tx"].vout[0].nValue) / COIN
|
||||
|
||||
assert a2['type'] == "receive"
|
||||
assert a2['blockhash'] == blockhash
|
||||
# sPK lacks address.
|
||||
assert_equal(list(a2['output_spk'].keys()), ['asm', 'desc', 'hex', 'type'])
|
||||
assert a2['amount'] == Decimal(no_addr_tx["tx"].vout[0].nValue) / COIN
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
GetBlocksActivityTest(__file__).main()
|
||||
@@ -378,6 +378,7 @@ BASE_SCRIPTS = [
|
||||
'rpc_deriveaddresses.py --usecli',
|
||||
'p2p_ping.py',
|
||||
'p2p_tx_privacy.py',
|
||||
'rpc_getdescriptoractivity.py',
|
||||
'rpc_scanblocks.py',
|
||||
'p2p_sendtxrcncl.py',
|
||||
'rpc_scantxoutset.py',
|
||||
|
||||
Reference in New Issue
Block a user