Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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,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)
}
Expand Down Expand Up @@ -337,6 +346,61 @@ fun TransactionDetailsScreen(
)
}
},
actions = {
if (lockState != TransactionLockState.NONE) {
IconButton(onClick = {
scope.launch {
try {
<<<<<<< HEAD
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

conflicts

=======
val previousState = lockState
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unused previousState variable

previousState is assigned from lockState but is never read afterward — the snackbar message is derived from newState. This will generate a Kotlin compiler warning and can be removed.

Suggested change
val previousState = lockState
try {
manager.rust.toggleTransactionLock(txId = txId)

>>>>>>> df221913f2ae388b340cd7564cbc6c71783652e7
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
)
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
Expand All @@ -356,12 +420,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
78 changes: 78 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 @@
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 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)
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,60 @@
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);

<<<<<<< HEAD

Check failure on line 362 in rust/src/database/wallet_data/label.rs

View workflow job for this annotation

GitHub Actions / rustfmt

encountered diff marker
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;
}
=======
let mut record = table.get(key.clone())?.map(|r| r.value()).unwrap_or_else(|| {
Record::new(OutputRecord {
ref_: outpoint,
label: None,
spendable: true,
})
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No-op records written during bulk unlock

When set_outputs_spendable is called with spendable = true (unlock path) and an outpoint has no existing record, a new OutputRecord is created with spendable: true — which is the default. This writes a semantically empty record to the database, adding unnecessary cloud-backup churn. A small guard would avoid the write:

let existing = table.get(key.clone())?.map(|r| r.value());
let mut record = match existing {
    Some(r) => r,
    None => {
        if spendable { continue; }
        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();

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 @@ -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,
Expand Down Expand Up @@ -576,6 +577,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
89 changes: 88 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 @@ -312,6 +314,91 @@ impl WalletActor {
transactions
}

pub async fn transaction_lock_state(&mut self, txid: TxId) -> ActorResult<TransactionLockState> {
<<<<<<< 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]
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(());
}

<<<<<<< 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
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.

<<<<<<< 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 {
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<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