From 3cf11e71177a9c45a583a56024650c227952c3b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= Date: Mon, 13 Apr 2026 15:09:01 +0200 Subject: [PATCH 01/10] move supported schema check --- src/wallet/online.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/wallet/online.rs b/src/wallet/online.rs index a5b972b9..233a398d 100644 --- a/src/wallet/online.rs +++ b/src/wallet/online.rs @@ -860,6 +860,16 @@ pub trait WalletOnline: WalletOffline { }; let contract_id = consignment.contract_id(); let asset_id = contract_id.to_string(); + let asset_schema: AssetSchema = consignment.schema_id().try_into()?; + + // check if the received schema is supported + if !self.supports_schema(&asset_schema) { + error!( + self.logger(), + "The wallet doesn't support the provided schema: {}", asset_schema + ); + return self.refuse_consignment(proxy_url, recipient_id, &mut updated_batch_transfer); + } // check if DB transfer is connected to an asset if let Some(aid) = asset_transfer.asset_id.clone() { @@ -892,7 +902,6 @@ pub trait WalletOnline: WalletOffline { // validate consignment debug!(self.logger(), "Validating consignment..."); - let asset_schema: AssetSchema = consignment.schema_id().try_into()?; let trusted_typesystem = asset_schema.types(); let validation_config = ValidationConfig { chain_net: self.chain_net(), @@ -986,14 +995,6 @@ pub trait WalletOnline: WalletOffline { } } - if !self.supports_schema(&asset_schema) { - error!( - self.logger(), - "The wallet doesn't support the provided schema: {}", asset_schema - ); - return self.refuse_consignment(proxy_url, recipient_id, &mut updated_batch_transfer); - } - let known_concealed = if let Some(RecipientTypeFull::Blind { .. }) = transfer.recipient_type { let beneficiary = XChainNet::::from_str(&recipient_id) From ee00977a541b035e09d1fe2d445f04c9fe9499cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= Date: Mon, 13 Apr 2026 17:14:12 +0200 Subject: [PATCH 02/10] list_transactions: newest first --- src/wallet/offline.rs | 5 +++-- src/wallet/test/multisig.rs | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/wallet/offline.rs b/src/wallet/offline.rs index 0a265893..0b8c30ac 100644 --- a/src/wallet/offline.rs +++ b/src/wallet/offline.rs @@ -1965,7 +1965,8 @@ pub trait WalletOffline: WalletBackup { .collect(); Ok(self .bdk_wallet() - .transactions() + .transactions_sort_by(|tx1, tx2| tx2.chain_position.cmp(&tx1.chain_position)) + .into_iter() .map(|t| { let txid = t.tx_node.txid.to_string(); let transaction_type = if drain_txids.contains(&txid) { @@ -2737,7 +2738,7 @@ pub trait RgbWalletOpsOffline: WalletOffline + WalletBackup { Ok(assets) } - /// List the Bitcoin [`Transaction`]s known to the wallet. + /// List the Bitcoin [`Transaction`]s known to the wallet, newest first. fn list_transactions( &mut self, online: Option, diff --git a/src/wallet/test/multisig.rs b/src/wallet/test/multisig.rs index 6151d278..055d1d48 100644 --- a/src/wallet/test/multisig.rs +++ b/src/wallet/test/multisig.rs @@ -1468,15 +1468,15 @@ fn send_btc_completed( wlt_1.sync(); wlt_2.sync(); let transactions = test_list_transactions(wlt_1.multisig, Some(wlt_1.online)); - let transaction = transactions.last().unwrap(); + let transaction = transactions.first().unwrap(); assert_eq!(transaction.txid, txid); assert_matches!(transaction.transaction_type, TransactionType::User); let transactions = test_list_transactions(wlt_2.multisig, Some(wlt_2.online)); - let transaction = transactions.last().unwrap(); + let transaction = transactions.first().unwrap(); assert_eq!(transaction.txid, txid); assert_matches!(transaction.transaction_type, TransactionType::User); let transactions = test_list_transactions(wlt_3.multisig, Some(wlt_3.online)); - let transaction = transactions.last().unwrap(); + let transaction = transactions.first().unwrap(); assert_eq!(transaction.txid, txid); assert_matches!(transaction.transaction_type, TransactionType::User); mine(false, false); From f7571e2c33a1953f7b1838a24a2209ee4ce2bc2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= Date: Mon, 13 Apr 2026 17:14:32 +0200 Subject: [PATCH 03/10] minor change to inspect_psbt_impl --- src/wallet/offline.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wallet/offline.rs b/src/wallet/offline.rs index 0b8c30ac..0a78e055 100644 --- a/src/wallet/offline.rs +++ b/src/wallet/offline.rs @@ -2265,9 +2265,9 @@ pub trait WalletOffline: WalletBackup { Ok(signature_count) } - fn inspect_psbt_impl(&self, psbt: String) -> Result { + fn inspect_psbt_impl(&self, psbt: &str) -> Result { // check request data validity - let psbt = Psbt::from_str(&psbt)?; + let psbt = Psbt::from_str(psbt)?; // collect PSBT inputs let mut inputs = Vec::new(); @@ -2849,7 +2849,7 @@ pub trait RgbWalletOpsOffline: WalletOffline + WalletBackup { /// Inspect a PSBT to return its information. fn inspect_psbt(&self, psbt: String) -> Result { info!(self.logger(), "Inspecting PSBT..."); - let inspection = self.inspect_psbt_impl(psbt)?; + let inspection = self.inspect_psbt_impl(&psbt)?; info!(self.logger(), "PSBT inspection completed"); Ok(inspection) } From 8a2f1592b61e8d829d075727e6bc358f70367c6d Mon Sep 17 00:00:00 2001 From: Nicola Busanello Date: Tue, 17 Mar 2026 12:51:32 +0100 Subject: [PATCH 04/10] multisig test refactor --- src/wallet/multisig.rs | 24 +- src/wallet/objects.rs | 4 +- src/wallet/test/multisig.rs | 2830 ----------------------------- src/wallet/test/multisig/mod.rs | 1264 +++++++++++++ src/wallet/test/multisig/utils.rs | 1915 +++++++++++++++++++ src/wallet/test/utils/chain.rs | 4 +- src/wallet/test/utils/helpers.rs | 14 +- 7 files changed, 3206 insertions(+), 2849 deletions(-) delete mode 100644 src/wallet/test/multisig.rs create mode 100644 src/wallet/test/multisig/mod.rs create mode 100644 src/wallet/test/multisig/utils.rs diff --git a/src/wallet/multisig.rs b/src/wallet/multisig.rs index ebd463d7..9226d794 100644 --- a/src/wallet/multisig.rs +++ b/src/wallet/multisig.rs @@ -504,9 +504,9 @@ pub enum Operation { #[derive(Debug, Clone)] #[cfg(any(feature = "electrum", feature = "esplora"))] -struct FileResponse { - r#type: FileType, - filepath: PathBuf, +pub(crate) struct FileResponse { + pub(crate) r#type: FileType, + pub(crate) filepath: PathBuf, } #[cfg(any(feature = "electrum", feature = "esplora"))] @@ -554,10 +554,10 @@ fn extract_fascia_from_files(files: &[FileResponse]) -> Result { #[derive(Debug, Clone)] #[cfg(any(feature = "electrum", feature = "esplora"))] -struct NoDetails; +pub(crate) struct NoDetails; #[cfg(any(feature = "electrum", feature = "esplora"))] -trait OperationHandler { +pub(crate) trait OperationHandler { type Details: Clone; fn extract_details(files: &[FileResponse]) -> Result; @@ -585,7 +585,7 @@ trait OperationHandler { } #[cfg(any(feature = "electrum", feature = "esplora"))] -struct CreateUtxosHandler; +pub(crate) struct CreateUtxosHandler; #[cfg(any(feature = "electrum", feature = "esplora"))] impl OperationHandler for CreateUtxosHandler { @@ -621,7 +621,7 @@ impl OperationHandler for CreateUtxosHandler { } #[cfg(any(feature = "electrum", feature = "esplora"))] -struct SendBtcHandler; +pub(crate) struct SendBtcHandler; #[cfg(any(feature = "electrum", feature = "esplora"))] impl OperationHandler for SendBtcHandler { @@ -657,7 +657,7 @@ impl OperationHandler for SendBtcHandler { } #[cfg(any(feature = "electrum", feature = "esplora"))] -struct SendRgbHandler; +pub(crate) struct SendRgbHandler; #[cfg(any(feature = "electrum", feature = "esplora"))] impl OperationHandler for SendRgbHandler { @@ -716,7 +716,7 @@ impl OperationHandler for SendRgbHandler { } #[cfg(any(feature = "electrum", feature = "esplora"))] -struct InflateHandler; +pub(crate) struct InflateHandler; #[cfg(any(feature = "electrum", feature = "esplora"))] impl OperationHandler for InflateHandler { @@ -977,7 +977,7 @@ impl MultisigWallet { Ok(()) } - fn hub_client(&self) -> &MultisigHubClient { + pub(crate) fn hub_client(&self) -> &MultisigHubClient { self.online_data() .as_ref() .unwrap() @@ -1010,7 +1010,7 @@ impl MultisigWallet { Ok(filepath) } - fn get_or_download_files( + pub(crate) fn get_or_download_files( &self, file_metadata: Vec, ) -> Result, Error> { @@ -1692,7 +1692,7 @@ impl MultisigWallet { })) } - fn read_psbt_from_file(path: &Path) -> Result { + pub(crate) fn read_psbt_from_file(path: &Path) -> Result { let file = fs::File::open(path)?; let mut reader = io::BufReader::new(file); Psbt::deserialize_from_reader(&mut reader).map_err(|_| Error::MultisigUnexpectedData { diff --git a/src/wallet/objects.rs b/src/wallet/objects.rs index 88bae659..552d27b9 100644 --- a/src/wallet/objects.rs +++ b/src/wallet/objects.rs @@ -116,7 +116,7 @@ impl From for OutPoint { /// /// This structure is used both for RGB assets and BTC balances (in sats). When used for a BTC /// balance it can be used both for the vanilla wallet and the colored wallet. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Deserialize, Serialize)] #[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] pub struct Balance { /// Settled balance, based on operations that have reached the final status @@ -1405,7 +1405,7 @@ impl From for RgbAllocation { // ──────────────────────────────────────────────────────────── /// The type of a transaction. -#[derive(Debug, Clone, Deserialize, Serialize)] +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] pub enum TransactionType { /// Transaction used to perform an RGB send RgbSend, diff --git a/src/wallet/test/multisig.rs b/src/wallet/test/multisig.rs deleted file mode 100644 index 055d1d48..00000000 --- a/src/wallet/test/multisig.rs +++ /dev/null @@ -1,2830 +0,0 @@ -use super::*; - -static OP_COUNTER: AtomicU64 = AtomicU64::new(0); - -// utilities - -struct MultisigParty<'a> { - signer: &'a Wallet, - multisig: &'a mut MultisigWallet, - online: Online, - xpub: &'a str, -} - -struct WatchOnlyParty<'a> { - multisig: &'a mut MultisigWallet, - online: Online, -} -macro_rules! ms_party { - ($signer:expr, $multisig:expr, $online:expr, $xpub:expr) => { - MultisigParty { - signer: $signer, - multisig: $multisig, - online: $online, - xpub: $xpub, - } - }; - ($multisig:expr, $online:expr) => { - WatchOnlyParty { - multisig: $multisig, - online: $online, - } - }; -} - -struct SinglesigParty<'a> { - wallet: &'a mut Wallet, - online: Online, -} - -macro_rules! party { - ($wallet:expr, $online:expr) => { - SinglesigParty { - wallet: $wallet, - online: $online, - } - }; -} - -trait MultisigOps { - fn multisig_mut(&mut self) -> &mut MultisigWallet; - fn multisig_ref(&self) -> &MultisigWallet; - fn online(&self) -> Online; - - fn assert_up_to_date(&mut self) { - assert!(self.sync_opt().is_none()); - } - - fn asset_balance(&self, asset_id: &str) -> Balance { - test_get_asset_balance(self.multisig_ref(), asset_id) - } - - fn bak_ts(&mut self) -> String { - self.multisig_ref() - .database() - .get_backup_info() - .unwrap() - .unwrap() - .last_operation_timestamp - } - - fn hub_info(&mut self) -> HubInfo { - let online = self.online(); - self.multisig_mut().hub_info(online).unwrap() - } - - fn blind_receive(&mut self) -> ReceiveData { - self.blind_receive_res().unwrap() - } - - fn blind_receive_res(&mut self) -> Result { - let online = self.online(); - self.multisig_mut().blind_receive( - online, - None, - Assignment::Any, - None, - TRANSPORT_ENDPOINTS.clone(), - MIN_CONFIRMATIONS, - ) - } - - fn create_utxos_init( - &mut self, - up_to: bool, - num: Option, - size: Option, - fee_rate: u64, - ) -> InitOperationResult { - self.create_utxos_init_res(up_to, num, size, fee_rate) - .unwrap() - } - - fn create_utxos_init_res( - &mut self, - up_to: bool, - num: Option, - size: Option, - fee_rate: u64, - ) -> Result { - let online = self.online(); - self.multisig_mut() - .create_utxos_init(online, up_to, num, size, fee_rate, false) - } - - fn get_address(&mut self) -> String { - let online = self.online(); - self.multisig_mut().get_address(online).unwrap() - } - - fn inflate_init( - &mut self, - asset_id: String, - inflation_amounts: Vec, - ) -> InitOperationResult { - self.inflate_init_res(asset_id, inflation_amounts).unwrap() - } - - fn inflate_init_res( - &mut self, - asset_id: String, - inflation_amounts: Vec, - ) -> Result { - let online = self.online(); - self.multisig_mut() - .inflate_init(online, asset_id, inflation_amounts, FEE_RATE, 1) - } - - fn issue_asset_cfa(&mut self, amounts: Option<&[u64]>, file_path: Option) -> AssetCFA { - self.issue_asset_cfa_res(amounts, file_path).unwrap() - } - - fn issue_asset_cfa_res( - &mut self, - amounts: Option<&[u64]>, - file_path: Option, - ) -> Result { - let amounts = amounts.map_or_else(|| vec![AMOUNT], |a| a.to_vec()); - let online = self.online(); - self.multisig_mut().issue_asset_cfa( - online, - NAME.to_string(), - Some(DETAILS.to_string()), - PRECISION, - amounts, - file_path, - ) - } - - fn issue_asset_ifa( - &mut self, - amounts: Option<&[u64]>, - inflation_amounts: Option<&[u64]>, - reject_list_url: Option, - ) -> AssetIFA { - self.issue_asset_ifa_res(amounts, inflation_amounts, reject_list_url) - .unwrap() - } - - fn issue_asset_ifa_res( - &mut self, - amounts: Option<&[u64]>, - inflation_amounts: Option<&[u64]>, - reject_list_url: Option, - ) -> Result { - let amounts = amounts.map_or_else(|| vec![AMOUNT], |a| a.to_vec()); - let inflation_amounts = - inflation_amounts.map_or_else(|| vec![AMOUNT_INFLATION], |a| a.to_vec()); - let online = self.online(); - self.multisig_mut().issue_asset_ifa( - online, - TICKER.to_string(), - NAME.to_string(), - PRECISION, - amounts, - inflation_amounts, - reject_list_url, - ) - } - - fn issue_asset_nia(&mut self, amounts: Option<&[u64]>) -> AssetNIA { - self.issue_asset_nia_res(amounts).unwrap() - } - - fn issue_asset_nia_res(&mut self, amounts: Option<&[u64]>) -> Result { - let amounts = amounts.map_or_else(|| vec![AMOUNT], |a| a.to_vec()); - let online = self.online(); - self.multisig_mut().issue_asset_nia( - online, - TICKER.to_string(), - NAME.to_string(), - PRECISION, - amounts, - ) - } - - fn issue_asset_uda( - &mut self, - details: Option<&str>, - media_file_path: Option<&str>, - attachments_file_paths: Vec<&str>, - ) -> AssetUDA { - self.issue_asset_uda_res(details, media_file_path, attachments_file_paths) - .unwrap() - } - - fn issue_asset_uda_res( - &mut self, - details: Option<&str>, - media_file_path: Option<&str>, - attachments_file_paths: Vec<&str>, - ) -> Result { - let online = self.online(); - self.multisig_mut().issue_asset_uda( - online, - TICKER.to_string(), - NAME.to_string(), - details.map(|d| d.to_string()), - PRECISION, - media_file_path.map(|m| m.to_string()), - attachments_file_paths - .iter() - .map(|a| a.to_string()) - .collect(), - ) - } - - fn list_transfers(&self, asset_id: &str) -> Vec { - test_list_transfers(self.multisig_ref(), Some(asset_id)) - } - - fn list_unspents(&mut self, settled_only: bool) -> Vec { - let online = self.online(); - test_list_unspents(self.multisig_mut(), Some(online), settled_only) - } - - fn nack(&mut self, op_idx: i32) -> OperationInfo { - self.respond_to_operation(op_idx, RespondToOperation::Nack) - } - - fn nack_res(&mut self, op_idx: i32) -> Result { - self.respond_to_operation_res(op_idx, RespondToOperation::Nack) - } - - fn respond_to_operation(&mut self, op_idx: i32, response: RespondToOperation) -> OperationInfo { - self.respond_to_operation_res(op_idx, response).unwrap() - } - - fn respond_to_operation_res( - &mut self, - op_idx: i32, - response: RespondToOperation, - ) -> Result { - let online = self.online(); - self.multisig_mut() - .respond_to_operation(online, op_idx, response) - } - - fn send_btc_init(&mut self, address: &str, amount: u64) -> InitOperationResult { - self.send_btc_init_res(address, amount).unwrap() - } - - fn send_btc_init_res( - &mut self, - address: &str, - amount: u64, - ) -> Result { - let online = self.online(); - self.multisig_mut() - .send_btc_init(online, address.to_string(), amount, FEE_RATE, false) - } - - fn send_init(&mut self, recipient_map: HashMap>) -> InitOperationResult { - self.send_init_res(recipient_map).unwrap() - } - - fn send_init_res( - &mut self, - recipient_map: HashMap>, - ) -> Result { - let online = self.online(); - self.multisig_mut() - .send_init(online, recipient_map, false, FEE_RATE, 1, None) - } - - fn sync(&mut self) -> OperationInfo { - self.sync_opt().unwrap() - } - - fn sync_opt(&mut self) -> Option { - let online = self.online(); - self.multisig_mut().sync_with_hub(online).unwrap() - } - - fn sync_to_head(&mut self) { - let online = self.online(); - let last_processed = self - .multisig_mut() - .get_local_last_processed_operation_idx() - .unwrap(); - let last_hub_operation = self - .multisig_mut() - .hub_info(online) - .unwrap() - .last_operation_idx - .unwrap(); - assert!(last_hub_operation > last_processed); - for i in (last_processed + 1)..=last_hub_operation { - println!("syncing operation {i}"); - let op_info = self.sync(); - assert_eq!(op_info.operation_idx, i); - } - let final_processed = self - .multisig_mut() - .get_local_last_processed_operation_idx() - .unwrap(); - assert_eq!(final_processed, last_hub_operation); - self.assert_up_to_date(); - } - - #[cfg(any(feature = "electrum", feature = "esplora"))] - fn wait_refresh(&mut self, asset_id: Option<&str>, transfer_ids: Option<&[i32]>) { - let online = self.online(); - wait_for_refresh(self.multisig_mut(), online, asset_id, transfer_ids) - } - - #[cfg(any(feature = "electrum", feature = "esplora"))] - fn witness_receive(&mut self) -> ReceiveData { - self.witness_receive_res().unwrap() - } - - #[cfg(any(feature = "electrum", feature = "esplora"))] - fn witness_receive_res(&mut self) -> Result { - let online = self.online(); - self.multisig_mut().witness_receive( - online, - None, - Assignment::Any, - None, - TRANSPORT_ENDPOINTS.clone(), - MIN_CONFIRMATIONS, - ) - } -} - -impl<'a> MultisigOps for MultisigParty<'a> { - fn multisig_mut(&mut self) -> &mut MultisigWallet { - self.multisig - } - - fn multisig_ref(&self) -> &MultisigWallet { - self.multisig - } - - fn online(&self) -> Online { - self.online - } -} - -impl<'a> MultisigOps for WatchOnlyParty<'a> { - fn multisig_mut(&mut self) -> &mut MultisigWallet { - self.multisig - } - - fn multisig_ref(&self) -> &MultisigWallet { - self.multisig - } - - fn online(&self) -> Online { - self.online - } -} - -impl<'a> MultisigParty<'a> { - fn sign_and_ack(&mut self, psbt: String, op_idx: i32) -> OperationInfo { - let signed = self.signer.sign_psbt(psbt, None).unwrap(); - self.respond_to_operation(op_idx, RespondToOperation::Ack(signed)) - } -} - -#[derive(Deserialize, Serialize)] -pub(crate) struct AppConfig { - pub(crate) cosigner_xpubs: Vec, - pub(crate) threshold_colored: u8, - pub(crate) threshold_vanilla: u8, - pub(crate) root_public_key: String, - pub(crate) rgb_lib_version: String, -} - -enum Role { - Cosigner(String), - WatchOnly, -} - -fn assert_last_transfer_settled(transfers: &[Transfer]) { - assert_eq!(transfers.last().unwrap().status, TransferStatus::Settled); -} - -fn assert_synced_wallet_state(wallet: &MultisigWallet) { - let assets = wallet.list_assets(vec![]).unwrap(); - let nia_assets = assets.nia.unwrap(); - assert_eq!(nia_assets.len(), 2); - let ifa_assets = assets.ifa.unwrap(); - assert_eq!(ifa_assets.len(), 1); - let cfa_assets = assets.cfa.unwrap(); - assert_eq!(cfa_assets.len(), 1); - let uda_assets = assets.uda.unwrap(); - assert_eq!(uda_assets.len(), 1); - let mut nia_counts: Vec = [&nia_assets[0].asset_id, &nia_assets[1].asset_id] - .iter() - .map(|id| test_list_transfers(wallet, Some(id)).len()) - .collect(); - nia_counts.sort_unstable(); - assert_eq!(nia_counts, [3, 4]); - for asset_id in [ - &ifa_assets[0].asset_id, - &cfa_assets[0].asset_id, - &uda_assets[0].asset_id, - ] { - assert_eq!(test_list_transfers(wallet, Some(asset_id)).len(), 3); - } -} - -fn create_token(root: &KeyPair, role: Role, expiration_date: Option>) -> String { - let mut authority = biscuit!(""); - match role { - Role::Cosigner(xpub) => { - authority = biscuit_merge!(authority, r#"role("cosigner"); xpub({xpub});"#); - } - Role::WatchOnly => { - authority = biscuit_merge!(authority, r#"role("watch-only");"#); - } - } - if let Some(expiration_date) = expiration_date { - let exp = date(&expiration_date.into()); - authority = biscuit_merge!(authority, r#"check if time($t), $t < {exp};"#); - } - authority.build(root).unwrap().to_base64().unwrap() -} - -fn get_test_ms_wallet(keys: &MultisigKeys, dir: String) -> MultisigWallet { - let data_dir = get_test_data_dir_path() - .join(dir) - .to_string_lossy() - .to_string(); - let _ = fs::create_dir_all(&data_dir); - let wallet = MultisigWallet::new( - WalletData { - data_dir, - bitcoin_network: BitcoinNetwork::Regtest, - database_type: DatabaseType::Sqlite, - max_allocations_per_utxo: MAX_ALLOCATIONS_PER_UTXO, - supported_schemas: AssetSchema::VALUES.to_vec(), - }, - keys.clone(), - ) - .unwrap(); - println!( - "multisig wallet directory: {:?}", - test_get_wallet_dir(&wallet) - ); - wallet -} - -fn issuance_assertions( - issuer: &mut MultisigParty, - observer_1: &mut MultisigParty, - observer_2: &mut MultisigParty, - asset_id: &str, - schema: AssetSchema, -) { - fn assert_wallet_has_asset(wallet: &MultisigWallet, schema: AssetSchema, asset_id: &str) { - let assets = wallet.list_assets(vec![schema]).unwrap(); - let found_ids: Vec<&str> = match schema { - AssetSchema::Cfa => assets - .cfa - .as_deref() - .unwrap_or_default() - .iter() - .map(|a| a.asset_id.as_str()) - .collect(), - AssetSchema::Nia => assets - .nia - .as_deref() - .unwrap_or_default() - .iter() - .map(|a| a.asset_id.as_str()) - .collect(), - AssetSchema::Ifa => assets - .ifa - .as_deref() - .unwrap_or_default() - .iter() - .map(|a| a.asset_id.as_str()) - .collect(), - AssetSchema::Uda => assets - .uda - .as_deref() - .unwrap_or_default() - .iter() - .map(|a| a.asset_id.as_str()) - .collect(), - }; - assert_eq!(found_ids, vec![asset_id]); - } - let bi = observer_1.bak_ts(); - let op_info = observer_1.sync(); - assert!(observer_1.bak_ts() > bi); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, issuer.xpub); - assert_matches!(op_info.operation, Operation::IssuanceCompleted { .. }); - let op_info = observer_2.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, issuer.xpub); - assert_matches!(op_info.operation, Operation::IssuanceCompleted { .. }); - assert_wallet_has_asset(issuer.multisig, schema, asset_id); - assert_wallet_has_asset(observer_1.multisig, schema, asset_id); - assert_wallet_has_asset(observer_2.multisig, schema, asset_id); - let meta_ref = observer_1 - .multisig - .get_asset_metadata(asset_id.to_string()) - .unwrap(); - for meta in [ - issuer - .multisig - .get_asset_metadata(asset_id.to_string()) - .unwrap(), - observer_2 - .multisig - .get_asset_metadata(asset_id.to_string()) - .unwrap(), - ] { - assert_eq!(meta.ticker, meta_ref.ticker); - assert_eq!(meta.name, meta_ref.name); - assert_eq!(meta.precision, meta_ref.precision); - assert_eq!(meta.initial_supply, meta_ref.initial_supply); - assert_eq!(meta.asset_schema, meta_ref.asset_schema); - } -} - -fn local_rgb_lib_version() -> String { - env!("CARGO_PKG_VERSION") - .split('.') - .take(2) - .collect::>() - .join(".") -} - -fn ms_go_online_res(wallet: &mut MultisigWallet, token: &str) -> Result { - wallet.go_online( - false, - ELECTRUM_URL.to_string(), - MULTISIG_HUB_URL.to_string(), - token.to_string(), - ) -} - -fn ms_go_online(wallet: &mut MultisigWallet, token: &str) -> Online { - ms_go_online_res(wallet, token).unwrap() -} - -fn write_hub_config( - cosigner_xpubs: &[String], - threshold_colored: u8, - threshold_vanilla: u8, - root_public_key: String, - rgb_lib_version: Option, -) { - let rgb_lib_version = rgb_lib_version.unwrap_or_else(local_rgb_lib_version); - let config = AppConfig { - cosigner_xpubs: cosigner_xpubs.to_vec(), - threshold_colored, - threshold_vanilla, - root_public_key, - rgb_lib_version, - }; - let conf_path = PathBuf::from(join_with_sep(&HUB_DIR_PARTS)).join("config.toml"); - confy::store_path(conf_path, config).unwrap(); -} - -// test helpers - -fn backup(multisig: &MultisigWallet, label: &str) -> (i32, String) { - println!("\n=== Backup ==="); - let last_processed = multisig.get_local_last_processed_operation_idx().unwrap(); - let bak_fpath = get_test_data_dir_path().join(format!("{label}_backup.rgb-lib_backup")); - let backup_file = bak_fpath.to_str().unwrap(); - let _ = std::fs::remove_file(backup_file); - multisig.backup(backup_file, PASSWORD).unwrap(); - (last_processed, backup_file.to_string()) -} - -fn backup_restore( - backup_file: &str, - random_str: &str, - multisig_wlt_keys: MultisigKeys, - wlt_last_processed_before_backup: i32, - token: &str, -) { - println!("\n=== Restore backup ==="); - let target_dir_path = get_restore_dir_path(Some(format!("{random_str}_1"))); - let target_dir = target_dir_path.to_str().unwrap(); - restore_backup(backup_file, PASSWORD, target_dir).unwrap(); - let mut wlt_restored = - MultisigWallet::new(get_test_wallet_data(target_dir), multisig_wlt_keys).unwrap(); - let wlt_restored_last_processed = wlt_restored - .get_local_last_processed_operation_idx() - .unwrap(); - assert_eq!( - wlt_restored_last_processed, - wlt_last_processed_before_backup - ); - let wlt_restored_online = ms_go_online(&mut wlt_restored, token); - ms_party!(&mut wlt_restored, wlt_restored_online).sync_to_head(); -} - -fn blind_receive_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - nia_asset_1: AssetNIA, -) { - println!("\n=== Blind receive ==="); - let bi = wlt_2.bak_ts(); - let receive_data = wlt_2.blind_receive(); - assert!(wlt_2.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - let recipient_map = HashMap::from([( - nia_asset_1.asset_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(AMOUNT_SMALL), - recipient_id: receive_data.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - )]); - let _txid = test_send(singlesig_wlt.wallet, singlesig_wlt.online, &recipient_map); - wlt_2.wait_refresh(None, None); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&nia_asset_1.asset_id), - None, - ); - mine(false, false); - wlt_2.wait_refresh(Some(&nia_asset_1.asset_id), None); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&nia_asset_1.asset_id), - None, - ); - let transfers = wlt_2.list_transfers(&nia_asset_1.asset_id); - assert_last_transfer_settled(&transfers); - let transfers = test_list_transfers(singlesig_wlt.wallet, Some(&nia_asset_1.asset_id)); - assert_last_transfer_settled(&transfers); - let bi = wlt_1.bak_ts(); - wlt_1.sync(); - assert!(wlt_1.bak_ts() > bi); - let transfers = wlt_1.list_transfers(&nia_asset_1.asset_id); - assert_last_transfer_settled(&transfers); - let bi = wlt_3.bak_ts(); - wlt_3.sync(); - assert!(wlt_3.bak_ts() > bi); - let transfers = wlt_3.list_transfers(&nia_asset_1.asset_id); - assert_last_transfer_settled(&transfers); - let expected_nia_asset_1_balance = nia_asset_1.balance.settled; - let balance = wlt_2.asset_balance(&nia_asset_1.asset_id); - assert_eq!(balance.settled, expected_nia_asset_1_balance); - let balance = wlt_1.asset_balance(&nia_asset_1.asset_id); - assert_eq!(balance.settled, expected_nia_asset_1_balance); - let balance = wlt_3.asset_balance(&nia_asset_1.asset_id); - assert_eq!(balance.settled, expected_nia_asset_1_balance); -} - -fn blind_receive_unknown_asset( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, -) -> AssetNIA { - println!("\n=== Blind receive unknown asset ==="); - let nia_asset_2 = test_issue_asset_nia(singlesig_wlt.wallet, singlesig_wlt.online, None); - let bi = wlt_1.bak_ts(); - let receive_data = wlt_1.blind_receive(); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - let recipient_map = HashMap::from([( - nia_asset_2.asset_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(AMOUNT_SMALL), - recipient_id: receive_data.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - )]); - let _txid = test_send(singlesig_wlt.wallet, singlesig_wlt.online, &recipient_map); - wlt_1.wait_refresh(None, None); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&nia_asset_2.asset_id), - None, - ); - mine(false, false); - wlt_1.wait_refresh(Some(&nia_asset_2.asset_id), None); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&nia_asset_2.asset_id), - None, - ); - let transfers = wlt_1.list_transfers(&nia_asset_2.asset_id); - assert_last_transfer_settled(&transfers); - let transfers = test_list_transfers(singlesig_wlt.wallet, Some(&nia_asset_2.asset_id)); - assert_last_transfer_settled(&transfers); - let bi = wlt_2.bak_ts(); - wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - let bi = wlt_3.bak_ts(); - wlt_3.sync(); - assert!(wlt_3.bak_ts() > bi); - let balance = wlt_1.asset_balance(&nia_asset_2.asset_id); - assert_eq!(balance.settled, AMOUNT_SMALL); - let balance = wlt_2.asset_balance(&nia_asset_2.asset_id); - assert_eq!(balance.settled, AMOUNT_SMALL); - let balance = wlt_3.asset_balance(&nia_asset_2.asset_id); - assert_eq!(balance.settled, AMOUNT_SMALL); - nia_asset_2 -} - -fn check_cosigner_hub_info<'a>( - wlt_1: &mut MultisigParty<'a>, - wlt_2: &mut MultisigParty<'a>, - wlt_3: &mut MultisigParty<'a>, - wlt_4: &mut MultisigParty<'a>, -) { - println!("\n=== Hub info ==="); - for wlt in [wlt_1, wlt_2, wlt_3, wlt_4] { - let info = wlt.hub_info(); - assert_eq!(info.user_role, UserRole::Cosigner); - assert_eq!(info.last_operation_idx, None); - assert_eq!(info.rgb_lib_version, local_rgb_lib_version()); - } -} - -fn check_change_consistency(wlt_1: &mut MultisigParty, wlt_4: &mut MultisigParty) { - println!("\n=== Check change_utxo_idx consistency (wlt_1 vs wlt_4) ==="); - let wlt_1_txos = wlt_1.multisig.database().iter_txos().unwrap(); - let wlt_1_colorings = wlt_1.multisig.database().iter_colorings().unwrap(); - let wlt_4_txos = wlt_4.multisig.database().iter_txos().unwrap(); - let wlt_4_colorings = wlt_4.multisig.database().iter_colorings().unwrap(); - let resolve_change_outpoints = |txos: &[DbTxo], colorings: &[DbColoring]| -> Vec { - colorings - .iter() - .filter(|c| c.r#type == ColoringType::Change) - .map(|c| { - txos.iter() - .find(|t| t.idx == c.txo_idx) - .unwrap_or_else(|| panic!("coloring txo_idx {} not found in TXOs", c.txo_idx)) - .outpoint() - .to_string() - }) - .collect() - }; - let mut wlt_1_outpoints = resolve_change_outpoints(&wlt_1_txos, &wlt_1_colorings); - let mut wlt_4_outpoints = resolve_change_outpoints(&wlt_4_txos, &wlt_4_colorings); - assert_eq!(wlt_1_outpoints.len(), wlt_4_outpoints.len()); - wlt_1_outpoints.sort(); - wlt_4_outpoints.sort(); - assert_eq!(wlt_1_outpoints, wlt_4_outpoints); -} - -fn create_utxos_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - sats: u64, -) { - println!("\n=== Create UTXOs ==="); - let unspents = wlt_2.list_unspents(false); - let outpoints = unspents - .into_iter() - .map(|u| u.utxo.outpoint) - .collect::>(); - let num_utxos = 20; - let utxo_size = 1000; - let bi = wlt_1.bak_ts(); - let init_res = wlt_1.create_utxos_init(false, Some(num_utxos), Some(utxo_size), FEE_RATE); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.sign_and_ack(init_res.psbt.clone(), init_res.operation_idx); - let bi = wlt_2.bak_ts(); - let op_info = wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::CreateUtxosToReview { psbt, status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 1); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, None); - let psbt_info = wlt_2.multisig.inspect_psbt(psbt.clone()).unwrap(); - assert!(!psbt_info.txid.is_empty()); - assert_eq!(psbt_info.total_input_sat, sats); - assert!(psbt_info.size_vbytes > 0); - assert!(psbt_info.fee_sat > 0); - assert_eq!(psbt_info.inputs.len(), 1); - for inp in &psbt_info.inputs { - assert_eq!(inp.amount_sat, sats); - assert!(outpoints.contains(&inp.outpoint)); - } - assert_eq!(psbt_info.outputs.len() as u8, num_utxos + 1); - let mut change_out = false; - for out in &psbt_info.outputs { - assert!(out.address.is_some()); - assert!(out.is_mine); - assert!(!out.is_op_return); - if !change_out && out.amount_sat != utxo_size as u64 { - change_out = true - } else { - assert_eq!(out.amount_sat, utxo_size as u64); - } - } - assert_eq!( - psbt_info.total_input_sat - psbt_info.total_output_sat, - psbt_info.fee_sat - ); - assert_eq!(psbt_info.signature_count, 0); - let signed_2 = wlt_2.signer.sign_psbt(psbt.clone(), None).unwrap(); - let psbt_info = wlt_2.multisig.inspect_psbt(signed_2.clone()).unwrap(); - assert_eq!(psbt_info.signature_count, 1); - let op_info = wlt_2.respond_to_operation( - op_info.operation_idx, - RespondToOperation::Ack(signed_2.to_string()), - ); - let Operation::CreateUtxosPending { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 2); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(true)); - let op_info = wlt_3.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::CreateUtxosToReview { psbt, status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 2); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, None); - let bi = wlt_3.bak_ts(); - let op_info = wlt_3.sign_and_ack(psbt, op_info.operation_idx); - assert!(wlt_3.bak_ts() > bi); - assert_matches!(op_info.operation, Operation::CreateUtxosCompleted { .. }); - let unspents = test_list_unspents(wlt_3.multisig, None, false); - assert_eq!(unspents.len(), (num_utxos + 1) as usize); - let op_info = wlt_1.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - assert_matches!(op_info.operation, Operation::CreateUtxosCompleted { .. }); - let op_info = wlt_2.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - assert_matches!(op_info.operation, Operation::CreateUtxosCompleted { .. }); - wlt_3.assert_up_to_date(); - let unspents = test_list_unspents(wlt_3.multisig, None, false); - assert_eq!(unspents.len(), (num_utxos + 1) as usize); - mine(false, false); -} - -fn create_utxos_discarded( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - wlt_4: &mut MultisigParty, -) { - println!("\n=== Create UTXOs discarded ==="); - let init_res = wlt_1.create_utxos_init(false, None, None, FEE_RATE); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.sign_and_ack(init_res.psbt.clone(), init_res.operation_idx); - let op_info = wlt_2.sync(); - let op_info = wlt_2.nack(op_info.operation_idx); - assert_matches!( - op_info.operation, - Operation::CreateUtxosPending { status: _ } - ); - let op_info = wlt_3.nack(op_info.operation_idx); - assert_matches!( - op_info.operation, - Operation::CreateUtxosDiscarded { status: _ } - ); - let op_info = wlt_1.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::CreateUtxosDiscarded { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, Some(true)); - let op_info = wlt_2.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::CreateUtxosDiscarded { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, Some(false)); - let op_info = wlt_4.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::CreateUtxosDiscarded { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, None); -} - -fn inflate_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, -) -> AssetIFA { - println!("\n=== Inflation ==="); - let ifa_amounts = vec![100, 50]; - let initial_supply = ifa_amounts.iter().sum::(); - let bi = wlt_1.bak_ts(); - let ifa_asset = wlt_1.issue_asset_ifa(Some(&ifa_amounts), Some(&[AMOUNT_INFLATION]), None); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - assert_eq!(ifa_asset.balance.settled, initial_supply); - issuance_assertions(wlt_1, wlt_2, wlt_3, &ifa_asset.asset_id, AssetSchema::Ifa); - let ifa_balance_1 = wlt_1.asset_balance(&ifa_asset.asset_id); - let ifa_balance_2 = wlt_2.asset_balance(&ifa_asset.asset_id); - let ifa_balance_3 = wlt_3.asset_balance(&ifa_asset.asset_id); - assert_eq!(ifa_balance_1.settled, initial_supply); - assert_eq!(ifa_balance_2.settled, initial_supply); - assert_eq!(ifa_balance_3.settled, initial_supply); - wlt_2.assert_up_to_date(); - let inflation_amounts = vec![25, 26]; - let bi = wlt_2.bak_ts(); - let init_res = wlt_2.inflate_init(ifa_asset.asset_id.clone(), inflation_amounts.clone()); - assert!(wlt_2.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_2.sign_and_ack(init_res.psbt.clone(), init_res.operation_idx); - let op_info = wlt_3.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let Operation::InflationToReview { - psbt, - details, - status: _, - } = op_info.operation - else { - panic!("unexpected operation {op_info:?}"); - }; - let rgb_inspection = wlt_3 - .multisig - .inspect_rgb_transfer(psbt.clone(), details.fascia_path, details.entropy) - .unwrap(); - assert_eq!(rgb_inspection.close_method, CloseMethod::OpretFirst); - assert_eq!(rgb_inspection.operations.len(), 1); - let ifa_op = &rgb_inspection.operations[0]; - assert_eq!(ifa_op.asset_id, ifa_asset.asset_id); - let inflate_transitions: Vec<_> = ifa_op - .transitions - .iter() - .filter(|t| t.r#type == TypeOfTransition::Inflate) - .collect(); - assert_eq!(inflate_transitions.len(), 1); - let inflate_outputs: Vec<_> = inflate_transitions[0] - .outputs - .iter() - .filter(|o| matches!(o.assignment, Assignment::Fungible(_))) - .map(|o| o.assignment.main_amount()) - .collect(); - let mut sorted_inflate_outputs = inflate_outputs.clone(); - sorted_inflate_outputs.sort(); - let mut sorted_expected = inflation_amounts.clone(); - sorted_expected.sort(); - assert_eq!(sorted_inflate_outputs, sorted_expected); - let op_info = wlt_3.sign_and_ack(psbt, op_info.operation_idx); - let Operation::InflationPending { status, details: _ } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 2); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(true)); - let op_info = wlt_1.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let Operation::InflationToReview { - psbt, - details: _, - status: _, - } = op_info.operation - else { - panic!("unexpected operation {op_info:?}"); - }; - let bi = wlt_1.bak_ts(); - let op_info = wlt_1.sign_and_ack(psbt, op_info.operation_idx); - assert!(wlt_1.bak_ts() > bi); - assert_matches!(op_info.operation, Operation::InflationCompleted { .. }); - let op_info = wlt_2.sync(); - assert_matches!(op_info.operation, Operation::InflationCompleted { .. }); - let op_info = wlt_3.sync(); - assert_matches!(op_info.operation, Operation::InflationCompleted { .. }); - mine(false, false); - wlt_1.wait_refresh(Some(&ifa_asset.asset_id), None); - wlt_2.wait_refresh(Some(&ifa_asset.asset_id), None); - wlt_3.wait_refresh(Some(&ifa_asset.asset_id), None); - let inflate_transfers_1 = wlt_1.list_transfers(&ifa_asset.asset_id); - assert_last_transfer_settled(&inflate_transfers_1); - let inflate_transfers_2 = wlt_2.list_transfers(&ifa_asset.asset_id); - assert_last_transfer_settled(&inflate_transfers_2); - let inflate_transfers_3 = wlt_3.list_transfers(&ifa_asset.asset_id); - assert_last_transfer_settled(&inflate_transfers_3); - let inflation_amount_total = inflation_amounts.iter().sum::(); - let new_supply = initial_supply + inflation_amount_total; - let final_balance_1 = wlt_1.asset_balance(&ifa_asset.asset_id); - let final_balance_2 = wlt_2.asset_balance(&ifa_asset.asset_id); - let final_balance_3 = wlt_3.asset_balance(&ifa_asset.asset_id); - assert_eq!(final_balance_1.settled, new_supply); - assert_eq!(final_balance_2.settled, new_supply); - assert_eq!(final_balance_3.settled, new_supply); - ifa_asset -} - -fn inflate_discarded( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - ifa_asset: AssetIFA, -) { - println!("\n=== Inflation discarded ==="); - let init_res = wlt_3.inflate_init(ifa_asset.asset_id.clone(), vec![1]); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - assert_eq!( - init_res.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - wlt_3.nack(init_res.operation_idx); - let op_info = wlt_2.nack(init_res.operation_idx); - let Operation::InflationDiscarded { status, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, Some(false)); - let op_info = wlt_1.sync(); - assert_eq!(op_info.initiator_xpub, wlt_3.xpub); - let Operation::InflationDiscarded { status, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, None); - let op_info = wlt_3.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let Operation::InflationDiscarded { status, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.nacked_by.len(), 2); - assert_eq!(status.acked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(false)); -} - -fn issue_cfa_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, -) -> AssetCFA { - println!("\n=== Issue CFA ==="); - let amts = vec![200, AMOUNT_SMALL]; - let bi = wlt_2.bak_ts(); - let cfa_asset = wlt_2.issue_asset_cfa(Some(&amts), Some(FILE_STR.to_string())); - assert!(wlt_2.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - let settled = amts.iter().sum::(); - assert_eq!(cfa_asset.balance.settled, settled); - issuance_assertions(wlt_2, wlt_1, wlt_3, &cfa_asset.asset_id, AssetSchema::Cfa); - let meta_1 = wlt_1 - .multisig - .get_asset_metadata(cfa_asset.asset_id.clone()) - .unwrap(); - assert_eq!(meta_1.ticker, None); - assert_eq!(meta_1.name, NAME); - assert_eq!(meta_1.precision, PRECISION); - assert_eq!(meta_1.initial_supply, settled); - assert_eq!(meta_1.asset_schema, AssetSchema::Cfa); - let asset_db_1 = wlt_1 - .multisig - .database() - .get_asset(cfa_asset.asset_id.clone()) - .unwrap() - .unwrap(); - let asset_db_2 = wlt_2 - .multisig - .database() - .get_asset(cfa_asset.asset_id.clone()) - .unwrap() - .unwrap(); - let asset_db_3 = wlt_3 - .multisig - .database() - .get_asset(cfa_asset.asset_id.clone()) - .unwrap() - .unwrap(); - assert!(asset_db_1.media_idx.is_some()); - assert!(asset_db_2.media_idx.is_some()); - assert!(asset_db_3.media_idx.is_some()); - let media_1 = wlt_1 - .multisig - .database() - .get_media(asset_db_1.media_idx.unwrap()) - .unwrap() - .unwrap(); - let media_2 = wlt_2 - .multisig - .database() - .get_media(asset_db_2.media_idx.unwrap()) - .unwrap() - .unwrap(); - let media_3 = wlt_3 - .multisig - .database() - .get_media(asset_db_3.media_idx.unwrap()) - .unwrap() - .unwrap(); - assert_eq!(media_1.digest, media_2.digest); - assert_eq!(media_2.digest, media_3.digest); - let media_file_1 = wlt_1.multisig.media_dir().join(&media_1.digest); - let media_file_2 = wlt_2.multisig.media_dir().join(&media_2.digest); - let media_file_3 = wlt_3.multisig.media_dir().join(&media_3.digest); - assert!(media_file_1.exists()); - assert!(media_file_2.exists()); - assert!(media_file_3.exists()); - let content_1 = std::fs::read(&media_file_1).unwrap(); - let content_2 = std::fs::read(&media_file_2).unwrap(); - let content_3 = std::fs::read(&media_file_3).unwrap(); - assert_eq!(content_1, content_2); - assert_eq!(content_2, content_3); - wlt_2.assert_up_to_date(); - wlt_1.assert_up_to_date(); - cfa_asset -} - -fn issue_nia_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, -) -> AssetNIA { - println!("\n=== Issue NIA ==="); - let amts = vec![50, 70, 30]; - let bi = wlt_2.bak_ts(); - let nia_asset_1 = wlt_2.issue_asset_nia(Some(&amts)); - assert!(wlt_2.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - let settled = amts.iter().sum::(); - assert_eq!(nia_asset_1.balance.settled, settled); - issuance_assertions(wlt_2, wlt_1, wlt_3, &nia_asset_1.asset_id, AssetSchema::Nia); - let meta_1 = wlt_1 - .multisig - .get_asset_metadata(nia_asset_1.asset_id.clone()) - .unwrap(); - assert_eq!(meta_1.ticker, Some(TICKER.to_string())); - assert_eq!(meta_1.name, NAME); - assert_eq!(meta_1.precision, PRECISION); - assert_eq!(meta_1.initial_supply, settled); - assert_eq!(meta_1.asset_schema, AssetSchema::Nia); - wlt_2.assert_up_to_date(); - wlt_1.assert_up_to_date(); - nia_asset_1 -} - -fn issue_uda_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, -) -> AssetUDA { - println!("\n=== Issue UDA ==="); - let image_str = ["tests", "qrcode.png"].join(MAIN_SEPARATOR_STR); - let bi = wlt_3.bak_ts(); - let uda_asset = - wlt_3.issue_asset_uda(Some(DETAILS), Some(FILE_STR), vec![&image_str, FILE_STR]); - assert!(wlt_3.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - assert_eq!(uda_asset.balance.settled, 1); - issuance_assertions(wlt_3, wlt_1, wlt_2, &uda_asset.asset_id, AssetSchema::Uda); - let meta_1 = wlt_1 - .multisig - .get_asset_metadata(uda_asset.asset_id.clone()) - .unwrap(); - assert_eq!(meta_1.ticker, Some(TICKER.to_string())); - assert_eq!(meta_1.name, NAME); - assert_eq!(meta_1.precision, PRECISION); - assert_eq!(meta_1.initial_supply, 1); - assert_eq!(meta_1.asset_schema, AssetSchema::Uda); - let token_1 = uda_asset.token.as_ref().unwrap(); - assert_eq!(token_1.index, UDA_FIXED_INDEX); - let asset_db_1 = wlt_1 - .multisig - .database() - .get_asset(uda_asset.asset_id.clone()) - .unwrap() - .unwrap(); - let asset_db_2 = wlt_2 - .multisig - .database() - .get_asset(uda_asset.asset_id.clone()) - .unwrap() - .unwrap(); - let asset_db_3 = wlt_3 - .multisig - .database() - .get_asset(uda_asset.asset_id.clone()) - .unwrap() - .unwrap(); - assert!(asset_db_1.media_idx.is_none()); - assert!(asset_db_2.media_idx.is_none()); - assert!(asset_db_3.media_idx.is_none()); - let tokens_1 = wlt_1.multisig.database().iter_tokens().unwrap(); - let token_db_1 = tokens_1 - .into_iter() - .find(|t| t.asset_idx == asset_db_1.idx) - .unwrap(); - let tokens_2 = wlt_2.multisig.database().iter_tokens().unwrap(); - let token_db_2 = tokens_2 - .into_iter() - .find(|t| t.asset_idx == asset_db_2.idx) - .unwrap(); - let tokens_3 = wlt_3.multisig.database().iter_tokens().unwrap(); - let token_db_3 = tokens_3 - .into_iter() - .find(|t| t.asset_idx == asset_db_3.idx) - .unwrap(); - assert_eq!(token_db_1.index, UDA_FIXED_INDEX); - assert_eq!(token_db_2.index, UDA_FIXED_INDEX); - assert_eq!(token_db_3.index, UDA_FIXED_INDEX); - let token_medias_1 = wlt_1.multisig.database().iter_token_medias().unwrap(); - let token_media_entries_1: Vec<_> = token_medias_1 - .into_iter() - .filter(|tm| tm.token_idx == token_db_1.idx) - .collect(); - let token_medias_2 = wlt_2.multisig.database().iter_token_medias().unwrap(); - let token_media_entries_2: Vec<_> = token_medias_2 - .into_iter() - .filter(|tm| tm.token_idx == token_db_2.idx) - .collect(); - let token_medias_3 = wlt_3.multisig.database().iter_token_medias().unwrap(); - let token_media_entries_3: Vec<_> = token_medias_3 - .into_iter() - .filter(|tm| tm.token_idx == token_db_3.idx) - .collect(); - assert_eq!(token_media_entries_1.len(), 3); - assert_eq!(token_media_entries_2.len(), 3); - assert_eq!(token_media_entries_3.len(), 3); - let medias_1 = wlt_1.multisig.database().iter_media().unwrap(); - let medias_2 = wlt_2.multisig.database().iter_media().unwrap(); - let medias_3 = wlt_3.multisig.database().iter_media().unwrap(); - let mut digests_1: Vec = token_media_entries_1 - .iter() - .map(|tm| { - medias_1 - .iter() - .find(|m| m.idx == tm.media_idx) - .unwrap() - .digest - .clone() - }) - .collect(); - digests_1.sort(); - let mut digests_2: Vec = token_media_entries_2 - .iter() - .map(|tm| { - medias_2 - .iter() - .find(|m| m.idx == tm.media_idx) - .unwrap() - .digest - .clone() - }) - .collect(); - digests_2.sort(); - let mut digests_3: Vec = token_media_entries_3 - .iter() - .map(|tm| { - medias_3 - .iter() - .find(|m| m.idx == tm.media_idx) - .unwrap() - .digest - .clone() - }) - .collect(); - digests_3.sort(); - assert_eq!(digests_1, digests_2); - assert_eq!(digests_2, digests_3); - let attachments = &token_1.attachments; - assert_eq!(attachments.len(), 2); - assert!(token_1.media.is_some()); - wlt_3.assert_up_to_date(); - uda_asset -} - -fn receive_failed(wlt_1: &mut MultisigParty, wlt_2: &mut MultisigParty, wlt_3: &mut MultisigParty) { - println!("\n=== Receive RGB failed ==="); - let receive_data = wlt_1.blind_receive(); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - let batch_transfer_idx = receive_data.batch_transfer_idx; - let recipient_id = receive_data.recipient_id.clone(); - let online = wlt_1.online(); - let changed = wlt_1 - .multisig - .fail_transfers(online, Some(batch_transfer_idx), false, false) - .unwrap(); - assert!(changed); - let transfers = test_list_transfers(wlt_1.multisig_ref(), None); - let t = transfers - .iter() - .find(|t| t.batch_transfer_idx == batch_transfer_idx) - .unwrap(); - assert_eq!(t.status, TransferStatus::Failed); - wlt_2.sync(); - let transfers = test_list_transfers(wlt_2.multisig_ref(), None); - let t = transfers - .iter() - .find(|t| t.recipient_id.as_deref() == Some(&recipient_id)) - .unwrap(); - assert_eq!(t.status, TransferStatus::Failed); - wlt_3.sync(); - let transfers = test_list_transfers(wlt_3.multisig_ref(), None); - let t = transfers - .iter() - .find(|t| t.recipient_id.as_deref() == Some(&recipient_id)) - .unwrap(); - assert_eq!(t.status, TransferStatus::Failed); -} - -fn send_btc_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, -) { - println!("\n=== Send BTC ==="); - let prev_balance = test_get_btc_balance(singlesig_wlt.wallet, singlesig_wlt.online); - let addr = test_get_address(singlesig_wlt.wallet); - let amount = 1000; - let bi = wlt_1.bak_ts(); - let init_res = wlt_1.send_btc_init(&addr, amount); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - assert_eq!( - init_res.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let op_info = wlt_1.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let Operation::SendBtcToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - wlt_1.sign_and_ack(psbt.clone(), init_res.operation_idx); - let bi = wlt_2.bak_ts(); - let op_info = wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::SendBtcToReview { psbt, status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 1); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, None); - let op_info = wlt_2.sign_and_ack(psbt.clone(), op_info.operation_idx); - let Operation::SendBtcPending { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 2); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(true)); - let op_info = wlt_3.sync(); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::SendBtcToReview { psbt, status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 2); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, None); - let bi = wlt_3.bak_ts(); - let op_info = wlt_3.sign_and_ack(psbt.clone(), op_info.operation_idx); - assert!(wlt_3.bak_ts() > bi); - let Operation::SendBtcCompleted { txid, status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 3); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(true)); - wlt_1.sync(); - wlt_2.sync(); - let transactions = test_list_transactions(wlt_1.multisig, Some(wlt_1.online)); - let transaction = transactions.first().unwrap(); - assert_eq!(transaction.txid, txid); - assert_matches!(transaction.transaction_type, TransactionType::User); - let transactions = test_list_transactions(wlt_2.multisig, Some(wlt_2.online)); - let transaction = transactions.first().unwrap(); - assert_eq!(transaction.txid, txid); - assert_matches!(transaction.transaction_type, TransactionType::User); - let transactions = test_list_transactions(wlt_3.multisig, Some(wlt_3.online)); - let transaction = transactions.first().unwrap(); - assert_eq!(transaction.txid, txid); - assert_matches!(transaction.transaction_type, TransactionType::User); - mine(false, false); - let balance = test_get_btc_balance(singlesig_wlt.wallet, singlesig_wlt.online); - assert_eq!( - balance.vanilla.settled, - prev_balance.vanilla.settled + amount - ); -} - -fn send_btc_discarded( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, -) { - println!("\n=== Send BTC discarded ==="); - let addr = wlt_1.get_address(); - let init_res = wlt_3.send_btc_init(&addr, 999); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - assert_eq!( - init_res.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - wlt_3.nack(init_res.operation_idx); - let op_info = wlt_2.nack(init_res.operation_idx); - let Operation::SendBtcDiscarded { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, Some(false)); - let op_info = wlt_1.sync(); - assert_eq!(op_info.initiator_xpub, wlt_3.xpub); - let Operation::SendBtcDiscarded { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_2.xpub.to_string(), wlt_3.xpub.to_string()] - ); - assert_eq!(status.my_response, None); - let op_info = wlt_3.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let Operation::SendBtcDiscarded { status } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.nacked_by.len(), 2); - assert_eq!(status.acked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(false)); -} - -fn send_discarded( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - cfa_asset: AssetCFA, - nia_asset_1: AssetNIA, -) { - println!("\n=== Send RGB discarded from multisig to singlesig ==="); - let rcv_data_1 = test_witness_receive(singlesig_wlt.wallet); - let rcv_data_2 = test_blind_receive(singlesig_wlt.wallet); - let rcv_data_3 = test_blind_receive(singlesig_wlt.wallet); - let cfa_amount_witness = AMOUNT_SMALL; - let cfa_amount_blind = 20; - let send_recipient_map = HashMap::from([ - ( - cfa_asset.asset_id.clone(), - vec![ - Recipient { - assignment: Assignment::Fungible(cfa_amount_witness), - recipient_id: rcv_data_1.recipient_id.clone(), - witness_data: Some(WitnessData { - amount_sat: 1000, - blinding: None, - }), - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }, - Recipient { - assignment: Assignment::Fungible(cfa_amount_blind), - recipient_id: rcv_data_3.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }, - ], - ), - ( - nia_asset_1.asset_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(AMOUNT_SMALL), - recipient_id: rcv_data_2.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - ), - ]); - wlt_1.assert_up_to_date(); - let init_res = wlt_1.send_init(send_recipient_map); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.nack(init_res.operation_idx); - let op_info = wlt_2.sync(); - let op_info = wlt_2.nack(op_info.operation_idx); - assert_matches!(op_info.operation, Operation::SendDiscarded { .. }); - let op_info = wlt_3.sync(); - assert_matches!(op_info.operation, Operation::SendDiscarded { .. }); - let op_info = wlt_1.sync(); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - assert_eq!(op_info.initiator_xpub, wlt_1.xpub); - let Operation::SendDiscarded { status, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!( - status.nacked_by, - set![wlt_1.xpub.to_string(), wlt_2.xpub.to_string()] - ); - assert_eq!(status.my_response, Some(false)); -} - -fn send_extra_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - nia_asset_2: &AssetNIA, - cfa_asset: &AssetCFA, - nia_asset_1: &AssetNIA, -) { - println!("\n=== Send with extra ==="); - let nia_asset_2_id = &nia_asset_2.asset_id; - let cfa_asset_id = &cfa_asset.asset_id; - let nia_asset_1_id = &nia_asset_1.asset_id; - let cfa_balance_before = wlt_1.asset_balance(cfa_asset_id).settled; - let nia_balance_before = wlt_1.asset_balance(nia_asset_1_id).settled; - let nia_asset_2_balance_before = wlt_1.asset_balance(nia_asset_2_id).settled; - let extra_amount = 10u64; - let rcv_data = test_blind_receive(singlesig_wlt.wallet); - let send_recipient_map = HashMap::from([( - nia_asset_2_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(extra_amount), - recipient_id: rcv_data.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - )]); - let bi = wlt_1.bak_ts(); - let init_res = wlt_1.send_init(send_recipient_map); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.sign_and_ack(init_res.psbt, init_res.operation_idx); - let bi = wlt_2.bak_ts(); - let op_info = wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - let Operation::SendToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - wlt_2.sign_and_ack(psbt, op_info.operation_idx); - let op_info = wlt_3.sync(); - let Operation::SendToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - let bi = wlt_3.bak_ts(); - let op_info = wlt_3.sign_and_ack(psbt, op_info.operation_idx); - assert!(wlt_3.bak_ts() > bi); - assert_matches!(op_info.operation, Operation::SendCompleted { .. }); - wlt_1.sync(); - wlt_2.sync(); - wait_for_refresh(singlesig_wlt.wallet, singlesig_wlt.online, None, None); - wlt_1.wait_refresh(Some(nia_asset_2_id), None); - wlt_2.wait_refresh(Some(nia_asset_2_id), None); - wlt_3.wait_refresh(Some(nia_asset_2_id), None); - mine(false, false); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(nia_asset_2_id), - None, - ); - wlt_1.wait_refresh(Some(nia_asset_2_id), None); - wlt_2.wait_refresh(Some(nia_asset_2_id), None); - wlt_3.wait_refresh(Some(nia_asset_2_id), None); - let expected_nia_asset_2 = nia_asset_2_balance_before - extra_amount; - assert_eq!( - wlt_1.asset_balance(nia_asset_2_id).settled, - expected_nia_asset_2, - ); - let cfa_balance_after = wlt_1.asset_balance(cfa_asset_id).settled; - assert_eq!(cfa_balance_after, cfa_balance_before); - let nia_balance_after = wlt_1.asset_balance(nia_asset_1_id).settled; - assert_eq!(nia_balance_after, nia_balance_before); - let unspents_1 = wlt_1.list_unspents(true); - let cfa_found = unspents_1.iter().any(|u| { - u.rgb_allocations - .iter() - .any(|a| a.asset_id.as_deref() == Some(cfa_asset_id)) - }); - assert!(cfa_found); - let nia_found = unspents_1.iter().any(|u| { - u.rgb_allocations - .iter() - .any(|a| a.asset_id.as_deref() == Some(nia_asset_1_id)) - }); - assert!(nia_found); - assert_eq!( - wlt_2.asset_balance(nia_asset_2_id).settled, - expected_nia_asset_2, - ); - assert_eq!( - wlt_2.asset_balance(cfa_asset_id).settled, - cfa_balance_before - ); - assert_eq!( - wlt_2.asset_balance(nia_asset_1_id).settled, - nia_balance_before - ); - let unspents_2 = wlt_2.list_unspents(true); - assert!(unspents_2.iter().any(|u| { - u.rgb_allocations - .iter() - .any(|a| a.asset_id.as_deref() == Some(cfa_asset_id)) - })); - assert!(unspents_2.iter().any(|u| { - u.rgb_allocations - .iter() - .any(|a| a.asset_id.as_deref() == Some(nia_asset_1_id)) - })); - assert_eq!( - wlt_3.asset_balance(nia_asset_2_id).settled, - expected_nia_asset_2, - ); - assert_eq!( - wlt_3.asset_balance(cfa_asset_id).settled, - cfa_balance_before - ); - assert_eq!( - wlt_3.asset_balance(nia_asset_1_id).settled, - nia_balance_before - ); - let unspents_3 = wlt_3.list_unspents(true); - assert!(unspents_3.iter().any(|u| { - u.rgb_allocations - .iter() - .any(|a| a.asset_id.as_deref() == Some(cfa_asset_id)) - })); - assert!(unspents_3.iter().any(|u| { - u.rgb_allocations - .iter() - .any(|a| a.asset_id.as_deref() == Some(nia_asset_1_id)) - })); -} - -fn send_failed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - nia_asset_2: &AssetNIA, -) { - println!("\n=== Send RGB failed from multisig ==="); - let send_amount = 10u64; - let rcv_data = test_blind_receive(singlesig_wlt.wallet); - let send_recipient_map = HashMap::from([( - nia_asset_2.asset_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(send_amount), - recipient_id: rcv_data.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - )]); - wlt_1.assert_up_to_date(); - let init_res = wlt_1.send_init(send_recipient_map); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.sign_and_ack(init_res.psbt, init_res.operation_idx); - let op_info = wlt_2.sync(); - let Operation::SendToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - wlt_2.sign_and_ack(psbt, op_info.operation_idx); - let op_info = wlt_3.sync(); - let Operation::SendToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - let op_info = wlt_3.sign_and_ack(psbt, op_info.operation_idx); - assert_matches!(op_info.operation, Operation::SendCompleted { .. }); - wlt_1.sync(); - wlt_2.sync(); - let transfers = wlt_1.list_transfers(&nia_asset_2.asset_id); - let last = transfers.last().unwrap(); - assert_eq!(last.status, TransferStatus::WaitingCounterparty); - let batch_transfer_idx = last.batch_transfer_idx; - let online = wlt_1.online(); - let changed = wlt_1 - .multisig - .fail_transfers(online, Some(batch_transfer_idx), false, false) - .unwrap(); - assert!(changed); - let transfers = wlt_1.list_transfers(&nia_asset_2.asset_id); - assert_eq!(transfers.last().unwrap().status, TransferStatus::Failed); - wlt_2.wait_refresh(Some(&nia_asset_2.asset_id), None); - let transfers = wlt_2.list_transfers(&nia_asset_2.asset_id); - assert_eq!(transfers.last().unwrap().status, TransferStatus::Failed); - wlt_3.wait_refresh(Some(&nia_asset_2.asset_id), None); - let transfers = wlt_3.list_transfers(&nia_asset_2.asset_id); - assert_eq!(transfers.last().unwrap().status, TransferStatus::Failed); -} - -fn send_to_singlesig( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - cfa_asset: &AssetCFA, - nia_asset_1: &AssetNIA, - nia_asset_2: &AssetNIA, -) { - println!("\n=== Send RGB from multisig to singlesig ==="); - let rcv_data_1 = test_witness_receive(singlesig_wlt.wallet); - let rcv_data_2 = test_blind_receive(singlesig_wlt.wallet); - let rcv_data_3 = test_blind_receive(singlesig_wlt.wallet); - let rcv_data_4 = test_blind_receive(singlesig_wlt.wallet); - let cfa_amount_witness = AMOUNT_SMALL; - let cfa_amount_blind = 20; - let nia_asset_2_amount = 30; - let send_recipient_map = HashMap::from([ - ( - cfa_asset.asset_id.clone(), - vec![ - Recipient { - assignment: Assignment::Fungible(cfa_amount_witness), - recipient_id: rcv_data_1.recipient_id.clone(), - witness_data: Some(WitnessData { - amount_sat: 1000, - blinding: None, - }), - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }, - Recipient { - assignment: Assignment::Fungible(cfa_amount_blind), - recipient_id: rcv_data_3.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }, - ], - ), - ( - nia_asset_1.asset_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(AMOUNT_SMALL), - recipient_id: rcv_data_2.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - ), - ( - nia_asset_2.asset_id.clone(), - vec![Recipient { - assignment: Assignment::Fungible(nia_asset_2_amount), - recipient_id: rcv_data_4.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - ), - ]); - wlt_1.assert_up_to_date(); - let bi = wlt_1.bak_ts(); - let init_res = wlt_1.send_init(send_recipient_map); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.sign_and_ack(init_res.psbt, init_res.operation_idx); - let bi = wlt_2.bak_ts(); - let op_info = wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - assert_eq!( - op_info.operation_idx, - OP_COUNTER.load(Ordering::SeqCst) as i32 - ); - let Operation::SendToReview { - psbt, - details, - status, - } = op_info.operation - else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 1); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, None); - assert!(!details.is_donation); - let psbt_info = wlt_2.multisig.inspect_psbt(psbt.clone()).unwrap(); - assert!(psbt_info.inputs.len() >= 2); - assert!(psbt_info.outputs.len() >= 2); - let op_return_out = &psbt_info.outputs[0]; - assert!(op_return_out.is_op_return); - for out in &psbt_info.outputs[1..] { - assert!(out.address.is_some()); - } - let rgb_inspection = wlt_2 - .multisig - .inspect_rgb_transfer(psbt.clone(), details.fascia_path, details.entropy) - .unwrap(); - assert_eq!(rgb_inspection.close_method, CloseMethod::OpretFirst); - assert_eq!(rgb_inspection.commitment_hex.len(), 64); - assert_eq!(rgb_inspection.operations.len(), 3); - let cfa_transfer = rgb_inspection - .operations - .iter() - .find(|op| op.asset_id == cfa_asset.asset_id) - .unwrap(); - let nia_transfer = rgb_inspection - .operations - .iter() - .find(|op| op.asset_id == nia_asset_1.asset_id) - .unwrap(); - let cfa_inputs: Vec<_> = cfa_transfer - .transitions - .iter() - .flat_map(|t| &t.inputs) - .collect(); - let cfa_outputs: Vec<_> = cfa_transfer - .transitions - .iter() - .flat_map(|t| &t.outputs) - .collect(); - assert_eq!(cfa_outputs.len(), 3); - let cfa_sent_outputs: Vec<_> = cfa_outputs.iter().filter(|o| !o.is_ours).collect(); - let cfa_change_outputs: Vec<_> = cfa_outputs.iter().filter(|o| o.is_ours).collect(); - assert_eq!(cfa_sent_outputs.len(), 2); - assert_eq!(cfa_change_outputs.len(), 1); - let cfa_witness_output = cfa_sent_outputs.iter().find(|o| !o.is_concealed).unwrap(); - assert_eq!( - cfa_witness_output.assignment.main_amount(), - cfa_amount_witness - ); - assert!(!cfa_witness_output.is_concealed); - assert!(!cfa_witness_output.is_ours); - let cfa_blind_output = cfa_sent_outputs.iter().find(|o| o.is_concealed).unwrap(); - assert_eq!(cfa_blind_output.assignment.main_amount(), cfa_amount_blind); - assert!(cfa_blind_output.is_concealed); - assert!(!cfa_blind_output.is_ours); - let cfa_change_amount: u64 = cfa_change_outputs - .iter() - .map(|o| o.assignment.main_amount()) - .sum(); - for o in &cfa_change_outputs { - assert!(!o.is_concealed); - assert!(o.is_ours); - } - let cfa_sent_amount = cfa_amount_witness + cfa_amount_blind; - let total_cfa_output: u64 = cfa_outputs.iter().map(|o| o.assignment.main_amount()).sum(); - assert_eq!(total_cfa_output, cfa_sent_amount + cfa_change_amount); - assert!(!cfa_inputs.is_empty()); - let total_cfa_input: u64 = cfa_inputs.iter().map(|i| i.assignment.main_amount()).sum(); - assert_eq!(total_cfa_input, total_cfa_output); - for input in &cfa_inputs { - assert!((input.vin as usize) < psbt_info.inputs.len()); - assert_matches!(input.assignment, Assignment::Fungible(_)); - } - let nia_inputs: Vec<_> = nia_transfer - .transitions - .iter() - .flat_map(|t| &t.inputs) - .collect(); - let nia_outputs: Vec<_> = nia_transfer - .transitions - .iter() - .flat_map(|t| &t.outputs) - .collect(); - let nia_sent_outputs: Vec<_> = nia_outputs.iter().filter(|o| !o.is_ours).collect(); - let nia_change_outputs: Vec<_> = nia_outputs.iter().filter(|o| o.is_ours).collect(); - assert_eq!(nia_sent_outputs.len(), 1); - assert!(!nia_change_outputs.is_empty()); - assert_eq!(nia_sent_outputs[0].assignment.main_amount(), AMOUNT_SMALL); - assert!(nia_sent_outputs[0].is_concealed); - assert!(!nia_sent_outputs[0].is_ours); - let total_nia_output: u64 = nia_outputs.iter().map(|o| o.assignment.main_amount()).sum(); - let total_nia_input: u64 = nia_inputs.iter().map(|i| i.assignment.main_amount()).sum(); - assert_eq!(total_nia_input, total_nia_output); - assert!(total_nia_input >= AMOUNT_SMALL); - for input in &nia_inputs { - assert!((input.vin as usize) < psbt_info.inputs.len()); - assert_matches!(input.assignment, Assignment::Fungible(_)); - } - let nia_asset_2_transfer = rgb_inspection - .operations - .iter() - .find(|op| op.asset_id == nia_asset_2.asset_id) - .unwrap(); - let nia_asset_2_inputs: Vec<_> = nia_asset_2_transfer - .transitions - .iter() - .flat_map(|t| &t.inputs) - .collect(); - let nia_asset_2_outputs: Vec<_> = nia_asset_2_transfer - .transitions - .iter() - .flat_map(|t| &t.outputs) - .collect(); - assert_eq!(nia_asset_2_outputs.len(), 2); - let nia_asset_2_sent: Vec<_> = nia_asset_2_outputs.iter().filter(|o| !o.is_ours).collect(); - let nia_asset_2_change: Vec<_> = nia_asset_2_outputs.iter().filter(|o| o.is_ours).collect(); - assert_eq!(nia_asset_2_sent.len(), 1); - assert_eq!(nia_asset_2_change.len(), 1); - assert_eq!( - nia_asset_2_sent[0].assignment.main_amount(), - nia_asset_2_amount - ); - assert!(nia_asset_2_sent[0].is_concealed); - assert!(!nia_asset_2_sent[0].is_ours); - let expected_nia_asset_2_change = AMOUNT_SMALL - nia_asset_2_amount; - assert_eq!( - nia_asset_2_change[0].assignment.main_amount(), - expected_nia_asset_2_change - ); - assert!(!nia_asset_2_change[0].is_concealed); - assert!(nia_asset_2_change[0].is_ours); - let nia_asset_2_total_output: u64 = nia_asset_2_outputs - .iter() - .map(|o| o.assignment.main_amount()) - .sum(); - assert_eq!(nia_asset_2_total_output, AMOUNT_SMALL); - assert!(!nia_asset_2_inputs.is_empty()); - let nia_asset_2_total_input: u64 = nia_asset_2_inputs - .iter() - .map(|i| i.assignment.main_amount()) - .sum(); - assert_eq!(nia_asset_2_total_input, nia_asset_2_total_output); - assert_eq!(nia_asset_2_total_input, AMOUNT_SMALL); - for input in &nia_asset_2_inputs { - assert!((input.vin as usize) < psbt_info.inputs.len()); - assert_matches!(input.assignment, Assignment::Fungible(_)); - } - let op_info = wlt_2.sign_and_ack(psbt, op_info.operation_idx); - let Operation::SendPending { status, details: _ } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - assert_eq!(status.acked_by.len(), 2); - assert_eq!(status.nacked_by.len(), 0); - assert_eq!(status.threshold, 3); - assert_eq!(status.my_response, Some(true)); - let op_info = wlt_3.sync(); - let Operation::SendToReview { - psbt, - details: _, - status: _, - } = op_info.operation - else { - panic!("unexpected operation {op_info:?}"); - }; - let bi = wlt_3.bak_ts(); - let op_info = wlt_3.sign_and_ack(psbt, op_info.operation_idx); - assert!(wlt_3.bak_ts() > bi); - assert_matches!(op_info.operation, Operation::SendCompleted { .. }); - let op_info = wlt_1.sync(); - assert_matches!(op_info.operation, Operation::SendCompleted { .. }); - let op_info = wlt_2.sync(); - assert_matches!(op_info.operation, Operation::SendCompleted { .. }); - wait_for_refresh(singlesig_wlt.wallet, singlesig_wlt.online, None, None); - wlt_1.wait_refresh(Some(&cfa_asset.asset_id), None); - wlt_2.wait_refresh(Some(&cfa_asset.asset_id), None); - wlt_3.wait_refresh(Some(&cfa_asset.asset_id), None); - mine(false, false); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&cfa_asset.asset_id), - None, - ); - wlt_1.wait_refresh(Some(&cfa_asset.asset_id), None); - wlt_2.wait_refresh(Some(&cfa_asset.asset_id), None); - wlt_3.wait_refresh(Some(&cfa_asset.asset_id), None); - let rcv_transfers = test_list_transfers(singlesig_wlt.wallet, Some(&cfa_asset.asset_id)); - assert_last_transfer_settled(&rcv_transfers); - let send_transfers_1 = wlt_1.list_transfers(&cfa_asset.asset_id); - assert_last_transfer_settled(&send_transfers_1); - let send_transfers_2 = wlt_2.list_transfers(&cfa_asset.asset_id); - assert_last_transfer_settled(&send_transfers_2); - let send_transfers_3 = wlt_3.list_transfers(&cfa_asset.asset_id); - assert_last_transfer_settled(&send_transfers_3); - let balance_1 = wlt_1.asset_balance(&cfa_asset.asset_id); - let balance_2 = wlt_2.asset_balance(&cfa_asset.asset_id); - let balance_3 = wlt_3.asset_balance(&cfa_asset.asset_id); - assert!(balance_1.settled < 300); - assert!(balance_2.settled < 300); - assert!(balance_3.settled < 300); - let rcv_balance = test_get_asset_balance(singlesig_wlt.wallet, &cfa_asset.asset_id); - let expected_rcv_balance = cfa_amount_witness + cfa_amount_blind; - assert_eq!(rcv_balance.settled, expected_rcv_balance); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&nia_asset_1.asset_id), - None, - ); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&nia_asset_2.asset_id), - None, - ); - let rcv_transfers = test_list_transfers(singlesig_wlt.wallet, Some(&nia_asset_2.asset_id)); - assert_last_transfer_settled(&rcv_transfers); - let send_transfers = wlt_1.list_transfers(&nia_asset_2.asset_id); - assert_last_transfer_settled(&send_transfers); - let expected_nia_asset_2_remaining = AMOUNT_SMALL - nia_asset_2_amount; - let balance_1 = wlt_1.asset_balance(&nia_asset_2.asset_id); - let balance_2 = wlt_2.asset_balance(&nia_asset_2.asset_id); - let balance_3 = wlt_3.asset_balance(&nia_asset_2.asset_id); - assert_eq!(balance_1.settled, expected_nia_asset_2_remaining); - assert_eq!(balance_2.settled, expected_nia_asset_2_remaining); - assert_eq!(balance_3.settled, expected_nia_asset_2_remaining); - let rcv_balance = test_get_asset_balance(singlesig_wlt.wallet, &nia_asset_2.asset_id); - let expected_singlesig_nia_asset_2 = AMOUNT - AMOUNT_SMALL + nia_asset_2_amount; - assert_eq!(rcv_balance.settled, expected_singlesig_nia_asset_2); -} - -fn send_uda_to_singlesig( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - uda_asset: &AssetUDA, -) { - println!("\n=== Send UDA to singlesig ==="); - let rcv_data = test_blind_receive(singlesig_wlt.wallet); - let send_recipient_map = HashMap::from([( - uda_asset.asset_id.clone(), - vec![Recipient { - assignment: Assignment::NonFungible, - recipient_id: rcv_data.recipient_id.clone(), - witness_data: None, - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - )]); - wlt_1.assert_up_to_date(); - let bi = wlt_1.bak_ts(); - let init_res = wlt_1.send_init(send_recipient_map); - assert!(wlt_1.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - wlt_1.sign_and_ack(init_res.psbt, init_res.operation_idx); - let bi = wlt_2.bak_ts(); - let op_info = wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - let Operation::SendToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - wlt_2.sign_and_ack(psbt, op_info.operation_idx); - let op_info = wlt_3.sync(); - let Operation::SendToReview { psbt, .. } = op_info.operation else { - panic!("unexpected operation {op_info:?}"); - }; - let bi = wlt_3.bak_ts(); - let op_info = wlt_3.sign_and_ack(psbt, op_info.operation_idx); - assert!(wlt_3.bak_ts() > bi); - assert_matches!(op_info.operation, Operation::SendCompleted { .. }); - wlt_1.sync(); - wlt_2.sync(); - wait_for_refresh(singlesig_wlt.wallet, singlesig_wlt.online, None, None); - wlt_1.wait_refresh(Some(&uda_asset.asset_id), None); - mine(false, false); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&uda_asset.asset_id), - None, - ); - wlt_1.wait_refresh(Some(&uda_asset.asset_id), None); - let transfers = test_list_transfers(singlesig_wlt.wallet, Some(&uda_asset.asset_id)); - assert_last_transfer_settled(&transfers); - let balance = test_get_asset_balance(singlesig_wlt.wallet, &uda_asset.asset_id); - assert_eq!(balance.settled, 1); - let balance = wlt_1.asset_balance(&uda_asset.asset_id); - assert_eq!(balance.settled, 0); -} - -fn watch_only_wallet_sync(root: &KeyPair, keys: &MultisigKeys, dir: String) { - println!("\n=== Watch-only sync ==="); - let token = create_token(root, Role::WatchOnly, None); - let mut watch_only_wlt = get_test_ms_wallet(keys, dir); - let watch_only_wlt_last_processed = watch_only_wlt - .get_local_last_processed_operation_idx() - .unwrap(); - assert_eq!(watch_only_wlt_last_processed, 0); - let online = ms_go_online(&mut watch_only_wlt, &token); - let info = watch_only_wlt.hub_info(online).unwrap(); - assert_eq!(info.user_role, UserRole::WatchOnly); - assert!(info.last_operation_idx.is_some()); - ms_party!(&mut watch_only_wlt, online).sync_to_head(); - assert_synced_wallet_state(&watch_only_wlt); -} - -fn witness_receive_completed( - wlt_1: &mut MultisigParty, - wlt_2: &mut MultisigParty, - wlt_3: &mut MultisigParty, - singlesig_wlt: &mut SinglesigParty, - uda_asset: &AssetUDA, -) { - println!("\n=== Witness receive ==="); - let bi = wlt_3.bak_ts(); - let receive_data = wlt_3.witness_receive(); - assert!(wlt_3.bak_ts() > bi); - OP_COUNTER.fetch_add(1, Ordering::SeqCst); - let recipient_map = HashMap::from([( - uda_asset.asset_id.clone(), - vec![Recipient { - assignment: Assignment::NonFungible, - recipient_id: receive_data.recipient_id.clone(), - witness_data: Some(WitnessData { - amount_sat: 1000, - blinding: None, - }), - transport_endpoints: TRANSPORT_ENDPOINTS.clone(), - }], - )]); - let _txid = test_send(singlesig_wlt.wallet, singlesig_wlt.online, &recipient_map); - wlt_3.wait_refresh(None, None); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&uda_asset.asset_id), - None, - ); - mine(false, false); - wlt_3.wait_refresh(Some(&uda_asset.asset_id), None); - wait_for_refresh( - singlesig_wlt.wallet, - singlesig_wlt.online, - Some(&uda_asset.asset_id), - None, - ); - let transfers = wlt_3.list_transfers(&uda_asset.asset_id); - assert_last_transfer_settled(&transfers); - let transfers = test_list_transfers(singlesig_wlt.wallet, Some(&uda_asset.asset_id)); - assert_last_transfer_settled(&transfers); - let bi = wlt_1.bak_ts(); - wlt_1.sync(); - assert!(wlt_1.bak_ts() > bi); - let transfers = wlt_1.list_transfers(&uda_asset.asset_id); - assert_last_transfer_settled(&transfers); - let bi = wlt_2.bak_ts(); - wlt_2.sync(); - assert!(wlt_2.bak_ts() > bi); - let transfers = wlt_2.list_transfers(&uda_asset.asset_id); - assert_last_transfer_settled(&transfers); - let balance = wlt_3.asset_balance(&uda_asset.asset_id); - assert_eq!(balance.settled, 1); - let balance = wlt_1.asset_balance(&uda_asset.asset_id); - assert_eq!(balance.settled, 1); - let balance = wlt_2.asset_balance(&uda_asset.asset_id); - assert_eq!(balance.settled, 1); - let balance = test_get_asset_balance(singlesig_wlt.wallet, &uda_asset.asset_id); - assert_eq!(balance.settled, 0); -} - -fn wlt_4_sync(wlt_4: &mut MultisigParty) { - println!("\n=== Wallet 4 sync ==="); - let wlt_4_last_processed = wlt_4 - .multisig - .get_local_last_processed_operation_idx() - .unwrap(); - assert_eq!(wlt_4_last_processed, 1); - wlt_4.sync_to_head(); - assert_synced_wallet_state(wlt_4.multisig_ref()); -} - -// tests - -#[cfg(feature = "electrum")] -#[test] -#[serial] -fn success() { - initialize(); - - let bitcoin_network = BitcoinNetwork::Regtest; - let threshold_colored = 3; - let threshold_vanilla = 3; - - let wlt_1_keys = generate_keys(bitcoin_network); - let wlt_2_keys = generate_keys(bitcoin_network); - let wlt_3_keys = generate_keys(bitcoin_network); - let wlt_4_keys = generate_keys(bitcoin_network); - - let cosigners = vec![ - Cosigner::from_keys(&wlt_1_keys, None), - Cosigner::from_keys(&wlt_2_keys, None), - Cosigner::from_keys(&wlt_3_keys, None), - Cosigner::from_keys(&wlt_4_keys, None), - ]; - let cosigner_xpubs: Vec = cosigners - .iter() - .map(|c| c.account_xpub_colored.clone()) - .collect(); - - // write hub configuration file and restart hub - let root_keypair = KeyPair::new(); - let root_public_key = root_keypair.public(); - write_hub_config( - &cosigner_xpubs, - threshold_colored, - threshold_vanilla, - root_public_key.to_bytes_hex(), - None, - ); - restart_multisig_hub(); - - // create biscuit tokens for cosigners - let mut cosigner_tokens = vec![]; - for cosigner_xpub in &cosigner_xpubs { - cosigner_tokens.push(create_token( - &root_keypair, - Role::Cosigner(cosigner_xpub.clone()), - None, - )); - } - - // single-sig wallets for signing - let wlt_1_singlesig = get_test_wallet_with_keys(&wlt_1_keys); - let wlt_2_singlesig = get_test_wallet_with_keys(&wlt_2_keys); - let wlt_3_singlesig = get_test_wallet_with_keys(&wlt_3_keys); - let wlt_4_singlesig = get_test_wallet_with_keys(&wlt_4_keys); - - // multi-sig wallets - let multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), threshold_colored, threshold_vanilla); - let random_str: String = rand::rng() - .sample_iter(&Alphanumeric) - .take(6) - .map(char::from) - .collect(); - let mut wlt_1_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_1")); - let wlt_1_multisig_online = ms_go_online(&mut wlt_1_multisig, &cosigner_tokens[0]); - let mut wlt_2_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_2")); - let wlt_2_multisig_online = ms_go_online(&mut wlt_2_multisig, &cosigner_tokens[1]); - let mut wlt_3_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_3")); - let wlt_3_multisig_online = ms_go_online(&mut wlt_3_multisig, &cosigner_tokens[2]); - let mut wlt_4_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_4")); - let wlt_4_multisig_online = ms_go_online(&mut wlt_4_multisig, &cosigner_tokens[3]); - - let mut wlt_1 = ms_party!( - &wlt_1_singlesig, - &mut wlt_1_multisig, - wlt_1_multisig_online, - &cosigner_xpubs[0] - ); - let mut wlt_2 = ms_party!( - &wlt_2_singlesig, - &mut wlt_2_multisig, - wlt_2_multisig_online, - &cosigner_xpubs[1] - ); - let mut wlt_3 = ms_party!( - &wlt_3_singlesig, - &mut wlt_3_multisig, - wlt_3_multisig_online, - &cosigner_xpubs[2] - ); - let mut wlt_4 = ms_party!( - &wlt_4_singlesig, - &mut wlt_4_multisig, - wlt_4_multisig_online, - &cosigner_xpubs[3] - ); - - let descriptors = multisig_wlt_keys - .build_descriptors(bitcoin_network) - .unwrap(); - for wlt in [&wlt_1, &wlt_2, &wlt_3, &wlt_4] { - let wlt_keys = wlt.multisig.get_keys(); - assert_eq!(wlt_keys, multisig_wlt_keys); - let wlt_descriptors = wlt.multisig.get_descriptors(); - assert_eq!(wlt_descriptors, descriptors); - } - - let sats = 30_000; - send_sats_to_address(wlt_1.get_address(), Some(sats)); - mine(false, false); - - check_cosigner_hub_info(&mut wlt_1, &mut wlt_2, &mut wlt_3, &mut wlt_4); - - create_utxos_discarded(&mut wlt_1, &mut wlt_2, &mut wlt_3, &mut wlt_4); - - create_utxos_completed(&mut wlt_1, &mut wlt_2, &mut wlt_3, sats); - - let cfa_asset = issue_cfa_completed(&mut wlt_1, &mut wlt_2, &mut wlt_3); - - send_btc_discarded(&mut wlt_1, &mut wlt_2, &mut wlt_3); - - let nia_asset_1 = issue_nia_completed(&mut wlt_1, &mut wlt_2, &mut wlt_3); - - let uda_asset = issue_uda_completed(&mut wlt_1, &mut wlt_2, &mut wlt_3); - - let (mut singlesig_wlt, singlesig_wlt_online) = get_funded_wallet!(); - let mut singlesig_wlt = party!(&mut singlesig_wlt, singlesig_wlt_online); - - send_uda_to_singlesig( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - &uda_asset, - ); - - witness_receive_completed( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - &uda_asset, - ); - - send_discarded( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - cfa_asset.clone(), - nia_asset_1.clone(), - ); - - let nia_asset_2 = - blind_receive_unknown_asset(&mut wlt_1, &mut wlt_2, &mut wlt_3, &mut singlesig_wlt); - - send_to_singlesig( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - &cfa_asset, - &nia_asset_1, - &nia_asset_2, - ); - - let (wlt_1_last_processed_before_backup, backup_file) = - backup(wlt_1.multisig, &format!("{random_str}_1")); - - send_extra_completed( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - &nia_asset_2, - &cfa_asset, - &nia_asset_1, - ); - - let ifa_asset = inflate_completed(&mut wlt_1, &mut wlt_2, &mut wlt_3); - inflate_discarded(&mut wlt_1, &mut wlt_2, &mut wlt_3, ifa_asset); - - blind_receive_completed( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - nia_asset_1, - ); - - send_btc_completed(&mut wlt_1, &mut wlt_2, &mut wlt_3, &mut singlesig_wlt); - - receive_failed(&mut wlt_1, &mut wlt_2, &mut wlt_3); - - send_failed( - &mut wlt_1, - &mut wlt_2, - &mut wlt_3, - &mut singlesig_wlt, - &nia_asset_2, - ); - - wlt_4_sync(&mut wlt_4); - - check_change_consistency(&mut wlt_1, &mut wlt_4); - - watch_only_wallet_sync( - &root_keypair, - &multisig_wlt_keys, - format!("{random_str}_watch_only"), - ); - - backup_restore( - &backup_file, - &random_str, - multisig_wlt_keys, - wlt_1_last_processed_before_backup, - &cosigner_tokens[0], - ); -} - -#[cfg(feature = "electrum")] -#[test] -#[serial] -fn fail() { - initialize(); - - let bitcoin_network = BitcoinNetwork::Regtest; - let threshold_colored = 2; - let threshold_vanilla = 2; - - let wlt_1_keys = generate_keys(bitcoin_network); - let wlt_2_keys = generate_keys(bitcoin_network); - let wlt_3_keys = generate_keys(bitcoin_network); - - let cosigners = vec![ - Cosigner::from_keys(&wlt_1_keys, None), - Cosigner::from_keys(&wlt_2_keys, None), - Cosigner::from_keys(&wlt_3_keys, None), - ]; - let num_cosigners = cosigners.len() as u8; - let cosigner_xpubs: Vec = cosigners - .iter() - .map(|c| c.account_xpub_colored.clone()) - .collect(); - - // write hub configuration file and restart hub - let root_keypair = KeyPair::new(); - let root_public_key = root_keypair.public(); - write_hub_config( - &cosigner_xpubs, - threshold_colored, - threshold_vanilla, - root_public_key.to_bytes_hex(), - None, - ); - restart_multisig_hub(); - - // create biscuit tokens for cosigners - let mut cosigner_tokens = vec![]; - for cosigner_xpub in &cosigner_xpubs { - cosigner_tokens.push(create_token( - &root_keypair, - Role::Cosigner(cosigner_xpub.clone()), - None, - )); - } - - let random_str: String = rand::rng() - .sample_iter(&Alphanumeric) - .take(6) - .map(char::from) - .collect(); - let data_dir = get_test_data_dir_path() - .join(format!("{random_str}_1")) - .to_string_lossy() - .to_string(); - let _ = fs::create_dir_all(&data_dir); - - // no cosigners supplied - let invalid_multisig_wlt_keys = MultisigKeys::new(vec![], threshold_colored, threshold_vanilla); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_eq!(err, Error::NoCosignersSupplied); - - // invalid thresholds: higher than total cosigners - let invalid_threshold = num_cosigners + 1; - // - colored threshold - let invalid_multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), invalid_threshold, threshold_vanilla); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidMultisigThreshold { required, total } if required == invalid_threshold && total == num_cosigners); - // - vanilla threshold - let invalid_multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), threshold_colored, invalid_threshold); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidMultisigThreshold { required, total } if required == invalid_threshold && total == num_cosigners); - - // invalid thresholds: k=0 - let invalid_threshold = 0; - // - colored threshold - let invalid_multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), invalid_threshold, threshold_vanilla); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidMultisigThreshold { required, total } if required == invalid_threshold && total == num_cosigners); - // - vanilla threshold - let invalid_multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), threshold_colored, invalid_threshold); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidMultisigThreshold { required, total } if required == invalid_threshold && total == num_cosigners); - - // invalid fingerprint - let mut invalid_cosigners = cosigners.clone(); - let invalid_fingerprint = s!("invalid"); - invalid_cosigners[1].master_fingerprint = invalid_fingerprint.clone(); - let invalid_multisig_wlt_keys = MultisigKeys::new( - invalid_cosigners.clone(), - threshold_colored, - threshold_vanilla, - ); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidCosigner { details: d } if d == format!("invalid master_fingerprint '{invalid_fingerprint}'")); - - // invalid xpub content - let invalid_xpub = s!("invalid"); - // - colored xpub - let mut invalid_cosigners = cosigners.clone(); - invalid_cosigners[1].account_xpub_colored = invalid_xpub.clone(); - let invalid_multisig_wlt_keys = MultisigKeys::new( - invalid_cosigners.clone(), - threshold_colored, - threshold_vanilla, - ); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidCosigner { details: d } if d == format!("invalid colored xpub '{invalid_xpub}'")); - // - vanilla xpub - let mut invalid_cosigners = cosigners.clone(); - invalid_cosigners[1].account_xpub_vanilla = invalid_xpub.clone(); - let invalid_multisig_wlt_keys = MultisigKeys::new( - invalid_cosigners.clone(), - threshold_colored, - threshold_vanilla, - ); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidCosigner { details: d } if d == format!("invalid vanilla xpub '{invalid_xpub}'")); - - // invalid xpub network - let invalid_keys = generate_keys(BitcoinNetwork::Mainnet); - let invalid_cosigner = Cosigner::from_keys(&invalid_keys, None); - // - colored xpub - let mut invalid_cosigners = cosigners.clone(); - invalid_cosigners[1].account_xpub_colored = invalid_cosigner.account_xpub_colored.clone(); - let invalid_multisig_wlt_keys = MultisigKeys::new( - invalid_cosigners.clone(), - threshold_colored, - threshold_vanilla, - ); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidCosigner { details: d } if d == format!("colored xpub '{}' is for the wrong network", invalid_cosigner.account_xpub_colored)); - // - vanilla xpub - let mut invalid_cosigners = cosigners.clone(); - invalid_cosigners[1].account_xpub_vanilla = invalid_cosigner.account_xpub_vanilla.clone(); - let invalid_multisig_wlt_keys = MultisigKeys::new( - invalid_cosigners.clone(), - threshold_colored, - threshold_vanilla, - ); - let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); - let Err(err) = res else { - panic!("expected Err, got Ok") - }; - assert_matches!(err, Error::InvalidCosigner { details: d } if d == format!("vanilla xpub '{}' is for the wrong network", invalid_cosigner.account_xpub_vanilla)); - - // invalid rgb-lib version - println!("setting MOCK_LOCAL_VERSION"); - MOCK_LOCAL_VERSION.replace(Some(s!("0.2"))); - let multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), threshold_colored, threshold_vanilla); - let mut wlt_1_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_1")); - let res = ms_go_online_res(&mut wlt_1_multisig, &cosigner_tokens[0]).unwrap_err(); - assert_matches!(res, Error::MultisigHubService { details: d } if d == "rgb-lib version mismatch: local version is 0.2 but hub requires 0.3"); - - // expired token - let multisig_wlt_keys = - MultisigKeys::new(cosigners.clone(), threshold_colored, threshold_vanilla); - let mut wlt_1_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_1")); - let expired_token = create_token( - &root_keypair, - Role::Cosigner(cosigner_xpubs[0].clone()), - Some(Utc::now() - Duration::from_secs(1)), - ); - let res = ms_go_online_res(&mut wlt_1_multisig, &expired_token).unwrap_err(); - assert_matches!(res, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); - - // invalid token - let invalid_token = s!("invalid"); - let res = ms_go_online_res(&mut wlt_1_multisig, &invalid_token).unwrap_err(); - assert_matches!(res, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); - - // token for cosigner not in hub config - let wlt_3_keys = generate_keys(bitcoin_network); - let invalid_cosigner_token = - create_token(&root_keypair, Role::Cosigner(wlt_3_keys.xpub.clone()), None); - let res = ms_go_online_res(&mut wlt_1_multisig, &invalid_cosigner_token).unwrap_err(); - assert_matches!(res, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); - - // token with no xpub nor role - let invalid_token = biscuit!("") - .build(&root_keypair) - .unwrap() - .to_base64() - .unwrap(); - let res = ms_go_online_res(&mut wlt_1_multisig, &invalid_token).unwrap_err(); - assert_matches!(res, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); - - // invalid hub URL - let res = wlt_1_multisig - .go_online( - false, - ELECTRUM_URL.to_string(), - s!("invalid"), - cosigner_tokens[0].to_string(), - ) - .unwrap_err(); - assert_matches!(res, Error::MultisigHubService { details: d } if d == "URL must be valid and start with http:// or https://"); - - // respond with PSBT that has no signatures - let wlt_1_multisig_online = ms_go_online(&mut wlt_1_multisig, &cosigner_tokens[0]); - send_sats_to_address( - wlt_1_multisig.get_address(wlt_1_multisig_online).unwrap(), - Some(10_000), - ); - mine(false, false); - let mut wlt_1 = ms_party!(&mut wlt_1_multisig, wlt_1_multisig_online); - let init_res = wlt_1.create_utxos_init(false, None, None, FEE_RATE); - let op_idx_1 = init_res.operation_idx; - let unsigned_psbt = init_res.psbt.clone(); - let res = wlt_1 - .respond_to_operation_res(op_idx_1, RespondToOperation::Ack(unsigned_psbt.clone())) - .unwrap_err(); - assert_matches!( - res, - Error::InvalidPsbt { details: d } if d == "PSBT has no signatures" - ); - - // cannot initiate a new operation if another is pending - let res = wlt_1.create_utxos_init_res(false, None, None, FEE_RATE); - assert_matches!(res, Err(Error::MultisigOperationInProgress)); - - // respond to a non-pending operation - let wlt_1_singlesig = get_test_wallet_with_keys(&wlt_1_keys); - let signed_psbt = wlt_1_singlesig - .sign_psbt(unsigned_psbt.clone(), None) - .unwrap(); - wlt_1.respond_to_operation(op_idx_1, RespondToOperation::Ack(signed_psbt)); - let wlt_2_singlesig = get_test_wallet_with_keys(&wlt_2_keys); - let signed_psbt = wlt_2_singlesig - .sign_psbt(unsigned_psbt.clone(), None) - .unwrap(); - let mut wlt_2_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_2")); - let wlt_2_multisig_online = ms_go_online(&mut wlt_2_multisig, &cosigner_tokens[1]); - let mut wlt_2 = ms_party!(&mut wlt_2_multisig, wlt_2_multisig_online); - let op_2 = wlt_2.sync(); - wlt_2.respond_to_operation( - op_2.operation_idx, - RespondToOperation::Ack(signed_psbt.clone()), - ); - let mut wlt_3_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_3")); - let wlt_3_multisig_online = ms_go_online(&mut wlt_3_multisig, &cosigner_tokens[2]); - let mut wlt_3 = ms_party!(&mut wlt_3_multisig, wlt_3_multisig_online); - let res = wlt_3 - .respond_to_operation_res(op_idx_1, RespondToOperation::Nack) - .unwrap_err(); - assert_matches!( - res, - Error::MultisigCannotRespondToOperation { details: d } if d == "not pending" - ); - - // respond with PSBT that has the wrong TXID - wlt_1.sync(); - wlt_2.assert_up_to_date(); - let init_res = wlt_1.create_utxos_init(false, Some(5), None, FEE_RATE); - let op_idx_2 = init_res.operation_idx; - let res = wlt_1 - .respond_to_operation_res(op_idx_2, RespondToOperation::Ack(signed_psbt.to_string())) - .unwrap_err(); - assert_matches!( - res, - Error::InvalidPsbt { details: d } if d == "PSBT unrelated to operation" - ); - - // respond to already responded - wlt_1.respond_to_operation(op_idx_2, RespondToOperation::Nack); - let res = wlt_1 - .respond_to_operation_res(op_idx_2, RespondToOperation::Nack) - .unwrap_err(); - assert_matches!( - res, - Error::MultisigCannotRespondToOperation { details: d } if d == "already responded" - ); - - // respond to a non-next operation - wlt_2.respond_to_operation(op_idx_2, RespondToOperation::Nack); - wlt_1.sync(); - wlt_2.assert_up_to_date(); - let init_res = wlt_1.create_utxos_init(false, Some(3), None, FEE_RATE); - let op_idx_3 = init_res.operation_idx; - let res = wlt_3 - .respond_to_operation_res(op_idx_3, RespondToOperation::Nack) - .unwrap_err(); - assert_matches!( - res, - Error::MultisigCannotRespondToOperation { details: d } if d == "Cannot respond to operation: operation is not the next one to be processed" - ); - - // watch-only forbidden - let token = create_token(&root_keypair, Role::WatchOnly, None); - let mut watch_only_wlt = - get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_watch_only")); - let online = ms_go_online(&mut watch_only_wlt, &token); - let res = watch_only_wlt.get_address(online).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let mut wlt_wo = ms_party!(&mut watch_only_wlt, online); - let res = wlt_wo.issue_asset_cfa_res(None, None).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.issue_asset_nia_res(None).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.issue_asset_ifa_res(None, None, None).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.issue_asset_uda_res(None, None, vec![]).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.blind_receive_res().unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.witness_receive_res().unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.nack_res(0).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo - .create_utxos_init_res(false, None, None, FEE_RATE) - .unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.send_btc_init_res("address", AMOUNT).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.send_init_res(HashMap::new()).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); - let res = wlt_wo.inflate_init_res(s!("asset_id"), vec![]).unwrap_err(); - assert_eq!(res, Error::MultisigUserNotCosigner); -} diff --git a/src/wallet/test/multisig/mod.rs b/src/wallet/test/multisig/mod.rs new file mode 100644 index 00000000..0c634e8a --- /dev/null +++ b/src/wallet/test/multisig/mod.rs @@ -0,0 +1,1264 @@ +#[macro_use] +mod utils; + +use super::*; +use utils::*; + +#[cfg(feature = "electrum")] +#[test] +#[serial] +fn success() { + initialize(); + op_counter_reset(); + + let bitcoin_network = BitcoinNetwork::Regtest; + let threshold_colored = 3; + let threshold_vanilla = 3; + let random_str: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(6) + .map(char::from) + .collect(); + + // multisig wallet keys + let wlt_1_keys = generate_keys(bitcoin_network); + let wlt_2_keys = generate_keys(bitcoin_network); + let wlt_3_keys = generate_keys(bitcoin_network); + let wlt_4_keys = generate_keys(bitcoin_network); + + // cosigners + let cosigners = vec![ + Cosigner::from_keys(&wlt_1_keys, None), + Cosigner::from_keys(&wlt_2_keys, None), + Cosigner::from_keys(&wlt_3_keys, None), + Cosigner::from_keys(&wlt_4_keys, None), + ]; + let cosigner_xpubs: Vec = cosigners + .iter() + .map(|c| c.account_xpub_colored.clone()) + .collect(); + + // biscuit token setup + // - roots + let root_keypair = KeyPair::new(); + let root_public_key = root_keypair.public(); + // - cosigners + let mut cosigner_tokens = vec![]; + for cosigner_xpub in &cosigner_xpubs { + cosigner_tokens.push(create_token( + &root_keypair, + Role::Cosigner(cosigner_xpub.clone()), + None, + )); + } + // - watch-only + let wo_token = create_token(&root_keypair, Role::WatchOnly, None); + + // hub setup + write_hub_config( + &cosigner_xpubs, + threshold_colored, + threshold_vanilla, + root_public_key.to_bytes_hex(), + None, + ); + restart_multisig_hub(); + + // multisig wallets + let multisig_wlt_keys = + MultisigKeys::new(cosigners.clone(), threshold_colored, threshold_vanilla); + let mut wlt_1_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_1")); + let wlt_1_multisig_online = ms_go_online(&mut wlt_1_multisig, &cosigner_tokens[0]); + let mut wlt_2_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_2")); + let wlt_2_multisig_online = ms_go_online(&mut wlt_2_multisig, &cosigner_tokens[1]); + let mut wlt_3_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_3")); + let wlt_3_multisig_online = ms_go_online(&mut wlt_3_multisig, &cosigner_tokens[2]); + let mut wlt_4_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_4")); + let wlt_4_multisig_online = ms_go_online(&mut wlt_4_multisig, &cosigner_tokens[3]); + + // singlesig wallets (for signing) + let wlt_1_singlesig = get_test_wallet_with_keys(&wlt_1_keys); + let wlt_2_singlesig = get_test_wallet_with_keys(&wlt_2_keys); + let wlt_3_singlesig = get_test_wallet_with_keys(&wlt_3_keys); + let wlt_4_singlesig = get_test_wallet_with_keys(&wlt_4_keys); + + // watch-only wallet + let mut wlt_wo_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_wo")); + let wlt_wo_multisig_online = ms_go_online(&mut wlt_wo_multisig, &wo_token); + + // multisig parties + let mut wlt_1 = ms_party!( + &wlt_1_singlesig, + &mut wlt_1_multisig, + wlt_1_multisig_online, + &cosigner_xpubs[0] + ); + let mut wlt_2 = ms_party!( + &wlt_2_singlesig, + &mut wlt_2_multisig, + wlt_2_multisig_online, + &cosigner_xpubs[1] + ); + let mut wlt_3 = ms_party!( + &wlt_3_singlesig, + &mut wlt_3_multisig, + wlt_3_multisig_online, + &cosigner_xpubs[2] + ); + let mut wlt_4 = ms_party!( + &wlt_4_singlesig, + &mut wlt_4_multisig, + wlt_4_multisig_online, + &cosigner_xpubs[3] + ); + + // watch-only party + let mut wlt_wo = ms_party!(&mut wlt_wo_multisig, wlt_wo_multisig_online); + + // check descriptors + let descriptors = multisig_wlt_keys + .build_descriptors(bitcoin_network) + .unwrap(); + for wlt in [&wlt_1, &wlt_2, &wlt_3, &wlt_4] { + let wlt_keys = wlt.multisig.get_keys(); + assert_eq!(wlt_keys, multisig_wlt_keys); + let wlt_descriptors = wlt.multisig.get_descriptors(); + assert_eq!(wlt_descriptors, descriptors); + } + let wlt_keys = wlt_wo.multisig.get_keys(); + assert_eq!(wlt_keys, multisig_wlt_keys); + let wlt_descriptors = wlt_wo.multisig.get_descriptors(); + assert_eq!(wlt_descriptors, descriptors); + + // fund wallet 1 + let sats = 30_000; + send_sats_to_address(wlt_1.get_address(), Some(sats)); + mine(false, false); + + check_hub_info(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3]); + + println!("\n=== create UTXOs discarded (wlt_1) ==="); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3]); + let op_init = wlt_1.create_utxos_init(false, None, None, FEE_RATE); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1], + &mut [&mut wlt_2, &mut wlt_3], + &mut [], + false, + ); + + // check PSBT signature inspection + inspect_create_utxos( + &mut wlt_1, + &op_init.psbt, + None, + &HashMap::from_iter([(0, sats)]), + None, + None, + 0, + ); + let signed_1 = wlt_1.sign(&op_init.psbt); + inspect_create_utxos( + &mut wlt_1, + &signed_1, + None, + &HashMap::from_iter([(0, sats)]), + None, + None, + 1, + ); + let signed_2 = wlt_2.sign(&signed_1); + inspect_create_utxos( + &mut wlt_1, + &signed_2, + None, + &HashMap::from_iter([(0, sats)]), + None, + None, + 2, + ); + let signed_3 = wlt_3.sign(&signed_2); + inspect_create_utxos( + &mut wlt_1, + &signed_3, + None, + &HashMap::from_iter([(0, sats)]), + None, + None, + 3, + ); + let signed_4 = wlt_4.sign(&signed_3); + inspect_create_utxos( + &mut wlt_1, + &signed_4, + None, + &HashMap::from_iter([(0, sats)]), + None, + None, + 4, + ); + + println!("\n=== create UTXOs (wlt_1) ==="); + sync_wallets_full(&mut [&mut wlt_4]); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3, &mut wlt_4]); + let utxo_num = 20; + let utxo_size = 1000; + let op_init = wlt_1.create_utxos_init(false, Some(utxo_num), Some(utxo_size), FEE_RATE); + inspect_create_utxos( + &mut wlt_1, + &op_init.psbt, + None, + &HashMap::from_iter([(0, sats)]), + Some(utxo_num), + Some(utxo_size), + 0, + ); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [&mut wlt_4], + &mut [], + true, + ); + check_last_transaction( + &mut [ + wlt_1.multisig_mut(), + wlt_2.multisig_mut(), + wlt_3.multisig_mut(), + ], + &op_init.psbt, + &TransactionType::CreateUtxos, + ); + + println!("\n=== issue CFA ==="); + let IssuedAsset::Cfa(cfa_asset) = issue_asset( + &mut wlt_2, + &mut [&mut wlt_1, &mut wlt_3], + AssetSchema::Cfa, + Some(&[200, AMOUNT_SMALL]), + None, + ) else { + unreachable!() + }; + + println!("\n=== send BTC discarded (wlt_3 → wlt_1) ==="); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3]); + let addr = wlt_1.get_address(); + let op_init = wlt_3.send_btc_init(&addr, 999); + operation_complete::( + op_init.operation_idx, + &mut [], + &mut [&mut wlt_3, &mut wlt_2], + &mut [&mut wlt_1], + false, + ); + + println!("\n=== issue NIA ==="); + let IssuedAsset::Nia(nia_asset_1) = issue_asset( + &mut wlt_2, + &mut [&mut wlt_1, &mut wlt_3], + AssetSchema::Nia, + Some(&[50, 70, 30]), + None, + ) else { + unreachable!() + }; + + println!("\n=== issue UDA ==="); + let IssuedAsset::Uda(uda_asset) = issue_asset( + &mut wlt_3, + &mut [&mut wlt_1, &mut wlt_2], + AssetSchema::Uda, + None, + None, + ) else { + unreachable!() + }; + + let (mut singlesig_wlt, singlesig_wlt_online) = get_funded_wallet!(); + let mut singlesig_wlt = party!(&mut singlesig_wlt, singlesig_wlt_online); + + println!("\n=== send UDA (wlt_1 → singlesig) ==="); + sync_wallets_full(&mut [&mut wlt_4]); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3, &mut wlt_4]); + let rcv_data = test_blind_receive(singlesig_wlt.wallet); + let recipient_map = HashMap::from([( + uda_asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::NonFungible, + recipient_id: rcv_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let op_init = wlt_1.send_init(recipient_map); + let bt_before = wlt_2.bak_ts(); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [], + &mut [&mut wlt_4], + true, + ); + assert!(wlt_2.bak_ts() > bt_before); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3]); + settle_transfer( + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [&mut singlesig_wlt], + Some(&uda_asset.asset_id), + None, + Some(&op_init.psbt), + true, + ); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + &singlesig_wlt as &dyn SigParty, + ], + &[Some(&uda_asset.asset_id)], + None, + TransferStatus::Settled, + ); + check_asset_balance(&[&singlesig_wlt], &uda_asset.asset_id, (1, 1, 1)); + check_asset_balance(&[&wlt_1, &wlt_2, &wlt_3], &uda_asset.asset_id, (0, 0, 0)); + + let last_wlt_4_op = op_init.operation_idx; + + println!("\n=== witness receive UDA (singlesig → wlt_3) ==="); + let receive_data = wlt_3.witness_receive(); + sync_wallets_full(&mut [&mut wlt_1, &mut wlt_2]); + let recipient_map = HashMap::from([( + uda_asset.asset_id.clone(), + vec![Recipient { + assignment: Assignment::NonFungible, + recipient_id: receive_data.recipient_id.clone(), + witness_data: Some(WitnessData { + amount_sat: 1000, + blinding: None, + }), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(singlesig_wlt.wallet, singlesig_wlt.online, &recipient_map); + settle_transfer( + &mut [&mut singlesig_wlt], + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + Some(&uda_asset.asset_id), + Some(&txid), + None, + true, + ); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + &singlesig_wlt as &dyn SigParty, + ], + &[Some(&uda_asset.asset_id)], + None, + TransferStatus::Settled, + ); + check_asset_balance(&[&wlt_1, &wlt_2, &wlt_3], &uda_asset.asset_id, (1, 1, 1)); + check_asset_balance(&[&singlesig_wlt], &uda_asset.asset_id, (0, 0, 0)); + + println!("\n=== send RGB discarded (wlt_1 → singlesig) ==="); + let rcv_data_1 = test_witness_receive(singlesig_wlt.wallet); + let rcv_data_2 = test_blind_receive(singlesig_wlt.wallet); + let rcv_data_3 = test_blind_receive(singlesig_wlt.wallet); + let cfa_amount_witness = AMOUNT_SMALL; + let cfa_amount_blind = 20; + let recipient_map = HashMap::from([ + ( + cfa_asset.asset_id.clone(), + vec![ + Recipient { + assignment: Assignment::Fungible(cfa_amount_witness), + recipient_id: rcv_data_1.recipient_id.clone(), + witness_data: Some(WitnessData { + amount_sat: 1000, + blinding: None, + }), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }, + Recipient { + assignment: Assignment::Fungible(cfa_amount_blind), + recipient_id: rcv_data_3.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }, + ], + ), + ( + nia_asset_1.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(AMOUNT_SMALL), + recipient_id: rcv_data_2.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + ), + ]); + let op_init = wlt_1.send_init(recipient_map); + operation_complete::( + op_init.operation_idx, + &mut [], + &mut [&mut wlt_1, &mut wlt_2], + &mut [&mut wlt_3], + false, + ); + + println!("\n=== blind receive new asset (singlesig → wlt_1) ==="); + let nia_asset_2 = test_issue_asset_nia(singlesig_wlt.wallet, singlesig_wlt.online, None); + let receive_data = wlt_1.blind_receive(); + sync_wallets_full(&mut [&mut wlt_2, &mut wlt_3]); + let recipient_map = HashMap::from([( + nia_asset_2.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(AMOUNT_SMALL), + recipient_id: receive_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let txid = test_send(singlesig_wlt.wallet, singlesig_wlt.online, &recipient_map); + settle_transfer( + &mut [&mut singlesig_wlt], + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + Some(&nia_asset_2.asset_id), + Some(&txid), + None, + true, + ); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + &singlesig_wlt as &dyn SigParty, + ], + &[Some(&uda_asset.asset_id)], + None, + TransferStatus::Settled, + ); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &nia_asset_2.asset_id, + (AMOUNT_SMALL, AMOUNT_SMALL, AMOUNT_SMALL), + ); + let change = AMOUNT - AMOUNT_SMALL; + check_asset_balance( + &[&singlesig_wlt], + &nia_asset_2.asset_id, + (change, change, change), + ); + + println!("\n=== send RGB (wlt_1 → singlesig) ==="); + let rcv_data_1 = test_witness_receive(singlesig_wlt.wallet); + let rcv_data_2 = test_blind_receive(singlesig_wlt.wallet); + let rcv_data_3 = test_blind_receive(singlesig_wlt.wallet); + let rcv_data_4 = test_blind_receive(singlesig_wlt.wallet); + let cfa_amount_witness = AMOUNT_SMALL; + let cfa_amount_blind = 20; + let nia_2_amount = 30; + let recipient_map = HashMap::from([ + ( + cfa_asset.asset_id.clone(), + vec![ + Recipient { + assignment: Assignment::Fungible(cfa_amount_witness), + recipient_id: rcv_data_1.recipient_id.clone(), + witness_data: Some(WitnessData { + amount_sat: 1000, + blinding: None, + }), + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }, + Recipient { + assignment: Assignment::Fungible(cfa_amount_blind), + recipient_id: rcv_data_3.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }, + ], + ), + ( + nia_asset_1.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(AMOUNT_SMALL), + recipient_id: rcv_data_2.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + ), + ( + nia_asset_2.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(nia_2_amount), + recipient_id: rcv_data_4.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + ), + ]); + let op_init = wlt_1.send_init(recipient_map); + inspect_send( + &wlt_2, + &op_init, + &cfa_asset, + &nia_asset_1, + &nia_asset_2, + cfa_amount_blind, + cfa_amount_witness, + nia_2_amount, + ); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [], + &mut [], + true, + ); + settle_transfer( + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [&mut singlesig_wlt], + None, + None, + Some(&op_init.psbt), + true, + ); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + &singlesig_wlt as &dyn SigParty, + ], + &[ + Some(&cfa_asset.asset_id), + Some(&nia_asset_1.asset_id), + Some(&nia_asset_2.asset_id), + ], + None, + TransferStatus::Settled, + ); + check_asset_balance(&[&singlesig_wlt], &cfa_asset.asset_id, (86, 86, 66)); // pending receive (20) + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &cfa_asset.asset_id, + (180, 180, 180), + ); + check_asset_balance(&[&singlesig_wlt], &nia_asset_1.asset_id, (66, 66, 66)); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &nia_asset_1.asset_id, + (84, 84, 84), + ); + check_asset_balance(&[&singlesig_wlt], &nia_asset_2.asset_id, (630, 630, 600)); // pending receive (30) + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &nia_asset_2.asset_id, + (36, 36, 36), + ); + + println!("\n=== backup (wlt_1) ==="); + // pre-backup state expectations + check + let op_init_last_before_backup = op_init; + let btc_pre_backup_vanilla = (7904, 7904, 7904); + let btc_pre_backup_colored = (15538, 17914, 17914); + let tx_type_pre_backup = TransactionType::RgbSend; + #[rustfmt::skip] + let assets_pre_backup = HashMap::from([ + (cfa_asset.asset_id.as_str(), (180, 180, 180, 3, TransferStatus::Settled)), + (nia_asset_1.asset_id.as_str(), (84, 84, 84, 2, TransferStatus::Settled)), + (nia_asset_2.asset_id.as_str(), (36, 36, 36, 2, TransferStatus::Settled)), + (uda_asset.asset_id.as_str(), (1, 1, 1, 3, TransferStatus::Settled)), + ]); + check_wallet_state( + wlt_1.multisig_mut(), + &op_init_last_before_backup, + &op_init_last_before_backup, + btc_pre_backup_vanilla, + btc_pre_backup_colored, + &tx_type_pre_backup, + &assets_pre_backup, + ); + // actual backup + let backup_file = backup(&wlt_1, &format!("{random_str}_1")); + + println!("\n=== send with extra (wlt_1 → singlesig) ==="); + // make sure there are allocations for other assets on the same UTXO that will be spent + let unspents = wlt_1.list_unspents(false); + let mut unspents_nia_asset_2 = unspents.iter().filter(|u| { + u.rgb_allocations + .iter() + .any(|a| a.asset_id == Some(nia_asset_2.asset_id.clone())) + }); + assert!( + unspents_nia_asset_2 + .next() + .unwrap() + .rgb_allocations + .iter() + .any(|a| a.asset_id != Some(nia_asset_2.asset_id.clone())) + ); + assert!(unspents_nia_asset_2.next().is_none()); + // send the assets + let rcv_data = test_blind_receive(singlesig_wlt.wallet); + let recipient_map = HashMap::from([( + nia_asset_2.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(10), + recipient_id: rcv_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let op_init = wlt_1.send_init(recipient_map); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [], + &mut [], + true, + ); + settle_transfer( + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [&mut singlesig_wlt], + Some(&nia_asset_2.asset_id), + None, + Some(&op_init.psbt), + true, + ); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + &singlesig_wlt as &dyn SigParty, + ], + &[Some(&nia_asset_2.asset_id)], + None, + TransferStatus::Settled, + ); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &cfa_asset.asset_id, + (180, 180, 180), + ); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &nia_asset_1.asset_id, + (84, 84, 84), + ); + check_asset_balance(&[&singlesig_wlt], &nia_asset_2.asset_id, (640, 640, 610)); // pending receive (30) + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &nia_asset_2.asset_id, + (26, 26, 26), + ); + + let ifa_amounts = vec![100, 50]; + let IssuedAsset::Ifa(ifa_asset) = issue_asset( + &mut wlt_1, + &mut [&mut wlt_2, &mut wlt_3], + AssetSchema::Ifa, + Some(&ifa_amounts), + Some(&[AMOUNT_INFLATION]), + ) else { + unreachable!() + }; + let initial_supply = ifa_amounts.iter().sum::(); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &ifa_asset.asset_id, + (initial_supply, initial_supply, initial_supply), + ); + + println!("\n=== inflate (wlt_2) ==="); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3]); + let inflation_amounts = [25, 26]; + let op_init = wlt_2.inflate_init(&ifa_asset.asset_id, &inflation_amounts); + inspect_inflate(&wlt_3, &op_init, &ifa_asset, &inflation_amounts); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [], + &mut [], + true, + ); + settle_transfer( + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [] as &mut [&mut MultisigParty], + Some(&ifa_asset.asset_id), + None, + Some(&op_init.psbt), + false, + ); + let new_supply = initial_supply + inflation_amounts.iter().sum::(); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &ifa_asset.asset_id, + (new_supply, new_supply, new_supply), + ); + + println!("\n=== inflate discarded (wlt_3) ==="); + let op_init = wlt_3.inflate_init(&ifa_asset.asset_id, &[1]); + operation_complete::( + op_init.operation_idx, + &mut [], + &mut [&mut wlt_1, &mut wlt_2], + &mut [&mut wlt_3], + false, + ); + + println!("\n=== send BTC (wlt_1 → singlesig) ==="); + check_wallets_up_to_date(&mut [&mut wlt_1, &mut wlt_2, &mut wlt_3]); + let amount = 1000; + let addr = test_get_address(singlesig_wlt.wallet); + let op_init = wlt_1.send_btc_init(&addr, amount); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [], + &mut [], + true, + ); + check_last_transaction( + &mut [ + wlt_1.multisig_mut(), + wlt_2.multisig_mut(), + wlt_3.multisig_mut(), + ], + &op_init.psbt, + &TransactionType::User, + ); + check_btc_balance( + &mut [ + wlt_1.multisig_mut(), + wlt_2.multisig_mut(), + wlt_3.multisig_mut(), + ], + (0, 6442, 6442), + (16452, 16452, 16452), + ); + let op_init_last_successful = op_init; + + println!("\n=== receive failed (wlt_1) ==="); + let receive_data = wlt_1.blind_receive(); + wlt_1 + .multisig + .fail_transfers( + wlt_1.online(), + Some(receive_data.batch_transfer_idx), + false, + false, + ) + .unwrap(); + sync_wallets_full(&mut [&mut wlt_2, &mut wlt_3]); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + ], + &[None], + Some(receive_data.batch_transfer_idx), + TransferStatus::Failed, + ); + + println!("\n=== send failed (wlt_1) ==="); + let receive_data = test_blind_receive(singlesig_wlt.wallet); + let recipient_map = HashMap::from([( + nia_asset_2.asset_id.clone(), + vec![Recipient { + assignment: Assignment::Fungible(10), + recipient_id: receive_data.recipient_id.clone(), + witness_data: None, + transport_endpoints: TRANSPORT_ENDPOINTS.clone(), + }], + )]); + let op_init = wlt_1.send_init(recipient_map); + operation_complete::( + op_init.operation_idx, + &mut [&mut wlt_1, &mut wlt_2, &mut wlt_3], + &mut [], + &mut [], + true, + ); + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + ], + &[Some(&nia_asset_2.asset_id)], + None, + TransferStatus::WaitingCounterparty, + ); + let transfers = wlt_1.list_transfers(Some(&nia_asset_2.asset_id)); + let transfer = transfers.last().unwrap(); + wlt_1 + .multisig + .fail_transfers( + wlt_1.online(), + Some(transfer.batch_transfer_idx), + false, + false, + ) + .unwrap(); + for wallet in [&mut wlt_2, &mut wlt_3] { + wallet.refresh(Some(&nia_asset_2.asset_id)); + } + check_transfer_status( + &[ + &wlt_1 as &dyn SigParty, + &wlt_2 as &dyn SigParty, + &wlt_3 as &dyn SigParty, + ], + &[Some(&nia_asset_2.asset_id)], + None, + TransferStatus::Failed, + ); + check_asset_balance( + &[&wlt_1, &wlt_2, &wlt_3], + &nia_asset_2.asset_id, + (26, 26, 26), + ); + + // final state expectations + let btc_final_vanilla = (0, 6442, 6442); + let btc_final_colored = (16452, 16452, 16452); + let tx_type_final = TransactionType::User; + #[rustfmt::skip] + let assets_final = HashMap::from([ + (cfa_asset.asset_id.as_str(), (180, 180, 180, 3, TransferStatus::Settled)), + (ifa_asset.asset_id.as_str(), (201, 201, 201, 3, TransferStatus::Settled)), + (nia_asset_1.asset_id.as_str(), (84, 84, 84, 2, TransferStatus::Settled)), + (nia_asset_2.asset_id.as_str(), (26, 26, 26, 4, TransferStatus::Failed)), + (uda_asset.asset_id.as_str(), (1, 1, 1, 3, TransferStatus::Settled)), + ]); + + println!("\n=== sync wallet 4 (from scratch) ==="); + let last_processed_op = wlt_4 + .multisig + .get_local_last_processed_operation_idx() + .unwrap(); + assert_eq!(last_processed_op, last_wlt_4_op); + sync_wallets_full(&mut [&mut wlt_4]); + check_wallets_up_to_date(&mut [&mut wlt_4]); + check_wallet_state( + wlt_4.multisig_mut(), + &op_init_last_successful, + &op_init, + btc_final_vanilla, + btc_final_colored, + &tx_type_final, + &assets_final, + ); + check_change_consistency(&mut wlt_1, &mut wlt_4); + + println!("\n=== watch-only sync ==="); + watch_only_wallet_sync(&mut wlt_wo); + check_wallet_state( + wlt_wo.multisig_mut(), + &op_init_last_successful, + &op_init, + btc_final_vanilla, + btc_final_colored, + &tx_type_final, + &assets_final, + ); + + println!("\n=== restore backup ==="); + let mut wlt_restored_multisig = backup_restore(&backup_file, &random_str, multisig_wlt_keys); + let wlt_restored_multisig_online = + ms_go_online(&mut wlt_restored_multisig, &cosigner_tokens[0]); + let mut wlt_restored = ms_party!( + &wlt_1_singlesig, + &mut wlt_restored_multisig, + wlt_restored_multisig_online, + &cosigner_xpubs[0] + ); + // post-restore checks + check_wallet_state( + wlt_restored.multisig_mut(), + &op_init_last_before_backup, + &op_init_last_before_backup, + btc_pre_backup_vanilla, + btc_pre_backup_colored, + &tx_type_pre_backup, + &assets_pre_backup, + ); + // sync and check it aligns with other cosigners + wlt_restored.sync_to_head(); + check_wallet_state( + wlt_restored.multisig_mut(), + &op_init_last_successful, + &op_init, + btc_final_vanilla, + btc_final_colored, + &tx_type_final, + &assets_final, + ); +} + +#[cfg(feature = "electrum")] +#[test] +#[serial] +fn fail() { + initialize(); + op_counter_reset(); + + let bitcoin_network = BitcoinNetwork::Regtest; + let threshold_colored = 2; + let threshold_vanilla = 2; + let random_str: String = rand::rng() + .sample_iter(&Alphanumeric) + .take(6) + .map(char::from) + .collect(); + let data_dir = get_test_data_dir_path() + .join(format!("{random_str}_1")) + .to_string_lossy() + .to_string(); + let _ = fs::create_dir_all(&data_dir); + + // multisig wallet keys + let wlt_1_keys = generate_keys(bitcoin_network); + let wlt_2_keys = generate_keys(bitcoin_network); + let wlt_3_keys = generate_keys(bitcoin_network); + + // cosigners + let cosigners = vec![ + Cosigner::from_keys(&wlt_1_keys, None), + Cosigner::from_keys(&wlt_2_keys, None), + Cosigner::from_keys(&wlt_3_keys, None), + ]; + let num_cosigners = cosigners.len() as u8; + let cosigner_xpubs: Vec = cosigners + .iter() + .map(|c| c.account_xpub_colored.clone()) + .collect(); + + // biscuit token setup + // - roots + let root_keypair = KeyPair::new(); + let root_public_key = root_keypair.public(); + // - cosigners + let mut cosigner_tokens = vec![]; + for cosigner_xpub in &cosigner_xpubs { + cosigner_tokens.push(create_token( + &root_keypair, + Role::Cosigner(cosigner_xpub.clone()), + None, + )); + } + // - watch-only + let wo_token = create_token(&root_keypair, Role::WatchOnly, None); + + // hub setup + write_hub_config( + &cosigner_xpubs, + threshold_colored, + threshold_vanilla, + root_public_key.to_bytes_hex(), + None, + ); + restart_multisig_hub(); + + // multisig wallets + let multisig_wlt_keys = + MultisigKeys::new(cosigners.clone(), threshold_colored, threshold_vanilla); + let mut wlt_1_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_1")); + let wlt_1_multisig_online = ms_go_online(&mut wlt_1_multisig, &cosigner_tokens[0]); + let mut wlt_2_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_2")); + let wlt_2_multisig_online = ms_go_online(&mut wlt_2_multisig, &cosigner_tokens[1]); + let mut wlt_3_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_3")); + let wlt_3_multisig_online = ms_go_online(&mut wlt_3_multisig, &cosigner_tokens[2]); + + // singlesig wallets (for signing) + let wlt_1_singlesig = get_test_wallet_with_keys(&wlt_1_keys); + let wlt_2_singlesig = get_test_wallet_with_keys(&wlt_2_keys); + let wlt_3_singlesig = get_test_wallet_with_keys(&wlt_3_keys); + + // watch-only wallet + let mut wlt_wo_multisig = get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_wo")); + let wlt_wo_multisig_online = ms_go_online(&mut wlt_wo_multisig, &wo_token); + + // multisig parties + let mut wlt_1 = ms_party!( + &wlt_1_singlesig, + &mut wlt_1_multisig, + wlt_1_multisig_online, + &cosigner_xpubs[0] + ); + let mut wlt_2 = ms_party!( + &wlt_2_singlesig, + &mut wlt_2_multisig, + wlt_2_multisig_online, + &cosigner_xpubs[1] + ); + let mut wlt_3 = ms_party!( + &wlt_3_singlesig, + &mut wlt_3_multisig, + wlt_3_multisig_online, + &cosigner_xpubs[2] + ); + + // watch-only party + let mut wlt_wo = ms_party!(&mut wlt_wo_multisig, wlt_wo_multisig_online); + + // no cosigners supplied + let invalid_multisig_wlt_keys = MultisigKeys::new(vec![], threshold_colored, threshold_vanilla); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_eq!(res.err().unwrap(), Error::NoCosignersSupplied); + + // invalid thresholds: higher than total cosigners + let invalid_threshold = num_cosigners + 1; + // - colored threshold + let invalid_multisig_wlt_keys = + MultisigKeys::new(cosigners.clone(), invalid_threshold, threshold_vanilla); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidMultisigThreshold { required, total } if *required == invalid_threshold && *total == num_cosigners); + // - vanilla threshold + let invalid_multisig_wlt_keys = + MultisigKeys::new(cosigners.clone(), threshold_colored, invalid_threshold); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidMultisigThreshold { required, total } if *required == invalid_threshold && *total == num_cosigners); + + // invalid thresholds: k=0 + let invalid_threshold = 0; + // - colored threshold + let invalid_multisig_wlt_keys = + MultisigKeys::new(cosigners.clone(), invalid_threshold, threshold_vanilla); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidMultisigThreshold { required, total } if *required == invalid_threshold && *total == num_cosigners); + // - vanilla threshold + let invalid_multisig_wlt_keys = + MultisigKeys::new(cosigners.clone(), threshold_colored, invalid_threshold); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidMultisigThreshold { required, total } if *required == invalid_threshold && *total == num_cosigners); + + // invalid fingerprint + let mut invalid_cosigners = cosigners.clone(); + let invalid_fingerprint = s!("invalid"); + invalid_cosigners[1].master_fingerprint = invalid_fingerprint.clone(); + let invalid_multisig_wlt_keys = MultisigKeys::new( + invalid_cosigners.clone(), + threshold_colored, + threshold_vanilla, + ); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidCosigner { details: d } if *d == format!("invalid master_fingerprint '{invalid_fingerprint}'")); + + // invalid xpub content + let invalid_xpub = s!("invalid"); + // - colored xpub + let mut invalid_cosigners = cosigners.clone(); + invalid_cosigners[1].account_xpub_colored = invalid_xpub.clone(); + let invalid_multisig_wlt_keys = MultisigKeys::new( + invalid_cosigners.clone(), + threshold_colored, + threshold_vanilla, + ); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidCosigner { details: d } if *d == format!("invalid colored xpub '{invalid_xpub}'")); + // - vanilla xpub + let mut invalid_cosigners = cosigners.clone(); + invalid_cosigners[1].account_xpub_vanilla = invalid_xpub.clone(); + let invalid_multisig_wlt_keys = MultisigKeys::new( + invalid_cosigners.clone(), + threshold_colored, + threshold_vanilla, + ); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidCosigner { details: d } if *d == format!("invalid vanilla xpub '{invalid_xpub}'")); + + // invalid xpub network + let invalid_keys = generate_keys(BitcoinNetwork::Mainnet); + let invalid_cosigner = Cosigner::from_keys(&invalid_keys, None); + // - colored xpub + let mut invalid_cosigners = cosigners.clone(); + invalid_cosigners[1].account_xpub_colored = invalid_cosigner.account_xpub_colored.clone(); + let invalid_multisig_wlt_keys = MultisigKeys::new( + invalid_cosigners.clone(), + threshold_colored, + threshold_vanilla, + ); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidCosigner { details: d } if *d == format!("colored xpub '{}' is for the wrong network", invalid_cosigner.account_xpub_colored)); + // - vanilla xpub + let mut invalid_cosigners = cosigners.clone(); + invalid_cosigners[1].account_xpub_vanilla = invalid_cosigner.account_xpub_vanilla.clone(); + let invalid_multisig_wlt_keys = MultisigKeys::new( + invalid_cosigners.clone(), + threshold_colored, + threshold_vanilla, + ); + let res = MultisigWallet::new(get_test_wallet_data(&data_dir), invalid_multisig_wlt_keys); + assert_matches!(res.as_ref().err().unwrap(), Error::InvalidCosigner { details: d } if *d == format!("vanilla xpub '{}' is for the wrong network", invalid_cosigner.account_xpub_vanilla)); + + // invalid rgb-lib version + println!("setting MOCK_LOCAL_VERSION"); + MOCK_LOCAL_VERSION.replace(Some(s!("0.2"))); + let mut wlt_badversion_multisig = + get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_1")); + let err = ms_go_online_res(&mut wlt_badversion_multisig, &cosigner_tokens[0]).unwrap_err(); + assert_matches!(err, Error::MultisigHubService { details: d } if d == "rgb-lib version mismatch: local version is 0.2 but hub requires 0.3"); + + // expired token + let mut wlt_badtoken_multisig = + get_test_ms_wallet(&multisig_wlt_keys, format!("{random_str}_3")); + let expired_token = create_token( + &root_keypair, + Role::Cosigner(cosigner_xpubs[0].clone()), + Some(Utc::now() - Duration::from_secs(1)), + ); + let err = ms_go_online_res(&mut wlt_badtoken_multisig, &expired_token).unwrap_err(); + assert_matches!(err, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); + + // invalid token + let invalid_token = s!("invalid"); + let err = ms_go_online_res(&mut wlt_badtoken_multisig, &invalid_token).unwrap_err(); + assert_matches!(err, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); + + // token for cosigner not in hub config + let wlt_badtoken_keys = generate_keys(bitcoin_network); + let invalid_cosigner_token = create_token( + &root_keypair, + Role::Cosigner(wlt_badtoken_keys.account_xpub_colored), + None, + ); + let err = ms_go_online_res(&mut wlt_badtoken_multisig, &invalid_cosigner_token).unwrap_err(); + assert_matches!(err, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); + + // token with no xpub nor role + let invalid_token = biscuit!("") + .build(&root_keypair) + .unwrap() + .to_base64() + .unwrap(); + let err = ms_go_online_res(&mut wlt_badtoken_multisig, &invalid_token).unwrap_err(); + assert_matches!(err, Error::MultisigHubService { details: d } if d == "Missing or invalid credentials"); + + // invalid hub URL + let err = wlt_badtoken_multisig + .go_online( + false, + ELECTRUM_URL.to_string(), + s!("invalid"), + cosigner_tokens[0].to_string(), + ) + .unwrap_err(); + assert_matches!(err, Error::MultisigHubService { details: d } if d == "URL must be valid and start with http:// or https://"); + + // respond with PSBT that has no signatures + send_sats_to_address(wlt_1.get_address(), Some(10_000)); + mine(false, false); + let op_init = wlt_1.create_utxos_init(false, None, None, FEE_RATE); + let op_idx_1 = op_init.operation_idx; + let unsigned_psbt = op_init.psbt.clone(); + let err = wlt_1 + .respond_to_operation_res(op_idx_1, RespondToOperation::Ack(unsigned_psbt.clone())) + .unwrap_err(); + assert_matches!( + err, + Error::InvalidPsbt { details: d } if d == "PSBT has no signatures" + ); + + // cannot initiate a new operation if another is pending + let err = wlt_1 + .create_utxos_init_res(false, None, None, FEE_RATE) + .unwrap_err(); + assert_matches!(err, Error::MultisigOperationInProgress); + + // respond to a non-pending operation + let signed_psbt = wlt_1_singlesig + .sign_psbt(unsigned_psbt.clone(), None) + .unwrap(); + wlt_1.respond_to_operation(op_idx_1, RespondToOperation::Ack(signed_psbt)); + let signed_psbt = wlt_2_singlesig + .sign_psbt(unsigned_psbt.clone(), None) + .unwrap(); + wlt_2.multisig_mut().sync_db_txos(false, false).unwrap(); + wlt_2.respond_to_operation(op_idx_1, RespondToOperation::Ack(signed_psbt.clone())); + let err = wlt_3 + .respond_to_operation_res(op_idx_1, RespondToOperation::Nack) + .unwrap_err(); + assert_matches!( + err, + Error::MultisigCannotRespondToOperation { details: d } if d == "not pending" + ); + + // respond with PSBT for the wrong operation (wrong TXID) + wlt_1.sync(); + wlt_2.assert_up_to_date(); + let op_init = wlt_1.create_utxos_init(false, Some(5), None, FEE_RATE); + let op_idx_2 = op_init.operation_idx; + let err = wlt_1 + .respond_to_operation_res(op_idx_2, RespondToOperation::Ack(signed_psbt.to_string())) + .unwrap_err(); + assert_matches!( + err, + Error::InvalidPsbt { details: d } if d == "PSBT unrelated to operation" + ); + + // respond to already responded + wlt_1.respond_to_operation(op_idx_2, RespondToOperation::Nack); + let err = wlt_1 + .respond_to_operation_res(op_idx_2, RespondToOperation::Nack) + .unwrap_err(); + assert_matches!( + err, + Error::MultisigCannotRespondToOperation { details: d } if d == "already responded" + ); + + // respond to an operation that's not the next one + wlt_2.respond_to_operation(op_idx_2, RespondToOperation::Nack); + wlt_1.sync(); + wlt_2.assert_up_to_date(); + let op_init = wlt_1.create_utxos_init(false, Some(3), None, FEE_RATE); + let op_idx_3 = op_init.operation_idx; + let err = wlt_3 + .respond_to_operation_res(op_idx_3, RespondToOperation::Nack) + .unwrap_err(); + assert_matches!( + err, + Error::MultisigCannotRespondToOperation { details: d } if d == "Cannot respond to operation: operation is not the next one to be processed" + ); + + // watch-only forbidden + let err = wlt_wo + .multisig_mut() + .get_address(wlt_wo_multisig_online) + .unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.issue_asset_cfa_res(None, None).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.issue_asset_nia_res(None).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.issue_asset_ifa_res(None, None, None).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.issue_asset_uda_res(None, None, vec![]).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.blind_receive_res().unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.witness_receive_res().unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.nack_res(0).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo + .create_utxos_init_res(false, None, None, FEE_RATE) + .unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.send_btc_init_res("address", AMOUNT).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.send_init_res(HashMap::new()).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); + let err = wlt_wo.inflate_init_res("asset_id", &[]).unwrap_err(); + assert_eq!(err, Error::MultisigUserNotCosigner); +} diff --git a/src/wallet/test/multisig/utils.rs b/src/wallet/test/multisig/utils.rs new file mode 100644 index 00000000..c6d20c24 --- /dev/null +++ b/src/wallet/test/multisig/utils.rs @@ -0,0 +1,1915 @@ +// Utility module for multisig tests +// +// Objects are split into sections for better readability + +use super::*; + +// ---------------------------------------- +// multisig hub +// ---------------------------------------- + +#[derive(Deserialize, Serialize)] +struct AppConfig { + cosigner_xpubs: Vec, + threshold_colored: u8, + threshold_vanilla: u8, + root_public_key: String, + rgb_lib_version: String, +} + +pub(super) enum Role { + Cosigner(String), + WatchOnly, +} + +pub(super) fn create_token( + root: &KeyPair, + role: Role, + expiration_date: Option>, +) -> String { + let mut authority = biscuit!(""); + match role { + Role::Cosigner(xpub) => { + authority = biscuit_merge!(authority, r#"role("cosigner"); xpub({xpub});"#); + } + Role::WatchOnly => { + authority = biscuit_merge!(authority, r#"role("watch-only");"#); + } + } + if let Some(expiration_date) = expiration_date { + let exp = date(&expiration_date.into()); + authority = biscuit_merge!(authority, r#"check if time($t), $t < {exp};"#); + } + authority.build(root).unwrap().to_base64().unwrap() +} + +pub(super) fn write_hub_config( + cosigner_xpubs: &[String], + threshold_colored: u8, + threshold_vanilla: u8, + root_public_key: String, + rgb_lib_version: Option, +) { + let rgb_lib_version = rgb_lib_version.unwrap_or_else(local_rgb_lib_version); + let config = AppConfig { + cosigner_xpubs: cosigner_xpubs.to_vec(), + threshold_colored, + threshold_vanilla, + root_public_key, + rgb_lib_version, + }; + let conf_path = PathBuf::from(join_with_sep(&HUB_DIR_PARTS)).join("config.toml"); + confy::store_path(conf_path, config).unwrap(); +} + +// ---------------------------------------- +// sanitization +// ---------------------------------------- +// +// this section defines and implements the Sanitizable trait, which allows to sanitize data +// structures containing variable data (file paths) to uniform them, allowing comparisons via +// PartialEq and thus assert_eq!, instead of having to check each field separately + +pub(super) trait Sanitizable { + fn sanitize(&mut self) {} +} + +// replace the variable part of file paths with a fixed string +fn sanitize_path(path: &str) -> String { + regex::Regex::new(r"tmp/[^/]*") + .unwrap() + .replace(path, "tmp/variable") + .to_string() +} + +// convenience macro to avoid duplicate implementation for Token and TokenLight +macro_rules! define_token_sanitizer { + ($token:ty) => { + impl Sanitizable for $token { + fn sanitize(&mut self) { + self.media.as_mut().map(|m| { + m.file_path = sanitize_path(&m.file_path); + }); + for att in self.attachments.values_mut() { + att.file_path = sanitize_path(&att.file_path); + } + } + } + }; +} +define_token_sanitizer!(Token); +define_token_sanitizer!(TokenLight); + +impl Sanitizable for NoDetails { + fn sanitize(&mut self) {} +} + +impl Sanitizable for InflateDetails { + fn sanitize(&mut self) { + self.fascia_path = sanitize_path(&self.fascia_path); + } +} + +impl Sanitizable for SendDetails { + fn sanitize(&mut self) { + self.fascia_path = sanitize_path(&self.fascia_path); + } +} + +impl Sanitizable for Operation { + fn sanitize(&mut self) { + match self { + Operation::InflationCompleted { + txid: _, + details, + status: _, + } => { + details.sanitize(); + } + Operation::InflationDiscarded { details, status: _ } => { + details.sanitize(); + } + Operation::InflationPending { details, status: _ } => { + details.sanitize(); + } + Operation::InflationToReview { + psbt: _, + details, + status: _, + } => { + details.sanitize(); + } + Operation::SendCompleted { + txid: _, + details, + status: _, + } => { + details.sanitize(); + } + Operation::SendDiscarded { details, status: _ } => { + details.sanitize(); + } + Operation::SendPending { details, status: _ } => { + details.sanitize(); + } + Operation::SendToReview { + psbt: _, + details, + status: _, + } => { + details.sanitize(); + } + _ => {} + } + } +} + +fn sanitize_meta(meta: &mut Metadata) { + if let Some(t) = meta.token.as_mut() { + t.sanitize() + } +} + +// ---------------------------------------- +// wallets and parties +// ---------------------------------------- + +// singlesig party (allows uniform access to some functionality via SigParty trait) +pub(super) struct SinglesigParty<'a> { + pub(super) wallet: &'a mut Wallet, + pub(super) online: Online, +} + +// multisig party to be used for cosigners +pub(super) struct MultisigParty<'a> { + pub(super) signer: &'a Wallet, + pub(super) multisig: &'a mut MultisigWallet, + pub(super) online: Online, + pub(super) xpub: &'a str, +} + +// multisig party to be used for watch-only parties +pub(super) struct WatchOnlyParty<'a> { + pub(super) multisig: &'a mut MultisigWallet, + pub(super) online: Online, +} + +// cosigner-specific functionality +impl<'a> MultisigParty<'a> { + fn ack(&mut self, psbt: &str, op_idx: i32) -> OperationInfo { + self.respond_to_operation(op_idx, RespondToOperation::Ack(psbt.to_string())) + } + + pub(super) fn sign(&mut self, psbt: &str) -> String { + self.signer.sign_psbt(psbt.to_string(), None).unwrap() + } + + fn sign_and_ack(&mut self, psbt: &str, op_idx: i32) -> OperationInfo { + println!( + "sign and ack {op_idx} {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let signed = self.sign(psbt); + self.ack(&signed, op_idx) + } +} + +// convenience macro to instantiate MultisigParty or WatchOnlyParty based on the number of params +macro_rules! ms_party { + ($signer:expr, $multisig:expr, $online:expr, $xpub:expr) => { + MultisigParty { + signer: $signer, + multisig: $multisig, + online: $online, + xpub: $xpub, + } + }; + ($multisig:expr, $online:expr) => { + WatchOnlyParty { + multisig: $multisig, + online: $online, + } + }; +} + +// convenience macro to instantiate SinglesigParty +macro_rules! party { + ($wallet:expr, $online:expr) => { + SinglesigParty { + wallet: $wallet, + online: $online, + } + }; +} + +// convenience trait to allow uniform access to common functionality from all parties +pub(super) trait SigParty { + fn get_asset_balance(&self, asset_id: &str) -> Balance; + + fn list_transfers(&self, asset_id: Option<&str>) -> Vec; + + fn refresh(&mut self, asset_id: Option<&str>); + + fn get_data_dir(&self) -> String; +} + +impl SigParty for MultisigParty<'_> { + fn get_asset_balance(&self, asset_id: &str) -> Balance { + self.multisig + .get_asset_balance(asset_id.to_string()) + .unwrap() + } + + fn list_transfers(&self, asset_id: Option<&str>) -> Vec { + test_list_transfers(self.multisig, asset_id) + } + + fn refresh(&mut self, asset_id: Option<&str>) { + wait_for_refresh(self.multisig, self.online, asset_id, None); + } + + fn get_data_dir(&self) -> String { + self.multisig.internals.wallet_data.data_dir.clone() + } +} + +impl SigParty for SinglesigParty<'_> { + fn get_asset_balance(&self, asset_id: &str) -> Balance { + self.wallet.get_asset_balance(asset_id.to_string()).unwrap() + } + + fn list_transfers(&self, asset_id: Option<&str>) -> Vec { + test_list_transfers(self.wallet, asset_id) + } + + fn refresh(&mut self, asset_id: Option<&str>) { + wait_for_refresh(self.wallet, self.online, asset_id, None); + } + + fn get_data_dir(&self) -> String { + self.wallet.internals.wallet_data.data_dir.clone() + } +} + +pub(super) fn get_test_ms_wallet(keys: &MultisigKeys, dir: String) -> MultisigWallet { + let data_dir = get_test_data_dir_path() + .join(dir) + .to_string_lossy() + .to_string(); + let _ = fs::create_dir_all(&data_dir); + let wallet = MultisigWallet::new( + WalletData { + data_dir, + bitcoin_network: BitcoinNetwork::Regtest, + database_type: DatabaseType::Sqlite, + max_allocations_per_utxo: MAX_ALLOCATIONS_PER_UTXO, + supported_schemas: AssetSchema::VALUES.to_vec(), + }, + keys.clone(), + ) + .unwrap(); + println!( + "multisig wallet directory: {:?}", + test_get_wallet_dir(&wallet) + ); + wallet +} + +pub(super) fn ms_go_online_res(wallet: &mut MultisigWallet, token: &str) -> Result { + wallet.go_online( + false, + ELECTRUM_URL.to_string(), + MULTISIG_HUB_URL.to_string(), + token.to_string(), + ) +} + +pub(super) fn ms_go_online(wallet: &mut MultisigWallet, token: &str) -> Online { + ms_go_online_res(wallet, token).unwrap() +} + +pub(super) fn watch_only_wallet_sync(wallet: &mut WatchOnlyParty) { + let last_processed_op = wallet + .multisig_ref() + .get_local_last_processed_operation_idx() + .unwrap(); + assert_eq!(last_processed_op, 0); + let hub_info = wallet.hub_info(); + assert_eq!(hub_info.user_role, UserRole::WatchOnly); + assert!(hub_info.last_operation_idx.is_some()); + wallet.sync_to_head(); + wallet.assert_up_to_date(); +} + +fn local_rgb_lib_version() -> String { + env!("CARGO_PKG_VERSION") + .split('.') + .take(2) + .collect::>() + .join(".") +} + +// ---------------------------------------- +// multisig operations +// ---------------------------------------- + +// global operation counter +// use only in serial tests and initialize to 0 at test start for a deterministic behavior +static OP_COUNTER: AtomicU64 = AtomicU64::new(0); + +// convenience trait exposing multisig functionality +pub(super) trait MultisigOps { + fn multisig_mut(&mut self) -> &mut MultisigWallet; + fn multisig_ref(&self) -> &MultisigWallet; + fn online(&self) -> Online; + + fn assert_up_to_date(&mut self) { + if self.sync_opt().is_some() { + panic!( + "wallet {} is not up to date", + self.multisig_ref().internals.wallet_data.data_dir + ); + } + let last_processed_op = self + .multisig_ref() + .get_local_last_processed_operation_idx() + .unwrap(); + let current_op = OP_COUNTER.load(Ordering::SeqCst) as i32; + assert_eq!(last_processed_op, current_op); + } + + fn bak_info_opt(&mut self) -> Option> { + self.multisig_ref().database().get_backup_info().ok() + } + + fn bak_ts(&mut self) -> String { + // using last_operation_timestamp instead of last_backup_timestamp for convenience + self.bak_info_opt() + .unwrap() + .unwrap() + .last_operation_timestamp + } + + fn hub_info(&mut self) -> HubInfo { + let online = self.online(); + self.multisig_mut().hub_info(online).unwrap() + } + + fn blind_receive(&mut self) -> ReceiveData { + println!( + "blind_receive {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.blind_receive_res().unwrap(); + assert!(self.bak_ts() > bt_before); + let op_idx = op_counter_bump(); + println!("initiated blind_receive with operation ID {op_idx}"); + res + } + + fn blind_receive_res(&mut self) -> Result { + let online = self.online(); + self.multisig_mut().blind_receive( + online, + None, + Assignment::Any, + None, + TRANSPORT_ENDPOINTS.clone(), + MIN_CONFIRMATIONS, + ) + } + + fn create_utxos_init( + &mut self, + up_to: bool, + num: Option, + size: Option, + fee_rate: u64, + ) -> InitOperationResult { + println!( + "create_utxos init {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self + .create_utxos_init_res(up_to, num, size, fee_rate) + .unwrap(); + assert!(self.bak_ts() > bt_before); + let op_idx = op_counter_bump(); + assert_eq!(res.operation_idx, op_idx); + println!( + "initiated create_utxos with operation ID {}", + res.operation_idx + ); + res + } + + fn create_utxos_init_res( + &mut self, + up_to: bool, + num: Option, + size: Option, + fee_rate: u64, + ) -> Result { + let online = self.online(); + self.multisig_mut() + .create_utxos_init(online, up_to, num, size, fee_rate, false) + } + + fn get_address(&mut self) -> String { + let online = self.online(); + self.multisig_mut().get_address(online).unwrap() + } + + fn get_op(&self, idx: i32) -> OperationResponse { + self.multisig_ref() + .hub_client() + .get_operation_by_idx(idx) + .unwrap() + .unwrap() + } + + fn get_op_and_files(&self, op_idx: i32) -> (OperationResponse, Vec) { + let op = self.get_op(op_idx); + let files = self.get_or_download_files(op.files.clone()); + (op, files) + } + + fn get_or_download_files(&self, files: Vec) -> Vec { + self.multisig_ref().get_or_download_files(files).unwrap() + } + + fn inflate_init(&mut self, asset_id: &str, inflation_amounts: &[u64]) -> InitOperationResult { + println!( + "inflate init {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.inflate_init_res(asset_id, inflation_amounts).unwrap(); + assert!(self.bak_ts() > bt_before); + op_counter_bump(); + println!("initiated inflate with operation ID {}", res.operation_idx); + res + } + + fn inflate_init_res( + &mut self, + asset_id: &str, + inflation_amounts: &[u64], + ) -> Result { + let online = self.online(); + self.multisig_mut().inflate_init( + online, + asset_id.to_string(), + inflation_amounts.to_vec(), + FEE_RATE, + 1, + ) + } + + fn issue_asset_cfa(&mut self, amounts: Option<&[u64]>, file_path: Option) -> AssetCFA { + println!( + "issue CFA asset {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.issue_asset_cfa_res(amounts, file_path).unwrap(); + assert!(self.bak_ts() > bt_before); + op_counter_bump(); + println!("issued CFA asset with ID {}", res.asset_id); + res + } + + fn issue_asset_cfa_res( + &mut self, + amounts: Option<&[u64]>, + file_path: Option, + ) -> Result { + let amounts = amounts.map_or_else(|| vec![AMOUNT], |a| a.to_vec()); + let online = self.online(); + self.multisig_mut().issue_asset_cfa( + online, + NAME.to_string(), + Some(DETAILS.to_string()), + PRECISION, + amounts, + file_path, + ) + } + + fn issue_asset_ifa( + &mut self, + amounts: Option<&[u64]>, + inflation_amounts: Option<&[u64]>, + reject_list_url: Option, + ) -> AssetIFA { + println!( + "issue IFA asset {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self + .issue_asset_ifa_res(amounts, inflation_amounts, reject_list_url) + .unwrap(); + assert!(self.bak_ts() > bt_before); + op_counter_bump(); + println!("issued IFA asset with ID {}", res.asset_id); + res + } + + fn issue_asset_ifa_res( + &mut self, + amounts: Option<&[u64]>, + inflation_amounts: Option<&[u64]>, + reject_list_url: Option, + ) -> Result { + let amounts = amounts.map_or_else(|| vec![AMOUNT], |a| a.to_vec()); + let inflation_amounts = + inflation_amounts.map_or_else(|| vec![AMOUNT_INFLATION], |a| a.to_vec()); + let online = self.online(); + self.multisig_mut().issue_asset_ifa( + online, + TICKER.to_string(), + NAME.to_string(), + PRECISION, + amounts, + inflation_amounts, + reject_list_url, + ) + } + + fn issue_asset_nia(&mut self, amounts: Option<&[u64]>) -> AssetNIA { + println!( + "issue NIA asset {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.issue_asset_nia_res(amounts).unwrap(); + assert!(self.bak_ts() > bt_before); + op_counter_bump(); + println!("issued NIA asset with ID {}", res.asset_id); + res + } + + fn issue_asset_nia_res(&mut self, amounts: Option<&[u64]>) -> Result { + let amounts = amounts.map_or_else(|| vec![AMOUNT], |a| a.to_vec()); + let online = self.online(); + self.multisig_mut().issue_asset_nia( + online, + TICKER.to_string(), + NAME.to_string(), + PRECISION, + amounts, + ) + } + + fn issue_asset_uda( + &mut self, + details: Option<&str>, + media_file_path: Option<&str>, + attachments_file_paths: Vec<&str>, + ) -> AssetUDA { + println!( + "issue UDA asset {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self + .issue_asset_uda_res(details, media_file_path, attachments_file_paths) + .unwrap(); + assert!(self.bak_ts() > bt_before); + op_counter_bump(); + println!("issued UDA asset with ID {}", res.asset_id); + res + } + + fn issue_asset_uda_res( + &mut self, + details: Option<&str>, + media_file_path: Option<&str>, + attachments_file_paths: Vec<&str>, + ) -> Result { + let online = self.online(); + self.multisig_mut().issue_asset_uda( + online, + TICKER.to_string(), + NAME.to_string(), + details.map(|d| d.to_string()), + PRECISION, + media_file_path.map(|m| m.to_string()), + attachments_file_paths + .iter() + .map(|a| a.to_string()) + .collect(), + ) + } + + fn list_unspents(&mut self, settled_only: bool) -> Vec { + let online = self.online(); + test_list_unspents(self.multisig_mut(), Some(online), settled_only) + } + + fn nack(&mut self, op_idx: i32) -> OperationInfo { + println!( + "nack {op_idx} {}", + self.multisig_mut().get_wallet_data().data_dir + ); + self.respond_to_operation(op_idx, RespondToOperation::Nack) + } + + fn nack_res(&mut self, op_idx: i32) -> Result { + self.respond_to_operation_res(op_idx, RespondToOperation::Nack) + } + + fn respond_to_operation(&mut self, op_idx: i32, response: RespondToOperation) -> OperationInfo { + self.respond_to_operation_res(op_idx, response).unwrap() + } + + fn respond_to_operation_res( + &mut self, + op_idx: i32, + response: RespondToOperation, + ) -> Result { + let online = self.online(); + self.multisig_mut() + .respond_to_operation(online, op_idx, response) + } + + fn send_btc_init(&mut self, address: &str, amount: u64) -> InitOperationResult { + println!( + "send_btc init {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.send_btc_init_res(address, amount).unwrap(); + assert!(self.bak_ts() > bt_before); + let op_idx = op_counter_bump(); + assert_eq!(res.operation_idx, op_idx); + println!("initiated send_btc with operation ID {}", res.operation_idx); + res + } + + fn send_btc_init_res( + &mut self, + address: &str, + amount: u64, + ) -> Result { + let online = self.online(); + self.multisig_mut() + .send_btc_init(online, address.to_string(), amount, FEE_RATE, false) + } + + fn send_init(&mut self, recipient_map: HashMap>) -> InitOperationResult { + println!( + "send init {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.send_init_res(recipient_map).unwrap(); + assert!(self.bak_ts() > bt_before); + let op_idx = op_counter_bump(); + assert_eq!(res.operation_idx, op_idx); + println!("initiated send with operation ID {}", res.operation_idx); + res + } + + fn send_init_res( + &mut self, + recipient_map: HashMap>, + ) -> Result { + let online = self.online(); + self.multisig_mut() + .send_init(online, recipient_map, false, FEE_RATE, 1, None) + } + + fn sync(&mut self) -> OperationInfo { + self.sync_opt().unwrap() + } + + fn sync_opt(&mut self) -> Option { + println!("sync {}", self.multisig_mut().get_wallet_data().data_dir); + let online = self.online(); + self.multisig_mut().sync_with_hub(online).unwrap() + } + + fn sync_to_head(&mut self) { + let online = self.online(); + let last_processed = self + .multisig_mut() + .get_local_last_processed_operation_idx() + .unwrap(); + let last_hub_operation = self + .multisig_mut() + .hub_info(online) + .unwrap() + .last_operation_idx + .unwrap(); + assert!(last_hub_operation > last_processed); + for i in (last_processed + 1)..=last_hub_operation { + println!("syncing operation {i}"); + let op_info = self.sync(); + assert_eq!(op_info.operation_idx, i); + } + let final_processed = self + .multisig_mut() + .get_local_last_processed_operation_idx() + .unwrap(); + assert_eq!(final_processed, last_hub_operation); + self.assert_up_to_date(); + } + + fn witness_receive(&mut self) -> ReceiveData { + println!( + "witness_receive {}", + self.multisig_mut().get_wallet_data().data_dir + ); + let bt_before = self.bak_ts(); + let res = self.witness_receive_res().unwrap(); + assert!(self.bak_ts() > bt_before); + op_counter_bump(); + res + } + + fn witness_receive_res(&mut self) -> Result { + let online = self.online(); + self.multisig_mut().witness_receive( + online, + None, + Assignment::Any, + None, + TRANSPORT_ENDPOINTS.clone(), + MIN_CONFIRMATIONS, + ) + } +} + +impl<'a> MultisigOps for MultisigParty<'a> { + fn multisig_mut(&mut self) -> &mut MultisigWallet { + self.multisig + } + + fn multisig_ref(&self) -> &MultisigWallet { + self.multisig + } + + fn online(&self) -> Online { + self.online + } +} + +impl<'a> MultisigOps for WatchOnlyParty<'a> { + fn multisig_mut(&mut self) -> &mut MultisigWallet { + self.multisig + } + + fn multisig_ref(&self) -> &MultisigWallet { + self.multisig + } + + fn online(&self) -> Online { + self.online + } +} + +// convenience enum to allow for generic asset issuance +pub(super) enum IssuedAsset { + Cfa(AssetCFA), + Nia(AssetNIA), + Uda(AssetUDA), + Ifa(AssetIFA), +} + +impl IssuedAsset { + fn asset_id(&self) -> &str { + match self { + IssuedAsset::Cfa(a) => &a.asset_id, + IssuedAsset::Nia(a) => &a.asset_id, + IssuedAsset::Uda(a) => &a.asset_id, + IssuedAsset::Ifa(a) => &a.asset_id, + } + } +} + +pub(super) fn issue_asset( + initiator: &mut MultisigParty, + others: &mut [&mut MultisigParty], + schema: AssetSchema, + amounts: Option<&[u64]>, + inflation_amounts: Option<&[u64]>, +) -> IssuedAsset { + let image_str = ["tests", "qrcode.png"].join(MAIN_SEPARATOR_STR); + + let (asset, supply, inflation_supply) = match schema { + AssetSchema::Cfa => { + let amounts = amounts.expect("amounts required for CFA"); + println!("issue CFA asset with amounts: {amounts:?}"); + let asset = initiator.issue_asset_cfa(Some(amounts), Some(FILE_STR.to_string())); + let supply = amounts.iter().sum::(); + assert_eq!(asset.balance.settled, supply); + (IssuedAsset::Cfa(asset), supply, None) + } + AssetSchema::Nia => { + let amounts = amounts.expect("amounts required for NIA"); + println!("issue NIA asset with amounts: {amounts:?}"); + let asset = initiator.issue_asset_nia(Some(amounts)); + let supply = amounts.iter().sum::(); + assert_eq!(asset.balance.settled, supply); + (IssuedAsset::Nia(asset), supply, None) + } + AssetSchema::Uda => { + println!("issue UDA asset"); + let asset = initiator.issue_asset_uda( + Some(DETAILS), + Some(FILE_STR), + vec![&image_str, FILE_STR], + ); + assert_eq!(asset.balance.settled, 1); + (IssuedAsset::Uda(asset), 1, None) + } + AssetSchema::Ifa => { + println!("issue IFA asset with amounts: {amounts:?}, inflation: {inflation_amounts:?}"); + let asset = initiator.issue_asset_ifa(amounts, inflation_amounts, None); + let supply = amounts + .expect("amounts required for IFA") + .iter() + .sum::(); + assert_eq!(asset.balance.settled, supply); + let inflation_supply = inflation_amounts + .expect("inflation amounts required for IFA") + .iter() + .sum::(); + (IssuedAsset::Ifa(asset), supply, Some(inflation_supply)) + } + }; + + check_issuance(initiator, others, asset.asset_id(), schema); + + let mut all_wallets: Vec<&mut MultisigWallet> = vec![initiator.multisig_mut()]; + all_wallets.extend(others.iter_mut().map(|w| w.multisig_mut())); + check_asset_metadata( + &all_wallets, + asset.asset_id(), + NAME, + PRECISION, + supply, + inflation_supply, + schema, + ); + + check_all_parties_up_to_date(initiator, others); + asset +} + +fn get_op_psbt(files: &[FileResponse]) -> Psbt { + let op_psbt_path = files + .iter() + .find(|f| matches!(f.r#type, FileType::OperationPsbt)) + .map(|f| &f.filepath) + .unwrap(); + wallet::multisig::MultisigWallet::read_psbt_from_file(op_psbt_path).unwrap() +} + +// ---------------------------------------- +// checks +// ---------------------------------------- + +fn check_all_parties_up_to_date(initiator: &mut MultisigParty, others: &mut [&mut MultisigParty]) { + initiator.assert_up_to_date(); + for other in others { + other.assert_up_to_date(); + } +} + +pub(super) fn check_asset_balance( + wallets: &[&impl SigParty], + asset_id: &str, + expected: (u64, u64, u64), +) { + let expected = Balance { + settled: expected.0, + future: expected.1, + spendable: expected.2, + }; + for wallet in wallets { + let balance = wallet.get_asset_balance(asset_id); + if balance != expected { + panic!( + "wallet {} balance {balance:?} is not the expected {expected:?}", + wallet.get_data_dir() + ) + } + } +} + +fn check_bak_ts_opt(wallet: &mut MultisigParty, before: Option>, same: bool) { + if let Some(Some(before)) = before { + if before.last_operation_timestamp == "0" { + eprintln!( + "wallet {} has last operation timestamp 0", + wallet.get_data_dir() + ); + } + if same { + assert_eq!(wallet.bak_ts(), before.last_operation_timestamp); + } else { + assert!(wallet.bak_ts() > before.last_operation_timestamp); + } + } +} + +fn check_asset_metadata( + wallets: &[&mut MultisigWallet], + asset_id: &str, + name: &str, + precision: u8, + supply: u64, + inflation_supply: Option, + schema: AssetSchema, +) { + let mut content_1: Vec = vec![]; + let mut media_1: Option = None; + let mut digests_1: Vec = vec![]; + let mut token_1: Option = None; + let mut token_db_1: Option = None; + + for wallet in wallets { + let meta = wallet.get_asset_metadata(asset_id.to_string()).unwrap(); + assert_eq!(meta.asset_schema, schema); + assert_eq!(meta.initial_supply, supply); + assert_eq!(meta.known_circulating_supply, supply); + assert_eq!(meta.name, name); + assert_eq!(meta.precision, precision); + assert_eq!(meta.reject_list_url, None); + match schema { + AssetSchema::Cfa => assert_eq!(meta.ticker, None), + _ => assert_eq!(meta.ticker, Some(TICKER.to_string())), + } + match schema { + AssetSchema::Ifa => { + assert_eq!( + meta.max_supply, + meta.initial_supply + + inflation_supply.expect("inflation supply required for IFA") + ); + } + _ => assert_eq!(meta.max_supply, supply), + } + + match schema { + AssetSchema::Cfa => { + let asset_db = wallet + .database() + .get_asset(asset_id.to_string()) + .unwrap() + .unwrap(); + assert!(asset_db.media_idx.is_some()); + let media = wallet + .database() + .get_media(asset_db.media_idx.unwrap()) + .unwrap() + .unwrap(); + if let Some(ref media_1) = media_1 { + assert_eq!(media_1.digest, media.digest); + } else { + media_1 = Some(media.clone()); + } + let media_file = wallet.media_dir().join(&media.digest); + assert!(media_file.exists()); + let content = std::fs::read(&media_file).unwrap(); + if !content_1.is_empty() { + assert_eq!(content_1, content); + } else { + content_1 = content; + } + } + AssetSchema::Uda => { + let meta_token = meta.token.unwrap(); + assert_eq!(meta_token.index, UDA_FIXED_INDEX); + assert_eq!(meta_token.ticker, None); + assert_eq!(meta_token.name, None); + assert_eq!(meta_token.details, None); + assert_eq!(meta_token.embedded_media, None); + assert!(meta_token.media.is_some()); + assert!(!meta_token.attachments.is_empty()); + assert_eq!(meta_token.reserves, None); + + let asset_db = wallet + .database() + .get_asset(asset_id.to_string()) + .unwrap() + .unwrap(); + let assets_uda = wallet + .list_assets(vec![AssetSchema::Uda]) + .unwrap() + .uda + .unwrap(); + let asset_uda = assets_uda.iter().find(|a| a.asset_id == asset_id).unwrap(); + let token = asset_uda.token.clone().unwrap(); + assert_eq!(token.index, UDA_FIXED_INDEX); + let tokens = wallet.database().iter_tokens().unwrap(); + let token_db = tokens.iter().find(|t| t.asset_idx == asset_db.idx).unwrap(); + if let Some(ref token_db_1) = token_db_1 { + assert_eq!(token_db, token_db_1); + } else { + token_db_1 = Some(token_db.clone()); + } + let token_medias = wallet.database().iter_token_medias().unwrap(); + let token_media_entries: Vec<_> = token_medias + .into_iter() + .filter(|tm| tm.token_idx == token_db.idx) + .collect(); + assert_eq!(token_media_entries.len(), 3); + let medias = wallet.database().iter_media().unwrap(); + let mut digests: Vec = token_media_entries + .iter() + .map(|tm| { + medias + .iter() + .find(|m| m.idx == tm.media_idx) + .unwrap() + .digest + .clone() + }) + .collect(); + digests.sort(); + if !digests_1.is_empty() { + assert_eq!(digests_1, digests); + } else { + digests_1 = digests; + } + let attachments = &token.attachments; + assert_eq!(attachments.len(), 2); + if let Some(ref token_1) = token_1 { + let mut san_token = token.clone(); + let mut san_token_1 = token_1.clone(); + san_token.sanitize(); + san_token_1.sanitize(); + assert_eq!(san_token, san_token_1); + } else { + token_1 = Some(token.clone()); + } + } + AssetSchema::Nia | AssetSchema::Ifa => { + assert_eq!(meta.token, None); + } + } + } +} + +pub(super) fn check_btc_balance( + wallets: &mut [&mut MultisigWallet], + expected_vanilla: (u64, u64, u64), + expected_colored: (u64, u64, u64), +) { + let expected = BtcBalance { + vanilla: Balance { + settled: expected_vanilla.0, + future: expected_vanilla.1, + spendable: expected_vanilla.2, + }, + colored: Balance { + settled: expected_colored.0, + future: expected_colored.1, + spendable: expected_colored.2, + }, + }; + for wallet in wallets.iter_mut() { + let balance = wallet.get_btc_balance(None, true).unwrap(); + if balance != expected { + panic!( + "wallet {} BTC balance {balance:?} is not the expected {expected:?}", + wallet.get_wallet_data().data_dir + ) + } + } +} + +pub(super) fn check_change_consistency(wlt_a: &mut MultisigParty, wlt_b: &mut MultisigParty) { + let wlt_a_txos = wlt_a.multisig.database().iter_txos().unwrap(); + let wlt_b_txos = wlt_b.multisig.database().iter_txos().unwrap(); + let wlt_a_colorings = wlt_a.multisig.database().iter_colorings().unwrap(); + let wlt_b_colorings = wlt_b.multisig.database().iter_colorings().unwrap(); + let resolve_change_outpoints = |txos: &[DbTxo], colorings: &[DbColoring]| -> Vec { + colorings + .iter() + .filter(|c| c.r#type == ColoringType::Change) + .map(|c| { + txos.iter() + .find(|t| t.idx == c.txo_idx) + .unwrap_or_else(|| panic!("coloring txo_idx {} not found in TXOs", c.txo_idx)) + .outpoint() + .to_string() + }) + .collect() + }; + let mut wlt_a_outpoints = resolve_change_outpoints(&wlt_a_txos, &wlt_a_colorings); + let mut wlt_b_outpoints = resolve_change_outpoints(&wlt_b_txos, &wlt_b_colorings); + assert_eq!(wlt_a_outpoints.len(), wlt_b_outpoints.len()); + wlt_a_outpoints.sort(); + wlt_b_outpoints.sort(); + assert_eq!(wlt_a_outpoints, wlt_b_outpoints); +} + +pub(super) fn check_hub_info<'a>(parties: &mut [&mut MultisigParty<'a>]) { + println!("\n=== Hub info ==="); + for party in parties { + let info = party.hub_info(); + assert_eq!(info.user_role, UserRole::Cosigner); + assert_eq!(info.last_operation_idx, None); + assert_eq!(info.rgb_lib_version, local_rgb_lib_version()); + } +} + +fn check_issuance( + initiator: &mut MultisigParty, + others: &mut [&mut MultisigParty], + asset_id: &str, + schema: AssetSchema, +) { + fn assert_wallet_has_asset(wallet: &MultisigWallet, schema: AssetSchema, asset_id: &str) { + let assets = wallet.list_assets(vec![schema]).unwrap(); + let found_ids: Vec<&str> = match schema { + AssetSchema::Cfa => assets + .cfa + .as_deref() + .unwrap_or_default() + .iter() + .map(|a| a.asset_id.as_str()) + .collect(), + AssetSchema::Nia => assets + .nia + .as_deref() + .unwrap_or_default() + .iter() + .map(|a| a.asset_id.as_str()) + .collect(), + AssetSchema::Ifa => assets + .ifa + .as_deref() + .unwrap_or_default() + .iter() + .map(|a| a.asset_id.as_str()) + .collect(), + AssetSchema::Uda => assets + .uda + .as_deref() + .unwrap_or_default() + .iter() + .map(|a| a.asset_id.as_str()) + .collect(), + }; + assert_eq!(found_ids, vec![asset_id]); + } + + let meta_ref = initiator + .multisig + .get_asset_metadata(asset_id.to_string()) + .unwrap(); + for wallet in others { + let bt_before = wallet.bak_ts(); + let op_info = wallet.sync(); + assert!(wallet.bak_ts() > bt_before); + assert_eq!( + op_info.operation_idx, + OP_COUNTER.load(Ordering::SeqCst) as i32 + ); + assert_eq!(op_info.initiator_xpub, initiator.xpub); + assert_matches!(op_info.operation, Operation::IssuanceCompleted { .. }); + assert_wallet_has_asset(wallet.multisig, schema, asset_id); + let meta = wallet + .multisig + .get_asset_metadata(asset_id.to_string()) + .unwrap(); + let mut san_meta = meta.clone(); + let mut san_meta_ref = meta_ref.clone(); + sanitize_meta(&mut san_meta); + sanitize_meta(&mut san_meta_ref); + assert_eq!(san_meta, san_meta_ref); + } +} + +pub(super) fn check_last_transaction( + wallets: &mut [&mut MultisigWallet], + psbt: &str, + last_tx_type: &TransactionType, +) { + let txid = Psbt::from_str(psbt).unwrap().get_txid().to_string(); + for wallet in wallets { + let transactions = test_list_transactions(*wallet, None); + let transaction = transactions.first().unwrap(); + assert_eq!(transaction.txid, txid); + assert_eq!( + transaction.transaction_type, + *last_tx_type, + "wallet {} last transaction type {:?} != expected {last_tx_type:?}", + wallet.get_wallet_data().data_dir, + transaction.transaction_type + ); + } +} + +pub(super) fn check_transfer_status( + parties: &[&dyn SigParty], + asset_ids: &[Option<&str>], + batch_transfer_idx: Option, + status: TransferStatus, +) { + assert!(!asset_ids.is_empty(), "not checking anything"); + for asset_id in asset_ids { + for party in parties { + let transfers = party.list_transfers(*asset_id); + let transfer = if let Some(idx) = batch_transfer_idx { + transfers + .iter() + .find(|t| t.batch_transfer_idx == idx) + .unwrap() + } else { + transfers.last().unwrap() + }; + eprintln!( + "checking xfer {} for asset {asset_id:?} for {}", + transfer.batch_transfer_idx, + party.get_data_dir() + ); + assert_eq!(transfer.status, status); + } + } +} + +pub(super) fn check_wallet_state( + wallet: &mut MultisigWallet, + op_last_successful: &InitOperationResult, + op_last: &InitOperationResult, + btc_vanilla: (u64, u64, u64), + btc_colored: (u64, u64, u64), + last_tx_type: &TransactionType, + asset_expectations: &HashMap<&str, (u64, u64, u64, usize, TransferStatus)>, +) { + // check BTC balance + check_btc_balance(&mut [wallet], btc_vanilla, btc_colored); + // get all assets + let mut all_assets: Vec = vec![]; + let assets = wallet.list_assets(vec![]).unwrap(); + #[rustfmt::skip] + all_assets.extend(assets.cfa.unwrap_or_default().into_iter().map(|a| a.asset_id)); + #[rustfmt::skip] + all_assets.extend(assets.nia.unwrap_or_default().into_iter().map(|a| a.asset_id)); + #[rustfmt::skip] + all_assets.extend(assets.uda.unwrap_or_default().into_iter().map(|a| a.asset_id)); + #[rustfmt::skip] + all_assets.extend(assets.ifa.unwrap_or_default().into_iter().map(|a| a.asset_id)); + // check asset state + let data_dir = wallet.get_wallet_data().data_dir; + for (asset_id, (settled, future, spendable, transfer_count, status)) in asset_expectations { + assert!( + all_assets.contains(&asset_id.to_string()), + "asset {asset_id} not found in wallet {data_dir}" + ); + // asset balance + let expected = Balance { + settled: *settled, + future: *future, + spendable: *spendable, + }; + let balance = wallet.get_asset_balance(asset_id.to_string()).unwrap(); + assert_eq!( + balance, expected, + "wallet {data_dir} asset {asset_id} balance {balance:?} != expected {expected:?}", + ); + // asset transfer number and last transfer status + let transfers = test_list_transfers(wallet, Some(asset_id)); + assert_eq!( + transfers.len(), + *transfer_count, + "wallet {data_dir} asset {asset_id} transfer count {} != expected {transfer_count}", + transfers.len() + ); + assert_eq!( + transfers.last().unwrap().status, + *status, + "wallet {data_dir} asset {asset_id} last transfer status {:?} != expected {status:?}", + transfers.last().unwrap().status + ); + } + // check last TX ID and type + check_last_transaction(&mut [wallet], &op_last_successful.psbt, last_tx_type); + // check last processed op ID + let last_processed_op = wallet.get_local_last_processed_operation_idx().unwrap(); + assert_eq!( + last_processed_op, op_last.operation_idx, + "wallet {data_dir} op idx {last_processed_op} != expected {}", + op_last.operation_idx + ); +} + +pub(super) fn check_wallets_up_to_date(wallets: &mut [&mut MultisigParty]) { + for wallet in wallets { + wallet.assert_up_to_date(); + } +} + +// ---------------------------------------- +// test +// ---------------------------------------- + +pub(super) fn backup(multisig: &MultisigParty, label: &str) -> String { + println!("backup wallet {}", multisig.get_data_dir()); + let bak_fpath = get_test_data_dir_path().join(format!("{label}_backup.rgb-lib_backup")); + let backup_file = bak_fpath.to_str().unwrap(); + let _ = std::fs::remove_file(backup_file); + multisig.multisig.backup(backup_file, PASSWORD).unwrap(); + backup_file.to_string() +} + +pub(super) fn backup_restore(backup_file: &str, path: &str, keys: MultisigKeys) -> MultisigWallet { + println!("restore wallet from backup {backup_file}"); + let target_dir_path = get_restore_dir_path(Some(format!("{path}_1"))); + let target_dir = target_dir_path.to_str().unwrap(); + restore_backup(backup_file, PASSWORD, target_dir).unwrap(); + MultisigWallet::new(get_test_wallet_data(target_dir), keys).unwrap() +} + +pub(super) fn inspect_create_utxos( + cosigner: &mut MultisigParty, + psbt: &str, + txid: Option, + input_map: &HashMap, + utxo_num: Option, + utxo_size: Option, + sig_num: u16, +) { + let psbt_info = cosigner.multisig.inspect_psbt(psbt.to_string()).unwrap(); + if txid.is_none() { + assert!(!psbt_info.txid.is_empty()); + } + let total_input_sat = input_map.values().sum::(); + assert_eq!(psbt_info.total_input_sat, total_input_sat); + assert!(psbt_info.size_vbytes > 0); + assert!(psbt_info.fee_sat > 0); + assert_eq!(psbt_info.inputs.len(), input_map.len()); + let outpoints: Vec<_> = cosigner + .list_unspents(false) + .into_iter() + .map(|u| u.utxo.outpoint) + .collect(); + for (i, inp) in psbt_info.inputs.iter().enumerate() { + assert_eq!(inp.amount_sat, *input_map.get(&(i as u32)).unwrap()); + assert!(outpoints.contains(&inp.outpoint)); + } + let utxo_num = utxo_num.unwrap_or(UTXO_NUM); + let utxo_size = utxo_size.unwrap_or(UTXO_SIZE); + assert_eq!(psbt_info.outputs.len() as u8, utxo_num + 1); + let mut change_out = false; + for out in &psbt_info.outputs { + assert!(out.address.is_some()); + assert!(out.is_mine); + assert!(!out.is_op_return); + if !change_out && out.amount_sat != utxo_size as u64 { + change_out = true + } else { + assert_eq!(out.amount_sat, utxo_size as u64); + } + } + assert_eq!( + psbt_info.total_input_sat - psbt_info.total_output_sat, + psbt_info.fee_sat + ); + assert_eq!(psbt_info.signature_count, sig_num); +} + +pub(super) fn inspect_inflate( + wallet: &MultisigParty, + op_init: &InitOperationResult, + ifa_asset: &AssetIFA, + inflation_amounts: &[u64], +) { + let psbt = &op_init.psbt; + let (_, files) = wallet.get_op_and_files(op_init.operation_idx); + let details = InflateHandler::extract_details(&files).unwrap(); + let rgb_inspection = wallet + .multisig + .inspect_rgb_transfer(psbt.clone(), details.fascia_path, details.entropy) + .unwrap(); + assert_eq!(rgb_inspection.close_method, CloseMethod::OpretFirst); + assert_eq!(rgb_inspection.operations.len(), 1); + let ifa_op = &rgb_inspection.operations[0]; + assert_eq!(ifa_op.asset_id, ifa_asset.asset_id); + let inflate_transitions: Vec<_> = ifa_op + .transitions + .iter() + .filter(|t| t.r#type == TypeOfTransition::Inflate) + .collect(); + assert_eq!(inflate_transitions.len(), 1); + let inflate_outputs: Vec<_> = inflate_transitions[0] + .outputs + .iter() + .filter(|o| matches!(o.assignment, Assignment::Fungible(_))) + .map(|o| o.assignment.main_amount()) + .collect(); + let mut sorted_inflate_outputs = inflate_outputs.clone(); + sorted_inflate_outputs.sort(); + let mut sorted_expected = inflation_amounts.to_vec(); + sorted_expected.sort(); + assert_eq!(sorted_inflate_outputs, sorted_expected); +} + +pub(super) fn inspect_send( + wallet: &MultisigParty, + op_init: &InitOperationResult, + cfa: &AssetCFA, + nia_1: &AssetNIA, + nia_2: &AssetNIA, + cfa_amount_blind: u64, + cfa_amount_witness: u64, + nia_2_amount: u64, +) { + // PSBT inspection + let psbt = &op_init.psbt; + let psbt_info = wallet.multisig.inspect_psbt(psbt.to_string()).unwrap(); + assert!(psbt_info.inputs.len() >= 2); + assert!(psbt_info.outputs.len() >= 2); + let op_return_out = &psbt_info.outputs[0]; + assert!(op_return_out.is_op_return); + for out in &psbt_info.outputs[1..] { + assert!(out.address.is_some()); + } + + // RGB inspection + let (_, files) = wallet.get_op_and_files(op_init.operation_idx); + let details = SendRgbHandler::extract_details(&files).unwrap(); + let rgb_inspection = wallet + .multisig + .inspect_rgb_transfer(psbt.to_string(), details.fascia_path, details.entropy) + .unwrap(); + assert_eq!(rgb_inspection.close_method, CloseMethod::OpretFirst); + assert_eq!(rgb_inspection.commitment_hex.len(), 64); + assert_eq!(rgb_inspection.operations.len(), 3); + let cfa_transfer = rgb_inspection + .operations + .iter() + .find(|op| op.asset_id == cfa.asset_id) + .unwrap(); + let nia_transfer = rgb_inspection + .operations + .iter() + .find(|op| op.asset_id == nia_1.asset_id) + .unwrap(); + let cfa_inputs: Vec<_> = cfa_transfer + .transitions + .iter() + .flat_map(|t| &t.inputs) + .collect(); + let cfa_outputs: Vec<_> = cfa_transfer + .transitions + .iter() + .flat_map(|t| &t.outputs) + .collect(); + assert_eq!(cfa_outputs.len(), 3); + let cfa_sent_outputs: Vec<_> = cfa_outputs.iter().filter(|o| !o.is_ours).collect(); + let cfa_change_outputs: Vec<_> = cfa_outputs.iter().filter(|o| o.is_ours).collect(); + assert_eq!(cfa_sent_outputs.len(), 2); + assert_eq!(cfa_change_outputs.len(), 1); + let cfa_witness_output = cfa_sent_outputs.iter().find(|o| !o.is_concealed).unwrap(); + assert_eq!( + cfa_witness_output.assignment.main_amount(), + cfa_amount_witness + ); + assert!(!cfa_witness_output.is_concealed); + assert!(!cfa_witness_output.is_ours); + let cfa_blind_output = cfa_sent_outputs.iter().find(|o| o.is_concealed).unwrap(); + assert_eq!(cfa_blind_output.assignment.main_amount(), cfa_amount_blind); + assert!(cfa_blind_output.is_concealed); + assert!(!cfa_blind_output.is_ours); + let cfa_change_amount: u64 = cfa_change_outputs + .iter() + .map(|o| o.assignment.main_amount()) + .sum(); + for o in &cfa_change_outputs { + assert!(!o.is_concealed); + assert!(o.is_ours); + } + let cfa_sent_amount = cfa_amount_witness + cfa_amount_blind; + let total_cfa_output: u64 = cfa_outputs.iter().map(|o| o.assignment.main_amount()).sum(); + assert_eq!(total_cfa_output, cfa_sent_amount + cfa_change_amount); + assert!(!cfa_inputs.is_empty()); + let total_cfa_input: u64 = cfa_inputs.iter().map(|i| i.assignment.main_amount()).sum(); + assert_eq!(total_cfa_input, total_cfa_output); + for input in &cfa_inputs { + assert!((input.vin as usize) < psbt_info.inputs.len()); + assert_matches!(input.assignment, Assignment::Fungible(_)); + } + let nia_inputs: Vec<_> = nia_transfer + .transitions + .iter() + .flat_map(|t| &t.inputs) + .collect(); + let nia_outputs: Vec<_> = nia_transfer + .transitions + .iter() + .flat_map(|t| &t.outputs) + .collect(); + let nia_sent_outputs: Vec<_> = nia_outputs.iter().filter(|o| !o.is_ours).collect(); + let nia_change_outputs: Vec<_> = nia_outputs.iter().filter(|o| o.is_ours).collect(); + assert_eq!(nia_sent_outputs.len(), 1); + assert!(!nia_change_outputs.is_empty()); + assert_eq!(nia_sent_outputs[0].assignment.main_amount(), AMOUNT_SMALL); + assert!(nia_sent_outputs[0].is_concealed); + assert!(!nia_sent_outputs[0].is_ours); + let total_nia_output: u64 = nia_outputs.iter().map(|o| o.assignment.main_amount()).sum(); + let total_nia_input: u64 = nia_inputs.iter().map(|i| i.assignment.main_amount()).sum(); + assert_eq!(total_nia_input, total_nia_output); + assert!(total_nia_input >= AMOUNT_SMALL); + for input in &nia_inputs { + assert!((input.vin as usize) < psbt_info.inputs.len()); + assert_matches!(input.assignment, Assignment::Fungible(_)); + } + let nia_2_transfer = rgb_inspection + .operations + .iter() + .find(|op| op.asset_id == nia_2.asset_id) + .unwrap(); + let nia_2_inputs: Vec<_> = nia_2_transfer + .transitions + .iter() + .flat_map(|t| &t.inputs) + .collect(); + let nia_2_outputs: Vec<_> = nia_2_transfer + .transitions + .iter() + .flat_map(|t| &t.outputs) + .collect(); + assert_eq!(nia_2_outputs.len(), 2); + let nia_2_sent: Vec<_> = nia_2_outputs.iter().filter(|o| !o.is_ours).collect(); + let nia_2_change: Vec<_> = nia_2_outputs.iter().filter(|o| o.is_ours).collect(); + assert_eq!(nia_2_sent.len(), 1); + assert_eq!(nia_2_change.len(), 1); + assert_eq!(nia_2_sent[0].assignment.main_amount(), nia_2_amount); + assert!(nia_2_sent[0].is_concealed); + assert!(!nia_2_sent[0].is_ours); + let expected_nia_2_change = AMOUNT_SMALL - nia_2_amount; + assert_eq!( + nia_2_change[0].assignment.main_amount(), + expected_nia_2_change + ); + assert!(!nia_2_change[0].is_concealed); + assert!(nia_2_change[0].is_ours); + let nia_2_total_output: u64 = nia_2_outputs + .iter() + .map(|o| o.assignment.main_amount()) + .sum(); + assert_eq!(nia_2_total_output, AMOUNT_SMALL); + assert!(!nia_2_inputs.is_empty()); + let nia_2_total_input: u64 = nia_2_inputs + .iter() + .map(|i| i.assignment.main_amount()) + .sum(); + assert_eq!(nia_2_total_input, nia_2_total_output); + assert_eq!(nia_2_total_input, AMOUNT_SMALL); + for input in &nia_2_inputs { + assert!((input.vin as usize) < psbt_info.inputs.len()); + assert_matches!(input.assignment, Assignment::Fungible(_)); + } +} + +fn op_counter_bump() -> i32 { + OP_COUNTER.fetch_add(1, Ordering::SeqCst); + OP_COUNTER.load(Ordering::SeqCst) as i32 +} + +pub(super) fn op_counter_reset() { + OP_COUNTER.store(0, Ordering::SeqCst); +} + +pub(super) fn operation_complete( + op_idx: i32, + ackers: &mut [&mut MultisigParty], + nackers: &mut [&mut MultisigParty], + others: &mut [&mut MultisigParty], + approve: bool, +) where + H: OperationHandler, + H::Details: Sanitizable, +{ + let (op, files) = if !ackers.is_empty() { + let party = ackers.first().unwrap(); + party.get_op_and_files(op_idx) + } else { + let party = nackers.first().unwrap(); + party.get_op_and_files(op_idx) + }; + println!( + "{} {:?} operation with id {op_idx}", + if approve { "approve" } else { "disacrd" }, + op.operation_type + ); + + let op_psbt = get_op_psbt(&files); + let op_txid = op_psbt.get_txid(); + let mut details = H::extract_details(&files).unwrap(); + details.sanitize(); + let threshold = op.threshold.unwrap(); + + let last_acker = if !ackers.is_empty() { + ackers.len() - 1 + } else { + 0 + }; + let last_nacker = if !nackers.is_empty() { + nackers.len() - 1 + } else { + 0 + }; + let mut acked_by = set![]; + let mut nacked_by = set![]; + + let get_op_review = |status: &MultisigVotingStatus| { + H::to_review(op_psbt.to_string(), details.clone(), status.clone()) + }; + let get_op_pending = + |status: &MultisigVotingStatus| H::pending(details.clone(), status.clone()); + let get_op_final = |status: &MultisigVotingStatus| { + if approve { + H::completed(op_txid.to_string(), details.clone(), status.clone()) + } else { + H::discarded(details.clone(), status.clone()) + } + }; + + // approve or discard + if approve { + // nack with nacker cosigners + for nacker in nackers.iter_mut() { + let bt_before = nacker.bak_info_opt(); + let mut op_info = nacker.sync(); + check_bak_ts_opt(nacker, bt_before, false); + op_info.operation.sanitize(); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: None, + }; + assert_eq!(op_info.operation, get_op_review(&status)); + let bt_before = nacker.bak_info_opt(); + let mut response = nacker.nack(op_info.operation_idx); + check_bak_ts_opt(nacker, bt_before, false); + nacked_by.insert(nacker.xpub.to_string()); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: Some(false), + }; + response.operation.sanitize(); + assert_eq!(response.operation, get_op_pending(&status)); + } + // ack with acker cosigners + for (i, acker) in ackers.iter_mut().enumerate() { + let bt_before = acker.bak_info_opt(); + let mut op_info = acker.sync(); + check_bak_ts_opt(acker, bt_before, false); + op_info.operation.sanitize(); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: None, + }; + assert_eq!(op_info.operation, get_op_review(&status)); + let bt_before = acker.bak_info_opt(); + let mut response = acker.sign_and_ack(&op_psbt.to_string(), op_info.operation_idx); + check_bak_ts_opt(acker, bt_before, false); + acked_by.insert(acker.xpub.to_string()); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: Some(true), + }; + response.operation.sanitize(); + if i < last_acker { + assert_eq!(response.operation, get_op_pending(&status)); + } else { + assert_eq!(response.operation, get_op_final(&status)); + } + } + } else { + // ack with acker cosigners + for acker in ackers.iter_mut() { + let bt_before = acker.bak_info_opt(); + let mut op_info = acker.sync(); + check_bak_ts_opt(acker, bt_before, false); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: None, + }; + op_info.operation.sanitize(); + assert_eq!(op_info.operation, get_op_review(&status)); + let bt_before = acker.bak_info_opt(); + let mut response = acker.sign_and_ack(&op_psbt.to_string(), op_info.operation_idx); + check_bak_ts_opt(acker, bt_before, false); + acked_by.insert(acker.xpub.to_string()); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: Some(true), + }; + response.operation.sanitize(); + assert_eq!(response.operation, get_op_pending(&status)); + } + // nack with nacker cosigners + for (i, nacker) in nackers.iter_mut().enumerate() { + let bt_before = nacker.bak_info_opt(); + let mut op_info = nacker.sync(); + check_bak_ts_opt(nacker, bt_before, false); + op_info.operation.sanitize(); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: None, + }; + assert_eq!(op_info.operation, get_op_review(&status)); + let bt_before = nacker.bak_info_opt(); + let mut response = nacker.nack(op_info.operation_idx); + check_bak_ts_opt(nacker, bt_before, false); + nacked_by.insert(nacker.xpub.to_string()); + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: Some(false), + }; + response.operation.sanitize(); + if i < last_nacker { + assert_eq!(response.operation, get_op_pending(&status)); + } else { + assert_eq!(response.operation, get_op_final(&status)); + } + } + } + + // final situation + // - ackers + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: Some(true), + }; + for (i, acker) in ackers.iter_mut().enumerate() { + let bt_before = acker.bak_info_opt(); + let op_info_opt = acker.sync_opt(); + if approve && i == last_acker { + assert!(op_info_opt.is_none()); + check_bak_ts_opt(acker, bt_before, true); + continue; + } + check_bak_ts_opt(acker, bt_before, false); + let mut op_info = op_info_opt.unwrap(); + op_info.operation.sanitize(); + assert_eq!(op_info.operation, get_op_final(&status)); + } + // - nackers + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: Some(false), + }; + for (i, nacker) in nackers.iter_mut().enumerate() { + let bt_before = nacker.bak_info_opt(); + let op_info_opt = nacker.sync_opt(); + if !approve && i == last_nacker { + assert!(op_info_opt.is_none()); + check_bak_ts_opt(nacker, bt_before, true); + continue; + } + check_bak_ts_opt(nacker, bt_before, false); + let mut op_info = op_info_opt.unwrap(); + op_info.operation.sanitize(); + assert_eq!(op_info.operation, get_op_final(&status)); + } + // - others + let status = MultisigVotingStatus { + acked_by: acked_by.clone(), + nacked_by: nacked_by.clone(), + threshold, + my_response: None, + }; + for other in others.iter_mut() { + let bt_before = other.bak_info_opt(); + let mut op_info = other.sync(); + check_bak_ts_opt(other, bt_before, false); + op_info.operation.sanitize(); + assert_eq!(op_info.operation, get_op_final(&status)); + } +} + +pub(super) fn settle_transfer( + senders: &mut [&mut impl SigParty], + receivers: &mut [&mut impl SigParty], + asset_id: Option<&str>, + txid: Option<&str>, + psbt: Option<&str>, + stage_1: bool, +) { + if stage_1 { + for wallet in &mut *receivers { + wallet.refresh(None); // always None as the recipient might not know the asset yet + } + for wallet in &mut *senders { + wallet.refresh(asset_id); + } + } + if let Some(psbt) = psbt { + let txid = Psbt::from_str(psbt).unwrap().get_txid().to_string(); + mine_tx(false, false, &txid); + } else if let Some(txid) = txid { + mine_tx(false, false, txid); + } else { + mine(false, false); + } + for wallet in &mut *receivers { + wallet.refresh(asset_id); + } + for wallet in &mut *senders { + wallet.refresh(asset_id); + } +} + +pub(super) fn sync_wallets_full(wallets: &mut [&mut MultisigParty]) { + for wallet in wallets { + eprintln!("syncing wallet {}", wallet.get_data_dir()); + let online = wallet.online(); + let last_processed = wallet + .multisig_mut() + .get_local_last_processed_operation_idx() + .unwrap(); + let last_hub_operation = wallet + .multisig_mut() + .hub_info(online) + .unwrap() + .last_operation_idx + .unwrap(); + assert!( + last_hub_operation > last_processed, + "wallet already in sync" + ); + for i in (last_processed + 1)..=last_hub_operation { + println!("syncing operation {i}"); + let op_info = wallet.sync(); + assert_eq!(op_info.operation_idx, i); + } + let final_processed = wallet + .multisig_mut() + .get_local_last_processed_operation_idx() + .unwrap(); + assert_eq!(final_processed, last_hub_operation); + wallet.assert_up_to_date(); + } +} diff --git a/src/wallet/test/utils/chain.rs b/src/wallet/test/utils/chain.rs index a806fcde..df8c0c1e 100644 --- a/src/wallet/test/utils/chain.rs +++ b/src/wallet/test/utils/chain.rs @@ -130,7 +130,9 @@ pub fn get_tx_height(esplora: bool, txid: &str) -> Option { pub fn mine_tx(esplora: bool, resume: bool, txid: &str) { eprintln!("trying to have TX {txid} mined"); for _ in 0..10 { - if get_tx_height(esplora, txid).is_some() { + if let Some(conf_num) = get_tx_height(esplora, txid) + && conf_num > 0 + { println!("TX with ID {txid} has been mined"); return; } diff --git a/src/wallet/test/utils/helpers.rs b/src/wallet/test/utils/helpers.rs index 5f5d0949..1bfbd34b 100644 --- a/src/wallet/test/utils/helpers.rs +++ b/src/wallet/test/utils/helpers.rs @@ -522,7 +522,10 @@ pub(crate) fn wait_for_refresh( asset_id: Option<&str>, transfer_ids: Option<&[i32]>, ) { - println!("waiting for refresh"); + println!( + "waiting for refresh ({})", + wallet.internals().wallet_data.data_dir + ); let mut seen = HashSet::new(); let mut target_set = HashSet::new(); if let Some(t_ids) = transfer_ids { @@ -600,7 +603,7 @@ pub(crate) fn wait_for_unspents( } } -pub(crate) fn get_pending_blind_transfers(wallet: &mut Wallet) -> Vec { +pub(crate) fn get_pending_blind_transfers(wallet: &mut impl RgbWalletOpsOffline) -> Vec { let transfers = test_list_transfers(wallet, None); transfers .into_iter() @@ -674,8 +677,11 @@ pub(crate) fn write_opouts_to_reject_list(filename: &str, opouts: &[String]) { /// print the provided message, then get colorings for each wallet unspent and print their status, /// type, amount and asset #[cfg(any(feature = "electrum", feature = "esplora"))] -pub(crate) fn show_unspent_colorings(wallet: &mut Wallet, msg: &str) { - println!("\n{msg}"); +pub(crate) fn show_unspent_colorings(wallet: &mut impl RgbWalletOpsOnline, msg: &str) { + println!( + "\nwallet {} unspent colorings ({msg})", + wallet.get_wallet_data().data_dir + ); let unspents = test_list_unspents(wallet, None, false) .into_iter() .filter(|u| u.utxo.colorable); From 24e780b7a5c633e7cf898309d11bb4b75b63cbbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= Date: Tue, 14 Apr 2026 15:55:58 +0200 Subject: [PATCH 05/10] add pending vanilla TXs --- bindings/c-ffi/src/lib.rs | 3 +- bindings/c-ffi/src/utils.rs | 4 +- bindings/uniffi/src/lib.rs | 9 +- bindings/uniffi/src/rgb-lib.udl | 12 +- migration/src/lib.rs | 2 + .../src/m20260414_134758_add_reserved_txo.rs | 55 +++++ src/database/entities/mod.rs | 1 + src/database/entities/prelude.rs | 1 + src/database/entities/reserved_txo.rs | 76 ++++++ src/database/entities/wallet_transaction.rs | 14 +- src/database/enums.rs | 4 +- src/database/mod.rs | 67 ++++- src/error.rs | 4 + src/lib.rs | 2 + src/wallet/mod.rs | 10 +- src/wallet/multisig.rs | 4 +- src/wallet/objects.rs | 16 +- src/wallet/offline.rs | 39 ++- src/wallet/online.rs | 118 +++++++-- src/wallet/singlesig.rs | 79 +++++- src/wallet/test/abort_pending_vanilla_tx.rs | 90 +++++++ src/wallet/test/create_utxos.rs | 101 ++++++++ src/wallet/test/drain_to.rs | 85 ++++++- src/wallet/test/finalize_psbt.rs | 4 +- src/wallet/test/inflate.rs | 2 +- src/wallet/test/list_pending_vanilla_txs.rs | 63 +++++ src/wallet/test/list_transactions.rs | 4 +- src/wallet/test/mod.rs | 2 + src/wallet/test/multisig/mod.rs | 4 +- src/wallet/test/send_btc.rs | 233 +++++++++++++++++- src/wallet/test/sign_psbt.rs | 2 +- src/wallet/test/utils/api.rs | 4 +- 32 files changed, 1049 insertions(+), 65 deletions(-) create mode 100644 migration/src/m20260414_134758_add_reserved_txo.rs create mode 100644 src/database/entities/reserved_txo.rs create mode 100644 src/wallet/test/abort_pending_vanilla_tx.rs create mode 100644 src/wallet/test/list_pending_vanilla_txs.rs diff --git a/bindings/c-ffi/src/lib.rs b/bindings/c-ffi/src/lib.rs index fbde027e..3f53924e 100644 --- a/bindings/c-ffi/src/lib.rs +++ b/bindings/c-ffi/src/lib.rs @@ -117,9 +117,10 @@ pub extern "C" fn rgblib_create_utxos_begin( size_opt: *const c_char, fee_rate: *const c_char, skip_sync: bool, + dry_run: bool, ) -> CResultString { create_utxos_begin( - wallet, online, up_to, num_opt, size_opt, fee_rate, skip_sync, + wallet, online, up_to, num_opt, size_opt, fee_rate, skip_sync, dry_run, ) .into() } diff --git a/bindings/c-ffi/src/utils.rs b/bindings/c-ffi/src/utils.rs index 896a8efb..402dde25 100644 --- a/bindings/c-ffi/src/utils.rs +++ b/bindings/c-ffi/src/utils.rs @@ -228,6 +228,7 @@ pub(crate) fn create_utxos( Ok(serde_json::to_string(&res)?) } +#[allow(clippy::too_many_arguments)] pub(crate) fn create_utxos_begin( wallet: &COpaqueStruct, online: &COpaqueStruct, @@ -236,13 +237,14 @@ pub(crate) fn create_utxos_begin( size_opt: *const c_char, fee_rate: *const c_char, skip_sync: bool, + dry_run: bool, ) -> Result { let wallet = Wallet::from_opaque(wallet)?; let online = Online::from_opaque(online)?; let num = convert_optional_number(num_opt)?; let size = convert_optional_number(size_opt)?; let fee_rate = ptr_to_num(fee_rate)?; - let res = wallet.create_utxos_begin(*online, up_to, num, size, fee_rate, skip_sync)?; + let res = wallet.create_utxos_begin(*online, up_to, num, size, fee_rate, skip_sync, dry_run)?; Ok(res) } diff --git a/bindings/uniffi/src/lib.rs b/bindings/uniffi/src/lib.rs index c1209813..a8eb3109 100644 --- a/bindings/uniffi/src/lib.rs +++ b/bindings/uniffi/src/lib.rs @@ -928,9 +928,10 @@ impl Wallet { size: Option, fee_rate: u64, skip_sync: bool, + dry_run: bool, ) -> Result { self._get_wallet() - .create_utxos_begin(online, up_to, num, size, fee_rate, skip_sync) + .create_utxos_begin(online, up_to, num, size, fee_rate, skip_sync, dry_run) } fn create_utxos_end( @@ -969,9 +970,10 @@ impl Wallet { address: String, destroy_assets: bool, fee_rate: u64, + dry_run: bool, ) -> Result { self._get_wallet() - .drain_to_begin(online, address, destroy_assets, fee_rate) + .drain_to_begin(online, address, destroy_assets, fee_rate, dry_run) } fn drain_to_end(&self, online: Online, signed_psbt: String) -> Result { @@ -1243,9 +1245,10 @@ impl Wallet { amount: u64, fee_rate: u64, skip_sync: bool, + dry_run: bool, ) -> Result { self._get_wallet() - .send_btc_begin(online, address, amount, fee_rate, skip_sync) + .send_btc_begin(online, address, amount, fee_rate, skip_sync, dry_run) } fn send_btc_end( diff --git a/bindings/uniffi/src/rgb-lib.udl b/bindings/uniffi/src/rgb-lib.udl index 92fdf752..09a12224 100644 --- a/bindings/uniffi/src/rgb-lib.udl +++ b/bindings/uniffi/src/rgb-lib.udl @@ -14,6 +14,7 @@ interface RgbLibError { AssetNotFound(string asset_id); BatchTransferNotFound(i32 idx); BitcoinNetworkMismatch(); + CannotAbortPendingVanillaTx(); CannotChangeOnline(); CannotCombinePsbts(); CannotDeleteBatchTransfer(); @@ -465,7 +466,8 @@ enum TransactionType { "RgbSend", "Drain", "CreateUtxos", - "User", + "SendBtc", + "Untracked", }; [Remote] @@ -765,7 +767,7 @@ interface Wallet { [Throws=RgbLibError] string create_utxos_begin( Online online, boolean up_to, u8? num, u32? size, u64 fee_rate, - boolean skip_sync); + boolean skip_sync, boolean dry_run); [Throws=RgbLibError] u8 create_utxos_end(Online online, string signed_psbt, boolean skip_sync); @@ -779,7 +781,8 @@ interface Wallet { [Throws=RgbLibError] string drain_to_begin( - Online online, string address, boolean destroy_assets, u64 fee_rate); + Online online, string address, boolean destroy_assets, u64 fee_rate, + boolean dry_run); [Throws=RgbLibError] string drain_to_end(Online online, string signed_psbt); @@ -878,7 +881,8 @@ interface Wallet { [Throws=RgbLibError] string send_btc_begin( - Online online, string address, u64 amount, u64 fee_rate, boolean skip_sync); + Online online, string address, u64 amount, u64 fee_rate, boolean skip_sync, + boolean dry_run); [Throws=RgbLibError] string send_btc_end(Online online, string signed_psbt, boolean skip_sync); diff --git a/migration/src/lib.rs b/migration/src/lib.rs index 0ba560b3..b61d7608 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -4,6 +4,7 @@ mod m20230608_071249_init_db; mod m20251017_074408_asset_update; mod m20251105_132121_asset_update; mod m20251215_124959_backup_info_update; +mod m20260414_134758_add_reserved_txo; pub struct Migrator; @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { Box::new(m20251017_074408_asset_update::Migration), Box::new(m20251105_132121_asset_update::Migration), Box::new(m20251215_124959_backup_info_update::Migration), + Box::new(m20260414_134758_add_reserved_txo::Migration), ] } } diff --git a/migration/src/m20260414_134758_add_reserved_txo.rs b/migration/src/m20260414_134758_add_reserved_txo.rs new file mode 100644 index 00000000..59e1865d --- /dev/null +++ b/migration/src/m20260414_134758_add_reserved_txo.rs @@ -0,0 +1,55 @@ +use sea_orm_migration::{prelude::*, schema::*}; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ReservedTxo::Table) + .if_not_exists() + .col(pk_auto(ReservedTxo::Idx)) + .col(string(ReservedTxo::Txid)) + .col(big_unsigned(ReservedTxo::Vout)) + .col(integer_null(ReservedTxo::ReservedFor)) + .foreign_key( + ForeignKey::create() + .name("fk-reservedtxo-wallettransaction") + .from(ReservedTxo::Table, ReservedTxo::ReservedFor) + .to(WalletTransaction::Table, WalletTransaction::Idx) + .on_delete(ForeignKeyAction::Cascade) + .on_update(ForeignKeyAction::Restrict), + ) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ReservedTxo::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum ReservedTxo { + Table, + Idx, + Txid, + Vout, + ReservedFor, +} + +#[derive(DeriveIden)] +pub enum WalletTransaction { + Table, + Idx, +} diff --git a/src/database/entities/mod.rs b/src/database/entities/mod.rs index cebed346..494bda70 100644 --- a/src/database/entities/mod.rs +++ b/src/database/entities/mod.rs @@ -9,6 +9,7 @@ pub mod batch_transfer; pub mod coloring; pub mod media; pub mod pending_witness_script; +pub mod reserved_txo; pub mod token; pub mod token_media; pub mod transfer; diff --git a/src/database/entities/prelude.rs b/src/database/entities/prelude.rs index 4eb51fe7..94cd752e 100644 --- a/src/database/entities/prelude.rs +++ b/src/database/entities/prelude.rs @@ -7,6 +7,7 @@ pub use super::batch_transfer::Entity as BatchTransfer; pub use super::coloring::Entity as Coloring; pub use super::media::Entity as Media; pub use super::pending_witness_script::Entity as PendingWitnessScript; +pub use super::reserved_txo::Entity as ReservedTxo; pub use super::token::Entity as Token; pub use super::token_media::Entity as TokenMedia; pub use super::transfer::Entity as Transfer; diff --git a/src/database/entities/reserved_txo.rs b/src/database/entities/reserved_txo.rs new file mode 100644 index 00000000..60ebba08 --- /dev/null +++ b/src/database/entities/reserved_txo.rs @@ -0,0 +1,76 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 + +use sea_orm::entity::prelude::*; + +#[derive(Copy, Clone, Default, Debug, DeriveEntity)] +pub struct Entity; + +impl EntityName for Entity { + fn table_name(&self) -> &str { + "reserved_txo" + } +} + +#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel, Eq)] +pub struct Model { + pub idx: i32, + pub txid: String, + pub vout: u32, + pub reserved_for: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] +pub enum Column { + Idx, + Txid, + Vout, + ReservedFor, +} + +#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)] +pub enum PrimaryKey { + Idx, +} + +impl PrimaryKeyTrait for PrimaryKey { + type ValueType = i32; + fn auto_increment() -> bool { + true + } +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation { + WalletTransaction, +} + +impl ColumnTrait for Column { + type EntityName = Entity; + fn def(&self) -> ColumnDef { + match self { + Self::Idx => ColumnType::Integer.def(), + Self::Txid => ColumnType::String(StringLen::None).def(), + Self::Vout => ColumnType::BigInteger.def(), + Self::ReservedFor => ColumnType::Integer.def().null(), + } + } +} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + match self { + Self::WalletTransaction => Entity::belongs_to(super::wallet_transaction::Entity) + .from(Column::ReservedFor) + .to(super::wallet_transaction::Column::Idx) + .into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::WalletTransaction.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/database/entities/wallet_transaction.rs b/src/database/entities/wallet_transaction.rs index fc38baa9..d113ffcc 100644 --- a/src/database/entities/wallet_transaction.rs +++ b/src/database/entities/wallet_transaction.rs @@ -40,7 +40,9 @@ impl PrimaryKeyTrait for PrimaryKey { } #[derive(Copy, Clone, Debug, EnumIter)] -pub enum Relation {} +pub enum Relation { + ReservedTxo, +} impl ColumnTrait for Column { type EntityName = Entity; @@ -55,7 +57,15 @@ impl ColumnTrait for Column { impl RelationTrait for Relation { fn def(&self) -> RelationDef { - panic!("No RelationDef") + match self { + Self::ReservedTxo => Entity::has_many(super::reserved_txo::Entity).into(), + } + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ReservedTxo.def() } } diff --git a/src/database/enums.rs b/src/database/enums.rs index 958cf667..df960207 100644 --- a/src/database/enums.rs +++ b/src/database/enums.rs @@ -296,13 +296,15 @@ impl TransferStatus { } } -#[derive(Debug, Copy, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, EnumIter, DeriveActiveEnum, Deserialize, Serialize)] #[sea_orm(rs_type = "u8", db_type = "TinyUnsigned")] pub enum WalletTransactionType { #[sea_orm(num_value = 1)] CreateUtxos = 1, #[sea_orm(num_value = 2)] Drain = 2, + #[sea_orm(num_value = 3)] + SendBtc = 3, } /// An RGB assignment. diff --git a/src/database/mod.rs b/src/database/mod.rs index daf26402..94359677 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -4,9 +4,10 @@ use super::*; use crate::database::entities::{ asset, coloring, media, prelude::*, transfer_transport_endpoint, transport_endpoint, txo, + wallet_transaction, }; #[cfg(any(feature = "electrum", feature = "esplora"))] -use crate::database::entities::{batch_transfer, pending_witness_script}; +use crate::database::entities::{batch_transfer, pending_witness_script, reserved_txo}; #[derive(Debug, Clone)] #[cfg(any(feature = "electrum", feature = "esplora"))] @@ -145,6 +146,13 @@ impl DbTxo { } } +impl From for BdkOutPoint { + fn from(r: DbReservedTxo) -> BdkOutPoint { + BdkOutPoint::from_str(&format!("{}:{}", r.txid, r.vout)) + .expect("DB should contain a valid outpoint") + } +} + impl From for BdkOutPoint { fn from(x: DbTxo) -> BdkOutPoint { BdkOutPoint::from_str(&x.outpoint().to_string()) @@ -230,6 +238,15 @@ impl RgbLibDatabase { Ok(res.last_insert_id) } + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub(crate) fn set_reserved_txos( + &self, + reserved_txos: Vec, + ) -> Result<(), InternalError> { + block_on(ReservedTxo::insert_many(reserved_txos).exec(self.get_connection()))?; + Ok(()) + } + pub(crate) fn set_token(&self, token: DbTokenActMod) -> Result { let res = block_on(Token::insert(token).exec(self.get_connection()))?; Ok(res.last_insert_id) @@ -420,11 +437,30 @@ impl RgbLibDatabase { Ok(()) } + #[cfg(any(feature = "electrum", feature = "esplora"))] + pub(crate) fn del_reserved_txos( + &self, + reserved_txos: &[DbReservedTxo], + ) -> Result<(), InternalError> { + let idxs = reserved_txos.iter().map(|r| r.idx).collect::>(); + block_on( + ReservedTxo::delete_many() + .filter(reserved_txo::Column::Idx.is_in(idxs)) + .exec(self.get_connection()), + )?; + Ok(()) + } + pub(crate) fn del_txo(&self, idx: i32) -> Result<(), InternalError> { block_on(Coloring::delete_by_id(idx).exec(self.get_connection()))?; Ok(()) } + pub(crate) fn del_wallet_transaction(&self, idx: i32) -> Result<(), InternalError> { + block_on(WalletTransaction::delete_by_id(idx).exec(self.get_connection()))?; + Ok(()) + } + pub(crate) fn get_asset(&self, asset_id: String) -> Result, InternalError> { Ok(block_on( Asset::find() @@ -489,6 +525,31 @@ impl RgbLibDatabase { )?) } + pub(crate) fn get_wallet_transactions_by_idxs( + &self, + idxs: &[i32], + ) -> Result, InternalError> { + Ok(block_on( + WalletTransaction::find() + .filter(wallet_transaction::Column::Idx.is_in(idxs.to_vec())) + .all(self.get_connection()), + )?) + } + + pub(crate) fn get_wallet_transaction_with_reserved_txos_by_txid( + &self, + txid: &str, + ) -> Result)>, InternalError> { + Ok(block_on( + WalletTransaction::find() + .filter(wallet_transaction::Column::Txid.eq(txid)) + .find_with_related(ReservedTxo) + .all(self.get_connection()), + )? + .into_iter() + .next()) + } + pub(crate) fn iter_assets(&self) -> Result, InternalError> { Ok(block_on(Asset::find().all(self.get_connection()))?) } @@ -518,6 +579,10 @@ impl RgbLibDatabase { )?) } + pub(crate) fn iter_reserved_txos(&self) -> Result, InternalError> { + Ok(block_on(ReservedTxo::find().all(self.get_connection()))?) + } + pub(crate) fn iter_token_medias(&self) -> Result, InternalError> { Ok(block_on(TokenMedia::find().all(self.get_connection()))?) } diff --git a/src/error.rs b/src/error.rs index 90fef5c3..5d94f228 100644 --- a/src/error.rs +++ b/src/error.rs @@ -38,6 +38,10 @@ pub enum Error { #[error("The given PSBTs cannot be combined")] CannotCombinePsbts, + /// Requested pending vanilla TX cannot be aborted + #[error("Pending vanilla TX cannot be aborted")] + CannotAbortPendingVanillaTx, + /// Requested batch transfer cannot be deleted #[error("Batch transfer cannot be deleted")] CannotDeleteBatchTransfer, diff --git a/src/lib.rs b/src/lib.rs index e406d6d4..904f97bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -261,6 +261,7 @@ use crate::{ DbData, entities::{ pending_witness_script::Model as DbPendingWitnessScript, + reserved_txo::ActiveModel as DbReservedTxoActMod, wallet_transaction::ActiveModel as DbWalletTransactionActMod, }, }, @@ -282,6 +283,7 @@ use crate::{ coloring::{ActiveModel as DbColoringActMod, Model as DbColoring}, media::{ActiveModel as DbMediaActMod, Model as DbMedia}, pending_witness_script::ActiveModel as DbPendingWitnessScriptActMod, + reserved_txo::Model as DbReservedTxo, token::{ActiveModel as DbTokenActMod, Model as DbToken}, token_media::{ActiveModel as DbTokenMediaActMod, Model as DbTokenMedia}, transfer::{ActiveModel as DbTransferActMod, Model as DbTransfer}, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index dd094eb8..ce602a94 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -27,11 +27,11 @@ pub use multisig::{ pub use objects::{ Address, AssetCFA, AssetIFA, AssetNIA, AssetUDA, Assets, AssignmentsCollection, Balance, BlockTime, BtcBalance, DatabaseType, EmbeddedMedia, Invoice, InvoiceData, Media, Metadata, - Online, Outpoint, ProofOfReserves, PsbtInputInfo, PsbtInspection, PsbtOutputInfo, ReceiveData, - Recipient, RecipientInfo, RecipientType, RgbAllocation, RgbInputInfo, RgbInspection, - RgbOperationInfo, RgbOutputInfo, RgbTransitionInfo, Token, TokenLight, Transaction, - TransactionType, Transfer, TransferKind, TransferTransportEndpoint, TransportEndpoint, - TypeOfTransition, Unspent, Utxo, WalletData, WalletDescriptors, WitnessData, + Online, Outpoint, PendingVanillaTx, ProofOfReserves, PsbtInputInfo, PsbtInspection, + PsbtOutputInfo, ReceiveData, Recipient, RecipientInfo, RecipientType, RgbAllocation, + RgbInputInfo, RgbInspection, RgbOperationInfo, RgbOutputInfo, RgbTransitionInfo, Token, + TokenLight, Transaction, TransactionType, Transfer, TransferKind, TransferTransportEndpoint, + TransportEndpoint, TypeOfTransition, Unspent, Utxo, WalletData, WalletDescriptors, WitnessData, }; #[cfg(any(feature = "electrum", feature = "esplora"))] pub use objects::{ diff --git a/src/wallet/multisig.rs b/src/wallet/multisig.rs index 9226d794..adeb217d 100644 --- a/src/wallet/multisig.rs +++ b/src/wallet/multisig.rs @@ -1960,7 +1960,7 @@ impl MultisigWallet { info!(self.logger(), "Initiate creating UTXOs..."); self.check_online(online)?; self.check_is_cosigner()?; - let psbt = self.create_utxos_begin_impl(up_to, num, size, fee_rate, skip_sync)?; + let psbt = self.create_utxos_begin_impl(up_to, num, size, fee_rate, skip_sync, true)?; let res = self.post_operation(OperationType::CreateUtxos, PostData::Psbt(psbt))?; self.update_backup_info(false)?; info!(self.logger(), "Initiate creating UTXOs completed"); @@ -1983,7 +1983,7 @@ impl MultisigWallet { info!(self.logger(), "Initiate sending BTC..."); self.check_online(online)?; self.check_is_cosigner()?; - let psbt = self.send_btc_begin_impl(address, amount, fee_rate, skip_sync)?; + let psbt = self.send_btc_begin_impl(address, amount, fee_rate, skip_sync, true)?; let res = self.post_operation(OperationType::SendBtc, PostData::Psbt(psbt))?; self.update_backup_info(false)?; info!(self.logger(), "Initiate sending BTC completed"); diff --git a/src/wallet/objects.rs b/src/wallet/objects.rs index 552d27b9..5a5fc32b 100644 --- a/src/wallet/objects.rs +++ b/src/wallet/objects.rs @@ -1413,8 +1413,20 @@ pub enum TransactionType { Drain, /// Transaction used to create UTXOs CreateUtxos, - /// Transaction not created by rgb-lib directly - User, + /// Transaction used to perform a BTC send + SendBtc, + /// Transaction not created via rgb-lib + Untracked, +} + +/// A pending vanilla transaction that has reserved TXOs in the wallet. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[cfg_attr(feature = "camel_case", serde(rename_all = "camelCase"))] +pub struct PendingVanillaTx { + /// Transaction ID + pub txid: String, + /// Type of vanilla operation that reserved the TXOs + pub r#type: WalletTransactionType, } /// A Bitcoin transaction. diff --git a/src/wallet/offline.rs b/src/wallet/offline.rs index 0a78e055..0314a7af 100644 --- a/src/wallet/offline.rs +++ b/src/wallet/offline.rs @@ -1622,8 +1622,24 @@ pub trait WalletOffline: WalletBackup { skip_sync: bool, ) -> Result { self.sync_if_requested(online, skip_sync)?; - let vanilla = self.get_btc_balance_for_keychain(KeychainKind::Internal)?; + let mut vanilla = self.get_btc_balance_for_keychain(KeychainKind::Internal)?; let colored = self.get_btc_balance_for_keychain(KeychainKind::External)?; + + let reserved: HashSet = self + .database() + .iter_reserved_txos()? + .into_iter() + .map(BdkOutPoint::from) + .collect(); + if !reserved.is_empty() { + let reserved_sum: u64 = self + .internal_unspents() + .filter(|u| reserved.contains(&u.outpoint)) + .map(|u| u.txout.value.to_sat()) + .sum(); + vanilla.spendable = vanilla.spendable.saturating_sub(reserved_sum); + } + Ok(BtcBalance { vanilla, colored }) } @@ -1787,15 +1803,26 @@ pub trait WalletOffline: WalletBackup { Ok(local_asset_data) } - fn get_unspendable_bdk_outpoints(&self) -> Result, Error> { + fn get_reserved_vanilla_outpoints(&self) -> Result, Error> { Ok(self .database() - .iter_txos()? + .iter_reserved_txos()? .into_iter() .map(BdkOutPoint::from) .collect()) } + fn get_unspendable_bdk_outpoints(&self) -> Result, Error> { + let mut outpoints: Vec = self + .database() + .iter_txos()? + .into_iter() + .map(BdkOutPoint::from) + .collect(); + outpoints.extend(self.get_reserved_vanilla_outpoints()?); + Ok(outpoints) + } + fn get_script_pubkey(&self, address: &str) -> Result { Ok(parse_address_str(address, self.bitcoin_network())?.script_pubkey()) } @@ -1950,11 +1977,13 @@ pub trait WalletOffline: WalletBackup { let mut create_utxos_txids = vec![]; let mut drain_txids = vec![]; + let mut send_btc_txids = vec![]; let wallet_transactions = self.database().iter_wallet_transactions()?; for tx in wallet_transactions { match tx.r#type { WalletTransactionType::CreateUtxos => create_utxos_txids.push(tx.txid), WalletTransactionType::Drain => drain_txids.push(tx.txid), + WalletTransactionType::SendBtc => send_btc_txids.push(tx.txid), } } let rgb_send_txids: Vec = self @@ -1975,8 +2004,10 @@ pub trait WalletOffline: WalletBackup { TransactionType::CreateUtxos } else if rgb_send_txids.contains(&txid) { TransactionType::RgbSend + } else if send_btc_txids.contains(&txid) { + TransactionType::SendBtc } else { - TransactionType::User + TransactionType::Untracked }; let confirmation_time = match t.chain_position { ChainPosition::Confirmed { anchor, .. } => Some(BlockTime { diff --git a/src/wallet/online.rs b/src/wallet/online.rs index 233a398d..98911323 100644 --- a/src/wallet/online.rs +++ b/src/wallet/online.rs @@ -128,6 +128,59 @@ pub trait WalletOnline: WalletOffline { Ok(tx) } + fn reserve_vanilla_txos( + &self, + psbt: &Psbt, + r#type: WalletTransactionType, + ) -> Result<(), Error> { + let txid = psbt.unsigned_tx.compute_txid().to_string(); + let wt_idx = self + .database() + .set_wallet_transaction(DbWalletTransactionActMod { + txid: ActiveValue::Set(txid), + r#type: ActiveValue::Set(r#type), + ..Default::default() + })?; + let reservations: Vec = psbt + .unsigned_tx + .input + .iter() + .map(|i| DbReservedTxoActMod { + txid: ActiveValue::Set(i.previous_output.txid.to_string()), + vout: ActiveValue::Set(i.previous_output.vout), + reserved_for: ActiveValue::Set(Some(wt_idx)), + ..Default::default() + }) + .collect(); + self.database().set_reserved_txos(reservations)?; + Ok(()) + } + + fn finalize_vanilla_wallet_transaction( + &self, + psbt: &Psbt, + r#type: WalletTransactionType, + ) -> Result<(), Error> { + let txid = psbt.unsigned_tx.compute_txid().to_string(); + match self + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&txid)? + { + Some((_wt, reservations)) => { + self.database().del_reserved_txos(&reservations)?; + } + None => { + self.database() + .set_wallet_transaction(DbWalletTransactionActMod { + txid: ActiveValue::Set(txid), + r#type: ActiveValue::Set(r#type), + ..Default::default() + })?; + } + } + Ok(()) + } + fn broadcast_and_update_rgb( &mut self, runtime: &mut RgbRuntime, @@ -166,6 +219,7 @@ pub trait WalletOnline: WalletOffline { size: Option, fee_rate: u64, skip_sync: bool, + dry_run: bool, ) -> Result { let fee_rate_checked = self.check_fee_rate(fee_rate)?; @@ -191,9 +245,21 @@ pub trait WalletOnline: WalletOffline { "Will try to create {} UTXOs", utxos_to_create ); - let inputs: Vec = self.internal_unspents().map(|u| u.outpoint).collect(); - let inputs: &[BdkOutPoint] = &inputs; - let usable_btc_amount = self.get_uncolorable_btc_sum()?; + let reserved: HashSet = + self.get_reserved_vanilla_outpoints()?.into_iter().collect(); + let (inputs, usable_btc_amount) = self.internal_unspents().fold( + (Vec::new(), 0u64), + |(mut inputs, usable_btc_amount), u| { + let outpoint = u.outpoint; + let value = u.txout.value.to_sat(); + if reserved.contains(&outpoint) { + (inputs, usable_btc_amount) + } else { + inputs.push(outpoint); + (inputs, usable_btc_amount + value) + } + }, + ); let utxo_size = size.unwrap_or(UTXO_SIZE); if utxo_size == 0 { return Err(Error::InvalidAmountZero); @@ -212,8 +278,13 @@ pub trait WalletOnline: WalletOffline { addresses.push(self.get_new_address()?.script_pubkey()); } while !addresses.is_empty() { - match self.create_split_tx(inputs, &addresses, utxo_size, fee_rate_checked) { - Ok(psbt) => return Ok(psbt), + match self.create_split_tx(&inputs, &addresses, utxo_size, fee_rate_checked) { + Ok(psbt) => { + if !dry_run { + self.reserve_vanilla_txos(&psbt, WalletTransactionType::CreateUtxos)?; + } + return Ok(psbt); + } Err(e) => { (btc_needed, btc_available) = match e { bdk_wallet::error::CreateTxError::CoinSelection(InsufficientFunds { @@ -242,12 +313,7 @@ pub trait WalletOnline: WalletOffline { fn create_utxos_end_impl(&mut self, signed_psbt: &Psbt, skip_sync: bool) -> Result { let tx = self.broadcast_psbt(signed_psbt, skip_sync)?; - self.database() - .set_wallet_transaction(DbWalletTransactionActMod { - txid: ActiveValue::Set(tx.compute_txid().to_string()), - r#type: ActiveValue::Set(WalletTransactionType::CreateUtxos), - ..Default::default() - })?; + self.finalize_vanilla_wallet_transaction(signed_psbt, WalletTransactionType::CreateUtxos)?; let mut num_utxos_created = 0; if !skip_sync { @@ -268,6 +334,7 @@ pub trait WalletOnline: WalletOffline { address: String, destroy_assets: bool, fee_rate: u64, + dry_run: bool, ) -> Result { let fee_rate_checked = self.check_fee_rate(fee_rate)?; @@ -290,7 +357,7 @@ pub trait WalletOnline: WalletOffline { tx_builder.unspendable(unspendable); } - tx_builder.finish().map_err(|e| match e { + let psbt = tx_builder.finish().map_err(|e| match e { bdk_wallet::error::CreateTxError::CoinSelection(InsufficientFunds { needed, available, @@ -304,17 +371,18 @@ pub trait WalletOnline: WalletOffline { _ => Error::Internal { details: e.to_string(), }, - }) + })?; + + if !dry_run { + self.reserve_vanilla_txos(&psbt, WalletTransactionType::Drain)?; + } + + Ok(psbt) } fn drain_to_end_impl(&mut self, signed_psbt: &Psbt) -> Result { let tx = self.broadcast_psbt(signed_psbt, false)?; - self.database() - .set_wallet_transaction(DbWalletTransactionActMod { - txid: ActiveValue::Set(tx.compute_txid().to_string()), - r#type: ActiveValue::Set(WalletTransactionType::Drain), - ..Default::default() - })?; + self.finalize_vanilla_wallet_transaction(signed_psbt, WalletTransactionType::Drain)?; Ok(tx) } @@ -2954,6 +3022,7 @@ pub trait WalletOnline: WalletOffline { amount: u64, fee_rate: u64, skip_sync: bool, + dry_run: bool, ) -> Result { let fee_rate_checked = self.check_fee_rate(fee_rate)?; @@ -2970,7 +3039,7 @@ pub trait WalletOnline: WalletOffline { .unspendable(unspendable) .add_recipient(script_pubkey, BdkAmount::from_sat(amount)) .fee_rate(fee_rate_checked); - tx_builder.finish().map_err(|e| match e { + let psbt = tx_builder.finish().map_err(|e| match e { bdk_wallet::error::CreateTxError::CoinSelection(InsufficientFunds { needed, available, @@ -2984,11 +3053,18 @@ pub trait WalletOnline: WalletOffline { _ => Error::Internal { details: e.to_string(), }, - }) + })?; + + if !dry_run { + self.reserve_vanilla_txos(&psbt, WalletTransactionType::SendBtc)?; + } + + Ok(psbt) } fn send_btc_end_impl(&mut self, signed_psbt: &Psbt, skip_sync: bool) -> Result { let tx = self.broadcast_psbt(signed_psbt, skip_sync)?; + self.finalize_vanilla_wallet_transaction(signed_psbt, WalletTransactionType::SendBtc)?; Ok(tx.compute_txid().to_string()) } diff --git a/src/wallet/singlesig.rs b/src/wallet/singlesig.rs index 470fec72..b7063f09 100644 --- a/src/wallet/singlesig.rs +++ b/src/wallet/singlesig.rs @@ -246,6 +246,57 @@ impl Wallet { Ok(address.to_string()) } + /// List the pending vanilla transactions that have reserved TXOs in the wallet. + /// + /// A vanilla transaction becomes "pending" when the caller invokes a vanilla `_begin` method + /// (e.g. [`send_btc_begin`](Wallet::send_btc_begin)) with `dry_run = false`. The reserved + /// TXOs are freed when the matching `_end` method is called or via + /// [`abort_pending_vanilla_tx`](Wallet::abort_pending_vanilla_tx). + pub fn list_pending_vanilla_txs(&self) -> Result, Error> { + info!(self.logger(), "Listing pending vanilla TXs..."); + let reserved_idxs: Vec = self + .database() + .iter_reserved_txos()? + .into_iter() + .filter_map(|r| r.reserved_for) + .collect::>() + .into_iter() + .collect(); + if reserved_idxs.is_empty() { + return Ok(vec![]); + } + let result = self + .database() + .get_wallet_transactions_by_idxs(&reserved_idxs)? + .into_iter() + .map(|wt| PendingVanillaTx { + txid: wt.txid, + r#type: wt.r#type, + }) + .collect(); + info!(self.logger(), "List pending vanilla TXs completed"); + Ok(result) + } + + /// Abort a pending vanilla transaction, releasing the TXOs it reserved. + /// + /// Errors with [`Error::CannotAbortPendingVanillaTx`] if no pending vanilla transaction with + /// the given `txid` is found (e.g. because it was never created by the wallet, was already + /// aborted or has been broadcast). + pub fn abort_pending_vanilla_tx(&self, txid: String) -> Result<(), Error> { + info!(self.logger(), "Aborting pending vanilla TX {}...", txid); + let (wt, reservations) = self + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&txid)? + .ok_or(Error::CannotAbortPendingVanillaTx)?; + if reservations.is_empty() { + return Err(Error::CannotAbortPendingVanillaTx); + } + self.database().del_wallet_transaction(wt.idx)?; // relies on cascade to delete reserved txos + info!(self.logger(), "Abort pending vanilla TX completed"); + Ok(()) + } + fn finalize_offline_issuance( &self, issue_data: &IssueData, @@ -528,7 +579,7 @@ impl Wallet { info!(self.logger(), "Creating UTXOs..."); self.check_xprv()?; self.check_online(online)?; - let mut psbt = self.create_utxos_begin_impl(up_to, num, size, fee_rate, skip_sync)?; + let mut psbt = self.create_utxos_begin_impl(up_to, num, size, fee_rate, skip_sync, true)?; self.sign_psbt_impl(&mut psbt, None)?; let res = self.create_utxos_end_impl(&psbt, skip_sync)?; self.update_backup_info(false)?; @@ -552,6 +603,11 @@ impl Wallet { /// UTXOs, the number is decremented by one until it is possible to complete the operation. If /// the number reaches zero, an error is returned. /// + /// If `dry_run` is true, the wallet does not reserve the selected vanilla TXOs. The returned + /// PSBT can still be signed and completed with + /// [`create_utxos_end`](Wallet::create_utxos_end) but concurrent vanilla operations may try + /// to spend the same inputs. + /// /// Signing of the returned PSBT needs to be carried out separately. The signed PSBT then needs /// to be fed to the [`create_utxos_end`](Wallet::create_utxos_end) function. /// @@ -566,10 +622,11 @@ impl Wallet { size: Option, fee_rate: u64, skip_sync: bool, + dry_run: bool, ) -> Result { info!(self.logger(), "Creating UTXOs (begin)..."); self.check_online(online)?; - let res = self.create_utxos_begin_impl(up_to, num, size, fee_rate, skip_sync)?; + let res = self.create_utxos_begin_impl(up_to, num, size, fee_rate, skip_sync, dry_run)?; info!(self.logger(), "Create UTXOs (begin) completed"); Ok(res.to_string()) } @@ -636,7 +693,7 @@ impl Wallet { ); self.check_xprv()?; self.check_online(online)?; - let mut psbt = self.drain_to_begin_impl(address, destroy_assets, fee_rate)?; + let mut psbt = self.drain_to_begin_impl(address, destroy_assets, fee_rate, true)?; self.sign_psbt_impl(&mut psbt, None)?; let tx = self.drain_to_end_impl(&psbt)?; self.update_backup_info(false)?; @@ -652,6 +709,10 @@ impl Wallet { /// only do this if you know what you're doing! After destroying assets the wallet's RGB state /// could be compromised and therefore the wallet should not be used anymore. /// + /// If `dry_run` is true, the wallet does not reserve the selected vanilla TXOs. The returned + /// PSBT can still be signed and completed with [`drain_to_end`](Wallet::drain_to_end) but + /// concurrent vanilla operations may try to spend the same inputs. + /// /// Signing of the returned PSBT needs to be carried out separately. The signed PSBT then needs /// to be fed to the [`drain_to_end`](Wallet::drain_to_end) function. /// @@ -664,13 +725,14 @@ impl Wallet { address: String, destroy_assets: bool, fee_rate: u64, + dry_run: bool, ) -> Result { info!( self.logger(), "Draining (begin) to '{}' destroying asset '{}'...", address, destroy_assets ); self.check_online(online)?; - let psbt = self.drain_to_begin_impl(address, destroy_assets, fee_rate)?; + let psbt = self.drain_to_begin_impl(address, destroy_assets, fee_rate, dry_run)?; info!(self.logger(), "Drain (begin) completed"); Ok(psbt.to_string()) } @@ -840,7 +902,7 @@ impl Wallet { info!(self.logger(), "Sending BTC..."); self.check_xprv()?; self.check_online(online)?; - let mut psbt = self.send_btc_begin_impl(address, amount, fee_rate, skip_sync)?; + let mut psbt = self.send_btc_begin_impl(address, amount, fee_rate, skip_sync, true)?; self.sign_psbt_impl(&mut psbt, None)?; let res = self.send_btc_end_impl(&psbt, skip_sync)?; info!(self.logger(), "Send BTC completed"); @@ -850,6 +912,10 @@ impl Wallet { /// Prepare the PSBT to send the specified `amount` of bitcoins (in sats) using the vanilla /// wallet to the specified Bitcoin `address` with the specified `fee_rate` (in sat/vB). /// + /// If `dry_run` is true, the wallet does not reserve the selected vanilla TXOs. The returned + /// PSBT can still be signed and completed with [`send_btc_end`](Wallet::send_btc_end) but + /// concurrent vanilla operations may try to spend the same inputs. + /// /// Signing of the returned PSBT needs to be carried out separately. The signed PSBT then needs /// to be fed to the [`send_btc_end`](Wallet::send_btc_end) function. /// @@ -863,10 +929,11 @@ impl Wallet { amount: u64, fee_rate: u64, skip_sync: bool, + dry_run: bool, ) -> Result { info!(self.logger(), "Sending BTC (begin)..."); self.check_online(online)?; - let res = self.send_btc_begin_impl(address, amount, fee_rate, skip_sync)?; + let res = self.send_btc_begin_impl(address, amount, fee_rate, skip_sync, dry_run)?; info!(self.logger(), "Send BTC (begin) completed"); Ok(res.to_string()) } diff --git a/src/wallet/test/abort_pending_vanilla_tx.rs b/src/wallet/test/abort_pending_vanilla_tx.rs new file mode 100644 index 00000000..c4338e3d --- /dev/null +++ b/src/wallet/test/abort_pending_vanilla_tx.rs @@ -0,0 +1,90 @@ +use super::*; + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn success() { + initialize(); + + let (mut wallet, online) = get_funded_noutxo_wallet!(); + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + let unsigned_psbt_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + 1000, + FEE_RATE, + false, + false, + ) + .unwrap(); + let unsigned_psbt = Psbt::from_str(&unsigned_psbt_str).unwrap(); + let psbt_txid = unsigned_psbt.unsigned_tx.compute_txid().to_string(); + + // pre-abort: reservation + wallet_transaction exist + assert_eq!(wallet.list_pending_vanilla_txs().unwrap().len(), 1); + assert!(!wallet.database().iter_reserved_txos().unwrap().is_empty()); + + // abort + wallet.abort_pending_vanilla_tx(psbt_txid.clone()).unwrap(); + + // post-abort: reservation + wallet_transaction row both gone + assert!(wallet.list_pending_vanilla_txs().unwrap().is_empty()); + assert!(wallet.database().iter_reserved_txos().unwrap().is_empty()); + assert!( + wallet + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&psbt_txid) + .unwrap() + .is_none() + ); + + // the previously-reserved UTXOs are now available again: a fresh send_btc_begin + // re-selects them + let unsigned_psbt_2_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + 1000, + FEE_RATE, + true, + false, + ) + .unwrap(); + let unsigned_psbt_2 = Psbt::from_str(&unsigned_psbt_2_str).unwrap(); + assert_eq!( + unsigned_psbt.unsigned_tx.input, + unsigned_psbt_2.unsigned_tx.input + ); +} + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn fail() { + initialize(); + + let (mut wallet, online) = get_funded_wallet!(); + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + // unknown txid + let result = wallet.abort_pending_vanilla_tx(FAKE_TXID.to_string()); + assert!(matches!(result, Err(Error::CannotAbortPendingVanillaTx))); + + // a wallet_transaction row exists but has no attached reservations + let txid = test_send_btc( + &mut wallet, + online, + &test_get_address(&mut rcv_wallet), + 1000, + ); + let (_wt, reservations) = wallet + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&txid) + .unwrap() + .expect("SendBtc wallet_transaction should exist after send_btc"); + assert!(reservations.is_empty()); + let result = wallet.abort_pending_vanilla_tx(txid); + assert!(matches!(result, Err(Error::CannotAbortPendingVanillaTx))); +} diff --git a/src/wallet/test/create_utxos.rs b/src/wallet/test/create_utxos.rs index b53300c5..45163d3f 100644 --- a/src/wallet/test/create_utxos.rs +++ b/src/wallet/test/create_utxos.rs @@ -300,3 +300,104 @@ fn skip_sync() { let unspents = test_list_unspents(&mut wallet, None, false); assert_eq!(unspents.len(), (UTXO_NUM + 1) as usize); } + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn begin_reservation_interactions() { + initialize(); + + // two independent funding UTXOs, each with enough BTC to cover create_utxos + let (mut wallet, online) = get_empty_wallet!(); + fund_wallet(test_get_address(&mut wallet)); + fund_wallet(test_get_address(&mut wallet)); + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + // create_utxos_begin(dry_run=false) creates CreateUtxos reservation rows + let unsigned_psbt_str = wallet + .create_utxos_begin(online, true, None, None, FEE_RATE, false, false) + .unwrap(); + let unsigned_psbt = Psbt::from_str(&unsigned_psbt_str).unwrap(); + let psbt_txid = unsigned_psbt.unsigned_tx.compute_txid().to_string(); + let psbt_inputs: HashSet<(String, u32)> = unsigned_psbt + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + assert!(!psbt_inputs.is_empty()); + + // wallet_transaction(CreateUtxos) row exists with reservations matching inputs + let (wt, reservations) = wallet + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&psbt_txid) + .unwrap() + .expect("wallet_transaction should exist after dry_run=false begin"); + assert_eq!(wt.r#type, WalletTransactionType::CreateUtxos); + assert!(reservations.iter().all(|r| r.reserved_for == Some(wt.idx))); + let reserved_set: HashSet<(String, u32)> = reservations + .iter() + .map(|r| (r.txid.clone(), r.vout)) + .collect(); + assert_eq!(reserved_set, psbt_inputs); + + // list_pending_vanilla_txs reports the in-flight CreateUtxos entry + let pending = wallet.list_pending_vanilla_txs().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].txid, psbt_txid); + assert_eq!(pending[0].r#type, WalletTransactionType::CreateUtxos); + // clear this in-flight create_utxos reservation + wallet.abort_pending_vanilla_tx(psbt_txid).unwrap(); + assert!(wallet.database().iter_reserved_txos().unwrap().is_empty()); + + // reserve (at least) one vanilla UTXO via send_btc_begin(dry_run=false). BDK's + // coin selection only picks the minimum inputs for amount + fee, so one of the + // two funding UTXOs will be left untouched. + let _ = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + 1000, + FEE_RATE, + false, + false, + ) + .unwrap(); + let reserved_set: HashSet<(String, u32)> = wallet + .database() + .iter_reserved_txos() + .unwrap() + .iter() + .map(|r| (r.txid.clone(), r.vout)) + .collect(); + assert_eq!(reserved_set.len(), 1); + + // create_utxos_begin(dry_run=true) must not select any reserved outpoint + let unsigned_psbt_str = wallet + .create_utxos_begin(online, true, Some(1), None, FEE_RATE, false, true) + .unwrap(); + let unsigned_psbt = Psbt::from_str(&unsigned_psbt_str).unwrap(); + let create_inputs: HashSet<(String, u32)> = unsigned_psbt + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + assert_eq!(create_inputs.len(), 1); + assert!(create_inputs.is_disjoint(&reserved_set)); + + // reserve the remaining vanilla UTXO via create_utxos_begin(dry_run=false) + let _ = wallet + .create_utxos_begin(online, true, None, None, FEE_RATE, false, false) + .unwrap(); + + // now all vanilla UTXOs are reserved, so another begin has nothing usable left + let result = wallet.create_utxos_begin(online, false, Some(1), None, FEE_RATE, true, true); + assert!(matches!( + result, + Err(Error::InsufficientBitcoins { + needed: _, + available: 0 + }) + )); +} diff --git a/src/wallet/test/drain_to.rs b/src/wallet/test/drain_to.rs index 6decb830..c4a11b17 100644 --- a/src/wallet/test/drain_to.rs +++ b/src/wallet/test/drain_to.rs @@ -130,7 +130,7 @@ fn drain_to_begin_and_end_success() { // drain_to_begin does not update backup_info let unsigned_psbt = wallet - .drain_to_begin(online, address, false, FEE_RATE) + .drain_to_begin(online, address, false, FEE_RATE, true) .unwrap(); let bak_info_after_begin = wallet.database().get_backup_info().unwrap().unwrap(); assert_eq!( @@ -223,3 +223,86 @@ fn fail() { ); assert!(matches!(result, Err(Error::WatchOnly))); } + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn reservation_interaction() { + initialize(); + + // wallet with several vanilla UTXOs so drain keep can still succeed while one + // is reserved + let (mut wallet, online) = get_empty_wallet!(); + for _ in 0..3 { + fund_wallet(test_get_address(&mut wallet)); + } + wallet.sync(online).unwrap(); + + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + let (mut drain_wallet, _drain_online) = get_empty_wallet!(); + + // reserve one (or more) vanilla UTXO via send_btc_begin(dry_run=false) + let send_psbt_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + 1000, + FEE_RATE, + true, + false, + ) + .unwrap(); + let send_psbt = Psbt::from_str(&send_psbt_str).unwrap(); + let reserved_inputs: HashSet<(String, u32)> = send_psbt + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + assert!(!reserved_inputs.is_empty()); + + // drain (keep assets) must avoid the reserved outpoints + let drain_psbt_str = wallet + .drain_to_begin( + online, + test_get_address(&mut drain_wallet), + false, + FEE_RATE, + true, + ) + .unwrap(); + let drain_psbt = Psbt::from_str(&drain_psbt_str).unwrap(); + let drain_inputs: HashSet<(String, u32)> = drain_psbt + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + assert!(!drain_inputs.is_empty()); + assert!(drain_inputs.is_disjoint(&reserved_inputs)); + + // drain with destroy_assets=true ignores reservations: it consumes the reserved outpoints along + // with everything else + let destroy_psbt_str = wallet + .drain_to_begin( + online, + test_get_address(&mut drain_wallet), + true, + FEE_RATE, + true, + ) + .unwrap(); + let destroy_psbt = Psbt::from_str(&destroy_psbt_str).unwrap(); + let destroy_inputs: HashSet<(String, u32)> = destroy_psbt + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + // the destroy drain should include all reserved outpoints + assert!( + reserved_inputs + .iter() + .all(|outpoint| destroy_inputs.contains(outpoint)) + ); +} diff --git a/src/wallet/test/finalize_psbt.rs b/src/wallet/test/finalize_psbt.rs index 7cb63028..0801cb43 100644 --- a/src/wallet/test/finalize_psbt.rs +++ b/src/wallet/test/finalize_psbt.rs @@ -8,7 +8,7 @@ fn success() { let (mut wallet, online) = get_funded_wallet!(); let address = test_get_address(&mut wallet); let unsigned_psbt_str = wallet - .send_btc_begin(online, address, AMOUNT, FEE_RATE, false) + .send_btc_begin(online, address, AMOUNT, FEE_RATE, false, true) .unwrap(); let signed_psbt = wallet.sign_psbt(unsigned_psbt_str.clone(), None).unwrap(); let finalized_psbt = wallet.finalize_psbt(signed_psbt, None); @@ -26,7 +26,7 @@ fn fail() { let address = test_get_address(&mut wallet_1); let unsigned_psbt_str = wallet_1 - .send_btc_begin(online, address, AMOUNT, FEE_RATE, false) + .send_btc_begin(online, address, AMOUNT, FEE_RATE, false, true) .unwrap(); let wallet_2 = get_test_wallet(true, None); let result = wallet_2.finalize_psbt(unsigned_psbt_str, None); diff --git a/src/wallet/test/inflate.rs b/src/wallet/test/inflate.rs index 59a2f04d..1c675e85 100644 --- a/src/wallet/test/inflate.rs +++ b/src/wallet/test/inflate.rs @@ -501,7 +501,7 @@ fn fail() { // inflate_end input params let address = test_get_address(&mut wallet); let unsigned_psbt = wallet - .send_btc_begin(online, address, 1000, FEE_RATE, false) + .send_btc_begin(online, address, 1000, FEE_RATE, false, true) .unwrap(); let signed_psbt = wallet.sign_psbt(unsigned_psbt, None).unwrap(); // - check online is correct diff --git a/src/wallet/test/list_pending_vanilla_txs.rs b/src/wallet/test/list_pending_vanilla_txs.rs new file mode 100644 index 00000000..dd166fa4 --- /dev/null +++ b/src/wallet/test/list_pending_vanilla_txs.rs @@ -0,0 +1,63 @@ +use super::*; + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn success() { + initialize(); + + let (mut wallet, online) = get_empty_wallet!(); + for _ in 0..3 { + fund_wallet(test_get_address(&mut wallet)); + } + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + // empty to start + assert!(wallet.list_pending_vanilla_txs().unwrap().is_empty()); + + // one send_btc_begin(dry_run=false) creates a SendBtc pending entry + let send_psbt_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + 1000, + FEE_RATE, + false, + false, + ) + .unwrap(); + let send_psbt = Psbt::from_str(&send_psbt_str).unwrap(); + let send_txid = send_psbt.unsigned_tx.compute_txid().to_string(); + let pending = wallet.list_pending_vanilla_txs().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].txid, send_txid); + assert_eq!(pending[0].r#type, WalletTransactionType::SendBtc); + + // a concurrent create_utxos_begin(dry_run=false) adds a second pending entry + // with CreateUtxos type + let create_psbt_str = wallet + .create_utxos_begin(online, false, Some(1), None, FEE_RATE, true, false) + .unwrap(); + let create_psbt = Psbt::from_str(&create_psbt_str).unwrap(); + let create_txid = create_psbt.unsigned_tx.compute_txid().to_string(); + let pending = wallet.list_pending_vanilla_txs().unwrap(); + assert_eq!(pending.len(), 2); + assert!( + pending + .iter() + .any(|p| p.r#type == WalletTransactionType::SendBtc && p.txid == send_txid) + ); + assert!( + pending + .iter() + .any(|p| p.r#type == WalletTransactionType::CreateUtxos && p.txid == create_txid) + ); + + // completing the send_btc drops it from the list + let signed = wallet.sign_psbt(send_psbt_str, None).unwrap(); + let _ = wallet.send_btc_end(online, signed, false).unwrap(); + let pending = wallet.list_pending_vanilla_txs().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].r#type, WalletTransactionType::CreateUtxos); + assert_eq!(pending[0].txid, create_txid); +} diff --git a/src/wallet/test/list_transactions.rs b/src/wallet/test/list_transactions.rs index 5729bff3..60aa667b 100644 --- a/src/wallet/test/list_transactions.rs +++ b/src/wallet/test/list_transactions.rs @@ -33,7 +33,7 @@ fn success() { assert!( transactions .iter() - .any(|t| matches!(t.transaction_type, TransactionType::User)) + .any(|t| matches!(t.transaction_type, TransactionType::Untracked)) ); assert!( transactions @@ -43,7 +43,7 @@ fn success() { assert!( rcv_transactions .iter() - .any(|t| matches!(t.transaction_type, TransactionType::User)) + .any(|t| matches!(t.transaction_type, TransactionType::Untracked)) ); assert!( rcv_transactions diff --git a/src/wallet/test/mod.rs b/src/wallet/test/mod.rs index 812bbc47..2f16b067 100644 --- a/src/wallet/test/mod.rs +++ b/src/wallet/test/mod.rs @@ -300,6 +300,7 @@ mod utils; pub(crate) use utils::{api::*, chain::*, helpers::*}; // API tests +mod abort_pending_vanilla_tx; mod backup; mod blind_receive; mod create_utxos; @@ -321,6 +322,7 @@ mod issue_asset_ifa; mod issue_asset_nia; mod issue_asset_uda; mod list_assets; +mod list_pending_vanilla_txs; mod list_transactions; mod list_transfers; mod list_unspents; diff --git a/src/wallet/test/multisig/mod.rs b/src/wallet/test/multisig/mod.rs index 0c634e8a..faa8440e 100644 --- a/src/wallet/test/multisig/mod.rs +++ b/src/wallet/test/multisig/mod.rs @@ -734,7 +734,7 @@ fn success() { wlt_3.multisig_mut(), ], &op_init.psbt, - &TransactionType::User, + &TransactionType::SendBtc, ); check_btc_balance( &mut [ @@ -832,7 +832,7 @@ fn success() { // final state expectations let btc_final_vanilla = (0, 6442, 6442); let btc_final_colored = (16452, 16452, 16452); - let tx_type_final = TransactionType::User; + let tx_type_final = TransactionType::SendBtc; #[rustfmt::skip] let assets_final = HashMap::from([ (cfa_asset.asset_id.as_str(), (180, 180, 180, 3, TransferStatus::Settled)), diff --git a/src/wallet/test/send_btc.rs b/src/wallet/test/send_btc.rs index eb33ed1d..792b979a 100644 --- a/src/wallet/test/send_btc.rs +++ b/src/wallet/test/send_btc.rs @@ -119,7 +119,14 @@ fn fail() { assert!(matches!(result, Err(Error::OutputBelowDustLimit))); // invalid fee rate (low) - let result = wallet.send_btc_begin(online, test_get_address(&mut rcv_wallet), amount, 0, false); + let result = wallet.send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + amount, + 0, + false, + true, + ); assert!(matches!(result, Err(Error::InvalidFeeRate { details: m }) if m == FEE_MSG_LOW)); // invalid fee rate (overflow) @@ -129,6 +136,7 @@ fn fail() { amount, u64::MAX, false, + true, ); assert!(matches!(result, Err(Error::InvalidFeeRate { details: m }) if m == FEE_MSG_OVER)); } @@ -184,3 +192,226 @@ fn skip_sync() { .unwrap(); assert!(!txid.is_empty()); } + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn begin_reservation_interactions() { + initialize(); + + let amount: u64 = 1000; + + let (mut wallet, online) = get_funded_wallet!(); + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + // no reservations and no SendBtc wallet_transactions initially + assert!(wallet.database().iter_reserved_txos().unwrap().is_empty()); + assert!( + wallet + .database() + .iter_wallet_transactions() + .unwrap() + .iter() + .all(|wt| wt.r#type != WalletTransactionType::SendBtc) + ); + + // capture vanilla spendable balance before reservation + let balance_before = test_get_btc_balance(&mut wallet, online); + assert!(balance_before.vanilla.spendable > 0); + + // begin with dry_run=false reserves the selected inputs + let unsigned_psbt_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + amount, + FEE_RATE, + false, + false, + ) + .unwrap(); + let unsigned_psbt = Psbt::from_str(&unsigned_psbt_str).unwrap(); + let psbt_txid = unsigned_psbt.unsigned_tx.compute_txid().to_string(); + assert_eq!(unsigned_psbt.unsigned_tx.input.len(), 1); + + // vanilla spendable balance reflects the reservation + let balance_reserved = test_get_btc_balance(&mut wallet, online); + assert!(balance_reserved.vanilla.spendable < balance_before.vanilla.spendable); + + // wallet_transaction(SendBtc) row for this txid exists + let (wt, reservations) = wallet + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&psbt_txid) + .unwrap() + .expect("should exist after begin"); + assert_eq!(wt.r#type, WalletTransactionType::SendBtc); + // one reserved_txo per PSBT input, all pointing to that wt row + assert!(reservations.iter().all(|r| r.reserved_for == Some(wt.idx))); + // reservation set exactly matches PSBT inputs + let reserved_set: HashSet<(String, u32)> = reservations + .iter() + .map(|r| (r.txid.clone(), r.vout)) + .collect(); + let input_set: HashSet<(String, u32)> = unsigned_psbt + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + assert_eq!(reserved_set, input_set); + + // list_pending_vanilla_txs reports the in-flight reservation + let pending = wallet.list_pending_vanilla_txs().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].txid, psbt_txid); + assert_eq!(pending[0].r#type, WalletTransactionType::SendBtc); + + // sign + end releases the reservations but keeps the wallet_transaction row + let signed_psbt = wallet.sign_psbt(unsigned_psbt_str, None).unwrap(); + let _end_txid = wallet.send_btc_end(online, signed_psbt, false).unwrap(); + assert!(wallet.database().iter_reserved_txos().unwrap().is_empty()); + assert!(wallet.list_pending_vanilla_txs().unwrap().is_empty()); + + // after end, balance is no longer reduced by reservations (the UTXO is now spent, + // and the change output is the new spendable balance) + let balance_after_end = test_get_btc_balance(&mut wallet, online); + assert!(balance_after_end.vanilla.spendable > balance_reserved.vanilla.spendable); + // the wallet_transaction row is still there (so list_transactions classifies the tx) + let wts: Vec<_> = wallet + .database() + .iter_wallet_transactions() + .unwrap() + .into_iter() + .filter(|wt| wt.txid == psbt_txid && wt.r#type == WalletTransactionType::SendBtc) + .collect(); + assert_eq!(wts.len(), 1); + + // list_transactions sees it as SendBtc + mine(false, false); + let transactions = test_list_transactions(&mut wallet, Some(online)); + let entry = transactions + .iter() + .find(|t| t.txid == psbt_txid) + .expect("broadcast tx should show up"); + assert!(matches!(entry.transaction_type, TransactionType::SendBtc)); + + // dry_run=true begin does not create reservations/wallet_transaction row up-front + let unsigned_psbt_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + amount, + FEE_RATE, + false, + true, + ) + .unwrap(); + let unsigned_psbt = Psbt::from_str(&unsigned_psbt_str).unwrap(); + let psbt_txid = unsigned_psbt.unsigned_tx.compute_txid().to_string(); + + // no reservations, no wallet_transaction row yet + assert!(wallet.database().iter_reserved_txos().unwrap().is_empty()); + assert!( + wallet + .database() + .get_wallet_transaction_with_reserved_txos_by_txid(&psbt_txid) + .unwrap() + .is_none() + ); + assert!(wallet.list_pending_vanilla_txs().unwrap().is_empty()); + + // end still creates the SendBtc row after broadcast (for list_transactions) + let signed_psbt = wallet.sign_psbt(unsigned_psbt_str, None).unwrap(); + let end_txid = wallet.send_btc_end(online, signed_psbt, false).unwrap(); + assert_eq!(end_txid, psbt_txid); + assert!(wallet.database().iter_reserved_txos().unwrap().is_empty()); + let wts: Vec<_> = wallet + .database() + .iter_wallet_transactions() + .unwrap() + .into_iter() + .filter(|wt| wt.txid == psbt_txid && wt.r#type == WalletTransactionType::SendBtc) + .collect(); + assert_eq!(wts.len(), 1); +} + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn two_concurrent_begins_pick_disjoint_inputs() { + initialize(); + + let amount: u64 = 1000; + + // wallet with several separate vanilla UTXOs to choose from + let (mut wallet, online) = get_empty_wallet!(); + for _ in 0..3 { + fund_wallet(test_get_address(&mut wallet)); + } + wallet.sync(online).unwrap(); + + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + let psbt_1_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + amount, + FEE_RATE, + true, + false, + ) + .unwrap(); + let psbt_1 = Psbt::from_str(&psbt_1_str).unwrap(); + let inputs_1: HashSet<(String, u32)> = psbt_1 + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + + let psbt_2_str = wallet + .send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + amount, + FEE_RATE, + true, + false, + ) + .unwrap(); + let psbt_2 = Psbt::from_str(&psbt_2_str).unwrap(); + let inputs_2: HashSet<(String, u32)> = psbt_2 + .unsigned_tx + .input + .iter() + .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) + .collect(); + + assert!(!inputs_1.is_empty()); + assert!(!inputs_2.is_empty()); + assert!(inputs_1.is_disjoint(&inputs_2)); + + // both reservations are live + let pending = wallet.list_pending_vanilla_txs().unwrap(); + assert_eq!(pending.len(), 2); +} + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn full_send_btc_leaves_no_pending() { + initialize(); + + let (mut wallet, online) = get_funded_wallet!(); + let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); + + // a full send_btc (which uses dry_run=true internally) leaves no pending entry + let _ = test_send_btc( + &mut wallet, + online, + &test_get_address(&mut rcv_wallet), + 1000, + ); + assert!(wallet.list_pending_vanilla_txs().unwrap().is_empty()); +} diff --git a/src/wallet/test/sign_psbt.rs b/src/wallet/test/sign_psbt.rs index b4b27919..04c87b29 100644 --- a/src/wallet/test/sign_psbt.rs +++ b/src/wallet/test/sign_psbt.rs @@ -9,7 +9,7 @@ fn success() { let address = test_get_address(&mut wallet); let unsigned_psbt_str = wallet - .send_btc_begin(online, address, AMOUNT, FEE_RATE, false) + .send_btc_begin(online, address, AMOUNT, FEE_RATE, false, true) .unwrap(); // no SignOptions diff --git a/src/wallet/test/utils/api.rs b/src/wallet/test/utils/api.rs index df1abb51..7203cc29 100644 --- a/src/wallet/test/utils/api.rs +++ b/src/wallet/test/utils/api.rs @@ -84,7 +84,7 @@ pub(crate) fn test_create_utxos_begin_result( size: Option, fee_rate: u64, ) -> Result { - wallet.create_utxos_begin(online, up_to, num, size, fee_rate, false) + wallet.create_utxos_begin(online, up_to, num, size, fee_rate, false, true) } pub(crate) fn test_delete_transfers( @@ -121,7 +121,7 @@ pub(crate) fn test_drain_to_begin_result( destroy_assets: bool, fee_rate: u64, ) -> Result { - wallet.drain_to_begin(online, address.to_string(), destroy_assets, fee_rate) + wallet.drain_to_begin(online, address.to_string(), destroy_assets, fee_rate, true) } #[cfg(any(feature = "electrum", feature = "esplora"))] From 107d69e71d41f0adad44d0166b8e468373ba38dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= Date: Wed, 15 Apr 2026 14:37:22 +0200 Subject: [PATCH 06/10] remove destroy_assets bool from drain_to --- bindings/uniffi/src/lib.rs | 7 +-- bindings/uniffi/src/rgb-lib.udl | 4 +- src/wallet/online.rs | 10 ---- src/wallet/singlesig.rs | 25 ++++------ src/wallet/test/drain_to.rs | 81 ++++++-------------------------- src/wallet/test/go_online.rs | 10 ++-- src/wallet/test/rust_only.rs | 15 +++--- src/wallet/test/send.rs | 18 +++++-- src/wallet/test/utils/api.rs | 17 ++----- src/wallet/test/utils/helpers.rs | 2 +- 10 files changed, 60 insertions(+), 129 deletions(-) diff --git a/bindings/uniffi/src/lib.rs b/bindings/uniffi/src/lib.rs index a8eb3109..fbe6c6da 100644 --- a/bindings/uniffi/src/lib.rs +++ b/bindings/uniffi/src/lib.rs @@ -957,23 +957,20 @@ impl Wallet { &self, online: Online, address: String, - destroy_assets: bool, fee_rate: u64, ) -> Result { - self._get_wallet() - .drain_to(online, address, destroy_assets, fee_rate) + self._get_wallet().drain_to(online, address, fee_rate) } fn drain_to_begin( &self, online: Online, address: String, - destroy_assets: bool, fee_rate: u64, dry_run: bool, ) -> Result { self._get_wallet() - .drain_to_begin(online, address, destroy_assets, fee_rate, dry_run) + .drain_to_begin(online, address, fee_rate, dry_run) } fn drain_to_end(&self, online: Online, signed_psbt: String) -> Result { diff --git a/bindings/uniffi/src/rgb-lib.udl b/bindings/uniffi/src/rgb-lib.udl index 09a12224..034ea5fc 100644 --- a/bindings/uniffi/src/rgb-lib.udl +++ b/bindings/uniffi/src/rgb-lib.udl @@ -777,11 +777,11 @@ interface Wallet { [Throws=RgbLibError] string drain_to( - Online online, string address, boolean destroy_assets, u64 fee_rate); + Online online, string address, u64 fee_rate); [Throws=RgbLibError] string drain_to_begin( - Online online, string address, boolean destroy_assets, u64 fee_rate, + Online online, string address, u64 fee_rate, boolean dry_run); [Throws=RgbLibError] diff --git a/src/wallet/online.rs b/src/wallet/online.rs index 98911323..d0963bff 100644 --- a/src/wallet/online.rs +++ b/src/wallet/online.rs @@ -332,7 +332,6 @@ pub trait WalletOnline: WalletOffline { fn drain_to_begin_impl( &mut self, address: String, - destroy_assets: bool, fee_rate: u64, dry_run: bool, ) -> Result { @@ -342,21 +341,12 @@ pub trait WalletOnline: WalletOffline { let script_pubkey = self.get_script_pubkey(&address)?; - let mut unspendable = None; - if !destroy_assets { - unspendable = Some(self.get_unspendable_bdk_outpoints()?); - } - let mut tx_builder = self.bdk_wallet_mut().build_tx(); tx_builder .drain_wallet() .drain_to(script_pubkey) .fee_rate(fee_rate_checked); - if let Some(unspendable) = unspendable { - tx_builder.unspendable(unspendable); - } - let psbt = tx_builder.finish().map_err(|e| match e { bdk_wallet::error::CreateTxError::CoinSelection(InsufficientFunds { needed, diff --git a/src/wallet/singlesig.rs b/src/wallet/singlesig.rs index b7063f09..3618b82f 100644 --- a/src/wallet/singlesig.rs +++ b/src/wallet/singlesig.rs @@ -684,16 +684,12 @@ impl Wallet { &mut self, online: Online, address: String, - destroy_assets: bool, fee_rate: u64, ) -> Result { - info!( - self.logger(), - "Draining to '{}' destroying asset '{}'...", address, destroy_assets - ); + info!(self.logger(), "Draining to '{}'...", address); self.check_xprv()?; self.check_online(online)?; - let mut psbt = self.drain_to_begin_impl(address, destroy_assets, fee_rate, true)?; + let mut psbt = self.drain_to_begin_impl(address, fee_rate, true)?; self.sign_psbt_impl(&mut psbt, None)?; let tx = self.drain_to_end_impl(&psbt)?; self.update_backup_info(false)?; @@ -701,13 +697,12 @@ impl Wallet { Ok(tx.compute_txid().to_string()) } - /// Prepare the PSBT to send bitcoin funds not in use for RGB allocations, or all funds if - /// `destroy_assets` is set to true, to the provided Bitcoin `address` with the provided + /// Prepare the PSBT to send all bitcoin funds to the provided `address` with the provided /// `fee_rate` (in sat/vB). /// - ///
Warning: setting destroy_assets to true is dangerous, - /// only do this if you know what you're doing! After destroying assets the wallet's RGB state - /// could be compromised and therefore the wallet should not be used anymore.
+ ///
Warning: draining all funds is a destructive and irreversible + /// operation, only do this if you know what you're doing! After draining the wallet will not + /// be usable anymore.
/// /// If `dry_run` is true, the wallet does not reserve the selected vanilla TXOs. The returned /// PSBT can still be signed and completed with [`drain_to_end`](Wallet::drain_to_end) but @@ -723,16 +718,12 @@ impl Wallet { &mut self, online: Online, address: String, - destroy_assets: bool, fee_rate: u64, dry_run: bool, ) -> Result { - info!( - self.logger(), - "Draining (begin) to '{}' destroying asset '{}'...", address, destroy_assets - ); + info!(self.logger(), "Draining (begin) to '{}'...", address); self.check_online(online)?; - let psbt = self.drain_to_begin_impl(address, destroy_assets, fee_rate, dry_run)?; + let psbt = self.drain_to_begin_impl(address, fee_rate, dry_run)?; info!(self.logger(), "Drain (begin) completed"); Ok(psbt.to_string()) } diff --git a/src/wallet/test/drain_to.rs b/src/wallet/test/drain_to.rs index c4a11b17..8a38cdc7 100644 --- a/src/wallet/test/drain_to.rs +++ b/src/wallet/test/drain_to.rs @@ -26,7 +26,7 @@ fn success() { wait_for_btc_balance(&mut wallet, online, &expected_balance); let address = test_get_address(&mut rcv_wallet); // also updates backup_info let bak_info_before = wallet.database().get_backup_info().unwrap().unwrap(); - test_drain_to_keep(&mut wallet, online, &address); + test_drain_to(&mut wallet, online, &address); let bak_info_after = wallet.database().get_backup_info().unwrap().unwrap(); assert!(bak_info_after.last_operation_timestamp > bak_info_before.last_operation_timestamp); mine(false, false); @@ -52,10 +52,7 @@ fn success() { }, }; wait_for_btc_balance(&mut wallet, online, &expected_balance); - test_drain_to_keep(&mut wallet, online, &test_get_address(&mut rcv_wallet)); - mine(false, false); - wait_for_unspents(&mut wallet, Some(online), false, UTXO_NUM); - test_drain_to_destroy(&mut wallet, online, &test_get_address(&mut rcv_wallet)); + test_drain_to(&mut wallet, online, &test_get_address(&mut rcv_wallet)); mine(false, false); wait_for_unspents(&mut wallet, Some(online), false, 0); } @@ -105,7 +102,7 @@ fn pending_witness_receive() { // drain receiver, which syncs the wallet, detecting (and draining) the new UTXO as well let address = test_get_address(&mut drain_wallet); - test_drain_to_destroy(&mut rcv_wallet, rcv_online, &address); + test_drain_to(&mut rcv_wallet, rcv_online, &address); let unspents = list_test_unspents(&mut rcv_wallet, "after draining"); assert_eq!(unspents.len(), 0); @@ -130,7 +127,7 @@ fn drain_to_begin_and_end_success() { // drain_to_begin does not update backup_info let unsigned_psbt = wallet - .drain_to_begin(online, address, false, FEE_RATE, true) + .drain_to_begin(online, address, FEE_RATE, true) .unwrap(); let bak_info_after_begin = wallet.database().get_backup_info().unwrap().unwrap(); assert_eq!( @@ -163,12 +160,7 @@ fn fail() { let (mut rcv_wallet, rcv_online) = get_empty_wallet!(); // drain empty wallet - let result = test_drain_to_result( - &mut wallet, - online, - &test_get_address(&mut rcv_wallet), - true, - ); + let result = test_drain_to_result(&mut wallet, online, &test_get_address(&mut rcv_wallet)); assert!(matches!( result, Err(Error::InsufficientBitcoins { @@ -179,27 +171,17 @@ fn fail() { // bad online object fund_wallet(test_get_address(&mut wallet)); - let result = test_drain_to_result( - &mut wallet, - rcv_online, - &test_get_address(&mut rcv_wallet), - false, - ); + let result = test_drain_to_result(&mut wallet, rcv_online, &test_get_address(&mut rcv_wallet)); assert!(matches!(result, Err(Error::CannotChangeOnline))); // bad address - let result = test_drain_to_result(&mut wallet, online, "invalid address", false); + let result = test_drain_to_result(&mut wallet, online, "invalid address"); assert!(matches!(result, Err(Error::InvalidAddress { details: _ }))); // fee min fund_wallet(test_get_address(&mut wallet)); - let result = test_drain_to_begin_result( - &mut wallet, - online, - &test_get_address(&mut rcv_wallet), - true, - 0, - ); + let result = + test_drain_to_begin_result(&mut wallet, online, &test_get_address(&mut rcv_wallet), 0); assert!(matches!(result, Err(Error::InvalidFeeRate { details: m }) if m == FEE_MSG_LOW)); // fee overflow @@ -208,19 +190,13 @@ fn fail() { &mut wallet, online, &test_get_address(&mut rcv_wallet), - true, u64::MAX, ); assert!(matches!(result, Err(Error::InvalidFeeRate { details: m }) if m == FEE_MSG_OVER)); // no private keys let (mut wallet, online) = get_funded_noutxo_wallet(false, None); - let result = test_drain_to_result( - &mut wallet, - online, - &test_get_address(&mut rcv_wallet), - false, - ); + let result = test_drain_to_result(&mut wallet, online, &test_get_address(&mut rcv_wallet)); assert!(matches!(result, Err(Error::WatchOnly))); } @@ -230,8 +206,7 @@ fn fail() { fn reservation_interaction() { initialize(); - // wallet with several vanilla UTXOs so drain keep can still succeed while one - // is reserved + // wallet with several vanilla UTXOs so send_btc_begin can reserve a subset let (mut wallet, online) = get_empty_wallet!(); for _ in 0..3 { fund_wallet(test_get_address(&mut wallet)); @@ -261,15 +236,10 @@ fn reservation_interaction() { .collect(); assert!(!reserved_inputs.is_empty()); - // drain (keep assets) must avoid the reserved outpoints + // drain always spends all wallet UTXOs, including those reserved by in-flight vanilla + // transactions let drain_psbt_str = wallet - .drain_to_begin( - online, - test_get_address(&mut drain_wallet), - false, - FEE_RATE, - true, - ) + .drain_to_begin(online, test_get_address(&mut drain_wallet), FEE_RATE, true) .unwrap(); let drain_psbt = Psbt::from_str(&drain_psbt_str).unwrap(); let drain_inputs: HashSet<(String, u32)> = drain_psbt @@ -279,30 +249,9 @@ fn reservation_interaction() { .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) .collect(); assert!(!drain_inputs.is_empty()); - assert!(drain_inputs.is_disjoint(&reserved_inputs)); - - // drain with destroy_assets=true ignores reservations: it consumes the reserved outpoints along - // with everything else - let destroy_psbt_str = wallet - .drain_to_begin( - online, - test_get_address(&mut drain_wallet), - true, - FEE_RATE, - true, - ) - .unwrap(); - let destroy_psbt = Psbt::from_str(&destroy_psbt_str).unwrap(); - let destroy_inputs: HashSet<(String, u32)> = destroy_psbt - .unsigned_tx - .input - .iter() - .map(|i| (i.previous_output.txid.to_string(), i.previous_output.vout)) - .collect(); - // the destroy drain should include all reserved outpoints assert!( reserved_inputs .iter() - .all(|outpoint| destroy_inputs.contains(outpoint)) + .all(|outpoint| drain_inputs.contains(outpoint)) ); } diff --git a/src/wallet/test/go_online.rs b/src/wallet/test/go_online.rs index c5dbaac8..ec8ed8c6 100644 --- a/src/wallet/test/go_online.rs +++ b/src/wallet/test/go_online.rs @@ -168,7 +168,7 @@ fn consistency_check_fail_bitcoins() { bdk_wallet.persist(bdk_database).unwrap(); } let (mut rcv_wallet, _rcv_online) = get_funded_wallet!(); - test_drain_to_destroy( + test_drain_to( &mut wallet_empty, online_empty, &test_get_address(&mut rcv_wallet), @@ -258,17 +258,17 @@ fn consistency_check_fail_utxos() { bdk_wallet.persist(bdk_database).unwrap(); } let (mut rcv_wallet, _rcv_online) = get_funded_wallet!(); - test_drain_to_keep( + test_drain_to( &mut wallet_empty, online_empty, &test_get_address(&mut rcv_wallet), ); // detect asset inconsistency - let err = "DB assets do not match with ones stored in RGB"; + let err = "spent bitcoins with another wallet"; let mut wallet_prefill = Wallet::new(wallet_data_prefill, keys.clone()).unwrap(); let result = test_go_online_result(&mut wallet_prefill, false, None); - assert!(matches!(result, Err(Error::Inconsistency { details: e }) if e == err)); + assert!(matches!(result, Err(Error::Inconsistency { details: e }) if e.contains(err))); // make sure detection works multiple times (doesn't get reset on first failed check) let mut wallet_prefill_2 = Wallet::new(wallet_data_prefill_2, keys.clone()).unwrap(); @@ -278,7 +278,7 @@ fn consistency_check_fail_utxos() { fs::copy(src, dst).unwrap(); } let result = test_go_online_result(&mut wallet_prefill_2, false, None); - assert!(matches!(result, Err(Error::Inconsistency { details: e }) if e == err)); + assert!(matches!(result, Err(Error::Inconsistency { details: e }) if e.contains(err))); } #[cfg(feature = "electrum")] diff --git a/src/wallet/test/rust_only.rs b/src/wallet/test/rust_only.rs index 22202405..180722c3 100644 --- a/src/wallet/test/rust_only.rs +++ b/src/wallet/test/rust_only.rs @@ -13,7 +13,7 @@ fn success() { let (mut wallet_send, online_send) = get_funded_noutxo_wallet!(); let (mut wallet_recv, _online_recv) = get_empty_wallet!(); - // create 1 UTXO and drain the rest + // create 1 UTXO and send the rest test_create_utxos( &mut wallet_send, online_send, @@ -23,10 +23,11 @@ fn success() { FEE_RATE, None, ); - test_drain_to_keep( + test_send_btc( &mut wallet_send, online_send, &test_get_address(&mut wallet_recv), + 99_998_200, ); // issue @@ -342,7 +343,7 @@ fn color_psbt_uda() { let (mut wallet_send, online_send) = get_funded_noutxo_wallet!(); let (mut wallet_recv, _online_recv) = get_empty_wallet!(); - // create 1 UTXO and drain the rest + // create 1 UTXO and send the rest test_create_utxos( &mut wallet_send, online_send, @@ -352,10 +353,11 @@ fn color_psbt_uda() { FEE_RATE, None, ); - test_drain_to_keep( + test_send_btc( &mut wallet_send, online_send, &test_get_address(&mut wallet_recv), + 99_998_200, ); // issue @@ -471,7 +473,7 @@ fn color_psbt_fail() { let (mut wallet_send, online_send) = get_funded_noutxo_wallet!(); let (mut wallet_recv, _online_recv) = get_empty_wallet!(); - // create 1 UTXO and drain the rest + // create 1 UTXO and send the rest test_create_utxos( &mut wallet_send, online_send, @@ -481,10 +483,11 @@ fn color_psbt_fail() { FEE_RATE, None, ); - test_drain_to_keep( + test_send_btc( &mut wallet_send, online_send, &test_get_address(&mut wallet_recv), + 99_998_200, ); // issue diff --git a/src/wallet/test/send.rs b/src/wallet/test/send.rs index fd9eacc7..25881122 100644 --- a/src/wallet/test/send.rs +++ b/src/wallet/test/send.rs @@ -3454,7 +3454,7 @@ fn insufficient_bitcoins() { let (mut wallet, online) = get_funded_noutxo_wallet!(); let (mut rcv_wallet, _rcv_online) = get_funded_wallet!(); - // create 1 UTXO with not enough bitcoins for a send and drain the rest + // create 1 UTXO with not enough bitcoins for a send and send the rest test_create_utxos( &mut wallet, online, @@ -3464,7 +3464,12 @@ fn insufficient_bitcoins() { FEE_RATE, None, ); - test_drain_to_keep(&mut wallet, online, &test_get_address(&mut rcv_wallet)); + test_send_btc( + &mut wallet, + online, + &test_get_address(&mut rcv_wallet), + 99_999_000, + ); // issue an NIA asset let asset_nia_a = test_issue_asset_nia(&mut wallet, online, None); @@ -3491,7 +3496,7 @@ fn insufficient_bitcoins() { }) )); - // create 1 UTXO for change (add funds, create UTXO, drain the rest) + // create 1 UTXO for change (add funds, create UTXO, send the rest) fund_wallet(test_get_address(&mut wallet)); test_create_utxos( &mut wallet, @@ -3502,7 +3507,12 @@ fn insufficient_bitcoins() { FEE_RATE, None, ); - test_drain_to_keep(&mut wallet, online, &test_get_address(&mut rcv_wallet)); + test_send_btc( + &mut wallet, + online, + &test_get_address(&mut rcv_wallet), + 99_999_000, + ); // send works with no colorable UTXOs available as additional bitcoin inputs wait_for_unspents(&mut wallet, None, false, 2); diff --git a/src/wallet/test/utils/api.rs b/src/wallet/test/utils/api.rs index 7203cc29..81ffb585 100644 --- a/src/wallet/test/utils/api.rs +++ b/src/wallet/test/utils/api.rs @@ -108,9 +108,8 @@ pub(crate) fn test_drain_to_result( wallet: &mut Wallet, online: Online, address: &str, - destroy_assets: bool, ) -> Result { - wallet.drain_to(online, address.to_string(), destroy_assets, FEE_RATE) + wallet.drain_to(online, address.to_string(), FEE_RATE) } #[cfg(any(feature = "electrum", feature = "esplora"))] @@ -118,23 +117,15 @@ pub(crate) fn test_drain_to_begin_result( wallet: &mut Wallet, online: Online, address: &str, - destroy_assets: bool, fee_rate: u64, ) -> Result { - wallet.drain_to_begin(online, address.to_string(), destroy_assets, fee_rate, true) + wallet.drain_to_begin(online, address.to_string(), fee_rate, true) } #[cfg(any(feature = "electrum", feature = "esplora"))] -pub(crate) fn test_drain_to_destroy(wallet: &mut Wallet, online: Online, address: &str) -> String { +pub(crate) fn test_drain_to(wallet: &mut Wallet, online: Online, address: &str) -> String { wallet - .drain_to(online, address.to_string(), true, FEE_RATE) - .unwrap() -} - -#[cfg(any(feature = "electrum", feature = "esplora"))] -pub(crate) fn test_drain_to_keep(wallet: &mut Wallet, online: Online, address: &str) -> String { - wallet - .drain_to(online, address.to_string(), false, FEE_RATE) + .drain_to(online, address.to_string(), FEE_RATE) .unwrap() } diff --git a/src/wallet/test/utils/helpers.rs b/src/wallet/test/utils/helpers.rs index 1bfbd34b..0177a22d 100644 --- a/src/wallet/test/utils/helpers.rs +++ b/src/wallet/test/utils/helpers.rs @@ -149,7 +149,7 @@ pub(crate) fn get_funded_wallet( #[cfg(any(feature = "electrum", feature = "esplora"))] pub(crate) fn drain_wallet(wallet: &mut Wallet, online: Online) { let mut rcv_wallet = get_test_wallet(false, None); - test_drain_to_destroy(wallet, online, &rcv_wallet.get_address().unwrap()); + test_drain_to(wallet, online, &rcv_wallet.get_address().unwrap()); } #[cfg(any(feature = "electrum", feature = "esplora"))] From 9910c20331457626f93d902be72b3f44a6c2b20a Mon Sep 17 00:00:00 2001 From: Nicola Busanello Date: Wed, 15 Apr 2026 17:06:55 +0200 Subject: [PATCH 07/10] add send_btc_end_twice test --- src/wallet/test/send_btc.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/wallet/test/send_btc.rs b/src/wallet/test/send_btc.rs index 792b979a..9116749b 100644 --- a/src/wallet/test/send_btc.rs +++ b/src/wallet/test/send_btc.rs @@ -415,3 +415,22 @@ fn full_send_btc_leaves_no_pending() { ); assert!(wallet.list_pending_vanilla_txs().unwrap().is_empty()); } + +#[cfg(feature = "electrum")] +#[test] +#[parallel] +fn send_btc_end_twice() { + initialize(); + + // wallet + let (mut wallet, online) = get_funded_wallet!(); + + // prepare PSBT + let address = test_get_address(&mut wallet); + let unsigned_psbt = wallet.send_btc_begin(online, address, 1000, FEE_RATE, false, false).unwrap(); + let signed_psbt = wallet.sign_psbt(unsigned_psbt, None).unwrap(); + + // call send_btc_end twice with the same PSBT, which should work (idempotent) + wallet.send_btc_end(online, signed_psbt.clone(), false).unwrap(); + wallet.send_btc_end(online, signed_psbt, false).unwrap(); +} From fce0ee29231c62e85d63052092c317048f440ad4 Mon Sep 17 00:00:00 2001 From: Nicola Busanello Date: Thu, 16 Apr 2026 17:27:14 +0200 Subject: [PATCH 08/10] fix broadcast_psbt colored TXO detection --- src/wallet/multisig.rs | 4 ---- src/wallet/online.rs | 20 ++++---------------- src/wallet/singlesig.rs | 4 ---- 3 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/wallet/multisig.rs b/src/wallet/multisig.rs index adeb217d..730db901 100644 --- a/src/wallet/multisig.rs +++ b/src/wallet/multisig.rs @@ -271,10 +271,6 @@ impl WalletOnline for MultisigWallet { Ok(()) } - fn list_internal_for_broadcast(&self) -> impl Iterator + '_ { - self.internal_outputs() - } - fn get_hub_fail_status(&self, batch_transfer_idx: i32) -> Result { Ok(self .hub_client() diff --git a/src/wallet/online.rs b/src/wallet/online.rs index d0963bff..0bd08cfe 100644 --- a/src/wallet/online.rs +++ b/src/wallet/online.rs @@ -87,8 +87,6 @@ pub trait WalletOnline: WalletOffline { } } - fn list_internal_for_broadcast(&self) -> impl Iterator + '_; - fn broadcast_psbt( &mut self, signed_psbt: &Psbt, @@ -101,24 +99,14 @@ pub trait WalletOnline: WalletOffline { .map_err(InternalError::from)?, )?; - let internal_outpoints: Vec<(String, u32)> = self - .list_internal_for_broadcast() - .map(|u| (u.outpoint.txid.to_string(), u.outpoint.vout)) - .collect(); - for input in tx.clone().input { let txid = input.previous_output.txid.to_string(); let vout = input.previous_output.vout; - if internal_outpoints.contains(&(txid.clone(), vout)) { - continue; + if let Some(db_txo) = self.database().get_txo(&Outpoint { txid, vout })? { + let mut db_txo: DbTxoActMod = db_txo.into(); + db_txo.spent = ActiveValue::Set(true); + self.database().update_txo(db_txo)?; } - let mut db_txo: DbTxoActMod = self - .database() - .get_txo(&Outpoint { txid, vout })? - .expect("outpoint should be in the DB") - .into(); - db_txo.spent = ActiveValue::Set(true); - self.database().update_txo(db_txo)?; } if !skip_sync { diff --git a/src/wallet/singlesig.rs b/src/wallet/singlesig.rs index 3618b82f..bf5c7a7f 100644 --- a/src/wallet/singlesig.rs +++ b/src/wallet/singlesig.rs @@ -132,10 +132,6 @@ impl WalletOnline for Wallet { } Ok(()) } - - fn list_internal_for_broadcast(&self) -> impl Iterator + '_ { - self.internal_unspents() - } } /// Common offline APIs of the wallet. From 87d4720d389bcd81c9bf707f70e46ba2f529e0ff Mon Sep 17 00:00:00 2001 From: Nicola Busanello Date: Fri, 17 Apr 2026 10:44:45 +0200 Subject: [PATCH 09/10] test drain_to_begin with dry_run=false --- src/wallet/test/drain_to.rs | 28 +++++++++++++++++++++++++++- src/wallet/test/send_btc.rs | 8 ++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/wallet/test/drain_to.rs b/src/wallet/test/drain_to.rs index 8a38cdc7..6f7c69a4 100644 --- a/src/wallet/test/drain_to.rs +++ b/src/wallet/test/drain_to.rs @@ -206,7 +206,7 @@ fn fail() { fn reservation_interaction() { initialize(); - // wallet with several vanilla UTXOs so send_btc_begin can reserve a subset + // wallet with several vanilla UTXOs so they can be reserved let (mut wallet, online) = get_empty_wallet!(); for _ in 0..3 { fund_wallet(test_get_address(&mut wallet)); @@ -216,6 +216,32 @@ fn reservation_interaction() { let (mut rcv_wallet, _rcv_online) = get_empty_wallet!(); let (mut drain_wallet, _drain_online) = get_empty_wallet!(); + // reserve all vanilla UTXO via drain_to_begin(dry_run=false) + let psbt = wallet + .drain_to_begin(online, test_get_address(&mut rcv_wallet), FEE_RATE, false) + .unwrap(); + + // check send_btc cannot spend the reserved UTXOs + let res = wallet.send_btc_begin( + online, + test_get_address(&mut rcv_wallet), + 1000, + FEE_RATE, + true, + false, + ); + assert_matches!( + res, + Err(Error::InsufficientBitcoins { + needed: _, + available: _ + }) + ); + + // cancel pending drain_to to unlock the reserved inputs + let txid = Psbt::from_str(&psbt).unwrap().get_txid().to_string(); + wallet.abort_pending_vanilla_tx(txid).unwrap(); + // reserve one (or more) vanilla UTXO via send_btc_begin(dry_run=false) let send_psbt_str = wallet .send_btc_begin( diff --git a/src/wallet/test/send_btc.rs b/src/wallet/test/send_btc.rs index 9116749b..c5c0b329 100644 --- a/src/wallet/test/send_btc.rs +++ b/src/wallet/test/send_btc.rs @@ -427,10 +427,14 @@ fn send_btc_end_twice() { // prepare PSBT let address = test_get_address(&mut wallet); - let unsigned_psbt = wallet.send_btc_begin(online, address, 1000, FEE_RATE, false, false).unwrap(); + let unsigned_psbt = wallet + .send_btc_begin(online, address, 1000, FEE_RATE, false, false) + .unwrap(); let signed_psbt = wallet.sign_psbt(unsigned_psbt, None).unwrap(); // call send_btc_end twice with the same PSBT, which should work (idempotent) - wallet.send_btc_end(online, signed_psbt.clone(), false).unwrap(); + wallet + .send_btc_end(online, signed_psbt.clone(), false) + .unwrap(); wallet.send_btc_end(online, signed_psbt, false).unwrap(); } From 1d461afeb182ddb8266f38ebc46cd57b1b433e19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zoe=20Faltib=C3=A0?= Date: Fri, 17 Apr 2026 12:13:46 +0200 Subject: [PATCH 10/10] lint code with rust 1.95.0 --- src/wallet/test/send.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/wallet/test/send.rs b/src/wallet/test/send.rs index 25881122..8e5bee00 100644 --- a/src/wallet/test/send.rs +++ b/src/wallet/test/send.rs @@ -7391,8 +7391,8 @@ fn allocations() { .collect(); // check input colorings let input_colorings: Vec<_> = coloring_map - .iter() - .flat_map(|(_, c)| c) + .values() + .flatten() .filter(|c| c.r#type == ColoringType::Input) .collect(); if pending_xfer { @@ -7436,8 +7436,8 @@ fn allocations() { } // check change colorings let change_colorings: Vec<_> = coloring_map - .iter() - .flat_map(|(_, c)| c) + .values() + .flatten() .filter(|c| c.r#type == ColoringType::Change) .collect(); assert_eq!(change_colorings.len(), amounts_auto.len()); @@ -7733,8 +7733,8 @@ fn allocations() { let db_asset_transfers = wallet_2.database().iter_asset_transfers().unwrap(); // check input colorings let input_colorings: Vec<_> = coloring_map - .iter() - .flat_map(|(_, c)| c) + .values() + .flatten() .filter(|c| c.r#type == ColoringType::Input) .collect(); // - 4 colorings @@ -7771,8 +7771,8 @@ fn allocations() { } // check change colorings let change_colorings: Vec<_> = coloring_map - .iter() - .flat_map(|(_, c)| c) + .values() + .flatten() .filter(|c| c.r#type == ColoringType::Change) .filter(|c| { db_batch_transfers