From 01ac5f5b3df71d4470f45008f67dd4af79ce850e Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Sun, 18 Oct 2026 16:37:56 -0400 Subject: [PATCH] mempool: reject a replacement that spends its conflict's output The RBF plan dropped a parent that sat in the conflict set from the parent list and admitted the tx. The commit then evicted that parent, so the new tx kept an input that no longer existed, and getblocktemplate offered a block that fails bad-txns-inputs-missingorspent. Core rejects it with bad-txns-spends-conflicting-tx after the cluster check; so does this. Co-Authored-By: Claude Opus 5.5 --- changelog.d/mempool-spends-conflicting-tx.md | 7 ++++ crates/rbitcoin-mempool/src/accept.rs | 11 +++--- crates/rbitcoin-test/tests/cross_surface.rs | 38 ++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 changelog.d/mempool-spends-conflicting-tx.md diff --git a/changelog.d/mempool-spends-conflicting-tx.md b/changelog.d/mempool-spends-conflicting-tx.md new file mode 100644 index 000000000..384d38c43 --- /dev/null +++ b/changelog.d/mempool-spends-conflicting-tx.md @@ -0,0 +1,7 @@ +Fixed + +- **The mempool rejects a replacement that spends an output of a tx it + replaces** (`bad-txns-spends-conflicting-tx`, as in Core). It used to be + admitted with an input that left with the replaced tx, so + `getblocktemplate` offered a block that fails + `bad-txns-inputs-missingorspent`. diff --git a/crates/rbitcoin-mempool/src/accept.rs b/crates/rbitcoin-mempool/src/accept.rs index 60a561ee0..736488f5f 100644 --- a/crates/rbitcoin-mempool/src/accept.rs +++ b/crates/rbitcoin-mempool/src/accept.rs @@ -1316,11 +1316,7 @@ impl ActiveMempool { } else { BTreeSet::new() }; - let parent_txids: BTreeSet = scan - .parent_txids - .into_iter() - .filter(|p| !conflict_set.contains(p)) - .collect(); + let parent_txids = scan.parent_txids; // Cluster limits count raw weight (Libre): at 20 B/sigop an adjusted // cap would reject any tx above ~20,200 sigop cost. let (n_members, base_w) = self @@ -1335,6 +1331,11 @@ impl ActiveMempool { weight: combined_w, }); } + // Core `bad-txns-spends-conflicting-tx`. A parent in the conflict set + // is evicted by this accept, and its outputs with it. + if parent_txids.iter().any(|p| conflict_set.contains(p)) { + return Err(AcceptError::Policy("bad-txns-spends-conflicting-tx")); + } Ok((conflict_set, fee_sat, weight)) } diff --git a/crates/rbitcoin-test/tests/cross_surface.rs b/crates/rbitcoin-test/tests/cross_surface.rs index d5f3660ed..786d427f4 100644 --- a/crates/rbitcoin-test/tests/cross_surface.rs +++ b/crates/rbitcoin-test/tests/cross_surface.rs @@ -1800,6 +1800,44 @@ async fn esplora_broadcast_visible_in_rpc_and_electrum() { "too-low RBF must leave the original: {mem}" ); + // Core `bad-txns-spends-conflicting-tx`: a replacement cannot spend an + // output of the tx it evicts; that output leaves with it. + let mut spends_conflict = acs_spend( + rpc_cb, + 50_0000_0000 + 50_0000_0000 - 1_000, + 100_000, + ScriptBuf::from_bytes(vec![0x58]), + ); + let mut conflict_out = spends_conflict.input[0].clone(); + conflict_out.previous_output = OutPoint { + txid: rpc_spend.compute_txid(), + vout: 0, + }; + spends_conflict.input.push(conflict_out); + let spends_conflict_hex = encode_tx(&spends_conflict); + let tma = jsonrpc( + rpc_addr, + "testmempoolaccept", + json!([[spends_conflict_hex.clone()]]), + ) + .await; + assert_eq!(tma["result"][0]["allowed"], false, "{tma}"); + assert_eq!( + tma["result"][0]["reject-reason"], "bad-txns-spends-conflicting-tx", + "{tma}" + ); + let rejected = jsonrpc(rpc_addr, "sendrawtransaction", json!([spends_conflict_hex])).await; + assert_eq!(rejected["error"]["code"], -26, "{rejected}"); + assert_eq!( + rejected["error"]["message"], "bad-txns-spends-conflicting-tx", + "{rejected}" + ); + let mem = jsonrpc(rpc_addr, "getrawmempool", json!([])).await; + assert!( + mempool_has(&mem, &rpc_txid), + "a replacement spending its conflict must leave the original: {mem}" + ); + let high = acs_spend( rpc_cb, 50_0000_0000,