From 16b96b5456b46cd3703d4829b24746c8a489b3ed Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Fri, 2 May 2025 17:19:18 +0100 Subject: [PATCH 1/4] style: apply black formatting --- tests/fixtures.py | 9 ++------- tests/test_framework/lianad.py | 7 +------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index 9985b2c6..691e8055 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -308,6 +308,7 @@ def lianad_multisig(bitcoin_backend, directory): lianad.cleanup() + @pytest.fixture def lianad_multisig_legacy_datadir(bitcoin_backend, directory): datadir = os.path.join(directory, "lianad") @@ -318,13 +319,7 @@ def lianad_multisig_legacy_datadir(bitcoin_backend, directory): signer = MultiSigner(4, {csv_value: 5}, is_taproot=USE_TAPROOT) main_desc = Descriptor.from_str(multisig_desc(signer, csv_value, USE_TAPROOT, 3, 2)) - lianad = Lianad( - datadir, - signer, - main_desc, - bitcoin_backend, - legacy_datadir=True - ) + lianad = Lianad(datadir, signer, main_desc, bitcoin_backend, legacy_datadir=True) try: lianad.start() diff --git a/tests/test_framework/lianad.py b/tests/test_framework/lianad.py index 9e70f854..3e7c6483 100644 --- a/tests/test_framework/lianad.py +++ b/tests/test_framework/lianad.py @@ -26,12 +26,7 @@ from test_framework.serializations import ( class Lianad(TailableProc): def __init__( - self, - datadir, - signer, - multi_desc, - bitcoin_backend, - legacy_datadir=False + self, datadir, signer, multi_desc, bitcoin_backend, legacy_datadir=False ): TailableProc.__init__(self, datadir, verbose=VERBOSE) From 75f37708b3b1f6a89e0f5488c95618a07356f166 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Fri, 2 May 2025 13:02:08 +0100 Subject: [PATCH 2/4] test: implement methods for getting/setting labels --- lianad/src/testutils.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/lianad/src/testutils.rs b/lianad/src/testutils.rs index 2f4a11ba..5b24aeb8 100644 --- a/lianad/src/testutils.rs +++ b/lianad/src/testutils.rs @@ -152,6 +152,7 @@ struct DummyDbState { coins: HashMap, txs: HashMap, spend_txs: HashMap)>, + labels: HashMap, timestamp: u32, rescan_timestamp: Option, last_poll_timestamp: Option, @@ -186,6 +187,7 @@ impl DummyDatabase { coins: HashMap::new(), txs: HashMap::new(), spend_txs: HashMap::new(), + labels: HashMap::new(), timestamp: now, rescan_timestamp: None, last_poll_timestamp: None, @@ -438,12 +440,32 @@ impl DatabaseConnection for DummyDatabase { self.db.write().unwrap().last_poll_timestamp = Some(timestamp); } - fn update_labels(&mut self, _items: &HashMap>) { - todo!() + fn update_labels(&mut self, items: &HashMap>) { + for (lab_item, lab_val) in items { + if let Some(val) = lab_val { + self.db + .write() + .unwrap() + .labels + .insert(lab_item.clone(), val.clone()); + } else { + self.db.write().unwrap().labels.remove_entry(lab_item); + } + } } - fn labels(&mut self, _items: &HashSet) -> HashMap { - todo!() + fn labels(&mut self, items: &HashSet) -> HashMap { + self.db + .read() + .unwrap() + .labels + .iter() + .filter_map(|(lab_item, lab_val)| { + items + .contains(lab_item) + .then_some((lab_item.to_string(), lab_val.clone())) + }) + .collect() } fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { From 4945dba7c066d1434fa38a09a955339b7182e693 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Fri, 2 May 2025 15:34:34 +0100 Subject: [PATCH 3/4] feat: add command to list revealed addresses --- lianad/src/commands/mod.rs | 434 +++++++++++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) diff --git a/lianad/src/commands/mod.rs b/lianad/src/commands/mod.rs index 7162b63a..77ffcf21 100644 --- a/lianad/src/commands/mod.rs +++ b/lianad/src/commands/mod.rs @@ -477,6 +477,100 @@ impl DaemonControl { Ok(ListAddressesResult::new(addresses?)) } + /// List revealed addresses. Addresses will be returned in order of + /// descending derivation index. + /// + /// # Parameters + /// + /// - `is_change`: set to `false` to return receive addresses and `true` for change addresses. + /// + /// - `exclude_used`: set to `true` to return only those revealed addresses that + /// are unused by any coins in the wallet. + /// + /// - `limit`: the maximum number of addresses to return. + /// + /// - `start_index`: the derivation index from which to start listing addresses. As addresses are + /// returned in descending order, `start_index` is the highest index that can be returned. + /// If set to `None`, then addresses will be returned starting from the last revealed index. + /// As there are no revealed addresses with a derivation index higher than the last revealed index, + /// setting this parameter to a higher value will be the same as setting it to `None`. + pub fn list_revealed_addresses( + &self, + is_change: bool, + exclude_used: bool, + limit: usize, + start_index: Option, + ) -> Result { + let mut db_conn = self.db.connection(); + + let (desc, last_revealed) = if is_change { + ( + self.config.main_descriptor.change_descriptor(), + db_conn.change_index(), + ) + } else { + ( + self.config.main_descriptor.receive_descriptor(), + db_conn.receive_index(), + ) + }; + + // Determine the index to start deriving addresses from, ensuring it is not higher than the last revealed. + let start_index = start_index.unwrap_or(last_revealed).min(last_revealed); + + // Count how many times each (used) address has been used. + let mut used_counts = HashMap::::new(); + // TODO: consider adding DB method to get coins or used indices by index range. + for coin in db_conn.coins(&[], &[]).values() { + if coin.is_change == is_change && coin.derivation_index <= start_index { + *used_counts.entry(coin.derivation_index).or_insert(0) += 1; + } + } + + let mut addresses = Vec::<_>::with_capacity(limit); + let mut continue_from = None; + // This will store (index, address) pairs. + let mut derived_addresses = Vec::<_>::with_capacity(limit); + // Iterate in descending order. + for i in (0..=start_index.into()).rev() { + let index = ChildNumber::from(i); + if derived_addresses.len() == limit { + // We've reached the limit. There may be more addresses to list using pagination. + continue_from = Some(index); + break; + } + if !exclude_used || !used_counts.contains_key(&index) { + let addr = desc + .derive(index, &self.secp) + .address(self.config.bitcoin_config.network); + derived_addresses.push((index, addr)); + } + } + // Now get the labels from DB (in multiple chunks). + let mut labels = HashMap::::with_capacity(derived_addresses.len()); + const CHUNK_SIZE: usize = 100; + for chunk in derived_addresses.chunks(CHUNK_SIZE) { + let items = chunk + .iter() + .map(|(_, addr)| LabelItem::Address(addr.clone())) + .collect::>(); + labels.extend(db_conn.labels(&items)); + } + for (index, address) in derived_addresses { + let label = labels.get(&address.to_string()).cloned(); + addresses.push(ListRevealedAddressesEntry { + index, + address, + label, + used_count: *used_counts.get(&index).unwrap_or(&0), + }); + } + Ok(ListRevealedAddressesResult { + addresses, + continue_from, + }) + } + /// Get a list of all known coins, optionally by status and/or outpoint. pub fn list_coins( &self, @@ -1317,6 +1411,37 @@ impl ListAddressesResult { } } +/// A revealed address entry in the list returned by [`DaemonControl::list_revealed_addresses`]. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ListRevealedAddressesEntry { + /// The address's derivation index. + pub index: ChildNumber, + /// The address. + pub address: bitcoin::Address, + /// Label assigned to the address, if any. + pub label: Option, + /// How many coins, including those unconfirmed, that are currently in the wallet are using this address. + /// + /// This count does not include any coins that may have been replaced or otherwise dropped + /// from the mempool. + pub used_count: u32, +} + +/// Result of a [`DaemonControl::list_revealed_addresses`] request. +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ListRevealedAddressesResult { + /// Revealed addresses in order of descending derivation index. + pub addresses: Vec, + /// `continue_from` being set to some value indicates that there may + /// be more addresses that can be listed with pagination. The next + /// [`DaemonControl::list_revealed_addresses`] request can be continued + /// with this value passed to `start_index`. + /// + /// If `continue_from` is `None`, then there are no further + /// addresses to be listed. + pub continue_from: Option, +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct LCSpendInfo { pub txid: bitcoin::Txid, @@ -1565,6 +1690,315 @@ mod tests { ms.shutdown(); } + #[test] + fn list_revealed_addresses() { + let ms = DummyLiana::new(DummyBitcoind::new(), DummyDatabase::new()); + + let control = &ms.control(); + let mut db_conn = control.db().lock().unwrap().connection(); + + // $ bitcoin-cli deriveaddresses "wsh(or_d(pk([aabbccdd]xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/0/*),and_v(v:pkh([aabbccdd]xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/0/*),older(10000))))#wx6v3mks" 0 + // [ + // "bc1q9ksrc647hx8zp2cewl8p5f487dgux3777yees8rjcx46t4daqzzqt7yga8" + // ] + let addr0 = bitcoin::Address::from_str( + "bc1q9ksrc647hx8zp2cewl8p5f487dgux3777yees8rjcx46t4daqzzqt7yga8", + ) + .unwrap() + .assume_checked(); + + // The wallet starts with index 0 already revealed: + let list = control + .list_revealed_addresses(false, false, 3, None) + .unwrap(); + assert_eq!(list.addresses.len(), 1); + assert!(list.continue_from.is_none()); + let revealed = list.addresses.first().unwrap(); + assert_eq!(revealed.index, ChildNumber::from(0)); + assert_eq!(revealed.address, addr0); + assert_eq!(revealed.used_count, 0); + assert!(revealed.label.is_none()); + + // Generate new addresses up to and including index 7: + let addr1 = control.get_new_address().address; + let addr2 = control.get_new_address().address; + let addr3 = control.get_new_address().address; + let addr4 = control.get_new_address().address; + let addr5 = control.get_new_address().address; + let addr6 = control.get_new_address().address; + let addr7 = control.get_new_address().address; + assert_eq!(control.get_info().receive_index, 7); + + // Set some labels. + db_conn.update_labels(&HashMap::from([ + ( + LabelItem::Address(addr1.clone()), + Some("my test label 1".to_string()), + ), + ( + LabelItem::Address(addr5.clone()), + Some("my test label 5".to_string()), + ), + ])); + + // If we continue_from a value above our last index, we'll start from the last index. + let list = control + .list_revealed_addresses(false, false, 3, Some(ChildNumber::from(100))) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(4))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(7)); // this is our last revealed index + assert_eq!(list.addresses[0].address, addr7); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(6)); + assert_eq!(list.addresses[1].address, addr6); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(5)); + assert_eq!(list.addresses[2].address, addr5); + assert_eq!(list.addresses[2].used_count, 0); + assert_eq!(list.addresses[2].label, Some("my test label 5".to_string())); + + // If we start from a hardened index, we'll get the same result again: + assert_eq!( + list, + control + .list_revealed_addresses(false, false, 3, Some(ChildNumber::from(u32::MAX))) + .unwrap() + ); + + // Passing `None` for `continue_from` will also start from the last revealed index: + let list = control + .list_revealed_addresses(false, false, 3, None) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(4))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(7)); + assert_eq!(list.addresses[0].address, addr7); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(6)); + assert_eq!(list.addresses[1].address, addr6); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(5)); + assert_eq!(list.addresses[2].address, addr5); + assert_eq!(list.addresses[2].used_count, 0); + assert_eq!(list.addresses[2].label, Some("my test label 5".to_string())); + + // Now continue pagination using the `continue_from` value from the result above: + let list = control + .list_revealed_addresses(false, false, 3, Some(ChildNumber::from(4))) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(1))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(4)); + assert_eq!(list.addresses[0].address, addr4); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(3)); + assert_eq!(list.addresses[1].address, addr3); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(2)); + assert_eq!(list.addresses[2].address, addr2); + assert_eq!(list.addresses[2].used_count, 0); + assert!(list.addresses[2].label.is_none()); + + // This is the final page: + let list = control + .list_revealed_addresses(false, false, 3, Some(ChildNumber::from(1))) + .unwrap(); + assert_eq!(list.addresses.len(), 2); // only two addresses even though limit was 3 + assert!(list.continue_from.is_none()); // there are no more addresses to derive + + assert_eq!(list.addresses[0].index, ChildNumber::from(1)); + assert_eq!(list.addresses[0].address, addr1); + assert_eq!(list.addresses[0].used_count, 0); + assert_eq!(list.addresses[0].label, Some("my test label 1".to_string())); + assert_eq!(list.addresses[1].index, ChildNumber::from(0)); + assert_eq!(list.addresses[1].address, addr0); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + + // Add a coin so that address with index 5 is used: + db_conn.new_unspent_coins(&[Coin { + outpoint: OutPoint::new( + Txid::from_str("617eab1fc0b03ee7f82ba70166725291783461f1a0e7975eaf8b5f8f674234f3") + .unwrap(), + 0, + ), + is_immature: false, + block_info: None, + amount: bitcoin::Amount::from_sat(80_000), + derivation_index: ChildNumber::from(5), + is_change: false, + spend_txid: None, + spend_block: None, + is_from_self: true, + }]); + + // If we don't exclude used, results will be same as before, except index 5 is marked as used: + let list = control + .list_revealed_addresses(false, false, 3, None) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(4))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(7)); + assert_eq!(list.addresses[0].address, addr7); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(6)); + assert_eq!(list.addresses[1].address, addr6); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(5)); + assert_eq!(list.addresses[2].address, addr5); + assert_eq!(list.addresses[2].used_count, 1); // used + assert_eq!(list.addresses[2].label, Some("my test label 5".to_string())); + + // If we exclude used, index 5 will be skipped: + let list = control + .list_revealed_addresses(false, true, 3, None) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(3))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(7)); + assert_eq!(list.addresses[0].address, addr7); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(6)); + assert_eq!(list.addresses[1].address, addr6); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(4)); + assert_eq!(list.addresses[2].address, addr4); + assert_eq!(list.addresses[2].used_count, 0); + assert!(list.addresses[2].label.is_none()); + + // Similar behaviour if we continue from index 5. First without excluding used: + let list = control + .list_revealed_addresses(false, false, 3, Some(ChildNumber::from(5))) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(2))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(5)); + assert_eq!(list.addresses[0].address, addr5); + assert_eq!(list.addresses[0].used_count, 1); // used + assert_eq!(list.addresses[0].label, Some("my test label 5".to_string())); + assert_eq!(list.addresses[1].index, ChildNumber::from(4)); + assert_eq!(list.addresses[1].address, addr4); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(3)); + assert_eq!(list.addresses[2].address, addr3); + assert_eq!(list.addresses[2].used_count, 0); + assert!(list.addresses[2].label.is_none()); + + // Now excluding used: + let list = control + .list_revealed_addresses(false, true, 3, Some(ChildNumber::from(5))) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(1))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(4)); + assert_eq!(list.addresses[0].address, addr4); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(3)); + assert_eq!(list.addresses[1].address, addr3); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(2)); + assert_eq!(list.addresses[2].address, addr2); + assert_eq!(list.addresses[2].used_count, 0); + assert!(list.addresses[2].label.is_none()); + + // If we add another coin using the same derivation index, the count will increase: + db_conn.new_unspent_coins(&[Coin { + outpoint: OutPoint::new( + Txid::from_str("617eab1fc0b03ee7f82ba70166725291783461f1a0e7975eaf8b5f8f674234f3") + .unwrap(), + 1, + ), + is_immature: false, + block_info: None, + amount: bitcoin::Amount::from_sat(80_000), + derivation_index: ChildNumber::from(5), + is_change: false, + spend_txid: None, + spend_block: None, + is_from_self: true, + }]); + + let list = control + .list_revealed_addresses(false, false, 3, None) + .unwrap(); + assert_eq!(list.addresses.len(), 3); + assert_eq!(list.continue_from, Some(ChildNumber::from(4))); + + assert_eq!(list.addresses[0].index, ChildNumber::from(7)); + assert_eq!(list.addresses[0].address, addr7); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + assert_eq!(list.addresses[1].index, ChildNumber::from(6)); + assert_eq!(list.addresses[1].address, addr6); + assert_eq!(list.addresses[1].used_count, 0); + assert!(list.addresses[1].label.is_none()); + assert_eq!(list.addresses[2].index, ChildNumber::from(5)); + assert_eq!(list.addresses[2].address, addr5); + assert_eq!(list.addresses[2].used_count, 2); // count updated. + assert_eq!(list.addresses[2].label, Some("my test label 5".to_string())); + + // Check change address + // $ bitcoin-cli deriveaddresses "wsh(or_d(pk([aabbccdd]xpub68JJTXc1MWK8KLW4HGLXZBJknja7kDUJuFHnM424LbziEXsfkh1WQCiEjjHw4zLqSUm4rvhgyGkkuRowE9tCJSgt3TQB5J3SKAbZ2SdcKST/1/*),and_v(v:pkh([aabbccdd]xpub68JJTXc1MWK8PEQozKsRatrUHXKFNkD1Cb1BuQU9Xr5moCv87anqGyXLyUd4KpnDyZgo3gz4aN1r3NiaoweFW8UutBsBbgKHzaD5HkTkifK/1/*),older(10000))))#sqk8au7v" 0 + // [ + // "bc1qd5m23jemr8mfj8x4q482zpspnxehg0jg0pyzrhxfg8xrrtyqewvqjrq3x6" + // ] + let change_addr0 = bitcoin::Address::from_str( + "bc1qd5m23jemr8mfj8x4q482zpspnxehg0jg0pyzrhxfg8xrrtyqewvqjrq3x6", + ) + .unwrap() + .assume_checked(); + + // If we ask for change addresses, we only have the initial one revealed: + let list = control + .list_revealed_addresses(true, false, 3, None) + .unwrap(); + assert_eq!(list.addresses.len(), 1); + assert!(list.continue_from.is_none()); + + assert_eq!(list.addresses[0].index, ChildNumber::from(0)); + assert_eq!(list.addresses[0].address, change_addr0); + assert_eq!(list.addresses[0].used_count, 0); + assert!(list.addresses[0].label.is_none()); + + // Finally, passing limit 0 returns an empty vector and the continue_from is same as it started with. + // First, passing None for continue from: + let list = control + .list_revealed_addresses(false, false, 0, None) + .unwrap(); + assert_eq!(list.addresses.len(), 0); + assert_eq!(list.continue_from, Some(ChildNumber::from(7))); + + // Now, continuing from 4: + let list = control + .list_revealed_addresses(false, false, 0, Some(ChildNumber::from(4))) + .unwrap(); + assert_eq!(list.addresses.len(), 0); + assert_eq!(list.continue_from, Some(ChildNumber::from(4))); + + ms.shutdown(); + } + #[test] fn create_spend() { let dummy_tx = bitcoin::Transaction { From 3d6a22ea05df70d6f0c11f2f00c85dc16975ad28 Mon Sep 17 00:00:00 2001 From: Michael Mallan Date: Fri, 2 May 2025 15:34:48 +0100 Subject: [PATCH 4/4] 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()