Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
df22191
feat: add bulk UTXO lock control to Transaction Details (#661)
gitsofaryan Apr 19, 2026
55fcb17
feat: add bulk UTXO lock control to Transaction Details (#661)
gitsofaryan Apr 19, 2026
6753544
Merge branch 'bulk-utxo-lock-661' of https://github.com/gitsofaryan/c…
gitsofaryan Apr 19, 2026
20f3eb0
feat: implement transaction details screen and add wallet label manag…
gitsofaryan Apr 23, 2026
4dc6b20
feat: implement WalletActor for BDK wallet management and add label d…
gitsofaryan Apr 23, 2026
b70f5af
fix: address CodeRabbit review feedback
gitsofaryan Apr 23, 2026
56fa710
fix: resolve TxBuilder compile error and rustfmt
gitsofaryan Apr 23, 2026
02133a4
fix: revert TxBuilder chaining that caused borrow checker errors
gitsofaryan Apr 23, 2026
c8f083d
fix: use is_some_and instead of map_or for clippy
gitsofaryan Apr 23, 2026
aedde1c
fix: propagate locked_outpoints errors and use map_err_str convention
gitsofaryan Apr 23, 2026
6223a02
fix: use BuildTxError for locked_outpoints in tx build paths
gitsofaryan Apr 23, 2026
7e734ad
Merge remote-tracking branch 'upstream/master' into bulk-utxo-lock-661
gitsofaryan Apr 23, 2026
b6344a4
Merge remote-tracking branch 'upstream/master' into bulk-utxo-lock-661
gitsofaryan Apr 24, 2026
d69e29e
chore: regenerate Android (Kotlin) UniFFI bindings
github-actions[bot] Apr 24, 2026
6221734
chore: regenerate iOS (Swift) UniFFI bindings
github-actions[bot] Apr 24, 2026
e5e807f
ci: trigger CI after binding regeneration
gitsofaryan Apr 24, 2026
0edfb79
fix: use compatible .with() syntax for Compose animations
gitsofaryan Apr 24, 2026
3043eb7
fix: opt-in to ExperimentalAnimationApi for AnimatedContent
gitsofaryan Apr 24, 2026
c9fb012
ci: re-trigger checks for ktlint
gitsofaryan Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -125,11 +128,18 @@ 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) }
var isToggling by remember { mutableStateOf(false) }

// 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)
}
Expand Down Expand Up @@ -337,6 +347,64 @@ fun TransactionDetailsScreen(
)
}
},
actions = {
if (lockState != TransactionLockState.NONE) {
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
}
}
},
enabled = !isToggling
) {
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
)
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
Expand All @@ -356,12 +424,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)
}
Expand Down
69 changes: 69 additions & 0 deletions rust/src/database/wallet_data/label.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,30 @@ impl LabelsTable {
Ok(label)
}

pub fn get_output_record(
&self,
outpoint: impl Borrow<bitcoin::OutPoint>,
) -> Result<Option<Record<OutputRecord>>, 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<Vec<bitcoin::OutPoint>, Error> {
let table = self.read_table(OUTPUT_TABLE)?;
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

pub fn get_address_record(
&self,
address: impl Borrow<Address<NetworkUnchecked>>,
Expand Down Expand Up @@ -323,6 +347,51 @@ impl LabelsTable {
Ok(())
}

pub fn set_outputs_spendable(
&self,
outpoints: impl IntoIterator<Item = bitcoin::OutPoint>,
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 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
continue;
}
Record::new(OutputRecord { ref_: outpoint, label: None, spendable: true })
}
};

if record.item.spendable == spendable {
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();

table.insert(key, record)?;
}
}

write_txn.commit().map_err_str(DatabaseError::DatabaseAccess)?;

Ok(())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// MARK: DELETE
pub fn delete_labels(&self, labels: impl IntoIterator<Item = Label>) -> Result<(), Error> {
let write_txn = self.db.begin_write().map_err_str(DatabaseError::DatabaseAccess)?;
Expand Down
22 changes: 21 additions & 1 deletion rust/src/manager/wallet_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,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,
Expand Down Expand Up @@ -577,6 +578,25 @@ impl RustWalletManager {
Ok(address_info)
}

#[uniffi::method]
pub async fn transaction_lock_state(
&self,
tx_id: Arc<TxId>,
) -> Result<TransactionLockState, Error> {
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<TxId>) -> Result<(), Error> {
call!(self.actor.toggle_transaction_lock(Arc::unwrap_or_clone(tx_id)))
.await
.map_err_str(Error::UnknownError)??;
Ok(())
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[uniffi::method]
pub fn save_unsigned_transaction(&self, details: Arc<ConfirmDetails>) -> Result<(), Error> {
let wallet_id = self.id.clone();
Expand Down
81 changes: 80 additions & 1 deletion rust/src/manager/wallet_manager/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -203,7 +205,10 @@ impl WalletActor {
) -> Result<Psbt, Error> {
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::BuildTxError)?;
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)?;
Expand All @@ -224,8 +229,11 @@ 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::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);

tx_builder.ordering(TxOrdering::Untouched);
tx_builder.add_recipient(script_pubkey, amount);
Expand Down Expand Up @@ -323,6 +331,77 @@ impl WalletActor {
transactions
}

#[into_actor_result]
pub async fn transaction_lock_state(
&mut self,
txid: TxId,
) -> Result<TransactionLockState, Error> {
let outputs = self.wallet_unspent_outputs_for_tx(txid.0);
let state = self.compute_lock_state(&outputs).map_err_str(Error::UnknownError)?;
Ok(state)
}

#[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).map_err_str(Error::UnknownError)?;

// 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)?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// notify cloud backup that labels changed
crate::manager::cloud_backup_manager::CLOUD_BACKUP_MANAGER
.handle_wallet_backup_change(self.wallet.id.clone());

Ok(())
}
Comment thread
gitsofaryan marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Compute the aggregate lock state for the given wallet-owned unspent outputs.
fn compute_lock_state(
&self,
outputs: &[OutPoint],
) -> Result<TransactionLockState, crate::database::wallet_data::label::Error> {
if outputs.is_empty() {
return Ok(TransactionLockState::None);
}

let mut locked_count = 0;
for outpoint in outputs {
if self.db.labels.get_output_record(outpoint)?.is_some_and(|r| !r.item.spendable) {
locked_count += 1;
}
}

if locked_count == 0 {
Ok(TransactionLockState::Unlocked)
} else if locked_count == outputs.len() {
Ok(TransactionLockState::Locked)
} else {
Ok(TransactionLockState::Mixed)
}
}

/// Returns all wallet-owned, still-unspent outpoints created by the given txid.
fn wallet_unspent_outputs_for_tx(&self, txid: Txid) -> Vec<OutPoint> {
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<AddressAndAmount>,
Expand Down
1 change: 1 addition & 0 deletions rust/src/transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
8 changes: 8 additions & 0 deletions rust/src/transaction/transaction_details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading