From ff74e59703dea5a20532c52ddb1a530d038978e9 Mon Sep 17 00:00:00 2001 From: valued mammal Date: Mon, 2 Jun 2025 19:45:45 -0400 Subject: [PATCH 1/3] feat!: Support persistent UTXO locking New APIs added for locking and unlocking a UTXO by outpoint and to query the locked outpoints. Locking an outpoint means that it is excluded from coin selection. - Add `Wallet::lock_outpoint` - Add `Wallet::unlock_outpoint` - Add `Wallet::is_outpoint_locked` - Add `Wallet::list_locked_outpoints` - Add `Wallet::list_locked_unspent` `test_lock_outpoint_persist` tests the lock/unlock functionality and that the lock status is persistent. BREAKING: Added `locked_outpoints` member field to ChangeSet. A SQLite migration is included for adding the locked outpoints table. --- clippy.toml | 5 +- wallet/src/lib.rs | 3 + wallet/src/wallet/changeset.rs | 81 +++++++++++++++++++++++- wallet/src/wallet/locked_outpoints.rs | 26 ++++++++ wallet/src/wallet/mod.rs | 90 +++++++++++++++++++++++++++ wallet/tests/persisted_wallet.rs | 86 +++++++++++++++++++++++++ 6 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 wallet/src/wallet/locked_outpoints.rs diff --git a/clippy.toml b/clippy.toml index ead89212..b3c3a24c 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1,4 +1 @@ -# TODO fix, see: https://rust-lang.github.io/rust-clippy/master/index.html#large_enum_variant -enum-variant-size-threshold = 1032 -# TODO fix, see: https://rust-lang.github.io/rust-clippy/master/index.html#result_large_err -large-error-threshold = 993 \ No newline at end of file +msrv = "1.63.0" diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index f65fe653..c1fdf2b1 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -8,6 +8,9 @@ #![no_std] #![warn(missing_docs)] #![allow(clippy::uninlined_format_args)] +// TODO: these can be removed after +#![allow(clippy::result_large_err)] +#![allow(clippy::large_enum_variant)] #[cfg(feature = "std")] #[macro_use] diff --git a/wallet/src/wallet/changeset.rs b/wallet/src/wallet/changeset.rs index ebfdb9fb..607e0196 100644 --- a/wallet/src/wallet/changeset.rs +++ b/wallet/src/wallet/changeset.rs @@ -1,17 +1,20 @@ use bdk_chain::{ indexed_tx_graph, keychain_txout, local_chain, tx_graph, ConfirmationBlockTime, Merge, }; +use bitcoin::{OutPoint, Txid}; use miniscript::{Descriptor, DescriptorPublicKey}; use serde::{Deserialize, Serialize}; type IndexedTxGraphChangeSet = indexed_tx_graph::ChangeSet; -/// A change set for [`Wallet`] +use crate::locked_outpoints; + +/// A change set for [`Wallet`]. /// /// ## Definition /// -/// The change set is responsible for transmiting data between the persistent storage layer and the +/// The change set is responsible for transmitting data between the persistent storage layer and the /// core library components. Specifically, it serves two primary functions: /// /// 1) Recording incremental changes to the in-memory representation that need to be persisted to @@ -114,6 +117,8 @@ pub struct ChangeSet { pub tx_graph: tx_graph::ChangeSet, /// Changes to [`KeychainTxOutIndex`](keychain_txout::KeychainTxOutIndex). pub indexer: keychain_txout::ChangeSet, + /// Changes to locked outpoints. + pub locked_outpoints: locked_outpoints::ChangeSet, } impl Merge for ChangeSet { @@ -142,6 +147,9 @@ impl Merge for ChangeSet { self.network = other.network; } + // merge locked outpoints + self.locked_outpoints.merge(other.locked_outpoints); + Merge::merge(&mut self.local_chain, other.local_chain); Merge::merge(&mut self.tx_graph, other.tx_graph); Merge::merge(&mut self.indexer, other.indexer); @@ -154,6 +162,7 @@ impl Merge for ChangeSet { && self.local_chain.is_empty() && self.tx_graph.is_empty() && self.indexer.is_empty() + && self.locked_outpoints.is_empty() } } @@ -163,6 +172,8 @@ impl ChangeSet { pub const WALLET_SCHEMA_NAME: &'static str = "bdk_wallet"; /// Name of table to store wallet descriptors and network. pub const WALLET_TABLE_NAME: &'static str = "bdk_wallet"; + /// Name of table to store wallet locked outpoints. + pub const WALLET_OUTPOINT_LOCK_TABLE_NAME: &'static str = "bdk_wallet_locked_outpoints"; /// Get v0 sqlite [ChangeSet] schema pub fn schema_v0() -> alloc::string::String { @@ -177,12 +188,24 @@ impl ChangeSet { ) } + /// Get v1 sqlite [`ChangeSet`] schema. Schema v1 adds a table for locked outpoints. + pub fn schema_v1() -> alloc::string::String { + format!( + "CREATE TABLE {} ( \ + txid TEXT NOT NULL, \ + vout INTEGER NOT NULL, \ + PRIMARY KEY(txid, vout) \ + ) STRICT;", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME, + ) + } + /// Initialize sqlite tables for wallet tables. pub fn init_sqlite_tables(db_tx: &chain::rusqlite::Transaction) -> chain::rusqlite::Result<()> { crate::rusqlite_impl::migrate_schema( db_tx, Self::WALLET_SCHEMA_NAME, - &[&Self::schema_v0()], + &[&Self::schema_v0(), &Self::schema_v1()], )?; bdk_chain::local_chain::ChangeSet::init_sqlite_tables(db_tx)?; @@ -220,6 +243,24 @@ impl ChangeSet { changeset.network = network.map(Impl::into_inner); } + // Select locked outpoints. + let mut stmt = db_tx.prepare(&format!( + "SELECT txid, vout FROM {}", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME, + ))?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, Impl>("txid")?, + row.get::<_, u32>("vout")?, + )) + })?; + let locked_outpoints = &mut changeset.locked_outpoints.locked_outpoints; + for row in rows { + let (Impl(txid), vout) = row?; + let outpoint = OutPoint::new(txid, vout); + locked_outpoints.insert(outpoint, true); + } + changeset.local_chain = local_chain::ChangeSet::from_sqlite(db_tx)?; changeset.tx_graph = tx_graph::ChangeSet::<_>::from_sqlite(db_tx)?; changeset.indexer = keychain_txout::ChangeSet::from_sqlite(db_tx)?; @@ -268,6 +309,31 @@ impl ChangeSet { })?; } + // Insert or delete locked outpoints. + let mut insert_stmt = db_tx.prepare_cached(&format!( + "REPLACE INTO {}(txid, vout) VALUES(:txid, :vout)", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME + ))?; + let mut delete_stmt = db_tx.prepare_cached(&format!( + "DELETE FROM {} WHERE txid=:txid AND vout=:vout", + Self::WALLET_OUTPOINT_LOCK_TABLE_NAME, + ))?; + let locked_outpoints = &self.locked_outpoints.locked_outpoints; + for (&outpoint, &is_locked) in locked_outpoints.iter() { + let OutPoint { txid, vout } = outpoint; + if is_locked { + insert_stmt.execute(named_params! { + ":txid": Impl(txid), + ":vout": vout, + })?; + } else { + delete_stmt.execute(named_params! { + ":txid": Impl(txid), + ":vout": vout, + })?; + } + } + self.local_chain.persist_to_sqlite(db_tx)?; self.tx_graph.persist_to_sqlite(db_tx)?; self.indexer.persist_to_sqlite(db_tx)?; @@ -311,3 +377,12 @@ impl From for ChangeSet { } } } + +impl From for ChangeSet { + fn from(locked_outpoints: locked_outpoints::ChangeSet) -> Self { + Self { + locked_outpoints, + ..Default::default() + } + } +} diff --git a/wallet/src/wallet/locked_outpoints.rs b/wallet/src/wallet/locked_outpoints.rs new file mode 100644 index 00000000..c70542ab --- /dev/null +++ b/wallet/src/wallet/locked_outpoints.rs @@ -0,0 +1,26 @@ +//! Module containing the locked outpoints change set. + +use bdk_chain::Merge; +use bitcoin::OutPoint; +use serde::{Deserialize, Serialize}; + +use crate::collections::BTreeMap; + +/// Represents changes to locked outpoints. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct ChangeSet { + /// The lock status of an outpoint, `true == is_locked`. + pub locked_outpoints: BTreeMap, +} + +impl Merge for ChangeSet { + fn merge(&mut self, other: Self) { + // Extend self with other. Any entries in `self` that share the same + // outpoint are overwritten. + self.locked_outpoints.extend(other.locked_outpoints); + } + + fn is_empty(&self) -> bool { + self.locked_outpoints.is_empty() + } +} diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index c1aab082..a74ae437 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -53,6 +53,7 @@ mod changeset; pub mod coin_selection; pub mod error; pub mod export; +pub mod locked_outpoints; mod params; mod persisted; pub mod signer; @@ -109,6 +110,7 @@ pub struct Wallet { stage: ChangeSet, network: Network, secp: SecpCtx, + locked_outpoints: BTreeMap>, } /// An update to [`Wallet`]. @@ -471,6 +473,8 @@ impl Wallet { None => (None, Arc::new(SignersContainer::new())), }; + let locked_outpoints = BTreeMap::new(); + let mut stage = ChangeSet { descriptor: Some(descriptor.clone()), change_descriptor: change_descriptor.clone(), @@ -497,6 +501,7 @@ impl Wallet { indexed_graph, stage, secp, + locked_outpoints, }) } @@ -672,6 +677,13 @@ impl Wallet { None => Arc::new(SignersContainer::new()), }; + // Apply locked outpoints + let locked_outpoints = changeset.locked_outpoints.locked_outpoints; + let locked_outpoints = locked_outpoints + .into_iter() + .map(|(op, is_locked)| (op, if is_locked { Some(true) } else { None })) + .collect(); + let mut stage = ChangeSet::default(); let indexed_graph = make_indexed_graph( @@ -693,6 +705,7 @@ impl Wallet { stage, network, secp, + locked_outpoints, })) } @@ -2137,6 +2150,8 @@ impl Wallet { CanonicalizationParams::default(), self.indexed_graph.index.outpoints().iter().cloned(), ) + // Filter out locked outpoints + .filter(|(_, txo)| !self.is_outpoint_locked(txo.outpoint)) // only create LocalOutput if UTxO is mature .filter_map(move |((k, i), full_txo)| { full_txo @@ -2405,6 +2420,81 @@ impl Wallet { &self.chain } + /// List the locked outpoints. + pub fn list_locked_outpoints(&self) -> impl Iterator + '_ { + self.locked_outpoints + .iter() + .filter(|(_, lock)| matches!(lock, Some(true))) + .map(|(op, _)| *op) + } + + /// List unspent outpoints that are currently locked. + pub fn list_locked_unspent(&self) -> impl Iterator + '_ { + self.list_unspent() + .filter(|output| self.is_outpoint_locked(output.outpoint)) + .map(|output| output.outpoint) + } + + /// Whether the `outpoint` is locked. See [`Wallet::lock_outpoint`] for more. + pub fn is_outpoint_locked(&self, outpoint: OutPoint) -> bool { + self.locked_outpoints + .get(&outpoint) + .map_or(false, |lock| matches!(lock, Some(true))) + } + + /// Lock a wallet output identified by the given `outpoint`. + /// + /// A locked UTXO will not be selected as an input to fund a transaction. This is useful + /// for excluding or reserving candidate inputs during transaction creation. + /// + /// **You must persist the staged change for the lock status to be persistent**. To unlock a + /// previously locked outpoint, see [`Wallet::unlock_outpoint`]. + pub fn lock_outpoint(&mut self, outpoint: OutPoint) { + use crate::collections::btree_map; + let lock_value = true; + let mut changeset = locked_outpoints::ChangeSet::default(); + + // If the lock status changed, update the entry and record the change + // in the changeset. + match self.locked_outpoints.entry(outpoint) { + btree_map::Entry::Occupied(mut e) => { + let is_locked = e.get().unwrap_or(false); + if !is_locked { + e.insert(Some(lock_value)); + changeset.locked_outpoints.insert(outpoint, lock_value); + } + } + btree_map::Entry::Vacant(e) => { + e.insert(Some(lock_value)); + changeset.locked_outpoints.insert(outpoint, lock_value); + } + } + + self.stage.merge(changeset.into()); + } + + /// Unlock the wallet output of the specified `outpoint`. + /// + /// **You must persist the staged change for the lock status to be persistent**. + pub fn unlock_outpoint(&mut self, outpoint: OutPoint) { + use crate::collections::btree_map; + let mut changeset = locked_outpoints::ChangeSet::default(); + + // If the outpoint is currently locked, remove the value from the entry + // and stage the change. + match self.locked_outpoints.entry(outpoint) { + btree_map::Entry::Vacant(..) => {} + btree_map::Entry::Occupied(entry) => { + let is_locked = entry.get().unwrap_or(false); + if is_locked { + entry.remove(); + changeset.locked_outpoints.insert(outpoint, false); + self.stage.merge(changeset.into()); + } + } + } + } + /// Introduces a `block` of `height` to the wallet, and tries to connect it to the /// `prev_blockhash` of the block's header. /// diff --git a/wallet/tests/persisted_wallet.rs b/wallet/tests/persisted_wallet.rs index e2873799..800328e6 100644 --- a/wallet/tests/persisted_wallet.rs +++ b/wallet/tests/persisted_wallet.rs @@ -7,7 +7,9 @@ use bdk_chain::DescriptorId; use bdk_chain::{ keychain_txout::DEFAULT_LOOKAHEAD, ChainPosition, ConfirmationBlockTime, DescriptorExt, }; +use bdk_wallet::coin_selection::InsufficientFunds; use bdk_wallet::descriptor::IntoWalletDescriptor; +use bdk_wallet::error::CreateTxError; use bdk_wallet::test_utils::*; use bdk_wallet::{ ChangeSet, KeychainKind, LoadError, LoadMismatch, LoadWithPersistError, Wallet, WalletPersister, @@ -419,3 +421,87 @@ fn single_descriptor_wallet_persist_and_recover() { "single descriptor wallet should refuse change descriptor param" ); } + +#[test] +fn test_lock_outpoint_persist() -> anyhow::Result<()> { + use bdk_chain::rusqlite; + let mut conn = rusqlite::Connection::open_in_memory()?; + + let (desc, change_desc) = get_test_tr_single_sig_xprv_and_change_desc(); + let mut wallet = Wallet::create(desc, change_desc) + .network(Network::Signet) + .create_wallet(&mut conn)?; + + // Receive coins. + let mut outpoints = vec![]; + for i in 0..3 { + let op = receive_output(&mut wallet, Amount::from_sat(10_000), ReceiveTo::Mempool(i)); + outpoints.push(op); + } + + // Test: lock outpoints + let unspent = wallet.list_unspent().collect::>(); + assert!(!unspent.is_empty()); + for utxo in unspent { + wallet.lock_outpoint(utxo.outpoint); + assert!( + wallet.is_outpoint_locked(utxo.outpoint), + "Expect outpoint is locked" + ); + } + wallet.persist(&mut conn)?; + + // Test: The lock value is persistent + { + wallet = Wallet::load() + .load_wallet(&mut conn)? + .expect("wallet is persisted"); + + let unspent = wallet.list_unspent().collect::>(); + assert!(!unspent.is_empty()); + for utxo in unspent { + assert!( + wallet.is_outpoint_locked(utxo.outpoint), + "Expect recover lock value" + ); + } + let locked_unspent = wallet.list_locked_unspent().collect::>(); + assert_eq!(locked_unspent, outpoints); + + // Test: Locked outpoints are excluded from coin selection + let addr = wallet.next_unused_address(KeychainKind::External).address; + let mut tx_builder = wallet.build_tx(); + tx_builder.add_recipient(addr, Amount::from_sat(10_000)); + let res = tx_builder.finish(); + assert!( + matches!( + res, + Err(CreateTxError::CoinSelection(InsufficientFunds { + available: Amount::ZERO, + .. + })), + ), + "Locked outpoints should not be selected", + ); + } + + // Test: Unlock outpoints + { + wallet = Wallet::load() + .load_wallet(&mut conn)? + .expect("wallet is persisted"); + + let unspent = wallet.list_unspent().collect::>(); + for utxo in &unspent { + wallet.unlock_outpoint(utxo.outpoint); + assert!( + !wallet.is_outpoint_locked(utxo.outpoint), + "Expect outpoint is not locked" + ); + } + assert!(wallet.list_locked_outpoints().next().is_none()); + assert!(wallet.list_locked_unspent().next().is_none()); + } + + Ok(()) +} From d020559d783b520d9ad1f906a257f4ac7127443d Mon Sep 17 00:00:00 2001 From: valued mammal Date: Fri, 8 Aug 2025 10:46:38 -0400 Subject: [PATCH 2/3] clippy!: fix large enum variants ..by `Box`ing the descriptor in `LoadMismatch` enum, and by boxing the ChangeSet in `DataAlreadyExists` variant of `CreateWithPersistError`. We allow the large_enum_variant lint for `FileStoreError` for now, as it is planned to be fixed in a future version of `bdk_file_store`. --- wallet/src/lib.rs | 3 --- wallet/src/wallet/mod.rs | 18 +++++++++--------- wallet/src/wallet/persisted.rs | 11 ++++++++--- wallet/tests/persisted_wallet.rs | 2 +- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index c1fdf2b1..f65fe653 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -8,9 +8,6 @@ #![no_std] #![warn(missing_docs)] #![allow(clippy::uninlined_format_args)] -// TODO: these can be removed after -#![allow(clippy::result_large_err)] -#![allow(clippy::large_enum_variant)] #[cfg(feature = "std")] #[macro_use] diff --git a/wallet/src/wallet/mod.rs b/wallet/src/wallet/mod.rs index a74ae437..84d92ee1 100644 --- a/wallet/src/wallet/mod.rs +++ b/wallet/src/wallet/mod.rs @@ -229,9 +229,9 @@ pub enum LoadMismatch { /// Keychain identifying the descriptor. keychain: KeychainKind, /// The loaded descriptor. - loaded: Option, + loaded: Option>, /// The expected descriptor. - expected: Option, + expected: Option>, }, } @@ -600,8 +600,8 @@ impl Wallet { if descriptor.descriptor_id() != exp_desc.descriptor_id() { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::External, - loaded: Some(descriptor), - expected: Some(exp_desc), + loaded: Some(Box::new(descriptor)), + expected: Some(Box::new(exp_desc)), })); } if params.extract_keys { @@ -610,7 +610,7 @@ impl Wallet { } else { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::External, - loaded: Some(descriptor), + loaded: Some(Box::new(descriptor)), expected: None, })); } @@ -630,7 +630,7 @@ impl Wallet { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::Internal, loaded: None, - expected: Some(exp_desc), + expected: Some(Box::new(exp_desc)), })); } } @@ -644,7 +644,7 @@ impl Wallet { None => { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::Internal, - loaded: Some(desc), + loaded: Some(Box::new(desc)), expected: None, })) } @@ -656,8 +656,8 @@ impl Wallet { if desc.descriptor_id() != exp_desc.descriptor_id() { return Err(LoadError::Mismatch(LoadMismatch::Descriptor { keychain: KeychainKind::Internal, - loaded: Some(desc), - expected: Some(exp_desc), + loaded: Some(Box::new(desc)), + expected: Some(Box::new(exp_desc)), })); } if params.extract_keys { diff --git a/wallet/src/wallet/persisted.rs b/wallet/src/wallet/persisted.rs index 74516e4b..84229ef7 100644 --- a/wallet/src/wallet/persisted.rs +++ b/wallet/src/wallet/persisted.rs @@ -150,7 +150,9 @@ impl PersistedWallet

{ ) -> Result> { let existing = P::initialize(persister).map_err(CreateWithPersistError::Persist)?; if !existing.is_empty() { - return Err(CreateWithPersistError::DataAlreadyExists(existing)); + return Err(CreateWithPersistError::DataAlreadyExists(Box::new( + existing, + ))); } let mut inner = Wallet::create_with_params(params).map_err(CreateWithPersistError::Descriptor)?; @@ -207,7 +209,9 @@ impl PersistedWallet

{ .await .map_err(CreateWithPersistError::Persist)?; if !existing.is_empty() { - return Err(CreateWithPersistError::DataAlreadyExists(existing)); + return Err(CreateWithPersistError::DataAlreadyExists(Box::new( + existing, + ))); } let mut inner = Wallet::create_with_params(params).map_err(CreateWithPersistError::Descriptor)?; @@ -293,6 +297,7 @@ impl WalletPersister for bdk_chain::rusqlite::Connection { /// Error for [`bdk_file_store`]'s implementation of [`WalletPersister`]. #[cfg(feature = "file_store")] #[derive(Debug)] +#[allow(clippy::large_enum_variant)] pub enum FileStoreError { /// Error when loading from the store. Load(bdk_file_store::StoreErrorWithDump), @@ -357,7 +362,7 @@ pub enum CreateWithPersistError { /// Error from persistence. Persist(E), /// Persister already has wallet data. - DataAlreadyExists(ChangeSet), + DataAlreadyExists(Box), /// Occurs when the loaded changeset cannot construct [`Wallet`]. Descriptor(DescriptorError), } diff --git a/wallet/tests/persisted_wallet.rs b/wallet/tests/persisted_wallet.rs index 800328e6..fc59cc12 100644 --- a/wallet/tests/persisted_wallet.rs +++ b/wallet/tests/persisted_wallet.rs @@ -417,7 +417,7 @@ fn single_descriptor_wallet_persist_and_recover() { assert_matches!( err, Err(LoadWithPersistError::InvalidChangeSet(LoadError::Mismatch(LoadMismatch::Descriptor { keychain, loaded, expected }))) - if keychain == KeychainKind::Internal && loaded.is_none() && expected == Some(exp_desc), + if keychain == KeychainKind::Internal && loaded.is_none() && expected == Some(Box::new(exp_desc)), "single descriptor wallet should refuse change descriptor param" ); } From 977034cd10a8ad7b0ed24dabae807cb80d4f9baa Mon Sep 17 00:00:00 2001 From: valued mammal Date: Sat, 9 Aug 2025 11:25:30 -0400 Subject: [PATCH 3/3] ci: fix clippy check to exclude example crates --- .github/workflows/cont_integration.yml | 2 +- justfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cont_integration.yml b/.github/workflows/cont_integration.yml index ef34fa32..a3d513b3 100644 --- a/.github/workflows/cont_integration.yml +++ b/.github/workflows/cont_integration.yml @@ -149,7 +149,7 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} name: Clippy Results - args: --all-features --all-targets -- -D warnings + args: --workspace --exclude 'example_*' --all-features --all-targets -- -D warnings build-examples: needs: prepare diff --git a/justfile b/justfile index e7501ea3..b38530f4 100644 --- a/justfile +++ b/justfile @@ -12,7 +12,7 @@ build: check: cargo +nightly fmt --all -- --check cargo check --workspace --exclude 'example_*' --all-features - cargo clippy --all-features --all-targets -- -D warnings + cargo clippy --workspace --exclude 'example_*' --all-features --all-targets -- -D warnings @[ "$(git log --pretty='format:%G?' -1 HEAD)" = "N" ] && \ echo "\n⚠️ Unsigned commit: BDK requires that commits be signed." || \ true