From 3d6a22ea05df70d6f0c11f2f00c85dc16975ad28 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Fri, 2 May 2025 15:34:48 +0100 Subject: [PATCH] feat: add rpc command to list revealed addresses --- doc/API.md | 35 ++++++ lianad/src/jsonrpc/api.rs | 52 +++++++++ tests/test_rpc.py | 228 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 315 insertions(+) diff --git a/doc/API.md b/doc/API.md index 63bfc62f..9fbaacdb 100644 --- a/doc/API.md +++ b/doc/API.md @@ -12,6 +12,7 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`. | [`updatederivationindexes`](#updatederivationindexes) | Update last generated addresses derivation indexes | | [`getnewaddress`](#getnewaddress) | Get a new receiving address | | [`listaddresses`](#listaddresses) | List addresses given start_index and count | +| [`listrevealedaddresses`](#listrevealedaddresses) | List revealed addresses (both used and unused) | | [`listcoins`](#listcoins) | List all wallet transaction outputs. | | [`createspend`](#createspend) | Create a new Spend transaction | | [`updatespend`](#updatespend) | Store a created Spend transaction | @@ -140,6 +141,40 @@ If no value is passed for `count` the maximum generated index between receive an | `change` | string | Change address | +### `listrevealedaddresses` + +List revealed receive or change addresses, optionally filtering for those that are unused by any of the current coins in the wallet. + +Addresses are returned in order of descending derivation index. + +If `start_index` is omitted or `null`, then addresses will be returned starting from the last revealed address. +Otherwise, addresses will be returned starting from the specified derivation index. + +#### Request + +| Field | Type | Description | +| --------------- | ----------------- | --------------------------------------------------------------------------------------- | +| `is_change` | bool | Whether to return change or otherwise receive addresses. | +| `exclude_used` | bool | Whether to exclude those addresses that have been used by a current coin in the wallet. | +| `limit` | integer | The maximum number of addresses to list. | +| `start_index` | integer(optional) | For pagination, pass the `continue_from` value from the previous response. | + +#### Response + +The response contains two fields: +- `addresses`: an array of revealed addresses, with the structure given below. +- `continue_from`: used for pagination of results. If not `null`, this indicates that there may be additional addresses that can be listed and +this value can be passed to the next request as `start_index` to continue with the next page of results. + +Each element in the `addresses` array has the following fields: + +| Field | Type | Description | +| ------------- | ---------------- | ---------------------------------------------------------------------------- | +| `index` | integer | Derivation index. | +| `address` | string | Address. | +| `used_count` | integer | The number of current coins in the wallet that are using this address. | +| `label` | string or null | Address label, if any. | + ### `listcoins` List all our transaction outputs, optionally filtered by status and/or outpoint. diff --git a/lianad/src/jsonrpc/api.rs b/lianad/src/jsonrpc/api.rs index dc2cf5a0..7f6082e6 100644 --- a/lianad/src/jsonrpc/api.rs +++ b/lianad/src/jsonrpc/api.rs @@ -199,6 +199,50 @@ fn list_addresses( Ok(serde_json::json!(&res)) } +fn list_revealed_addresses( + control: &DaemonControl, + params: Params, +) -> Result { + let is_change = params + .get(0, "is_change") + .ok_or_else(|| Error::invalid_params("Missing 'is_change' parameter."))? + .as_bool() + .ok_or_else(|| Error::invalid_params("Invalid 'is_change' parameter."))?; + let exclude_used = params + .get(1, "exclude_used") + .ok_or_else(|| Error::invalid_params("Missing 'exclude_used' parameter."))? + .as_bool() + .ok_or_else(|| Error::invalid_params("Invalid 'exclude_used' parameter."))?; + let limit = params + .get(2, "limit") + .ok_or_else(|| Error::invalid_params("Missing 'limit' parameter."))? + .as_u64() + .and_then(|l| l.try_into().ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'limit' parameter."))?; + // A missing value and `null` are both mapped to `None`. + let start_index = if let Some(ind) = params.get(3, "start_index") { + if ind.as_null().is_some() { + None + } else { + let ind_u32: u32 = ind + .as_u64() + .and_then(|ind_u64| ind_u64.try_into().ok()) + .ok_or_else(|| Error::invalid_params("Invalid 'start_index' parameter."))?; + Some(ind_u32) + } + } else { + None + }; + + let res = &control.list_revealed_addresses( + is_change, + exclude_used, + limit, + start_index.map(|ind| ind.into()), + )?; + Ok(serde_json::json!(&res)) +} + fn update_deriv_indexes( control: &DaemonControl, params: Params, @@ -493,6 +537,14 @@ pub fn handle_request(control: &mut DaemonControl, req: Request) -> Result { + let params = req.params.ok_or_else(|| { + Error::invalid_params( + "The 'listrevealedaddresses' command requires 3 parameters: 'is_change', 'exclude_used' and 'limit'", + ) + })?; + list_revealed_addresses(control, params)? + } "listconfirmed" => { let params = req.params.ok_or_else(|| { Error::invalid_params( diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 5334c4ed..29bf6c4c 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -201,6 +201,234 @@ def test_listaddresses(lianad): lianad.rpc.listaddresses(0, "blb") +def test_listrevealedaddresses(lianad, bitcoind): + + # Get addresses for reference: + addresses = lianad.rpc.listaddresses(0, 10)["addresses"] + + # We start with index 0 already "revealed": + list_rec = lianad.rpc.listrevealedaddresses(False, False, 10) + assert list_rec["continue_from"] is None # there are no more addresses to list + assert len(list_rec["addresses"]) == 1 + assert list_rec["addresses"][0]["index"] == 0 + assert list_rec["addresses"][0]["address"] == addresses[0]["receive"] + assert list_rec["addresses"][0]["used_count"] == 0 + assert list_rec["addresses"][0]["label"] is None + + # Generate some addresses. + addr_1 = lianad.rpc.getnewaddress()["address"] + addr_2 = lianad.rpc.getnewaddress()["address"] + addr_3 = lianad.rpc.getnewaddress()["address"] + addr_4 = lianad.rpc.getnewaddress()["address"] + addr_5 = lianad.rpc.getnewaddress()["address"] + addr_6 = lianad.rpc.getnewaddress()["address"] + addr_7 = lianad.rpc.getnewaddress()["address"] + + # Last revealed receive index is 7. + assert lianad.rpc.getinfo()["receive_index"] == 7 + + # Set some labels + lianad.rpc.updatelabels( + {addr_1: "my test label 1", addr_5: "my test label 5"}, + ) + + # Passing None or omitting start_index parameter is the same: + assert lianad.rpc.listrevealedaddresses( + False, False, 10 + ) == lianad.rpc.listrevealedaddresses(False, False, 10, None) + + # If we continue_from a value above our last revealed index, we'll start from the last index. + assert lianad.rpc.listrevealedaddresses( + False, False, 10 + ) == lianad.rpc.listrevealedaddresses(False, False, 10, 100) + + # Similarly if we start from a hardened index: + assert lianad.rpc.listrevealedaddresses( + False, False, 10 + ) == lianad.rpc.listrevealedaddresses(False, False, 10, 4_294_967_295) + + # Get 3 addresses starting at last revealed index: + list_rec = lianad.rpc.listrevealedaddresses(False, False, 3) + assert list_rec["continue_from"] == 4 + assert len(list_rec["addresses"]) == 3 + assert list_rec["addresses"][0]["index"] == 7 + assert list_rec["addresses"][0]["address"] == addr_7 + assert list_rec["addresses"][0]["used_count"] == 0 + assert list_rec["addresses"][0]["label"] is None + assert list_rec["addresses"][1]["index"] == 6 + assert list_rec["addresses"][1]["address"] == addr_6 + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + assert list_rec["addresses"][2]["index"] == 5 + assert list_rec["addresses"][2]["address"] == addr_5 + assert list_rec["addresses"][2]["used_count"] == 0 + assert list_rec["addresses"][2]["label"] == "my test label 5" + + # Get next 3 using continue_from returned above as start_index: + list_rec = lianad.rpc.listrevealedaddresses(False, False, 3, 4) + assert list_rec["continue_from"] == 1 + assert len(list_rec["addresses"]) == 3 + assert list_rec["addresses"][0]["index"] == 4 + assert list_rec["addresses"][0]["address"] == addr_4 + assert list_rec["addresses"][0]["used_count"] == 0 + assert list_rec["addresses"][0]["label"] is None + assert list_rec["addresses"][1]["index"] == 3 + assert list_rec["addresses"][1]["address"] == addr_3 + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + assert list_rec["addresses"][2]["index"] == 2 + assert list_rec["addresses"][2]["address"] == addr_2 + assert list_rec["addresses"][2]["used_count"] == 0 + assert list_rec["addresses"][2]["label"] is None + + # Get final page of results consisting of 2 addresses: + list_rec = lianad.rpc.listrevealedaddresses(False, False, 3, 1) + assert list_rec["continue_from"] is None # final page + assert len(list_rec["addresses"]) == 2 # num addresses remaining is below limit + assert list_rec["addresses"][0]["index"] == 1 + assert list_rec["addresses"][0]["address"] == addr_1 + assert list_rec["addresses"][0]["used_count"] == 0 + assert list_rec["addresses"][0]["label"] == "my test label 1" + assert list_rec["addresses"][1]["index"] == 0 + assert list_rec["addresses"][1]["address"] == addresses[0]["receive"] + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + + # Receive funds at a couple of addresses. + destinations = { + addr_2: 0.003, + addr_4: 0.004, + addr_7: 0.005, + } + txid = bitcoind.rpc.sendmany("", destinations) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 3) + + # The addresses are shown as used. + list_rec = lianad.rpc.listrevealedaddresses(False, False, 3) + assert list_rec["continue_from"] == 4 + assert len(list_rec["addresses"]) == 3 + assert list_rec["addresses"][0]["index"] == 7 + assert list_rec["addresses"][0]["address"] == addr_7 + assert list_rec["addresses"][0]["used_count"] == 1 + assert list_rec["addresses"][0]["label"] is None + assert list_rec["addresses"][1]["index"] == 6 + assert list_rec["addresses"][1]["address"] == addr_6 + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + assert list_rec["addresses"][2]["index"] == 5 + assert list_rec["addresses"][2]["address"] == addr_5 + assert list_rec["addresses"][2]["used_count"] == 0 + assert list_rec["addresses"][2]["label"] == "my test label 5" + + list_rec = lianad.rpc.listrevealedaddresses(False, False, 3, 4) + assert list_rec["continue_from"] == 1 + assert len(list_rec["addresses"]) == 3 + assert list_rec["addresses"][0]["index"] == 4 + assert list_rec["addresses"][0]["address"] == addr_4 + assert list_rec["addresses"][0]["used_count"] == 1 + assert list_rec["addresses"][0]["label"] is None + assert list_rec["addresses"][1]["index"] == 3 + assert list_rec["addresses"][1]["address"] == addr_3 + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + assert list_rec["addresses"][2]["index"] == 2 + assert list_rec["addresses"][2]["address"] == addr_2 + assert list_rec["addresses"][2]["used_count"] == 1 + assert list_rec["addresses"][2]["label"] is None + + # We can exclude used addresses: + list_rec = lianad.rpc.listrevealedaddresses(False, True, 3) + assert list_rec["continue_from"] == 2 + assert len(list_rec["addresses"]) == 3 + assert list_rec["addresses"][0]["index"] == 6 + assert list_rec["addresses"][0]["address"] == addr_6 + assert list_rec["addresses"][0]["used_count"] == 0 + assert list_rec["addresses"][0]["label"] is None + assert list_rec["addresses"][1]["index"] == 5 + assert list_rec["addresses"][1]["address"] == addr_5 + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] == "my test label 5" + assert list_rec["addresses"][2]["index"] == 3 # index 4 was skipped + assert list_rec["addresses"][2]["address"] == addr_3 + assert list_rec["addresses"][2]["used_count"] == 0 + assert list_rec["addresses"][2]["label"] is None + + # We can exclude used also if we continue from the value in the response above: + list_rec = lianad.rpc.listrevealedaddresses(False, True, 3, 2) + assert list_rec["continue_from"] is None + assert len(list_rec["addresses"]) == 2 + assert list_rec["addresses"][0]["index"] == 1 # index 2 was skipped + assert list_rec["addresses"][0]["address"] == addr_1 + assert list_rec["addresses"][0]["used_count"] == 0 + assert list_rec["addresses"][0]["label"] == "my test label 1" + assert list_rec["addresses"][1]["index"] == 0 + assert list_rec["addresses"][1]["address"] == addresses[0]["receive"] + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + + # Receive funds at some of the same addresses again. + destinations = { + addr_2: 0.0031, + addr_4: 0.0041, + } + txid = bitcoind.rpc.sendmany("", destinations) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 5) + + # One more coin to addr_2. + destinations = { + addr_2: 0.0032, + } + txid = bitcoind.rpc.sendmany("", destinations) + bitcoind.generate_block(1, wait_for_mempool=txid) + wait_for(lambda: len(lianad.rpc.listcoins(["confirmed"])["coins"]) == 6) + + # The counts have updated: + list_rec = lianad.rpc.listrevealedaddresses(False, False, 3, 4) + assert list_rec["continue_from"] == 1 + assert len(list_rec["addresses"]) == 3 + assert list_rec["addresses"][0]["index"] == 4 + assert list_rec["addresses"][0]["address"] == addr_4 + assert list_rec["addresses"][0]["used_count"] == 2 + assert list_rec["addresses"][0]["label"] is None + assert list_rec["addresses"][1]["index"] == 3 + assert list_rec["addresses"][1]["address"] == addr_3 + assert list_rec["addresses"][1]["used_count"] == 0 + assert list_rec["addresses"][1]["label"] is None + assert list_rec["addresses"][2]["index"] == 2 + assert list_rec["addresses"][2]["address"] == addr_2 + assert list_rec["addresses"][2]["used_count"] == 3 + assert list_rec["addresses"][2]["label"] is None + + # If we request limit 0, we get empty list: + list_rec = lianad.rpc.listrevealedaddresses(False, False, 0) + assert list_rec["continue_from"] == 7 # same as starting index + assert len(list_rec["addresses"]) == 0 + + # The poller currently sets the change index to match the receive index. + # See https://github.com/wizardsardine/liana/issues/1333. + assert lianad.rpc.getinfo()["receive_index"] == 7 + assert lianad.rpc.getinfo()["change_index"] == 7 + + # We can get change addresses: + list_cha = lianad.rpc.listrevealedaddresses(True, False, 3) + assert list_cha["continue_from"] == 4 + assert len(list_cha["addresses"]) == 3 + assert list_cha["addresses"][0]["index"] == 7 + assert list_cha["addresses"][0]["address"] == addresses[7]["change"] + assert list_cha["addresses"][0]["used_count"] == 0 + assert list_cha["addresses"][0]["label"] is None + assert list_cha["addresses"][1]["index"] == 6 + assert list_cha["addresses"][1]["address"] == addresses[6]["change"] + assert list_cha["addresses"][1]["used_count"] == 0 + assert list_cha["addresses"][1]["label"] is None + assert list_cha["addresses"][2]["index"] == 5 + assert list_cha["addresses"][2]["address"] == addresses[5]["change"] + assert list_cha["addresses"][2]["used_count"] == 0 + assert list_cha["addresses"][2]["label"] is None + + def test_listcoins(lianad, bitcoind): # Initially empty res = lianad.rpc.listcoins()