Skip to content

Commit 6093418

Browse files
jkczyzclaude
andcommitted
Serialize on-chain RBF bumps with funding classification
bump_fee_rbf read the payment record and rejected channel-funding records before taking any lock, then took the locks and wrote the replacement. A funding classification landing in between re-types the record as channel funding, after which the bump retargets that record to the wallet-built replacement it broadcasts -- a double spend of the channel funding transaction. Hold the locks from the record read through the replacement writes so the two serialize: a record classified first is caught by the funding-kind check, and one that passes the check cannot be re-typed until the replacement is recorded. A funding round that wallet sync observes before classification leaves an untyped record that passes the funding-kind check outright; that is a stale-record problem rather than a race in this function, narrowed by the preceding classification-retry commit and by duplicate absorption later in the series. Generated with assistance from Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 75635da commit 6093418

1 file changed

Lines changed: 109 additions & 1 deletion

File tree

src/wallet/mod.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2033,6 +2033,13 @@ impl Wallet {
20332033
pub(crate) async fn bump_fee_rbf(
20342034
&self, payment_id: PaymentId, fee_rate: Option<FeeRate>, cur_anchor_reserve_sats: u64,
20352035
) -> Result<Txid, Error> {
2036+
let mut locked_persister = self.persister.lock().await;
2037+
// Hold the cross-store lock from the record read through the replacement writes: funding
2038+
// classification re-types records concurrently, and a classification landing after the
2039+
// funding-kind check below would let the RBF replace a funding transaction. Acquired
2040+
// after the persister, matching the lock order of the wallet sync paths.
2041+
let funding_guard = self.funding_payment_update_lock.lock().await;
2042+
20362043
let payment = self.payment_store.get(&payment_id).ok_or_else(|| {
20372044
log_error!(self.logger, "Payment {} not found in payment store", payment_id);
20382045
Error::InvalidPaymentId
@@ -2091,7 +2098,6 @@ impl Wallet {
20912098
},
20922099
};
20932100

2094-
let mut locked_persister = self.persister.lock().await;
20952101
let mut locked_wallet = self.inner.lock().expect("lock");
20962102

20972103
debug_assert!(
@@ -2278,6 +2284,7 @@ impl Wallet {
22782284

22792285
self.payment_store.insert_or_update(new_payment).await?;
22802286
self.pending_payment_store.insert_or_update(pending_payment_store).await?;
2287+
drop(funding_guard);
22812288

22822289
self.broadcaster.broadcast_unclassified_transaction(fee_bumped_tx);
22832290

@@ -4427,4 +4434,105 @@ mod tests {
44274434
PaymentKind::Onchain { tx_type: Some(TransactionType::InteractiveFunding { .. }), .. }
44284435
));
44294436
}
4437+
4438+
/// An on-chain RBF bump must not replace a record a concurrent classification re-types as
4439+
/// channel funding: the replacement would double-spend the channel's funding transaction.
4440+
/// The bump is parked on the persister lock and classification lands while it waits; unless
4441+
/// the bump holds the cross-store lock from its funding-kind check through its writes, it
4442+
/// proceeds on the stale pre-classification read and retargets the funding record to the
4443+
/// replacement it broadcasts.
4444+
#[tokio::test]
4445+
async fn fee_bump_waits_for_funding_classification() {
4446+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(InMemoryStore::new()));
4447+
let wallet = new_test_wallet(store, false).await;
4448+
4449+
// A confirmed parent funds the wallet so it can build and sign a replaceable spend. The
4450+
// checkpoint and anchor make the parent canonical: fee bumping resolves the spent
4451+
// prevouts and signing reads the full parent transaction from the graph.
4452+
let parent_spk = wallet
4453+
.inner
4454+
.lock()
4455+
.unwrap()
4456+
.reveal_next_address(KeychainKind::External)
4457+
.address
4458+
.script_pubkey();
4459+
let parent = Transaction {
4460+
version: bitcoin::transaction::Version::TWO,
4461+
lock_time: LockTime::ZERO,
4462+
input: Vec::new(),
4463+
output: vec![TxOut { value: Amount::from_sat(100_000), script_pubkey: parent_spk }],
4464+
};
4465+
let parent_txid = parent.compute_txid();
4466+
{
4467+
let mut locked_wallet = wallet.inner.lock().unwrap();
4468+
let block_id =
4469+
BlockId { height: 100, hash: bitcoin::BlockHash::from_byte_array([1u8; 32]) };
4470+
let chain = locked_wallet.latest_checkpoint().insert(block_id);
4471+
let mut tx_update = bdk_chain::TxUpdate::default();
4472+
tx_update.txs = vec![Arc::new(parent)];
4473+
tx_update.anchors =
4474+
[(ConfirmationBlockTime { block_id, confirmation_time: 0 }, parent_txid)].into();
4475+
let update = bdk_wallet::Update { chain: Some(chain), tx_update, ..Default::default() };
4476+
locked_wallet.apply_update(update).unwrap();
4477+
}
4478+
4479+
// The wallet builds and signs the replaceable transaction itself, which keeps it
4480+
// RBF-signaling and its change output claimable for the extra fee.
4481+
let tx = {
4482+
let mut locked_wallet = wallet.inner.lock().unwrap();
4483+
let foreign_spk =
4484+
ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0xab; 20]));
4485+
let mut builder = locked_wallet.build_tx();
4486+
builder.add_recipient(foreign_spk, Amount::from_sat(20_000));
4487+
let mut psbt = builder.finish().unwrap();
4488+
assert!(locked_wallet.sign(&mut psbt, SignOptions::default()).unwrap());
4489+
psbt.extract_tx().unwrap()
4490+
};
4491+
let txid = tx.compute_txid();
4492+
let payment_id = PaymentId(txid.to_byte_array());
4493+
4494+
// Observing the spend in the mempool mints the plain on-chain record — the same state an
4495+
// interactively funded round observed by wallet sync before classification leaves behind.
4496+
wallet.apply_mempool_txs(vec![(tx, 1_000)], Vec::new()).await.unwrap();
4497+
let seeded = wallet.payment_store.get(&payment_id).expect("record for the mempool tx");
4498+
assert!(matches!(&seeded.kind, PaymentKind::Onchain { tx_type: None, .. }));
4499+
assert_eq!(seeded.direction, PaymentDirection::Outbound);
4500+
4501+
// Park the bump on the persister lock and classify while it waits. Classification
4502+
// serializes on the cross-store lock, not the persister, so it runs to completion while
4503+
// the bump is parked. Polling the bump first runs it synchronously to its first await:
4504+
// with an unlocked gate that is the persister acquisition after the funding-kind check,
4505+
// so the stale decision is already made; code that takes the locks before reading parks
4506+
// before the read, so either arrival order converges on the same final state.
4507+
let persister_guard = wallet.persister.lock().await;
4508+
let candidates = vec![FundingTxCandidate {
4509+
txid,
4510+
amount_msat: Some(1_000_000),
4511+
fee_paid_msat: Some(500),
4512+
}];
4513+
let details = interactive_funding_details(payment_id, txid, Some(1_000_000), Some(500));
4514+
let bump = wallet.bump_fee_rbf(payment_id, None, 0);
4515+
let classify = async {
4516+
wallet.persist_funding_payment(details, candidates).await.unwrap();
4517+
drop(persister_guard);
4518+
};
4519+
let (result, ()) = tokio::join!(bump, classify);
4520+
4521+
assert!(result.is_err(), "an RBF bump replaced a freshly-classified funding record");
4522+
let payments = wallet.payment_store.list_filter(|_| true);
4523+
assert_eq!(payments.len(), 1);
4524+
let payment = &payments[0];
4525+
assert_eq!(payment.amount_msat, Some(1_000_000));
4526+
assert_eq!(payment.fee_paid_msat, Some(500));
4527+
match &payment.kind {
4528+
PaymentKind::Onchain {
4529+
txid: current,
4530+
tx_type: Some(TransactionType::InteractiveFunding { .. }),
4531+
..
4532+
} => {
4533+
assert_eq!(*current, txid, "the funding record must keep its own transaction");
4534+
},
4535+
kind => panic!("unexpected kind {:?}", kind),
4536+
}
4537+
}
44304538
}

0 commit comments

Comments
 (0)