Skip to content

Commit b0cf35a

Browse files
committed
feat(wallet): Add ConfirmationSpendPolicy to spend only [un]confirmed outputs
1 parent 63e62b4 commit b0cf35a

File tree

3 files changed

+172
-0
lines changed

3 files changed

+172
-0
lines changed

crates/wallet/src/wallet/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,6 +2026,8 @@ impl Wallet {
20262026
self.keychains().count() == 1
20272027
|| params.change_policy.is_satisfied_by(local_output)
20282028
})
2029+
// only add utxos that match the confirmation policy
2030+
.filter(|local_output| params.confirmation_policy.is_satisfied_by(local_output))
20292031
// only add to optional UTxOs those marked as spendable
20302032
.filter(|local_output| !params.unspendable.contains(&local_output.outpoint))
20312033
// if bumping fees only add to optional UTxOs those confirmed

crates/wallet/src/wallet/tx_builder.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ pub(crate) struct TxParams {
134134
pub(crate) sequence: Option<Sequence>,
135135
pub(crate) version: Option<Version>,
136136
pub(crate) change_policy: ChangeSpendPolicy,
137+
pub(crate) confirmation_policy: ConfirmationSpendPolicy,
137138
pub(crate) only_witness_utxo: bool,
138139
pub(crate) add_global_xpubs: bool,
139140
pub(crate) include_output_redeem_witness_script: bool,
@@ -513,6 +514,40 @@ impl<'a, Cs> TxBuilder<'a, Cs> {
513514
self
514515
}
515516

517+
/// Only spend confirmed outputs
518+
///
519+
/// This effectively adds all the unconfirmed outputs to the "unspendable" list. See
520+
/// [`TxBuilder::unspendable`].
521+
pub fn only_spend_confirmed(&mut self) -> &mut Self {
522+
self.params.confirmation_policy = ConfirmationSpendPolicy::OnlyConfirmed;
523+
self
524+
}
525+
526+
/// Only spend outputs confirmed before or on the given block height
527+
///
528+
/// This effectively adds all the outputs not confirmed before or on the
529+
/// given height to the "unspendable" list. See [`TxBuilder::unspendable`].
530+
pub fn only_spend_confirmed_since(&mut self, height: u32) -> &mut Self {
531+
self.params.confirmation_policy = ConfirmationSpendPolicy::OnlyConfirmedSince { height };
532+
self
533+
}
534+
535+
/// Only spend unconfirmed outputs
536+
///
537+
/// This effectively adds all the confirmed outputs to the "unspendable" list. See
538+
/// [`TxBuilder::unspendable`].
539+
pub fn only_spend_unconfirmed(&mut self) -> &mut Self {
540+
self.params.confirmation_policy = ConfirmationSpendPolicy::OnlyUnconfirmed;
541+
self
542+
}
543+
544+
/// Set a specific [`ConfirmationSpendPolicy`]. See [`TxBuilder::only_spend_confirmed`] and
545+
/// [`TxBuilder::only_spend_unconfirmed`] for some shortcuts.
546+
pub fn confirmation_policy(&mut self, confirmation_policy: ConfirmationSpendPolicy) -> &mut Self {
547+
self.params.confirmation_policy = confirmation_policy;
548+
self
549+
}
550+
516551
/// Only Fill-in the [`psbt::Input::witness_utxo`](bitcoin::psbt::Input::witness_utxo) field when spending from
517552
/// SegWit descriptors.
518553
///
@@ -838,6 +873,38 @@ impl ChangeSpendPolicy {
838873
}
839874
}
840875

876+
/// Policy regarding the use of unconfirmed outputs when creating a transaction
877+
#[derive(Default, Debug, Ord, PartialOrd, Eq, PartialEq, Hash, Clone, Copy)]
878+
pub enum ConfirmationSpendPolicy {
879+
/// Use both confirmed and unconfirmed outputs (default)
880+
#[default]
881+
UnconfirmedAllowed,
882+
/// Only use confirmed outputs (see [`TxBuilder::only_spend_confirmed`])
883+
OnlyConfirmed,
884+
/// Only use outputs confirmed since `height` (see [`TxBuilder::only_spend_confirmed`])
885+
OnlyConfirmedSince {
886+
/// The height at which the outputs should be confirmed
887+
height: u32,
888+
},
889+
/// Only use unconfirmed outputs (see [`TxBuilder::only_spend_unconfirmed`])
890+
OnlyUnconfirmed,
891+
}
892+
893+
impl ConfirmationSpendPolicy {
894+
pub(crate) fn is_satisfied_by(&self, utxo: &LocalOutput) -> bool {
895+
match self {
896+
ConfirmationSpendPolicy::UnconfirmedAllowed => true,
897+
ConfirmationSpendPolicy::OnlyConfirmed => utxo.chain_position.is_confirmed(),
898+
ConfirmationSpendPolicy::OnlyConfirmedSince { height } => {
899+
utxo.chain_position.confirmation_height_upper_bound().map(|h| {
900+
h <= *height
901+
}).unwrap_or(false)
902+
},
903+
ConfirmationSpendPolicy::OnlyUnconfirmed => !utxo.chain_position.is_confirmed(),
904+
}
905+
}
906+
}
907+
841908
#[cfg(test)]
842909
mod test {
843910
const ORDERING_TEST_TX: &str = "0200000003c26f3eb7932f7acddc5ddd26602b77e7516079b03090a16e2c2f54\

crates/wallet/tests/wallet.rs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -766,6 +766,109 @@ fn test_create_tx_change_policy() {
766766
));
767767
}
768768

769+
#[test]
770+
fn test_create_tx_confirmation_policy() {
771+
let (mut wallet, funding_txid) = get_funded_wallet_wpkh();
772+
assert_eq!(wallet.balance().confirmed, Amount::from_sat(50_000));
773+
insert_checkpoint(
774+
&mut wallet,
775+
BlockId {
776+
height: 3_000,
777+
hash: BlockHash::all_zeros(),
778+
},
779+
);
780+
781+
let confirmed_tx = Transaction {
782+
input: vec![],
783+
output: vec![TxOut {
784+
script_pubkey: wallet
785+
.next_unused_address(KeychainKind::External)
786+
.script_pubkey(),
787+
value: Amount::from_sat(25_000),
788+
}],
789+
version: transaction::Version::non_standard(0),
790+
lock_time: absolute::LockTime::ZERO,
791+
};
792+
let confirmed_txid = confirmed_tx.compute_txid();
793+
insert_tx(&mut wallet, confirmed_tx);
794+
let anchor = ConfirmationBlockTime {
795+
block_id: wallet.latest_checkpoint().get(3_000).unwrap().block_id(),
796+
confirmation_time: 200,
797+
};
798+
insert_anchor(&mut wallet, confirmed_txid, anchor);
799+
let unconfirmed_tx = Transaction {
800+
input: vec![],
801+
output: vec![TxOut {
802+
script_pubkey: wallet
803+
.next_unused_address(KeychainKind::External)
804+
.script_pubkey(),
805+
value: Amount::from_sat(25_000),
806+
}],
807+
version: transaction::Version::non_standard(0),
808+
lock_time: absolute::LockTime::ZERO,
809+
};
810+
let unconfirmed_txid = unconfirmed_tx.compute_txid();
811+
insert_tx(&mut wallet, unconfirmed_tx);
812+
813+
let addr = wallet.next_unused_address(KeychainKind::External);
814+
815+
let mut builder = wallet.build_tx();
816+
builder
817+
.add_recipient(addr.script_pubkey(), Amount::from_sat(51_000))
818+
.only_spend_confirmed();
819+
let ret = builder.finish().unwrap();
820+
assert_eq!(ret.unsigned_tx.input.len(), 2);
821+
assert!(ret.unsigned_tx.input.iter().any(|i| i.previous_output.txid == funding_txid));
822+
assert!(ret.unsigned_tx.input.iter().any(|i| i.previous_output.txid == confirmed_txid));
823+
824+
let mut builder = wallet.build_tx();
825+
builder
826+
.add_recipient(addr.script_pubkey(), Amount::from_sat(51_000))
827+
.only_spend_confirmed_since(3_000);
828+
let ret = builder.finish().unwrap();
829+
assert_eq!(ret.unsigned_tx.input.len(), 2);
830+
assert!(ret.unsigned_tx.input.iter().any(|i| i.previous_output.txid == funding_txid));
831+
assert!(ret.unsigned_tx.input.iter().any(|i| i.previous_output.txid == confirmed_txid));
832+
833+
let mut builder = wallet.build_tx();
834+
builder
835+
.add_recipient(addr.script_pubkey(), Amount::from_sat(25_000))
836+
.only_spend_confirmed_since(2_500);
837+
let ret = builder.finish().unwrap();
838+
assert_eq!(ret.unsigned_tx.input.len(), 1);
839+
assert!(ret.unsigned_tx.input.iter().any(|i| i.previous_output.txid == funding_txid));
840+
841+
let mut builder = wallet.build_tx();
842+
builder
843+
.add_recipient(addr.script_pubkey(), Amount::from_sat(24_000))
844+
.only_spend_unconfirmed();
845+
let ret = builder.finish().unwrap();
846+
assert_eq!(ret.unsigned_tx.input.len(), 1);
847+
assert!(ret.unsigned_tx.input.iter().any(|i| i.previous_output.txid == unconfirmed_txid));
848+
849+
let mut builder = wallet.build_tx();
850+
builder
851+
.add_recipient(addr.script_pubkey(), Amount::from_sat(76_000))
852+
.only_spend_confirmed();
853+
assert!(matches!(
854+
builder.finish(),
855+
Err(CreateTxError::CoinSelection(
856+
coin_selection::InsufficientFunds { .. }
857+
)),
858+
));
859+
860+
let mut builder = wallet.build_tx();
861+
builder
862+
.add_recipient(addr.script_pubkey(), Amount::from_sat(76_000))
863+
.only_spend_unconfirmed();
864+
assert!(matches!(
865+
builder.finish(),
866+
Err(CreateTxError::CoinSelection(
867+
coin_selection::InsufficientFunds { .. }
868+
)),
869+
));
870+
}
871+
769872
#[test]
770873
fn test_create_tx_default_sequence() {
771874
let (mut wallet, _) = get_funded_wallet_wpkh();

0 commit comments

Comments
 (0)