From df221913f2ae388b340cd7564cbc6c71783652e7 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Sun, 19 Apr 2026 23:21:53 +0530 Subject: [PATCH 01/16] feat: add bulk UTXO lock control to Transaction Details (#661) - Implement 3-state aggregate lock logic (Unlocked, Locked, Mixed) in WalletActor - Add bulk UTXO lock control to Transaction Details Top Bar - Include snackbar feedback and animated transitions for better UX - Exclude already-spent and external outputs from the lock scope - Refresh lock state on screen load and pull-to-refresh --- .../TransactionDetailsScreen.kt | 65 ++++++++++++++++- rust/src/database/wallet_data/label.rs | 56 +++++++++++++++ rust/src/manager/wallet_manager.rs | 22 +++++- rust/src/manager/wallet_manager/actor.rs | 71 ++++++++++++++++++- rust/src/transaction.rs | 1 + rust/src/transaction/transaction_details.rs | 8 +++ 6 files changed, 220 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index 85bf78df8..49f39fc9f 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -78,10 +78,13 @@ import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.HeaderIconPresenter import org.bitcoinppl.cove_core.TransactionDetails +import org.bitcoinppl.cove_core.TransactionLockState import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.types.FfiColorScheme import org.bitcoinppl.cove_core.types.TransactionDirection +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen private const val INITIAL_DELAY_MS = 2000L private const val FREQUENT_POLL_INTERVAL_MS = 30000L @@ -125,11 +128,17 @@ fun TransactionDetailsScreen( // get current color scheme (respects in-app theme toggle) val isDark = !MaterialTheme.colorScheme.isLight + // state for recovery lock and UTXO lock + var lockState by remember { mutableStateOf(TransactionLockState.NONE) } + // immediately fetch fresh transaction details on screen load LaunchedEffect(manager, txId) { try { val freshDetails = manager.rust.transactionDetails(txId = txId) manager.updateTransactionDetailsCache(txId, freshDetails) + + // also fetch lock state + lockState = manager.rust.transactionLockState(txId = txId) } catch (e: Exception) { android.util.Log.e("TransactionDetails", "error fetching fresh details", e) } @@ -337,6 +346,58 @@ fun TransactionDetailsScreen( ) } }, + actions = { + if (lockState != TransactionLockState.NONE) { + IconButton(onClick = { + scope.launch { + try { + val previousState = lockState + manager.rust.toggleTransactionLock(txId = txId) + // refresh state after toggle + val newState = manager.rust.transactionLockState(txId = txId) + lockState = newState + + val message = when (newState) { + TransactionLockState.LOCKED -> "Transaction outputs locked" + TransactionLockState.UNLOCKED -> "Transaction outputs unlocked" + TransactionLockState.MIXED -> "Remaining outputs locked" + else -> null + } + if (message != null) { + snackbarHostState.showSnackbar(message) + } + } catch (e: Exception) { + android.util.Log.e("TransactionDetails", "error toggling lock", e) + snackbarHostState.showSnackbar("Failed to update lock state") + } + } + }) { + androidx.compose.animation.AnimatedContent( + targetState = lockState, + transitionSpec = { + (fadeIn() + androidx.compose.animation.scaleIn()) + .togetherWith(fadeOut() + androidx.compose.animation.scaleOut()) + }, + label = "lock_state_anim" + ) { state -> + val icon = when (state) { + TransactionLockState.LOCKED -> Icons.Default.Lock + TransactionLockState.MIXED -> Icons.Default.Lock + else -> Icons.Default.LockOpen + } + Icon( + imageVector = icon, + contentDescription = when (state) { + TransactionLockState.LOCKED -> "Unlock all outputs" + TransactionLockState.MIXED -> "Lock remaining outputs" + else -> "Lock all outputs" + }, + tint = if (state == TransactionLockState.MIXED) CoveColor.WarningOrange else fg + ) + } + } + } + } ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -356,12 +417,14 @@ fun TransactionDetailsScreen( val freshDetails = manager.rust.transactionDetails(txId = txId) manager.updateTransactionDetailsCache(txId, freshDetails) - // also update confirmations + // also update confirmations and lock state val blockNumber = freshDetails.blockNumber() if (blockNumber != null) { val confirmations = manager.rust.numberOfConfirmations(blockHeight = blockNumber) numberOfConfirmations = confirmations.toInt() } + + lockState = manager.rust.transactionLockState(txId = txId) } catch (e: Exception) { android.util.Log.e("TransactionDetails", "error refreshing details", e) } diff --git a/rust/src/database/wallet_data/label.rs b/rust/src/database/wallet_data/label.rs index a7f3e22dc..2d3d6d719 100644 --- a/rust/src/database/wallet_data/label.rs +++ b/rust/src/database/wallet_data/label.rs @@ -197,6 +197,30 @@ impl LabelsTable { Ok(label) } + pub fn get_output_record( + &self, + outpoint: impl Borrow, + ) -> Result>, Error> { + let outpoint = outpoint.borrow(); + let table = self.read_table(OUTPUT_TABLE)?; + let key = OutPointKey::from(outpoint); + let record = table.get(key)?.map(|record| record.value()); + + Ok(record) + } + + pub fn locked_outpoints(&self) -> Result, Error> { + let table = self.read_table(OUTPUT_TABLE)?; + let locked = table + .iter()? + .filter_map(|r| r.ok()) + .map(|(_, v)| v.value()) + .filter(|r| !r.item.spendable) + .map(|r| r.item.ref_) + .collect(); + Ok(locked) + } + pub fn get_address_record( &self, address: impl Borrow>, @@ -323,6 +347,38 @@ impl LabelsTable { Ok(()) } + pub fn set_outputs_spendable( + &self, + outpoints: impl IntoIterator, + spendable: bool, + ) -> Result<(), Error> { + let write_txn = self.db.begin_write().map_err_str(DatabaseError::DatabaseAccess)?; + + { + let mut table = write_txn.open_table(OUTPUT_TABLE)?; + for outpoint in outpoints { + let key = OutPointKey::from(&outpoint); + + let mut record = table.get(key.clone())?.map(|r| r.value()).unwrap_or_else(|| { + Record::new(OutputRecord { + ref_: outpoint, + label: None, + spendable: true, + }) + }); + + record.item.spendable = spendable; + record.timestamps.updated_at = jiff::Timestamp::now().as_second().cast_unsigned(); + + table.insert(key, record)?; + } + } + + write_txn.commit().map_err_str(DatabaseError::DatabaseAccess)?; + + Ok(()) + } + // MARK: DELETE pub fn delete_labels(&self, labels: impl IntoIterator) -> Result<(), Error> { let write_txn = self.db.begin_write().map_err_str(DatabaseError::DatabaseAccess)?; diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index ec929c58b..c7529f595 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -34,7 +34,8 @@ use crate::{ tap_card::tap_signer_reader::DeriveInfo, transaction::{ Amount, FeeRate, SentAndReceived, Transaction, TransactionDetails, TransactionDirection, - TxId, Unit, ffi::BitcoinTransaction, unsigned_transaction::UnsignedTransaction, + TransactionLockState, TxId, Unit, ffi::BitcoinTransaction, + unsigned_transaction::UnsignedTransaction, }, wallet::{ Address, AddressInfo, Wallet, WalletAddressType, WalletError, @@ -576,6 +577,25 @@ impl RustWalletManager { Ok(address_info) } + #[uniffi::method] + pub async fn transaction_lock_state( + &self, + tx_id: Arc, + ) -> Result { + let state = call!(self.actor.transaction_lock_state(Arc::unwrap_or_clone(tx_id))) + .await + .map_err_str(Error::UnknownError)?; + Ok(state) + } + + #[uniffi::method] + pub async fn toggle_transaction_lock(&self, tx_id: Arc) -> Result<(), Error> { + call!(self.actor.toggle_transaction_lock(Arc::unwrap_or_clone(tx_id))) + .await + .map_err_str(Error::UnknownError)?; + Ok(()) + } + #[uniffi::method] pub fn save_unsigned_transaction(&self, details: Arc) -> Result<(), Error> { let wallet_id = self.id.clone(); diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index b8cd0efe2..3ef5b8901 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -8,7 +8,9 @@ use crate::{ client::{NodeClient, NodeClientOptions}, client_builder::NodeClientBuilder, }, - transaction::{ConfirmedTransaction, FeeRate, Transaction, TransactionDetails, TxId}, + transaction::{ + ConfirmedTransaction, FeeRate, Transaction, TransactionDetails, TransactionLockState, TxId, + }, transaction_watcher::TransactionWatcher, wallet::{ Address, AddressInfo, Wallet, WalletAddressType, balance::Balance, metadata::BlockSizeLast, @@ -312,6 +314,73 @@ impl WalletActor { transactions } + pub async fn transaction_lock_state(&mut self, txid: TxId) -> ActorResult { + Produces::ok(self.compute_lock_state(txid.0)) + } + + #[into_actor_result] + pub async fn toggle_transaction_lock(&mut self, txid: TxId) -> Result<(), Error> { + let outputs = self.wallet_unspent_outputs_for_tx(txid.0); + + if outputs.is_empty() { + return Ok(()); + } + + let current_state = self.compute_lock_state(txid.0); + + // unlocked or mixed -> lock all + // locked -> unlock all + let spendable = matches!(current_state, TransactionLockState::Locked); + + self.db + .labels + .set_outputs_spendable(outputs, spendable) + .map_err_str(Error::UnknownError)?; + + // notify cloud backup that labels changed + crate::manager::cloud_backup_manager::CLOUD_BACKUP_MANAGER + .handle_wallet_backup_change(self.wallet.id.clone()); + + Ok(()) + } + + /// Compute the aggregate lock state for all wallet-owned unspent outputs of a tx. + fn compute_lock_state(&self, txid: Txid) -> TransactionLockState { + let outputs = self.wallet_unspent_outputs_for_tx(txid); + + if outputs.is_empty() { + return TransactionLockState::None; + } + + let mut locked_count = 0; + for outpoint in &outputs { + let record = self.db.labels.get_output_record(outpoint).unwrap_or(None); + if let Some(record) = record { + if !record.item.spendable { + locked_count += 1; + } + } + } + + if locked_count == 0 { + TransactionLockState::Unlocked + } else if locked_count == outputs.len() { + TransactionLockState::Locked + } else { + TransactionLockState::Mixed + } + } + + /// Returns all wallet-owned, still-unspent outpoints created by the given txid. + fn wallet_unspent_outputs_for_tx(&self, txid: Txid) -> Vec { + self.wallet + .bdk + .list_unspent() + .filter(|utxo| utxo.outpoint.txid == txid) + .map(|utxo| utxo.outpoint) + .collect() + } + pub async fn split_transaction_outputs( &mut self, outputs: Vec, diff --git a/rust/src/transaction.rs b/rust/src/transaction.rs index 2d98109a7..6c3a83a2a 100644 --- a/rust/src/transaction.rs +++ b/rust/src/transaction.rs @@ -24,6 +24,7 @@ pub type TransactionDirection = cove_types::transaction::TransactionDirection; pub type TxId = cove_types::TxId; pub type TransactionDetails = transaction_details::TransactionDetails; +pub type TransactionLockState = transaction_details::TransactionLockState; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] pub enum TransactionState { diff --git a/rust/src/transaction/transaction_details.rs b/rust/src/transaction/transaction_details.rs index 4ba1ed88d..470f3efee 100644 --- a/rust/src/transaction/transaction_details.rs +++ b/rust/src/transaction/transaction_details.rs @@ -81,6 +81,14 @@ pub enum PendingOrConfirmed { Confirmed(ConfirmedDetails), } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum TransactionLockState { + Unlocked, + Locked, + Mixed, + None, +} + impl TransactionDetails { pub fn try_new( wallet: &BdkWallet, From 55fcb178cdb1b7bb9e7643d6b3263e94aa3381e6 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Sun, 19 Apr 2026 23:21:53 +0530 Subject: [PATCH 02/16] feat: add bulk UTXO lock control to Transaction Details (#661) - Implement 3-state aggregate lock logic (Unlocked, Locked, Mixed) in WalletActor - Add bulk UTXO lock control to Transaction Details Top Bar - Include snackbar feedback and animated transitions for better UX - Exclude already-spent and external outputs from the lock scope - Refresh lock state on screen load and pull-to-refresh --- .../TransactionDetailsScreen.kt | 64 ++++++++++++++++- rust/src/database/wallet_data/label.rs | 68 ++++++++++++++++++ rust/src/manager/wallet_manager.rs | 22 +++++- rust/src/manager/wallet_manager/actor.rs | 70 ++++++++++++++++++- rust/src/transaction.rs | 1 + rust/src/transaction/transaction_details.rs | 8 +++ 6 files changed, 230 insertions(+), 3 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index 85bf78df8..ed309c2a0 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -78,10 +78,13 @@ import org.bitcoinppl.cove.views.BalanceAutoSizeText import org.bitcoinppl.cove.views.ImageButton import org.bitcoinppl.cove_core.HeaderIconPresenter import org.bitcoinppl.cove_core.TransactionDetails +import org.bitcoinppl.cove_core.TransactionLockState import org.bitcoinppl.cove_core.TransactionState import org.bitcoinppl.cove_core.WalletManagerAction import org.bitcoinppl.cove_core.types.FfiColorScheme import org.bitcoinppl.cove_core.types.TransactionDirection +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen private const val INITIAL_DELAY_MS = 2000L private const val FREQUENT_POLL_INTERVAL_MS = 30000L @@ -125,11 +128,17 @@ fun TransactionDetailsScreen( // get current color scheme (respects in-app theme toggle) val isDark = !MaterialTheme.colorScheme.isLight + // state for recovery lock and UTXO lock + var lockState by remember { mutableStateOf(TransactionLockState.NONE) } + // immediately fetch fresh transaction details on screen load LaunchedEffect(manager, txId) { try { val freshDetails = manager.rust.transactionDetails(txId = txId) manager.updateTransactionDetailsCache(txId, freshDetails) + + // also fetch lock state + lockState = manager.rust.transactionLockState(txId = txId) } catch (e: Exception) { android.util.Log.e("TransactionDetails", "error fetching fresh details", e) } @@ -337,6 +346,57 @@ fun TransactionDetailsScreen( ) } }, + actions = { + if (lockState != TransactionLockState.NONE) { + IconButton(onClick = { + scope.launch { + try { + manager.rust.toggleTransactionLock(txId = txId) + // refresh state after toggle + val newState = manager.rust.transactionLockState(txId = txId) + lockState = newState + + val message = when (newState) { + TransactionLockState.LOCKED -> "Transaction outputs locked" + TransactionLockState.UNLOCKED -> "Transaction outputs unlocked" + TransactionLockState.MIXED -> "Remaining outputs locked" + else -> null + } + if (message != null) { + snackbarHostState.showSnackbar(message) + } + } catch (e: Exception) { + android.util.Log.e("TransactionDetails", "error toggling lock", e) + snackbarHostState.showSnackbar("Failed to update lock state") + } + } + }) { + androidx.compose.animation.AnimatedContent( + targetState = lockState, + transitionSpec = { + (fadeIn() + androidx.compose.animation.scaleIn()) + .togetherWith(fadeOut() + androidx.compose.animation.scaleOut()) + }, + label = "lock_state_anim" + ) { state -> + val icon = when (state) { + TransactionLockState.LOCKED -> Icons.Default.Lock + TransactionLockState.MIXED -> Icons.Default.Lock + else -> Icons.Default.LockOpen + } + Icon( + imageVector = icon, + contentDescription = when (state) { + TransactionLockState.LOCKED -> "Unlock all outputs" + TransactionLockState.MIXED -> "Lock remaining outputs" + else -> "Lock all outputs" + }, + tint = if (state == TransactionLockState.MIXED) CoveColor.WarningOrange else fg + ) + } + } + } + } ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -356,12 +416,14 @@ fun TransactionDetailsScreen( val freshDetails = manager.rust.transactionDetails(txId = txId) manager.updateTransactionDetailsCache(txId, freshDetails) - // also update confirmations + // also update confirmations and lock state val blockNumber = freshDetails.blockNumber() if (blockNumber != null) { val confirmations = manager.rust.numberOfConfirmations(blockHeight = blockNumber) numberOfConfirmations = confirmations.toInt() } + + lockState = manager.rust.transactionLockState(txId = txId) } catch (e: Exception) { android.util.Log.e("TransactionDetails", "error refreshing details", e) } diff --git a/rust/src/database/wallet_data/label.rs b/rust/src/database/wallet_data/label.rs index a7f3e22dc..9c7affd10 100644 --- a/rust/src/database/wallet_data/label.rs +++ b/rust/src/database/wallet_data/label.rs @@ -197,6 +197,30 @@ impl LabelsTable { Ok(label) } + pub fn get_output_record( + &self, + outpoint: impl Borrow, + ) -> Result>, Error> { + let outpoint = outpoint.borrow(); + let table = self.read_table(OUTPUT_TABLE)?; + let key = OutPointKey::from(outpoint); + let record = table.get(key)?.map(|record| record.value()); + + Ok(record) + } + + pub fn locked_outpoints(&self) -> Result, Error> { + let table = self.read_table(OUTPUT_TABLE)?; + let locked = table + .iter()? + .filter_map(|r| r.ok()) + .map(|(_, v)| v.value()) + .filter(|r| !r.item.spendable) + .map(|r| r.item.ref_) + .collect(); + Ok(locked) + } + pub fn get_address_record( &self, address: impl Borrow>, @@ -323,6 +347,50 @@ impl LabelsTable { Ok(()) } + pub fn set_outputs_spendable( + &self, + outpoints: impl IntoIterator, + spendable: bool, + ) -> Result<(), Error> { + let write_txn = self.db.begin_write().map_err_str(DatabaseError::DatabaseAccess)?; + + { + let mut table = write_txn.open_table(OUTPUT_TABLE)?; + for outpoint in outpoints { + let key = OutPointKey::from(&outpoint); + + let existing = table.get(key.clone())?.map(|r| r.value()); + let mut record = match existing { + Some(r) => r, + None => { + // if setting to spendable and no record exists, it's already spendable by default + if spendable { + continue; + } + Record::new(OutputRecord { + ref_: outpoint, + label: None, + spendable: true, + }) + } + }; + + if record.item.spendable == spendable { + continue; + } + + record.item.spendable = spendable; + record.timestamps.updated_at = jiff::Timestamp::now().as_second().cast_unsigned(); + + table.insert(key, record)?; + } + } + + write_txn.commit().map_err_str(DatabaseError::DatabaseAccess)?; + + Ok(()) + } + // MARK: DELETE pub fn delete_labels(&self, labels: impl IntoIterator) -> Result<(), Error> { let write_txn = self.db.begin_write().map_err_str(DatabaseError::DatabaseAccess)?; diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index ec929c58b..c7529f595 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -34,7 +34,8 @@ use crate::{ tap_card::tap_signer_reader::DeriveInfo, transaction::{ Amount, FeeRate, SentAndReceived, Transaction, TransactionDetails, TransactionDirection, - TxId, Unit, ffi::BitcoinTransaction, unsigned_transaction::UnsignedTransaction, + TransactionLockState, TxId, Unit, ffi::BitcoinTransaction, + unsigned_transaction::UnsignedTransaction, }, wallet::{ Address, AddressInfo, Wallet, WalletAddressType, WalletError, @@ -576,6 +577,25 @@ impl RustWalletManager { Ok(address_info) } + #[uniffi::method] + pub async fn transaction_lock_state( + &self, + tx_id: Arc, + ) -> Result { + let state = call!(self.actor.transaction_lock_state(Arc::unwrap_or_clone(tx_id))) + .await + .map_err_str(Error::UnknownError)?; + Ok(state) + } + + #[uniffi::method] + pub async fn toggle_transaction_lock(&self, tx_id: Arc) -> Result<(), Error> { + call!(self.actor.toggle_transaction_lock(Arc::unwrap_or_clone(tx_id))) + .await + .map_err_str(Error::UnknownError)?; + Ok(()) + } + #[uniffi::method] pub fn save_unsigned_transaction(&self, details: Arc) -> Result<(), Error> { let wallet_id = self.id.clone(); diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index b8cd0efe2..50e0d5681 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -8,7 +8,9 @@ use crate::{ client::{NodeClient, NodeClientOptions}, client_builder::NodeClientBuilder, }, - transaction::{ConfirmedTransaction, FeeRate, Transaction, TransactionDetails, TxId}, + transaction::{ + ConfirmedTransaction, FeeRate, Transaction, TransactionDetails, TransactionLockState, TxId, + }, transaction_watcher::TransactionWatcher, wallet::{ Address, AddressInfo, Wallet, WalletAddressType, balance::Balance, metadata::BlockSizeLast, @@ -312,6 +314,72 @@ impl WalletActor { transactions } + pub async fn transaction_lock_state(&mut self, txid: TxId) -> ActorResult { + let outputs = self.wallet_unspent_outputs_for_tx(txid.0); + Produces::ok(self.compute_lock_state(&outputs)) + } + + #[into_actor_result] + pub async fn toggle_transaction_lock(&mut self, txid: TxId) -> Result<(), Error> { + let outputs = self.wallet_unspent_outputs_for_tx(txid.0); + + if outputs.is_empty() { + return Ok(()); + } + + let current_state = self.compute_lock_state(&outputs); + + // unlocked or mixed -> lock all + // locked -> unlock all + let spendable = matches!(current_state, TransactionLockState::Locked); + + self.db + .labels + .set_outputs_spendable(outputs, spendable) + .map_err_str(Error::UnknownError)?; + + // notify cloud backup that labels changed + crate::manager::cloud_backup_manager::CLOUD_BACKUP_MANAGER + .handle_wallet_backup_change(self.wallet.id.clone()); + + Ok(()) + } + + /// Compute the aggregate lock state for the given wallet-owned unspent outputs. + fn compute_lock_state(&self, outputs: &[OutPoint]) -> TransactionLockState { + if outputs.is_empty() { + return TransactionLockState::None; + } + + let mut locked_count = 0; + for outpoint in outputs { + let record = self.db.labels.get_output_record(outpoint).unwrap_or(None); + if let Some(record) = record { + if !record.item.spendable { + locked_count += 1; + } + } + } + + if locked_count == 0 { + TransactionLockState::Unlocked + } else if locked_count == outputs.len() { + TransactionLockState::Locked + } else { + TransactionLockState::Mixed + } + } + + /// Returns all wallet-owned, still-unspent outpoints created by the given txid. + fn wallet_unspent_outputs_for_tx(&self, txid: Txid) -> Vec { + self.wallet + .bdk + .list_unspent() + .filter(|utxo| utxo.outpoint.txid == txid) + .map(|utxo| utxo.outpoint) + .collect() + } + pub async fn split_transaction_outputs( &mut self, outputs: Vec, diff --git a/rust/src/transaction.rs b/rust/src/transaction.rs index 2d98109a7..6c3a83a2a 100644 --- a/rust/src/transaction.rs +++ b/rust/src/transaction.rs @@ -24,6 +24,7 @@ pub type TransactionDirection = cove_types::transaction::TransactionDirection; pub type TxId = cove_types::TxId; pub type TransactionDetails = transaction_details::TransactionDetails; +pub type TransactionLockState = transaction_details::TransactionLockState; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] pub enum TransactionState { diff --git a/rust/src/transaction/transaction_details.rs b/rust/src/transaction/transaction_details.rs index 4ba1ed88d..470f3efee 100644 --- a/rust/src/transaction/transaction_details.rs +++ b/rust/src/transaction/transaction_details.rs @@ -81,6 +81,14 @@ pub enum PendingOrConfirmed { Confirmed(ConfirmedDetails), } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, uniffi::Enum)] +pub enum TransactionLockState { + Unlocked, + Locked, + Mixed, + None, +} + impl TransactionDetails { pub fn try_new( wallet: &BdkWallet, From 20f3eb0d943f56bb4700f9a050a9a7190df8a2d9 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Thu, 23 Apr 2026 11:27:16 +0530 Subject: [PATCH 03/16] feat: implement transaction details screen and add wallet label management support --- .../TransactionDetailsScreen.kt | 5 +---- rust/src/database/wallet_data/label.rs | 10 ---------- rust/src/manager/wallet_manager/actor.rs | 19 ------------------- 3 files changed, 1 insertion(+), 33 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index b01fd1f63..e0663965e 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -351,10 +351,7 @@ fun TransactionDetailsScreen( IconButton(onClick = { scope.launch { try { -<<<<<<< HEAD -======= - val previousState = lockState ->>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7 + manager.rust.toggleTransactionLock(txId = txId) // refresh state after toggle val newState = manager.rust.transactionLockState(txId = txId) diff --git a/rust/src/database/wallet_data/label.rs b/rust/src/database/wallet_data/label.rs index 2af5567dd..9c7affd10 100644 --- a/rust/src/database/wallet_data/label.rs +++ b/rust/src/database/wallet_data/label.rs @@ -359,7 +359,6 @@ impl LabelsTable { for outpoint in outpoints { let key = OutPointKey::from(&outpoint); -<<<<<<< HEAD let existing = table.get(key.clone())?.map(|r| r.value()); let mut record = match existing { Some(r) => r, @@ -379,15 +378,6 @@ impl LabelsTable { if record.item.spendable == spendable { continue; } -======= - let mut record = table.get(key.clone())?.map(|r| r.value()).unwrap_or_else(|| { - Record::new(OutputRecord { - ref_: outpoint, - label: None, - spendable: true, - }) - }); ->>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7 record.item.spendable = spendable; record.timestamps.updated_at = jiff::Timestamp::now().as_second().cast_unsigned(); diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index 62b13ec72..50e0d5681 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -315,12 +315,8 @@ impl WalletActor { } pub async fn transaction_lock_state(&mut self, txid: TxId) -> ActorResult { -<<<<<<< HEAD let outputs = self.wallet_unspent_outputs_for_tx(txid.0); Produces::ok(self.compute_lock_state(&outputs)) -======= - Produces::ok(self.compute_lock_state(txid.0)) ->>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7 } #[into_actor_result] @@ -331,11 +327,7 @@ impl WalletActor { return Ok(()); } -<<<<<<< HEAD let current_state = self.compute_lock_state(&outputs); -======= - let current_state = self.compute_lock_state(txid.0); ->>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7 // unlocked or mixed -> lock all // locked -> unlock all @@ -353,25 +345,14 @@ impl WalletActor { Ok(()) } -<<<<<<< HEAD /// Compute the aggregate lock state for the given wallet-owned unspent outputs. fn compute_lock_state(&self, outputs: &[OutPoint]) -> TransactionLockState { -======= - /// Compute the aggregate lock state for all wallet-owned unspent outputs of a tx. - fn compute_lock_state(&self, txid: Txid) -> TransactionLockState { - let outputs = self.wallet_unspent_outputs_for_tx(txid); - ->>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7 if outputs.is_empty() { return TransactionLockState::None; } let mut locked_count = 0; -<<<<<<< HEAD for outpoint in outputs { -======= - for outpoint in &outputs { ->>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7 let record = self.db.labels.get_output_record(outpoint).unwrap_or(None); if let Some(record) = record { if !record.item.spendable { From 4dc6b2021348b0818e7ddec0a2325b976849313d Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Thu, 23 Apr 2026 11:34:08 +0530 Subject: [PATCH 04/16] feat: implement WalletActor for BDK wallet management and add label database module --- rust/src/database/wallet_data/label.rs | 6 +----- rust/src/manager/wallet_manager/actor.rs | 5 ++++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/rust/src/database/wallet_data/label.rs b/rust/src/database/wallet_data/label.rs index 9c7affd10..979647e67 100644 --- a/rust/src/database/wallet_data/label.rs +++ b/rust/src/database/wallet_data/label.rs @@ -367,11 +367,7 @@ impl LabelsTable { if spendable { continue; } - Record::new(OutputRecord { - ref_: outpoint, - label: None, - spendable: true, - }) + Record::new(OutputRecord { ref_: outpoint, label: None, spendable: true }) } }; diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index 50e0d5681..54f905cee 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -314,7 +314,10 @@ impl WalletActor { transactions } - pub async fn transaction_lock_state(&mut self, txid: TxId) -> ActorResult { + pub async fn transaction_lock_state( + &mut self, + txid: TxId, + ) -> ActorResult { let outputs = self.wallet_unspent_outputs_for_tx(txid.0); Produces::ok(self.compute_lock_state(&outputs)) } From b70f5afa03ff6a465a7984b8096d33d770035723 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Thu, 23 Apr 2026 11:43:32 +0530 Subject: [PATCH 05/16] fix: address CodeRabbit review feedback --- .../TransactionDetailsScreen.kt | 51 +++++++++++-------- rust/src/database/wallet_data/label.rs | 19 ++++--- rust/src/manager/wallet_manager.rs | 4 +- rust/src/manager/wallet_manager/actor.rs | 29 ++++++----- 4 files changed, 59 insertions(+), 44 deletions(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index e0663965e..95d1456c5 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -130,6 +130,7 @@ fun TransactionDetailsScreen( // state for recovery lock and UTXO lock var lockState by remember { mutableStateOf(TransactionLockState.NONE) } + var isToggling by remember { mutableStateOf(false) } // immediately fetch fresh transaction details on screen load LaunchedEffect(manager, txId) { @@ -348,30 +349,36 @@ fun TransactionDetailsScreen( }, actions = { if (lockState != TransactionLockState.NONE) { - IconButton(onClick = { - scope.launch { - try { - - manager.rust.toggleTransactionLock(txId = txId) - // refresh state after toggle - val newState = manager.rust.transactionLockState(txId = txId) - lockState = newState - - val message = when (newState) { - TransactionLockState.LOCKED -> "Transaction outputs locked" - TransactionLockState.UNLOCKED -> "Transaction outputs unlocked" - TransactionLockState.MIXED -> "Remaining outputs locked" - else -> null - } - if (message != null) { - snackbarHostState.showSnackbar(message) + IconButton( + onClick = { + if (isToggling) return@IconButton + isToggling = true + scope.launch { + try { + manager.rust.toggleTransactionLock(txId = txId) + // refresh state after toggle + val newState = manager.rust.transactionLockState(txId = txId) + lockState = newState + + val message = when (newState) { + TransactionLockState.LOCKED -> "Transaction outputs locked" + TransactionLockState.UNLOCKED -> "Transaction outputs unlocked" + TransactionLockState.MIXED -> "Remaining outputs locked" + else -> null + } + if (message != null) { + snackbarHostState.showSnackbar(message) + } + } catch (e: Exception) { + android.util.Log.e("TransactionDetails", "error toggling lock", e) + snackbarHostState.showSnackbar("Failed to update lock state") + } finally { + isToggling = false } - } catch (e: Exception) { - android.util.Log.e("TransactionDetails", "error toggling lock", e) - snackbarHostState.showSnackbar("Failed to update lock state") } - } - }) { + }, + enabled = !isToggling + ) { androidx.compose.animation.AnimatedContent( targetState = lockState, transitionSpec = { diff --git a/rust/src/database/wallet_data/label.rs b/rust/src/database/wallet_data/label.rs index 979647e67..1b0bb9643 100644 --- a/rust/src/database/wallet_data/label.rs +++ b/rust/src/database/wallet_data/label.rs @@ -211,13 +211,13 @@ impl LabelsTable { pub fn locked_outpoints(&self) -> Result, Error> { let table = self.read_table(OUTPUT_TABLE)?; - let locked = table - .iter()? - .filter_map(|r| r.ok()) - .map(|(_, v)| v.value()) - .filter(|r| !r.item.spendable) - .map(|r| r.item.ref_) - .collect(); + let mut locked = Vec::new(); + for result in table.iter()? { + let (_, v) = result?; + if !v.value().item.spendable { + locked.push(v.value().item.ref_); + } + } Ok(locked) } @@ -375,6 +375,11 @@ impl LabelsTable { continue; } + if spendable && record.item.label.is_none() { + table.remove(key)?; + continue; + } + record.item.spendable = spendable; record.timestamps.updated_at = jiff::Timestamp::now().as_second().cast_unsigned(); diff --git a/rust/src/manager/wallet_manager.rs b/rust/src/manager/wallet_manager.rs index c7529f595..bdf482bfc 100644 --- a/rust/src/manager/wallet_manager.rs +++ b/rust/src/manager/wallet_manager.rs @@ -584,7 +584,7 @@ impl RustWalletManager { ) -> Result { let state = call!(self.actor.transaction_lock_state(Arc::unwrap_or_clone(tx_id))) .await - .map_err_str(Error::UnknownError)?; + .map_err_str(Error::UnknownError)??; Ok(state) } @@ -592,7 +592,7 @@ impl RustWalletManager { pub async fn toggle_transaction_lock(&self, tx_id: Arc) -> Result<(), Error> { call!(self.actor.toggle_transaction_lock(Arc::unwrap_or_clone(tx_id))) .await - .map_err_str(Error::UnknownError)?; + .map_err_str(Error::UnknownError)??; Ok(()) } diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index 54f905cee..c9474ce46 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -194,7 +194,9 @@ impl WalletActor { ) -> Result { debug!("build_ephemeral_drain_tx for fee rate {}", fee.sat_per_vb()); let script_pubkey = address.script_pubkey(); + let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); let mut tx_builder = self.wallet.bdk.build_tx(); + tx_builder.unspendable(locked_outpoints); tx_builder.drain_wallet().drain_to(script_pubkey).fee_rate(fee.into()); let psbt = tx_builder.finish().map_err_str(Error::BuildTxError)?; @@ -215,8 +217,10 @@ impl WalletActor { let fee_rate = fee_rate.into(); let script_pubkey = address.script_pubkey(); + let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); let coin_selection = CoveDefaultCoinSelection::new(self.seed); let mut tx_builder = self.wallet.bdk.build_tx().coin_selection(coin_selection); + tx_builder.unspendable(locked_outpoints); tx_builder.ordering(TxOrdering::Untouched); tx_builder.add_recipient(script_pubkey, amount); @@ -314,12 +318,14 @@ impl WalletActor { transactions } + #[into_actor_result] pub async fn transaction_lock_state( &mut self, txid: TxId, - ) -> ActorResult { + ) -> Result { let outputs = self.wallet_unspent_outputs_for_tx(txid.0); - Produces::ok(self.compute_lock_state(&outputs)) + let state = self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; + Ok(state) } #[into_actor_result] @@ -330,7 +336,7 @@ impl WalletActor { return Ok(()); } - let current_state = self.compute_lock_state(&outputs); + let current_state = self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; // unlocked or mixed -> lock all // locked -> unlock all @@ -349,27 +355,24 @@ impl WalletActor { } /// Compute the aggregate lock state for the given wallet-owned unspent outputs. - fn compute_lock_state(&self, outputs: &[OutPoint]) -> TransactionLockState { + fn compute_lock_state(&self, outputs: &[OutPoint]) -> Result { if outputs.is_empty() { - return TransactionLockState::None; + return Ok(TransactionLockState::None); } let mut locked_count = 0; for outpoint in outputs { - let record = self.db.labels.get_output_record(outpoint).unwrap_or(None); - if let Some(record) = record { - if !record.item.spendable { - locked_count += 1; - } + if self.db.labels.get_output_record(outpoint)?.map_or(false, |r| !r.item.spendable) { + locked_count += 1; } } if locked_count == 0 { - TransactionLockState::Unlocked + Ok(TransactionLockState::Unlocked) } else if locked_count == outputs.len() { - TransactionLockState::Locked + Ok(TransactionLockState::Locked) } else { - TransactionLockState::Mixed + Ok(TransactionLockState::Mixed) } } From 56fa710e07826c5fbc1c5bea25787f560c1f7eb8 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Thu, 23 Apr 2026 11:49:27 +0530 Subject: [PATCH 06/16] fix: resolve TxBuilder compile error and rustfmt --- rust/src/manager/wallet_manager/actor.rs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index c9474ce46..95831b73f 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -195,8 +195,7 @@ impl WalletActor { debug!("build_ephemeral_drain_tx for fee rate {}", fee.sat_per_vb()); let script_pubkey = address.script_pubkey(); let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); - let mut tx_builder = self.wallet.bdk.build_tx(); - tx_builder.unspendable(locked_outpoints); + let mut tx_builder = self.wallet.bdk.build_tx().unspendable(locked_outpoints); tx_builder.drain_wallet().drain_to(script_pubkey).fee_rate(fee.into()); let psbt = tx_builder.finish().map_err_str(Error::BuildTxError)?; @@ -219,8 +218,8 @@ impl WalletActor { let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); let coin_selection = CoveDefaultCoinSelection::new(self.seed); - let mut tx_builder = self.wallet.bdk.build_tx().coin_selection(coin_selection); - tx_builder.unspendable(locked_outpoints); + let mut tx_builder = + self.wallet.bdk.build_tx().coin_selection(coin_selection).unspendable(locked_outpoints); tx_builder.ordering(TxOrdering::Untouched); tx_builder.add_recipient(script_pubkey, amount); @@ -324,7 +323,8 @@ impl WalletActor { txid: TxId, ) -> Result { let outputs = self.wallet_unspent_outputs_for_tx(txid.0); - let state = self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; + let state = + self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; Ok(state) } @@ -336,7 +336,8 @@ impl WalletActor { return Ok(()); } - let current_state = self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; + let current_state = + self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; // unlocked or mixed -> lock all // locked -> unlock all @@ -355,7 +356,10 @@ impl WalletActor { } /// Compute the aggregate lock state for the given wallet-owned unspent outputs. - fn compute_lock_state(&self, outputs: &[OutPoint]) -> Result { + fn compute_lock_state( + &self, + outputs: &[OutPoint], + ) -> Result { if outputs.is_empty() { return Ok(TransactionLockState::None); } From 02133a40b53d26f5acb2650358fe6434c69a3567 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Thu, 23 Apr 2026 11:55:03 +0530 Subject: [PATCH 07/16] fix: revert TxBuilder chaining that caused borrow checker errors --- rust/src/manager/wallet_manager/actor.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index 95831b73f..3610ade9f 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -195,7 +195,8 @@ impl WalletActor { debug!("build_ephemeral_drain_tx for fee rate {}", fee.sat_per_vb()); let script_pubkey = address.script_pubkey(); let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); - let mut tx_builder = self.wallet.bdk.build_tx().unspendable(locked_outpoints); + let mut tx_builder = self.wallet.bdk.build_tx(); + tx_builder.unspendable(locked_outpoints); tx_builder.drain_wallet().drain_to(script_pubkey).fee_rate(fee.into()); let psbt = tx_builder.finish().map_err_str(Error::BuildTxError)?; @@ -218,8 +219,8 @@ impl WalletActor { let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); let coin_selection = CoveDefaultCoinSelection::new(self.seed); - let mut tx_builder = - self.wallet.bdk.build_tx().coin_selection(coin_selection).unspendable(locked_outpoints); + let mut tx_builder = self.wallet.bdk.build_tx().coin_selection(coin_selection); + tx_builder.unspendable(locked_outpoints); tx_builder.ordering(TxOrdering::Untouched); tx_builder.add_recipient(script_pubkey, amount); From c8f083d28321efefb657c121d0ce60a6be9f4389 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Thu, 23 Apr 2026 12:00:11 +0530 Subject: [PATCH 08/16] fix: use is_some_and instead of map_or for clippy --- rust/src/manager/wallet_manager/actor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index 3610ade9f..ea8671bc2 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -367,7 +367,7 @@ impl WalletActor { let mut locked_count = 0; for outpoint in outputs { - if self.db.labels.get_output_record(outpoint)?.map_or(false, |r| !r.item.spendable) { + if self.db.labels.get_output_record(outpoint)?.is_some_and(|r| !r.item.spendable) { locked_count += 1; } } From aedde1c54bfbb3062a31af74664237829bf96fd8 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Fri, 24 Apr 2026 01:30:28 +0530 Subject: [PATCH 09/16] fix: propagate locked_outpoints errors and use map_err_str convention --- rust/src/manager/wallet_manager/actor.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index ea8671bc2..bd6ea0ad6 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -194,7 +194,7 @@ impl WalletActor { ) -> Result { debug!("build_ephemeral_drain_tx for fee rate {}", fee.sat_per_vb()); let script_pubkey = address.script_pubkey(); - let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); + let locked_outpoints = self.db.labels.locked_outpoints().map_err_str(Error::UnknownError)?; let mut tx_builder = self.wallet.bdk.build_tx(); tx_builder.unspendable(locked_outpoints); @@ -217,7 +217,7 @@ impl WalletActor { let fee_rate = fee_rate.into(); let script_pubkey = address.script_pubkey(); - let locked_outpoints = self.db.labels.locked_outpoints().unwrap_or_default(); + let locked_outpoints = self.db.labels.locked_outpoints().map_err_str(Error::UnknownError)?; let coin_selection = CoveDefaultCoinSelection::new(self.seed); let mut tx_builder = self.wallet.bdk.build_tx().coin_selection(coin_selection); tx_builder.unspendable(locked_outpoints); @@ -324,8 +324,7 @@ impl WalletActor { txid: TxId, ) -> Result { let outputs = self.wallet_unspent_outputs_for_tx(txid.0); - let state = - self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; + let state = self.compute_lock_state(&outputs).map_err_str(Error::UnknownError)?; Ok(state) } @@ -337,8 +336,7 @@ impl WalletActor { return Ok(()); } - let current_state = - self.compute_lock_state(&outputs).map_err(|e| Error::UnknownError(e.to_string()))?; + let current_state = self.compute_lock_state(&outputs).map_err_str(Error::UnknownError)?; // unlocked or mixed -> lock all // locked -> unlock all From 6223a02e626335b593fc2c37ecdd664896c09670 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Fri, 24 Apr 2026 01:42:54 +0530 Subject: [PATCH 10/16] fix: use BuildTxError for locked_outpoints in tx build paths --- rust/src/manager/wallet_manager/actor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/rust/src/manager/wallet_manager/actor.rs b/rust/src/manager/wallet_manager/actor.rs index bd6ea0ad6..b5c98c420 100644 --- a/rust/src/manager/wallet_manager/actor.rs +++ b/rust/src/manager/wallet_manager/actor.rs @@ -194,7 +194,8 @@ impl WalletActor { ) -> Result { debug!("build_ephemeral_drain_tx for fee rate {}", fee.sat_per_vb()); let script_pubkey = address.script_pubkey(); - let locked_outpoints = self.db.labels.locked_outpoints().map_err_str(Error::UnknownError)?; + let locked_outpoints = + self.db.labels.locked_outpoints().map_err_str(Error::BuildTxError)?; let mut tx_builder = self.wallet.bdk.build_tx(); tx_builder.unspendable(locked_outpoints); @@ -217,7 +218,8 @@ impl WalletActor { let fee_rate = fee_rate.into(); let script_pubkey = address.script_pubkey(); - let locked_outpoints = self.db.labels.locked_outpoints().map_err_str(Error::UnknownError)?; + let locked_outpoints = + self.db.labels.locked_outpoints().map_err_str(Error::BuildTxError)?; let coin_selection = CoveDefaultCoinSelection::new(self.seed); let mut tx_builder = self.wallet.bdk.build_tx().coin_selection(coin_selection); tx_builder.unspendable(locked_outpoints); From d69e29e29d0f02f161dc14c1f83954ed3468285a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 15:42:13 +0000 Subject: [PATCH 11/16] chore: regenerate Android (Kotlin) UniFFI bindings --- .../java/org/bitcoinppl/cove_core/cove.kt | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt index d322a3731..c280bc96b 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove_core/cove.kt @@ -1622,8 +1622,12 @@ internal object IntegrityCheckingUniffiLib { ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_switch_to_different_wallet_address_type( ): Short + external fun uniffi_cove_checksum_method_rustwalletmanager_toggle_transaction_lock( + ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_transaction_details( ): Short + external fun uniffi_cove_checksum_method_rustwalletmanager_transaction_lock_state( + ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_validate_metadata( ): Short external fun uniffi_cove_checksum_method_rustwalletmanager_wallet_metadata( @@ -2712,8 +2716,12 @@ internal object UniffiLib { ): Long external fun uniffi_cove_fn_method_rustwalletmanager_switch_to_different_wallet_address_type(`ptr`: Long,`walletAddressType`: RustBuffer.ByValue, ): Long + external fun uniffi_cove_fn_method_rustwalletmanager_toggle_transaction_lock(`ptr`: Long,`txId`: Long, + ): Long external fun uniffi_cove_fn_method_rustwalletmanager_transaction_details(`ptr`: Long,`txId`: Long, ): Long + external fun uniffi_cove_fn_method_rustwalletmanager_transaction_lock_state(`ptr`: Long,`txId`: Long, + ): Long external fun uniffi_cove_fn_method_rustwalletmanager_validate_metadata(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_cove_fn_method_rustwalletmanager_wallet_metadata(`ptr`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -4388,9 +4396,15 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if (lib.uniffi_cove_checksum_method_rustwalletmanager_switch_to_different_wallet_address_type() != 64255.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustwalletmanager_toggle_transaction_lock() != 65256.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustwalletmanager_transaction_details() != 35364.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if (lib.uniffi_cove_checksum_method_rustwalletmanager_transaction_lock_state() != 4851.toShort()) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if (lib.uniffi_cove_checksum_method_rustwalletmanager_validate_metadata() != 36684.toShort()) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -20768,8 +20782,12 @@ public interface RustWalletManagerInterface { suspend fun `switchToDifferentWalletAddressType`(`walletAddressType`: WalletAddressType) + suspend fun `toggleTransactionLock`(`txId`: TxId) + suspend fun `transactionDetails`(`txId`: TxId): TransactionDetails + suspend fun `transactionLockState`(`txId`: TxId): TransactionLockState + fun `validateMetadata`() fun `walletMetadata`(): WalletMetadata @@ -21861,6 +21879,28 @@ open class RustWalletManager: Disposable, AutoCloseable, RustWalletManagerInterf } + @Throws(WalletManagerException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `toggleTransactionLock`(`txId`: TxId) { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_toggle_transaction_lock( + uniffiHandle, + FfiConverterTypeTxId.lower(`txId`), + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_void(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_void(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_void(future) }, + // lift function + { Unit }, + + // Error FFI converter + WalletManagerException.ErrorHandler, + ) + } + + @Throws(WalletManagerException::class) @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") override suspend fun `transactionDetails`(`txId`: TxId) : TransactionDetails { @@ -21881,6 +21921,27 @@ open class RustWalletManager: Disposable, AutoCloseable, RustWalletManagerInterf ) } + + @Throws(WalletManagerException::class) + @Suppress("ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE") + override suspend fun `transactionLockState`(`txId`: TxId) : TransactionLockState { + return uniffiRustCallAsync( + callWithHandle { uniffiHandle -> + UniffiLib.uniffi_cove_fn_method_rustwalletmanager_transaction_lock_state( + uniffiHandle, + FfiConverterTypeTxId.lower(`txId`), + ) + }, + { future, callback, continuation -> UniffiLib.ffi_cove_rust_future_poll_rust_buffer(future, callback, continuation) }, + { future, continuation -> UniffiLib.ffi_cove_rust_future_complete_rust_buffer(future, continuation) }, + { future -> UniffiLib.ffi_cove_rust_future_free_rust_buffer(future) }, + // lift function + { FfiConverterTypeTransactionLockState.lift(it) }, + // Error FFI converter + WalletManagerException.ErrorHandler, + ) + } + override fun `validateMetadata`() = callWithHandle { @@ -47640,6 +47701,42 @@ public object FfiConverterTypeTransactionDetailError : FfiConverterRustBuffer { + override fun read(buf: ByteBuffer) = try { + TransactionLockState.values()[buf.getInt() - 1] + } catch (e: IndexOutOfBoundsException) { + throw RuntimeException("invalid enum value, something is very wrong!!", e) + } + + override fun allocationSize(value: TransactionLockState) = 4UL + + override fun write(value: TransactionLockState, buf: ByteBuffer) { + buf.putInt(value.ordinal + 1) + } +} + + + + + + enum class TransactionState { PENDING, From 622173470b41af106aabdedee4dd6cb92a1878dc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Apr 2026 15:47:04 +0000 Subject: [PATCH 12/16] chore: regenerate iOS (Swift) UniFFI bindings --- .../Sources/CoveCore/generated/cove.swift | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/ios/CoveCore/Sources/CoveCore/generated/cove.swift b/ios/CoveCore/Sources/CoveCore/generated/cove.swift index e0019bf64..4d38fd838 100644 --- a/ios/CoveCore/Sources/CoveCore/generated/cove.swift +++ b/ios/CoveCore/Sources/CoveCore/generated/cove.swift @@ -8827,8 +8827,12 @@ public protocol RustWalletManagerProtocol: AnyObject, Sendable { func switchToDifferentWalletAddressType(walletAddressType: WalletAddressType) async throws + func toggleTransactionLock(txId: TxId) async throws + func transactionDetails(txId: TxId) async throws -> TransactionDetails + func transactionLockState(txId: TxId) async throws -> TransactionLockState + func validateMetadata() func walletMetadata() -> WalletMetadata @@ -9684,6 +9688,23 @@ open func switchToDifferentWalletAddressType(walletAddressType: WalletAddressTyp ) } +open func toggleTransactionLock(txId: TxId)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_rustwalletmanager_toggle_transaction_lock( + self.uniffiCloneHandle(), + FfiConverterTypeTxId_lower(txId) + ) + }, + pollFunc: ffi_cove_rust_future_poll_void, + completeFunc: ffi_cove_rust_future_complete_void, + freeFunc: ffi_cove_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeWalletManagerError_lift + ) +} + open func transactionDetails(txId: TxId)async throws -> TransactionDetails { return try await uniffiRustCallAsync( @@ -9701,6 +9722,23 @@ open func transactionDetails(txId: TxId)async throws -> TransactionDetails { ) } +open func transactionLockState(txId: TxId)async throws -> TransactionLockState { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_cove_fn_method_rustwalletmanager_transaction_lock_state( + self.uniffiCloneHandle(), + FfiConverterTypeTxId_lower(txId) + ) + }, + pollFunc: ffi_cove_rust_future_poll_rust_buffer, + completeFunc: ffi_cove_rust_future_complete_rust_buffer, + freeFunc: ffi_cove_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeTransactionLockState_lift, + errorHandler: FfiConverterTypeWalletManagerError_lift + ) +} + open func validateMetadata() {try! rustCall() { uniffi_cove_fn_method_rustwalletmanager_validate_metadata( self.uniffiCloneHandle(),$0 @@ -29256,6 +29294,86 @@ public func FfiConverterTypeTransactionDetailError_lower(_ value: TransactionDet +public enum TransactionLockState: Equatable, Hashable { + + case unlocked + case locked + case mixed + case none + + + + + +} + +#if compiler(>=6) +extension TransactionLockState: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeTransactionLockState: FfiConverterRustBuffer { + typealias SwiftType = TransactionLockState + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> TransactionLockState { + let variant: Int32 = try readInt(&buf) + switch variant { + + case 1: return .unlocked + + case 2: return .locked + + case 3: return .mixed + + case 4: return .none + + default: throw UniffiInternalError.unexpectedEnumCase + } + } + + public static func write(_ value: TransactionLockState, into buf: inout [UInt8]) { + switch value { + + + case .unlocked: + writeInt(&buf, Int32(1)) + + + case .locked: + writeInt(&buf, Int32(2)) + + + case .mixed: + writeInt(&buf, Int32(3)) + + + case .none: + writeInt(&buf, Int32(4)) + + } + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTransactionLockState_lift(_ buf: RustBuffer) throws -> TransactionLockState { + return try FfiConverterTypeTransactionLockState.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeTransactionLockState_lower(_ value: TransactionLockState) -> RustBuffer { + return FfiConverterTypeTransactionLockState.lower(value) +} + + + + public enum TransactionState: Equatable, Hashable { case pending @@ -36849,9 +36967,15 @@ private let initializationResult: InitializationResult = { if (uniffi_cove_checksum_method_rustwalletmanager_switch_to_different_wallet_address_type() != 64255) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustwalletmanager_toggle_transaction_lock() != 65256) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustwalletmanager_transaction_details() != 35364) { return InitializationResult.apiChecksumMismatch } + if (uniffi_cove_checksum_method_rustwalletmanager_transaction_lock_state() != 4851) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_cove_checksum_method_rustwalletmanager_validate_metadata() != 36684) { return InitializationResult.apiChecksumMismatch } From e5e807f31e0e708b85184ceaca72c7bf6c050b83 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Fri, 24 Apr 2026 21:21:15 +0530 Subject: [PATCH 13/16] ci: trigger CI after binding regeneration From 0edfb796a3e9c31e8b4f09a24e6b954ef46fc387 Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Fri, 24 Apr 2026 21:31:15 +0530 Subject: [PATCH 14/16] fix: use compatible .with() syntax for Compose animations --- .../TransactionDetails/TransactionDetailsScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index 95d1456c5..4f1b984fd 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.with import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -383,7 +384,7 @@ fun TransactionDetailsScreen( targetState = lockState, transitionSpec = { (fadeIn() + androidx.compose.animation.scaleIn()) - .togetherWith(fadeOut() + androidx.compose.animation.scaleOut()) + .with(fadeOut() + androidx.compose.animation.scaleOut()) }, label = "lock_state_anim" ) { state -> From 3043eb7cacfc262798a441ab8673e0659219969d Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Fri, 24 Apr 2026 21:49:06 +0530 Subject: [PATCH 15/16] fix: opt-in to ExperimentalAnimationApi for AnimatedContent --- .../TransactionDetails/TransactionDetailsScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt index 4f1b984fd..f284558ff 100644 --- a/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt +++ b/android/app/src/main/java/org/bitcoinppl/cove/flows/SelectedWalletFlow/TransactionDetails/TransactionDetailsScreen.kt @@ -3,6 +3,7 @@ package org.bitcoinppl.cove.flows.SelectedWalletFlow.TransactionDetails import android.content.Intent import android.net.Uri import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -97,7 +98,7 @@ private const val CONFIRMATIONS_THRESHOLD = 3 * Transaction details screen - now using manager-based pattern * Ported from iOS TransactionDetailsView.swift */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) @Composable fun TransactionDetailsScreen( app: AppManager, From c9fb0120b1a41831352fc87dbcebe5662cba25ee Mon Sep 17 00:00:00 2001 From: Aryan Jain Date: Fri, 24 Apr 2026 22:01:47 +0530 Subject: [PATCH 16/16] ci: re-trigger checks for ktlint