Merge bitcoin/bitcoin#30708: rpc: add getdescriptoractivity

37a5c5d836 doc: update descriptors.md for getdescriptoractivity (James O'Beirne)
ee3ce6a4f4 test: rpc: add no address case for getdescriptoractivity (James O'Beirne)
811f76f3a5 rpc: add getdescriptoractivity (James O'Beirne)
25fe087de5 rpc: 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:
    reACK 37a5c5d836
  achow101:
    ACK 37a5c5d836
  tdb3:
    Code review and light retest ACK 37a5c5d836
  rkrux:
    re-ACK 37a5c5d836

Tree-SHA512: 04aa51e329c6c2ed72464b9886281d5ebd7511a8a8e184ea81249033a4dad535a12829b1010afc2da79b344ea8b5ab8ed47e426d0bf2eb78ab395d20b1da8dbb
This commit is contained in:
Ava Chow
2024-11-27 12:23:35 -05:00
11 changed files with 486 additions and 11 deletions

View 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()

View File

@@ -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',