diff --git a/doc/API.md b/doc/API.md index 68c7a1f6..887cfc4d 100644 --- a/doc/API.md +++ b/doc/API.md @@ -20,6 +20,8 @@ Commands must be sent as valid JSONRPC 2.0 requests, ending with a `\n`. | [`listconfirmed`](#listconfirmed) | List of confirmed transactions of incoming and outgoing funds | | [`listtransactions`](#listtransactions) | List of transactions with the given txids | | [`createrecovery`](#createrecovery) | Create a recovery transaction to sweep expired coins | +| [`updatelabels`](#updatelabels) | Update the labels | +| [`getlabels`](#getlabels) | Get the labels for the given addresses, txids and outpoints | # Reference @@ -92,6 +94,7 @@ This command does not take any parameter for now. | Field | Type | Description | | -------------- | ------------- | ------------------------------------------------------------------------------------------------------------------ | +| `address` | string | Address containing the script pubkey of the coin | | `amount` | int | Value of the TxO in satoshis. | | `outpoint` | string | Transaction id and output index of this coin. | | `block_height` | int or null | Block height the transaction was confirmed at, or `null`. | @@ -300,3 +303,31 @@ cover the requested feerate. | Field | Type | Description | | -------------- | --------- | ---------------------------------------------------- | | `psbt` | string | PSBT of the recovery transaction, encoded as base64. | + +### `updatelabels` + +Update the labels from a given map of key/value, with the labelled bitcoin addresses, txids and outpoints as keys +and the label as value. If a label already exist for the given item, the new label overrides the previous one. + +#### Request + +| Field | Type | Description | +| -------- | ------ | --------------------------------------------------------------------------------------------------------------------- | +| `labels` | object | A mapping from an item to be labelled (an address, a txid or an outpoint) to a label string (at most 100 chars long). | + +### `getlabels` + +Retrieve a map of items and their respective labels from a list of addresses, txids and outpoints. +Items without labels are not present in the response map. + +#### Request + +| Field | Type | Description | +| --------| ------------ | -------------------------------------------------------------- | +| `items` | string array | Items (address, txid or outpoint) of which to fetch the label. | + +#### Response + +| Field | Type | Description | +| -------- | ------ | -------------------------------------------------------------------------------- | +| `labels` | object | A mapping of bitcoin addresses, txids and oupoints as keys, and string as values | diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 168b1f1f..4c264a77 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -10,13 +10,15 @@ use crate::{ descriptors, DaemonControl, VERSION, }; +pub use crate::database::LabelItem; + use utils::{ deser_addr_assume_checked, deser_amount_from_sats, deser_fromstr, deser_hex, ser_amount, ser_hex, ser_to_string, }; use std::{ - collections::{hash_map, BTreeMap, HashMap}, + collections::{hash_map, BTreeMap, HashMap, HashSet}, convert::TryInto, fmt, }; @@ -308,7 +310,11 @@ impl DaemonControl { height: spend_block.map(|b| b.height), }); let block_height = block_info.map(|b| b.height); + let address = self + .derived_desc(&coin) + .address(self.config.bitcoin_config.network); ListCoinsEntry { + address, amount, outpoint, block_height, @@ -575,6 +581,18 @@ impl DaemonControl { Ok(()) } + pub fn update_labels(&self, items: &HashMap) { + let mut db_conn = self.db.connection(); + db_conn.update_labels(items); + } + + pub fn get_labels(&self, items: &HashSet) -> GetLabelsResult { + let mut db_conn = self.db.connection(); + GetLabelsResult { + labels: db_conn.labels(items), + } + } + pub fn list_spend(&self) -> ListSpendResult { let mut db_conn = self.db.connection(); let spend_txs = db_conn @@ -820,6 +838,11 @@ pub struct GetAddressResult { address: bitcoin::Address, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GetLabelsResult { + pub labels: HashMap, +} + impl GetAddressResult { pub fn new(address: bitcoin::Address) -> Self { Self { address } @@ -837,7 +860,7 @@ pub struct LCSpendInfo { pub height: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListCoinsEntry { #[serde( serialize_with = "ser_amount", @@ -845,6 +868,11 @@ pub struct ListCoinsEntry { )] pub amount: bitcoin::Amount, pub outpoint: bitcoin::OutPoint, + #[serde( + serialize_with = "ser_to_string", + deserialize_with = "deser_addr_assume_checked" + )] + pub address: bitcoin::Address, pub block_height: Option, /// Information about the transaction spending this coin. pub spend_info: Option, diff --git a/src/database/mod.rs b/src/database/mod.rs index 21b5a32d..355d8024 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -12,7 +12,13 @@ use crate::{ }, }; -use std::{collections::HashMap, sync}; +use std::{ + collections::{HashMap, HashSet}, + fmt::Display, + iter::FromIterator, + str::FromStr, + sync, +}; use miniscript::bitcoin::{self, bip32, psbt::PartiallySignedTransaction as Psbt, secp256k1}; @@ -119,6 +125,10 @@ pub trait DatabaseConnection { /// Delete a Spend transaction from database. fn delete_spend(&mut self, txid: &bitcoin::Txid); + fn update_labels(&mut self, items: &HashMap); + + fn labels(&mut self, labels: &HashSet) -> HashMap; + /// Mark the given tip as the new best seen block. Update stored data accordingly. fn rollback_tip(&mut self, new_tip: &BlockChainTip); @@ -257,6 +267,15 @@ impl DatabaseConnection for SqliteConn { self.delete_spend(txid) } + fn update_labels(&mut self, items: &HashMap) { + self.update_labels(items) + } + + fn labels(&mut self, items: &HashSet) -> HashMap { + let labels = self.db_labels(items); + HashMap::from_iter(labels.into_iter().map(|label| (label.item, label.value))) + } + fn rollback_tip(&mut self, new_tip: &BlockChainTip) { self.rollback_tip(new_tip) } @@ -335,3 +354,56 @@ pub enum CoinType { Unspent, Spent, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum LabelItem { + Address(bitcoin::Address), + Txid(bitcoin::Txid), + OutPoint(bitcoin::OutPoint), +} + +impl From for LabelItem { + fn from(value: bitcoin::Address) -> Self { + Self::Address(value) + } +} + +impl From for LabelItem { + fn from(value: bitcoin::Txid) -> Self { + Self::Txid(value) + } +} + +impl From for LabelItem { + fn from(value: bitcoin::OutPoint) -> Self { + Self::OutPoint(value) + } +} + +impl Display for LabelItem { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + LabelItem::Address(a) => write!(f, "{}", a), + LabelItem::Txid(a) => write!(f, "{}", a), + LabelItem::OutPoint(a) => write!(f, "{}", a), + } + } +} + +impl LabelItem { + pub fn from_str(s: &str, network: bitcoin::Network) -> Option { + if let Ok(addr) = bitcoin::Address::from_str(s) { + if !addr.is_valid_for_network(network) { + None + } else { + Some(LabelItem::Address(addr.assume_checked())) + } + } else if let Ok(txid) = bitcoin::Txid::from_str(s) { + Some(LabelItem::Txid(txid)) + } else if let Ok(outpoint) = bitcoin::OutPoint::from_str(s) { + Some(LabelItem::OutPoint(outpoint)) + } else { + None + } + } +} diff --git a/src/database/sqlite/mod.rs b/src/database/sqlite/mod.rs index d05e4663..93b124bb 100644 --- a/src/database/sqlite/mod.rs +++ b/src/database/sqlite/mod.rs @@ -14,18 +14,26 @@ use crate::{ bitcoin::BlockChainTip, database::{ sqlite::{ - schema::{DbAddress, DbCoin, DbSpendTransaction, DbTip, DbWallet, SCHEMA}, + schema::{ + DbAddress, DbCoin, DbLabel, DbLabelledKind, DbSpendTransaction, DbTip, DbWallet, + SCHEMA, + }, utils::{ create_fresh_db, curr_timestamp, db_exec, db_query, db_tx_query, db_version, maybe_apply_migration, LOOK_AHEAD_LIMIT, }, }, - Coin, CoinType, + Coin, CoinType, LabelItem, }, descriptors::LianaDescriptor, }; -use std::{cmp, convert::TryInto, fmt, io, path}; +use std::{ + cmp, + collections::{HashMap, HashSet}, + convert::TryInto, + fmt, io, path, +}; use miniscript::bitcoin::{ self, bip32, @@ -35,7 +43,7 @@ use miniscript::bitcoin::{ secp256k1, }; -const DB_VERSION: i64 = 2; +const DB_VERSION: i64 = 3; #[derive(Debug)] pub enum SqliteDbError { @@ -554,6 +562,43 @@ impl SqliteConn { .expect("Db must not fail") } + pub fn update_labels(&mut self, items: &HashMap) { + db_exec(&mut self.conn, |db_tx| { + for (labelled, kind, value) in items + .iter() + .map(|(a, v)| { + match a { + LabelItem::Address(a) =>(a.to_string(), DbLabelledKind::Address, v), + LabelItem::Txid(a) =>(a.to_string(), DbLabelledKind::Txid, v), + LabelItem::OutPoint(a) =>(a.to_string(), DbLabelledKind::OutPoint, v), + } + }) { + db_tx.execute( + "INSERT INTO labels (wallet_id, item, item_kind, value) VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT DO UPDATE SET value=excluded.value", + rusqlite::params![WALLET_ID, labelled, kind as i64, value], + )?; + } + Ok(()) + }) + .expect("Db must not fail") + } + + pub fn db_labels(&mut self, items: &HashSet) -> Vec { + let query = format!( + "SELECT * FROM labels where item in ({})", + items + .iter() + .map(|a| format!("'{}'", a)) + .collect::>() + .join(",") + ); + db_query(&mut self.conn, &query, rusqlite::params![], |row| { + row.try_into() + }) + .expect("Db must not fail") + } + /// Retrieves a limited and ordered list of transactions ids that happened during the given /// range. pub fn db_list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { @@ -812,6 +857,38 @@ CREATE TABLE spend_transactions ( fs::remove_dir_all(tmp_dir).unwrap(); } + #[test] + fn db_labels_update() { + let (tmp_dir, _, _, db) = dummy_db(); + + { + let txid_str = "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7"; + let txid = LabelItem::from_str(txid_str, bitcoin::Network::Bitcoin).unwrap(); + let mut items = HashSet::new(); + items.insert(txid.clone()); + + let mut conn = db.connection().unwrap(); + let db_labels = conn.db_labels(&items); + assert!(db_labels.is_empty()); + + let mut txids_labels = HashMap::new(); + txids_labels.insert(txid.clone(), "hello".to_string()); + + conn.update_labels(&txids_labels); + + let db_labels = conn.db_labels(&items); + assert_eq!(db_labels[0].value, "hello"); + + txids_labels.insert(txid, "hello again".to_string()); + conn.update_labels(&txids_labels); + + let db_labels = conn.db_labels(&items); + assert_eq!(db_labels[0].value, "hello again"); + } + + fs::remove_dir_all(tmp_dir).unwrap(); + } + #[test] fn db_coins_update() { let (tmp_dir, _, _, db) = dummy_db(); @@ -1639,4 +1716,42 @@ CREATE TABLE spend_transactions ( fs::remove_dir_all(tmp_dir).unwrap(); } + + #[test] + fn v0_to_v3_migration() { + let secp = secp256k1::Secp256k1::verification_only(); + + // Create a database with version 0, using the old schema. + let tmp_dir = tmp_dir(); + fs::create_dir_all(&tmp_dir).unwrap(); + let db_path: path::PathBuf = [tmp_dir.as_path(), path::Path::new("lianad_v0.sqlite3")] + .iter() + .collect(); + let mut options = dummy_options(); + options.schema = V0_SCHEMA; + options.version = 0; + create_fresh_db(&db_path, options, &secp).unwrap(); + + // SqliteDb new is doing the migration. + let db = SqliteDb::new(db_path, None, &secp).unwrap(); + + { + let mut conn = db.connection().unwrap(); + let version = conn.db_version(); + assert_eq!(version, 3); + + let txid_str = "0c62a990d20d54429e70859292e82374ba6b1b951a3ab60f26bb65fee5724ff7"; + let txid = LabelItem::from_str(txid_str, bitcoin::Network::Bitcoin).unwrap(); + let mut txids_labels = HashMap::new(); + txids_labels.insert(txid.clone(), "hello".to_string()); + conn.update_labels(&txids_labels); + + let mut items = HashSet::new(); + items.insert(txid); + let db_labels = conn.db_labels(&items); + assert_eq!(db_labels[0].value, "hello"); + } + + fs::remove_dir_all(tmp_dir).unwrap(); + } } diff --git a/src/database/sqlite/schema.rs b/src/database/sqlite/schema.rs index b4b4b7b7..7ebb280b 100644 --- a/src/database/sqlite/schema.rs +++ b/src/database/sqlite/schema.rs @@ -81,6 +81,15 @@ CREATE TABLE spend_transactions ( txid BLOB UNIQUE NOT NULL, updated_at INTEGER ); + +/* Labels applied on addresses (0), outpoints (1), txids (2) */ +CREATE TABLE labels ( + id INTEGER PRIMARY KEY NOT NULL, + wallet_id INTEGER NOT NULL, + item_kind INTEGER NOT NULL CHECK (item_kind IN (0,1,2)), + item TEXT UNIQUE NOT NULL, + value TEXT NOT NULL +); "; /// A row in the "tip" table. @@ -293,3 +302,54 @@ impl TryFrom<&rusqlite::Row<'_>> for DbSpendTransaction { }) } } + +/// A row in the "labels" table +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DbLabel { + pub id: i64, + pub wallet_id: i64, + pub item_kind: DbLabelledKind, + pub item: String, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[repr(i64)] +pub enum DbLabelledKind { + Address = 0, + OutPoint = 1, + Txid = 2, +} + +impl From for DbLabelledKind { + fn from(value: i64) -> Self { + if value == 0 { + Self::Address + } else if value == 1 { + Self::OutPoint + } else { + assert_eq!(value, 2); + Self::Txid + } + } +} + +impl TryFrom<&rusqlite::Row<'_>> for DbLabel { + type Error = rusqlite::Error; + + fn try_from(row: &rusqlite::Row) -> Result { + let id: i64 = row.get(0)?; + let wallet_id: i64 = row.get(1)?; + let item_kind: i64 = row.get(2)?; + let item: String = row.get(3)?; + let value: String = row.get(4)?; + + Ok(DbLabel { + id, + wallet_id, + item_kind: item_kind.into(), + item, + value, + }) + } +} diff --git a/src/database/sqlite/utils.rs b/src/database/sqlite/utils.rs index 407f05f5..efe92644 100644 --- a/src/database/sqlite/utils.rs +++ b/src/database/sqlite/utils.rs @@ -180,6 +180,20 @@ fn migrate_v1_to_v2(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError Ok(()) } +// After Liana 1.1 we upgraded the schema to add the labels table. +fn migrate_v2_to_v3(conn: &mut rusqlite::Connection) -> Result<(), SqliteDbError> { + db_exec(conn, |tx| { + tx.execute( + "CREATE TABLE labels (id INTEGER PRIMARY KEY NOT NULL, wallet_id INTEGER NOT NULL, item_kind INTEGER NOT NULL CHECK (item_kind IN (0,1,2)), item TEXT UNIQUE NOT NULL, value TEXT NOT NULL)", + rusqlite::params![], + )?; + tx.execute("UPDATE version SET version = 3", rusqlite::params![])?; + Ok(()) + })?; + + Ok(()) +} + /// Check the database version and if necessary apply the migrations to upgrade it to the current /// one. pub fn maybe_apply_migration(db_path: &path::Path) -> Result<(), SqliteDbError> { @@ -203,6 +217,11 @@ pub fn maybe_apply_migration(db_path: &path::Path) -> Result<(), SqliteDbError> migrate_v1_to_v2(&mut conn)?; log::warn!("Migration from database version 1 to version 2 successful."); } + 2 => { + log::warn!("Upgrading database from version 2 to version 3."); + migrate_v2_to_v3(&mut conn)?; + log::warn!("Migration from database version 2 to version 3 successful."); + } _ => return Err(SqliteDbError::UnsupportedVersion(version)), } } diff --git a/src/jsonrpc/api.rs b/src/jsonrpc/api.rs index 7d54c85f..fc920bbf 100644 --- a/src/jsonrpc/api.rs +++ b/src/jsonrpc/api.rs @@ -1,9 +1,14 @@ use crate::{ + commands::LabelItem, jsonrpc::{Error, Params, Request, Response}, DaemonControl, }; -use std::{collections::HashMap, convert::TryInto, str::FromStr}; +use std::{ + collections::{HashMap, HashSet}, + convert::TryInto, + str::FromStr, +}; use miniscript::bitcoin::{self, psbt::PartiallySignedTransaction as Psbt}; @@ -160,6 +165,68 @@ fn create_recovery(control: &DaemonControl, params: Params) -> Result Result { + let mut items = HashMap::new(); + for (item, value) in params + .get(0, "labels") + .ok_or_else(|| Error::invalid_params("Missing 'labels' parameter."))? + .as_object() + .ok_or_else(|| Error::invalid_params("Invalid 'labels' parameter."))? + .iter() + { + let value = value + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| Error::invalid_params(format!("Invalid 'labels.{}' value.", item)))?; + if value.len() > 100 { + return Err(Error::invalid_params(format!( + "Invalid 'labels.{}' value length: must be less or equal than 100 characters", + item + ))); + } + let item = + LabelItem::from_str(item, control.config.bitcoin_config.network).ok_or_else(|| { + Error::invalid_params(format!( + "Invalid 'labels.{}' parameter: must be an address, a txid or an outpoint", + item + )) + })?; + items.insert(item, value); + } + + control.update_labels(&items); + Ok(serde_json::json!({})) +} + +fn get_labels(control: &DaemonControl, params: Params) -> Result { + let mut items = HashSet::new(); + for item in params + .get(0, "items") + .ok_or_else(|| Error::invalid_params("Missing 'items' parameter."))? + .as_array() + .ok_or_else(|| Error::invalid_params("Invalid 'items' parameter."))? + .iter() + { + let item = item.as_str().ok_or_else(|| { + Error::invalid_params(format!( + "Invalid item {} format: must be an address, a txid or an outpoint", + item + )) + })?; + + let item = + LabelItem::from_str(item, control.config.bitcoin_config.network).ok_or_else(|| { + Error::invalid_params(format!( + "Invalid item {} format: must be an address, a txid or an outpoint", + item + )) + })?; + items.insert(item); + } + + Ok(serde_json::json!(control.get_labels(&items))) +} + /// Handle an incoming JSONRPC2 request. pub fn handle_request(control: &DaemonControl, req: Request) -> Result { let result = match req.method.as_str() { @@ -222,6 +289,18 @@ pub fn handle_request(control: &DaemonControl, req: Request) -> Result { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'labels' parameter."))?; + update_labels(control, params)? + } + "getlabels" => { + let params = req + .params + .ok_or_else(|| Error::invalid_params("Missing 'items' parameter."))?; + get_labels(control, params)? + } _ => { return Err(Error::method_not_found()); } diff --git a/src/testutils.rs b/src/testutils.rs index 4f739183..6bb854c4 100644 --- a/src/testutils.rs +++ b/src/testutils.rs @@ -1,11 +1,16 @@ use crate::{ bitcoin::{BitcoinInterface, Block, BlockChainTip, UTxO}, config::{BitcoinConfig, Config}, - database::{BlockInfo, Coin, CoinType, DatabaseConnection, DatabaseInterface}, + database::{BlockInfo, Coin, CoinType, DatabaseConnection, DatabaseInterface, LabelItem}, descriptors, DaemonHandle, }; -use std::{collections::HashMap, env, fs, io, path, process, str::FromStr, sync, thread, time}; +use std::{ + collections::{HashMap, HashSet}, + env, fs, io, path, process, + str::FromStr, + sync, thread, time, +}; use miniscript::{ bitcoin::{ @@ -334,6 +339,14 @@ impl DatabaseConnection for DummyDatabase { todo!() } + fn update_labels(&mut self, _items: &HashMap) { + todo!() + } + + fn labels(&mut self, _items: &HashSet) -> HashMap { + todo!() + } + fn list_txids(&mut self, start: u32, end: u32, limit: u64) -> Vec { let mut txids_and_time = Vec::new(); let coins = &self.db.read().unwrap().coins; diff --git a/tests/test_rpc.py b/tests/test_rpc.py index 119b04c9..7a599945 100644 --- a/tests/test_rpc.py +++ b/tests/test_rpc.py @@ -596,3 +596,116 @@ def test_create_recovery(lianad, bitcoind): assert len(reco_psbt.tx.vout) == 1 assert int(0.39999 * COIN) < int(reco_psbt.tx.vout[0].nValue) < int(0.4 * COIN) sign_and_broadcast(lianad, bitcoind, reco_psbt, recovery=True) + + +def test_labels(lianad, bitcoind): + """Test the creation and updating of labels.""" + # We can set a label for an address. + addr = lianad.rpc.getnewaddress()["address"] + lianad.rpc.updatelabels({addr: "first-addr"}) + assert lianad.rpc.getlabels([addr])["labels"] == {addr: "first-addr"} + # And also update it. + lianad.rpc.updatelabels({addr: "first-addr-1"}) + assert lianad.rpc.getlabels([addr])["labels"] == {addr: "first-addr-1"} + # But we can't set a label larger than 100 characters + with pytest.raises(RpcError, match=".*must be less or equal than 100 characters"): + lianad.rpc.updatelabels({addr: "".join("a" for _ in range(101))}) + + # We can set a label for a coin. + sec_addr = lianad.rpc.getnewaddress()["address"] + txid = bitcoind.rpc.sendtoaddress(sec_addr, 1) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 1) + coin = lianad.rpc.listcoins()["coins"][0] + lianad.rpc.updatelabels({coin["outpoint"]: "first-coin"}) + assert lianad.rpc.getlabels([coin["outpoint"]])["labels"] == { + coin["outpoint"]: "first-coin" + } + # And also update it. + lianad.rpc.updatelabels({coin["outpoint"]: "first-coin-1"}) + assert lianad.rpc.getlabels([coin["outpoint"]])["labels"] == { + coin["outpoint"]: "first-coin-1" + } + # Its address though has no label. + assert lianad.rpc.getlabels([sec_addr])["labels"] == {} + # But we can receive a coin to the address that has a label set, and query both. + sec_txid = bitcoind.rpc.sendtoaddress(addr, 1) + wait_for(lambda: len(lianad.rpc.listcoins()["coins"]) == 2) + sec_coin = next( + c for c in lianad.rpc.listcoins()["coins"] if sec_txid in c["outpoint"] + ) + lianad.rpc.updatelabels({sec_coin["outpoint"]: "sec-coin"}) + res = lianad.rpc.getlabels([sec_coin["outpoint"], addr])["labels"] + assert len(res) == 2 + assert res[sec_coin["outpoint"]] == "sec-coin" + assert res[addr] == "first-addr-1" + # We can also query the labels for both coins, of course. + res = lianad.rpc.getlabels([coin["outpoint"], sec_coin["outpoint"]])["labels"] + assert len(res) == 2 + assert res[coin["outpoint"]] == "first-coin-1" + assert res[sec_coin["outpoint"]] == "sec-coin" + + # We can set, update and query labels for deposit transactions. + lianad.rpc.updatelabels({txid: "first-deposit"}) + assert lianad.rpc.getlabels([txid, sec_txid])["labels"] == {txid: "first-deposit"} + lianad.rpc.updatelabels({txid: "first-deposit-1", sec_txid: "second-deposit"}) + res = lianad.rpc.getlabels([txid, sec_txid])["labels"] + assert len(res) == 2 + assert res[txid] == "first-deposit-1" + assert res[sec_txid] == "second-deposit" + + # We can set and update a label for a spend transaction. + spend_txid = get_txid(spend_coins(lianad, bitcoind, [coin, sec_coin])) + lianad.rpc.updatelabels({spend_txid: "spend-tx"}) + assert lianad.rpc.getlabels([spend_txid])["labels"] == {spend_txid: "spend-tx"} + lianad.rpc.updatelabels({spend_txid: "spend-tx-1"}) + assert lianad.rpc.getlabels([spend_txid])["labels"] == {spend_txid: "spend-tx-1"} + + # We can set labels for inexistent stuff, as long as the format of the item being + # labelled is valid. + inexistent_txid = "".join("0" for _ in range(64)) + inexistent_outpoint = "".join("1" for _ in range(64)) + ":42" + random_address = bitcoind.rpc.getnewaddress() + lianad.rpc.updatelabels( + { + inexistent_txid: "inex_txid", + inexistent_outpoint: "inex_outpoint", + random_address: "bitcoind-addr", + } + ) + res = lianad.rpc.getlabels([inexistent_txid, inexistent_outpoint, random_address])[ + "labels" + ] + assert len(res) == 3 + assert res[inexistent_txid] == "inex_txid" + assert res[inexistent_outpoint] == "inex_outpoint" + assert res[random_address] == "bitcoind-addr" + + # We'll confirm everything, shouldn't affect any of the labels. + bitcoind.generate_block(1, wait_for_mempool=spend_txid) + wait_for( + lambda: bitcoind.rpc.getblockcount() == lianad.rpc.getinfo()["block_height"] + ) + res = lianad.rpc.getlabels( + [ + addr, + sec_addr, # No label for this one. + txid, + sec_txid, + coin["outpoint"], + sec_coin["outpoint"], + spend_txid, + inexistent_txid, + inexistent_outpoint, + random_address, + ] + )["labels"] + assert len(res) == 9 + assert res[sec_coin["outpoint"]] == "sec-coin" + assert res[addr] == "first-addr-1" + assert res[coin["outpoint"]] == "first-coin-1" + assert res[txid] == "first-deposit-1" + assert res[sec_txid] == "second-deposit" + assert res[spend_txid] == "spend-tx-1" + assert res[inexistent_txid] == "inex_txid" + assert res[inexistent_outpoint] == "inex_outpoint" + assert res[random_address] == "bitcoind-addr"