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,