Skip to content

Commit 4e7409c

Browse files
committed
Lock splice inputs during negotiation
Reserve wallet inputs as soon as splice coin selection returns so concurrent wallet operations cannot reuse them before the funding transaction reaches the wallet. Release discarded contributions so failed or superseded splice rounds do not strand funds. Co-Authored-By: HAL 9000
1 parent 59de289 commit 4e7409c

2 files changed

Lines changed: 185 additions & 31 deletions

File tree

src/event.rs

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -538,6 +538,28 @@ impl Future for EventFuture {
538538
}
539539
}
540540

541+
fn discarded_funding_transaction(funding_info: FundingInfo) -> Option<bitcoin::Transaction> {
542+
match funding_info {
543+
FundingInfo::Tx { transaction } => Some(transaction),
544+
FundingInfo::Contribution { inputs, outputs } => Some(bitcoin::Transaction {
545+
version: bitcoin::transaction::Version::TWO,
546+
lock_time: bitcoin::absolute::LockTime::ZERO,
547+
input: inputs
548+
.into_iter()
549+
.map(|previous_output| bitcoin::TxIn {
550+
previous_output,
551+
..bitcoin::TxIn::default()
552+
})
553+
.collect(),
554+
output: outputs
555+
.into_iter()
556+
.map(|script_pubkey| bitcoin::TxOut { value: bitcoin::Amount::ZERO, script_pubkey })
557+
.collect(),
558+
}),
559+
FundingInfo::OutPoint { .. } => None,
560+
}
561+
}
562+
541563
pub(crate) struct EventHandler<L: Deref + Clone + Sync + Send + 'static>
542564
where
543565
L::Target: LdkLogger,
@@ -1989,26 +2011,7 @@ where
19892011
}
19902012
},
19912013
LdkEvent::DiscardFunding { channel_id, funding_info } => {
1992-
let tx = match funding_info {
1993-
FundingInfo::Tx { transaction } => Some(transaction),
1994-
FundingInfo::Contribution { inputs: _, outputs } => {
1995-
Some(bitcoin::Transaction {
1996-
version: bitcoin::transaction::Version::TWO,
1997-
lock_time: bitcoin::absolute::LockTime::ZERO,
1998-
input: vec![],
1999-
output: outputs
2000-
.into_iter()
2001-
.map(|script_pubkey| bitcoin::TxOut {
2002-
value: bitcoin::Amount::ZERO,
2003-
script_pubkey,
2004-
})
2005-
.collect(),
2006-
})
2007-
},
2008-
FundingInfo::OutPoint { .. } => None,
2009-
};
2010-
2011-
if let Some(tx) = tx {
2014+
if let Some(tx) = discarded_funding_transaction(funding_info) {
20122015
log_info!(
20132016
self.logger,
20142017
"Reclaiming unused wallet state from channel {} funding",
@@ -2284,13 +2287,36 @@ mod tests {
22842287
use std::sync::atomic::{AtomicU16, Ordering};
22852288
use std::time::Duration;
22862289

2290+
use bitcoin::hashes::Hash;
22872291
use lightning::util::test_utils::TestLogger;
22882292

22892293
use super::*;
22902294
use crate::io::test_utils::InMemoryStore;
22912295
use crate::payment::store::LSPS2Parameters;
22922296
use crate::types::DynStoreWrapper;
22932297

2298+
#[test]
2299+
fn discarded_contribution_preserves_inputs_and_outputs() {
2300+
let inputs = vec![
2301+
OutPoint::new(bitcoin::Txid::from_byte_array([1; 32]), 2),
2302+
OutPoint::new(bitcoin::Txid::from_byte_array([3; 32]), 4),
2303+
];
2304+
let outputs =
2305+
vec![bitcoin::ScriptBuf::from_bytes(vec![5]), bitcoin::ScriptBuf::from_bytes(vec![6])];
2306+
2307+
let tx = discarded_funding_transaction(FundingInfo::Contribution {
2308+
inputs: inputs.clone(),
2309+
outputs: outputs.clone(),
2310+
})
2311+
.unwrap();
2312+
2313+
assert_eq!(tx.input.iter().map(|txin| txin.previous_output).collect::<Vec<_>>(), inputs,);
2314+
assert_eq!(
2315+
tx.output.iter().map(|txout| txout.script_pubkey.clone()).collect::<Vec<_>>(),
2316+
outputs,
2317+
);
2318+
}
2319+
22942320
#[test]
22952321
fn lsps2_payment_metadata_decodes_total_fee_limit() {
22962322
let metadata = PaymentMetadata {

src/wallet/mod.rs

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,24 +1364,26 @@ impl Wallet {
13641364
return Err(());
13651365
}
13661366

1367+
// Keep selected wallet inputs unavailable until LDK either broadcasts a transaction
1368+
// spending them or returns them through `DiscardFunding`.
1369+
for txin in unsigned_tx.input.iter().filter(|txin| {
1370+
must_spend.iter().all(|input| input.outpoint != txin.previous_output)
1371+
}) {
1372+
locked_wallet.lock_outpoint(txin.previous_output);
1373+
}
1374+
13671375
let change_output = unsigned_tx
13681376
.output
13691377
.into_iter()
13701378
.find(|txout| must_pay_to.iter().all(|output| output != txout));
1371-
let change_set = if change_output.is_some() {
1372-
Some(locked_wallet.take_staged().unwrap_or_default())
1373-
} else {
1374-
None
1375-
};
1379+
let change_set = locked_wallet.take_staged().unwrap_or_default();
13761380

13771381
(CoinSelection { confirmed_utxos, change_output }, change_set)
13781382
};
13791383

1380-
if let Some(change_set) = change_set {
1381-
locked_persister.persist_changeset(change_set).await.map_err(|e| {
1382-
log_error!(self.logger, "Failed to persist wallet: {}", e);
1383-
})?;
1384-
}
1384+
locked_persister.persist_changeset(change_set).await.map_err(|e| {
1385+
log_error!(self.logger, "Failed to persist wallet: {}", e);
1386+
})?;
13851387

13861388
Ok(coin_selection)
13871389
}
@@ -2837,7 +2839,7 @@ mod tests {
28372839
use std::sync::atomic::{AtomicBool, Ordering};
28382840
use std::time::Duration;
28392841

2840-
use bdk_chain::{BlockId, ConfirmationBlockTime};
2842+
use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate};
28412843
use bdk_wallet::Wallet as BdkWallet;
28422844
use bitcoin::hashes::Hash;
28432845
use bitcoin::{Network, TxIn};
@@ -3002,6 +3004,132 @@ mod tests {
30023004
))
30033005
}
30043006

3007+
#[tokio::test]
3008+
async fn splice_coin_selection_locks_inputs_until_cancelled() {
3009+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
3010+
let wallet = new_test_wallet(Arc::clone(&store), false).await;
3011+
let (funding_tx, block_id) = {
3012+
let mut locked_wallet = wallet.inner.lock().unwrap();
3013+
let outputs = (0..2)
3014+
.map(|_| TxOut {
3015+
value: Amount::from_sat(100_000),
3016+
script_pubkey: locked_wallet
3017+
.reveal_next_address(KeychainKind::External)
3018+
.address
3019+
.script_pubkey(),
3020+
})
3021+
.collect();
3022+
let funding_tx = Transaction {
3023+
version: bitcoin::transaction::Version::TWO,
3024+
lock_time: LockTime::ZERO,
3025+
input: Vec::new(),
3026+
output: outputs,
3027+
};
3028+
let block_id = BlockId {
3029+
height: locked_wallet.latest_checkpoint().height() + 1,
3030+
hash: bitcoin::BlockHash::from_byte_array([42; 32]),
3031+
};
3032+
(funding_tx, block_id)
3033+
};
3034+
let funding_txid = funding_tx.compute_txid();
3035+
let mut tx_update = TxUpdate::default();
3036+
tx_update.txs = vec![Arc::new(funding_tx)];
3037+
tx_update.anchors =
3038+
[(ConfirmationBlockTime { block_id, confirmation_time: 1 }, funding_txid)].into();
3039+
let chain = CheckPoint::from_block_ids([
3040+
wallet.inner.lock().unwrap().latest_checkpoint().block_id(),
3041+
block_id,
3042+
])
3043+
.unwrap();
3044+
wallet
3045+
.apply_update(Update { tx_update, chain: Some(chain), ..Default::default() })
3046+
.await
3047+
.unwrap();
3048+
3049+
let payment = TxOut {
3050+
value: Amount::from_sat(50_000),
3051+
script_pubkey: ScriptBuf::new_p2wpkh(&WPubkeyHash::from_slice(&[1; 20]).unwrap()),
3052+
};
3053+
let fee_rate = FeeRate::from_sat_per_kwu(250);
3054+
let selection =
3055+
Wallet::select_confirmed_utxos(&wallet, Vec::new(), &[payment.clone()], fee_rate)
3056+
.await
3057+
.unwrap();
3058+
let selected_outpoints = selection
3059+
.confirmed_utxos
3060+
.iter()
3061+
.cloned()
3062+
.map(ConfirmedUtxo::into_utxo)
3063+
.map(|utxo| utxo.outpoint)
3064+
.collect::<Vec<_>>();
3065+
assert!(!selected_outpoints.is_empty());
3066+
assert!(
3067+
selected_outpoints.iter().all(|outpoint| wallet
3068+
.inner
3069+
.lock()
3070+
.unwrap()
3071+
.is_outpoint_locked(*outpoint)),
3072+
"splice coin selection must lock selected wallet inputs",
3073+
);
3074+
drop(wallet);
3075+
3076+
let reloaded = new_test_wallet(Arc::clone(&store), true).await;
3077+
assert!(
3078+
selected_outpoints.iter().all(|outpoint| reloaded
3079+
.inner
3080+
.lock()
3081+
.unwrap()
3082+
.is_outpoint_locked(*outpoint)),
3083+
"splice input locks must survive a wallet reload",
3084+
);
3085+
let second_selection =
3086+
Wallet::select_confirmed_utxos(&reloaded, Vec::new(), &[payment.clone()], fee_rate)
3087+
.await
3088+
.unwrap();
3089+
let second_outpoints = second_selection
3090+
.confirmed_utxos
3091+
.into_iter()
3092+
.map(ConfirmedUtxo::into_utxo)
3093+
.map(|utxo| utxo.outpoint)
3094+
.collect::<Vec<_>>();
3095+
assert!(
3096+
selected_outpoints.iter().all(|outpoint| !second_outpoints.contains(outpoint)),
3097+
"subsequent splice coin selection must not reuse locked inputs",
3098+
);
3099+
3100+
let cancelled_tx = Transaction {
3101+
version: bitcoin::transaction::Version::TWO,
3102+
lock_time: LockTime::ZERO,
3103+
input: selected_outpoints
3104+
.iter()
3105+
.map(|outpoint| TxIn { previous_output: *outpoint, ..TxIn::default() })
3106+
.collect(),
3107+
output: selection.change_output.into_iter().collect(),
3108+
};
3109+
reloaded.cancel_tx(cancelled_tx).await.unwrap();
3110+
drop(reloaded);
3111+
let reloaded = new_test_wallet(store, true).await;
3112+
assert!(
3113+
selected_outpoints.iter().all(|outpoint| !reloaded
3114+
.inner
3115+
.lock()
3116+
.unwrap()
3117+
.is_outpoint_locked(*outpoint)),
3118+
"discarded splice inputs must be unlocked persistently",
3119+
);
3120+
let replacement_selection =
3121+
Wallet::select_confirmed_utxos(&reloaded, Vec::new(), &[payment], fee_rate)
3122+
.await
3123+
.unwrap();
3124+
let replacement_outpoints = replacement_selection
3125+
.confirmed_utxos
3126+
.into_iter()
3127+
.map(ConfirmedUtxo::into_utxo)
3128+
.map(|utxo| utxo.outpoint)
3129+
.collect::<Vec<_>>();
3130+
assert_eq!(replacement_outpoints, selected_outpoints);
3131+
}
3132+
30053133
fn pooled_indices(wallet: &Wallet) -> Vec<u32> {
30063134
wallet.address_pool.lock().unwrap().available.iter().map(|(index, _)| *index).collect()
30073135
}

0 commit comments

Comments
 (0)