@@ -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