Skip to content

Commit 6605157

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 1b90edf commit 6605157

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,
@@ -1954,26 +1976,7 @@ where
19541976
}
19551977
},
19561978
LdkEvent::DiscardFunding { channel_id, funding_info } => {
1957-
let tx = match funding_info {
1958-
FundingInfo::Tx { transaction } => Some(transaction),
1959-
FundingInfo::Contribution { inputs: _, outputs } => {
1960-
Some(bitcoin::Transaction {
1961-
version: bitcoin::transaction::Version::TWO,
1962-
lock_time: bitcoin::absolute::LockTime::ZERO,
1963-
input: vec![],
1964-
output: outputs
1965-
.into_iter()
1966-
.map(|script_pubkey| bitcoin::TxOut {
1967-
value: bitcoin::Amount::ZERO,
1968-
script_pubkey,
1969-
})
1970-
.collect(),
1971-
})
1972-
},
1973-
FundingInfo::OutPoint { .. } => None,
1974-
};
1975-
1976-
if let Some(tx) = tx {
1979+
if let Some(tx) = discarded_funding_transaction(funding_info) {
19771980
log_info!(
19781981
self.logger,
19791982
"Reclaiming unused wallet state from channel {} funding",
@@ -2249,13 +2252,36 @@ mod tests {
22492252
use std::sync::atomic::{AtomicU16, Ordering};
22502253
use std::time::Duration;
22512254

2255+
use bitcoin::hashes::Hash;
22522256
use lightning::util::test_utils::TestLogger;
22532257

22542258
use super::*;
22552259
use crate::io::test_utils::InMemoryStore;
22562260
use crate::payment::store::LSPS2Parameters;
22572261
use crate::types::DynStoreWrapper;
22582262

2263+
#[test]
2264+
fn discarded_contribution_preserves_inputs_and_outputs() {
2265+
let inputs = vec![
2266+
OutPoint::new(bitcoin::Txid::from_byte_array([1; 32]), 2),
2267+
OutPoint::new(bitcoin::Txid::from_byte_array([3; 32]), 4),
2268+
];
2269+
let outputs =
2270+
vec![bitcoin::ScriptBuf::from_bytes(vec![5]), bitcoin::ScriptBuf::from_bytes(vec![6])];
2271+
2272+
let tx = discarded_funding_transaction(FundingInfo::Contribution {
2273+
inputs: inputs.clone(),
2274+
outputs: outputs.clone(),
2275+
})
2276+
.unwrap();
2277+
2278+
assert_eq!(tx.input.iter().map(|txin| txin.previous_output).collect::<Vec<_>>(), inputs,);
2279+
assert_eq!(
2280+
tx.output.iter().map(|txout| txout.script_pubkey.clone()).collect::<Vec<_>>(),
2281+
outputs,
2282+
);
2283+
}
2284+
22592285
#[test]
22602286
fn lsps2_payment_metadata_decodes_total_fee_limit() {
22612287
let metadata = PaymentMetadata {

src/wallet/mod.rs

Lines changed: 139 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1371,24 +1371,26 @@ impl Wallet {
13711371
return Err(());
13721372
}
13731373

1374+
// Keep selected wallet inputs unavailable until LDK either broadcasts a transaction
1375+
// spending them or returns them through `DiscardFunding`.
1376+
for txin in unsigned_tx.input.iter().filter(|txin| {
1377+
must_spend.iter().all(|input| input.outpoint != txin.previous_output)
1378+
}) {
1379+
locked_wallet.lock_outpoint(txin.previous_output);
1380+
}
1381+
13741382
let change_output = unsigned_tx
13751383
.output
13761384
.into_iter()
13771385
.find(|txout| must_pay_to.iter().all(|output| output != txout));
1378-
let change_set = if change_output.is_some() {
1379-
Some(locked_wallet.take_staged().unwrap_or_default())
1380-
} else {
1381-
None
1382-
};
1386+
let change_set = locked_wallet.take_staged().unwrap_or_default();
13831387

13841388
(CoinSelection { confirmed_utxos, change_output }, change_set)
13851389
};
13861390

1387-
if let Some(change_set) = change_set {
1388-
locked_persister.persist_changeset(change_set).await.map_err(|e| {
1389-
log_error!(self.logger, "Failed to persist wallet: {}", e);
1390-
})?;
1391-
}
1391+
locked_persister.persist_changeset(change_set).await.map_err(|e| {
1392+
log_error!(self.logger, "Failed to persist wallet: {}", e);
1393+
})?;
13921394

13931395
Ok(coin_selection)
13941396
}
@@ -2849,7 +2851,7 @@ mod tests {
28492851
use std::sync::atomic::{AtomicBool, Ordering};
28502852
use std::time::Duration;
28512853

2852-
use bdk_chain::{BlockId, ConfirmationBlockTime};
2854+
use bdk_chain::{BlockId, CheckPoint, ConfirmationBlockTime, TxUpdate};
28532855
use bdk_wallet::Wallet as BdkWallet;
28542856
use bitcoin::hashes::Hash;
28552857
use bitcoin::{Network, TxIn};
@@ -3016,6 +3018,132 @@ mod tests {
30163018
))
30173019
}
30183020

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

0 commit comments

Comments
 (0)