From de00b315695f5e7e7495ead0ea4a09778a80992f Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Sun, 18 Oct 2026 16:53:43 -0400 Subject: [PATCH 1/2] rpc: submitblock leaves a side-branch block's inputs to connect The cheap input check read only the store. A held side-branch parent's txs are not there, so a child spending its side parent's output was bad-txns-inputs-missingorspent and cached invalid. The node then refused that chain from peers as well. Core checks inputs in ConnectBlock, when the branch activates. A block whose parent is off the best chain now skips the cheap input check; accept holds it or connects the branch. A heavier branch that then fails at connect reported "consensus: in < out" or "consensus: double spend in block". block_reject_reason maps both to Core's reasons, and accept_branch keeps the bare reason in ConnectFailed, as connect_failed_for_header does, instead of a doubled "consensus: " prefix. Co-Authored-By: Claude Opus 5.5 --- changelog.d/submitblock-side-branch-spend.md | 10 +++ crates/rbitcoin-consensus/src/error.rs | 3 + crates/rbitcoin-net/src/chain.rs | 7 +- crates/rbitcoin-rpc/src/methods/mine.rs | 5 ++ crates/rbitcoin-rpc/src/methods_tests.rs | 94 ++++++++++++++++++++ 5 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 changelog.d/submitblock-side-branch-spend.md diff --git a/changelog.d/submitblock-side-branch-spend.md b/changelog.d/submitblock-side-branch-spend.md new file mode 100644 index 000000000..daff33733 --- /dev/null +++ b/changelog.d/submitblock-side-branch-spend.md @@ -0,0 +1,10 @@ +Fixed + +- **`submitblock` accepts a side-branch block that spends its side parent's + outputs.** It used to answer `bad-txns-inputs-missingorspent` and remember + the block as invalid, so the node then refused that chain from peers too + and stayed on the old tip. For a block whose parent is off the best chain, + input checks now wait for the branch connect, as in Core. A heavier branch + that fails there returns Core's reason (`bad-txns-in-belowout`, + `bad-txns-inputs-missingorspent`, `bad-txns-premature-spend-of-coinbase`) + instead of `consensus: in < out` and similar. diff --git a/crates/rbitcoin-consensus/src/error.rs b/crates/rbitcoin-consensus/src/error.rs index 7b45cece2..3cc7c9214 100644 --- a/crates/rbitcoin-consensus/src/error.rs +++ b/crates/rbitcoin-consensus/src/error.rs @@ -71,6 +71,9 @@ pub fn block_reject_reason(err: &ConsensusError) -> String { ConsensusError::BadTx("no inputs") => "bad-txns-vin-empty".into(), ConsensusError::BadTx("no outputs") => "bad-txns-vout-empty".into(), ConsensusError::BadTx("coinbase immature") => "bad-txns-premature-spend-of-coinbase".into(), + ConsensusError::BadTx("in < out") => "bad-txns-in-belowout".into(), + // Core: the second spend finds the coin already spent. + ConsensusError::BadTx("double spend in block") => "bad-txns-inputs-missingorspent".into(), ConsensusError::BadTx(s) => (*s).into(), ConsensusError::BadBlock("no transactions" | "block stripped size too large") => { "bad-blk-length".into() diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 4e9fc8b72..051c3c01f 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -2612,9 +2612,14 @@ impl ChainHub { self.accept_branch_restore(fork_height, old_path, &e)?; return Err(e); } + // `ConnectFailed` displays its own "consensus: " prefix. + let msg = match &e { + NetError::Consensus(m) | NetError::Mutated(m) => m.clone(), + other => other.to_string(), + }; let failed = NetError::ConnectFailed { hash: b.block_hash().to_byte_array(), - msg: e.to_string(), + msg, }; let prefix = &blocks[..i]; if !prefix.is_empty() && work_better(self.branch_header_work(prefix)?, old_work) { diff --git a/crates/rbitcoin-rpc/src/methods/mine.rs b/crates/rbitcoin-rpc/src/methods/mine.rs index 9811c3d05..b0e858ba6 100644 --- a/crates/rbitcoin-rpc/src/methods/mine.rs +++ b/crates/rbitcoin-rpc/src/methods/mine.rs @@ -1126,6 +1126,11 @@ fn cheap_submit_tx_reject( if let Some(reason) = submit_checkblock_reason(block, spend_height, params, milestone)? { return Ok(Some(reason)); } + // A parent off the best chain can create the coins this block spends. + // Only the branch connect sees them (Core checks inputs in ConnectBlock). + if spend_height.is_none() { + return Ok(None); + } let mut spent = std::collections::HashSet::new(); let mut created: std::collections::HashMap = std::collections::HashMap::new(); for (i, (tx, &tid)) in block.txdata.iter().zip(&txids).enumerate() { diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index a77e6f280..1e77d1c03 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -3944,6 +3944,100 @@ fn submitblock_sibling_spend_is_not_cached_invalid() { let _ = std::fs::remove_dir_all(&dir); } +/// A side-branch child may spend an output its held parent created. Core +/// checks those inputs when the branch connects, so the child reorgs. +#[test] +fn submitblock_child_spends_its_side_parent_output() { + let (ctx, dir, hub) = ctx_from_mature_pad(300_000_000); + let op_true = ScriptBuf::from_bytes(vec![0x51]); + let spend = |txid: Txid, sats: u64| Transaction { + version: bitcoin::transaction::Version::TWO, + lock_time: bitcoin::absolute::LockTime::ZERO, + input: vec![bitcoin::TxIn { + previous_output: bitcoin::OutPoint { txid, vout: 0 }, + script_sig: ScriptBuf::new(), + sequence: bitcoin::Sequence::MAX, + witness: bitcoin::Witness::new(), + }], + output: vec![bitcoin::TxOut { + value: Amount::from_sat(sats), + script_pubkey: op_true.clone(), + }], + }; + let mature = hub.query.reconstruct_block_at_height(Height(1)).unwrap(); + let x = spend(mature.txdata[0].compute_txid(), 2); + let y = spend(x.compute_txid(), 1); + let height = hub.tip_height().unwrap(); + let (_, fork) = hub + .query + .header_at_height(Height(height - 1)) + .unwrap() + .unwrap(); + let t0 = hub.tip_header().unwrap().time; + let b1 = rbitcoin_consensus::mine_regtest_paying( + bitcoin::BlockHash::from_byte_array(fork.hash), + t0 + 1, + height, + op_true.clone(), + vec![x], + ); + let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&b1))]).unwrap(); + assert_eq!(r, "inconclusive", "{r}"); + let b2 = rbitcoin_consensus::mine_regtest_paying( + b1.block_hash(), + t0 + 2, + height + 1, + op_true.clone(), + vec![y], + ); + let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&b2))]).unwrap(); + assert!(r.is_null(), "{r}"); + assert_eq!(hub.tip_hash(), Some(b2.block_hash())); + + // A heavier side branch whose inputs fail at connect keeps Core's reason. + let coinbase_at = |h: u32| { + hub.query + .reconstruct_block_at_height(Height(h)) + .unwrap() + .txdata[0] + .compute_txid() + }; + let overspend = spend(coinbase_at(2), 50_0000_0001); + let first = spend(coinbase_at(3), 1); + let mut second = first.clone(); + second.output[0].value = Amount::from_sat(2); + let immature = spend(coinbase_at(height - 1), 1); + for (i, (txs, reason)) in [ + (vec![overspend], "bad-txns-in-belowout"), + (vec![first, second], "bad-txns-inputs-missingorspent"), + (vec![immature], "bad-txns-premature-spend-of-coinbase"), + ] + .into_iter() + .enumerate() + { + let c1 = rbitcoin_consensus::mine_regtest_paying( + b1.block_hash(), + t0 + 10 + i as u32, + height + 1, + op_true.clone(), + vec![], + ); + let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&c1))]).unwrap(); + assert_eq!(r, "inconclusive", "{r}"); + let c2 = rbitcoin_consensus::mine_regtest_paying( + c1.block_hash(), + t0 + 20 + i as u32, + height + 2, + op_true.clone(), + txs, + ); + let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&c2))]).unwrap(); + assert_eq!(r, reason); + assert_eq!(hub.tip_hash(), Some(b2.block_hash())); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Core `CheckTxInputs`: a confirmed txid with no output at `vout` is a /// missing input, not a store fault. #[test] From 1734fd67a0fbc28e7c6ba7945165e36dfe179b50 Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Sun, 18 Oct 2026 19:57:21 -0400 Subject: [PATCH 2/2] rpc: pin the side-branch submitblock reject and tip paths Each failing heavier branch is now resubmitted and must answer duplicate-invalid, the reorged-to block must stay off the invalid set, a branch that spends its side parent's own coinbase must be bad-txns-premature-spend-of-coinbase, and an overspend on the tip still takes the early input check (bad-txns-in-belowout). The changelog no longer lists the premature-coinbase reason as a new mapping. Co-Authored-By: Claude Opus 5.5 --- changelog.d/submitblock-side-branch-spend.md | 6 ++--- crates/rbitcoin-rpc/src/methods_tests.rs | 24 +++++++++++++++++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/changelog.d/submitblock-side-branch-spend.md b/changelog.d/submitblock-side-branch-spend.md index daff33733..2953edcc5 100644 --- a/changelog.d/submitblock-side-branch-spend.md +++ b/changelog.d/submitblock-side-branch-spend.md @@ -5,6 +5,6 @@ Fixed the block as invalid, so the node then refused that chain from peers too and stayed on the old tip. For a block whose parent is off the best chain, input checks now wait for the branch connect, as in Core. A heavier branch - that fails there returns Core's reason (`bad-txns-in-belowout`, - `bad-txns-inputs-missingorspent`, `bad-txns-premature-spend-of-coinbase`) - instead of `consensus: in < out` and similar. + that fails there returns Core's reason: `in < out` and `double spend in + block` map to `bad-txns-in-belowout` and `bad-txns-inputs-missingorspent`, + and no reason carries a doubled `consensus: ` prefix. diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index 1e77d1c03..e78253bea 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -3993,6 +3993,7 @@ fn submitblock_child_spends_its_side_parent_output() { let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&b2))]).unwrap(); assert!(r.is_null(), "{r}"); assert_eq!(hub.tip_hash(), Some(b2.block_hash())); + assert!(!hub.is_block_invalid(&b2.block_hash())); // A heavier side branch whose inputs fail at connect keeps Core's reason. let coinbase_at = |h: u32| { @@ -4007,10 +4008,12 @@ fn submitblock_child_spends_its_side_parent_output() { let mut second = first.clone(); second.output[0].value = Amount::from_sat(2); let immature = spend(coinbase_at(height - 1), 1); + // `None` spends the side parent's own coinbase. for (i, (txs, reason)) in [ - (vec![overspend], "bad-txns-in-belowout"), - (vec![first, second], "bad-txns-inputs-missingorspent"), - (vec![immature], "bad-txns-premature-spend-of-coinbase"), + (Some(vec![overspend]), "bad-txns-in-belowout"), + (Some(vec![first, second]), "bad-txns-inputs-missingorspent"), + (Some(vec![immature]), "bad-txns-premature-spend-of-coinbase"), + (None, "bad-txns-premature-spend-of-coinbase"), ] .into_iter() .enumerate() @@ -4024,6 +4027,7 @@ fn submitblock_child_spends_its_side_parent_output() { ); let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&c1))]).unwrap(); assert_eq!(r, "inconclusive", "{r}"); + let txs = txs.unwrap_or_else(|| vec![spend(c1.txdata[0].compute_txid(), 1)]); let c2 = rbitcoin_consensus::mine_regtest_paying( c1.block_hash(), t0 + 20 + i as u32, @@ -4034,7 +4038,21 @@ fn submitblock_child_spends_its_side_parent_output() { let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&c2))]).unwrap(); assert_eq!(r, reason); assert_eq!(hub.tip_hash(), Some(b2.block_hash())); + let again = dispatch(&ctx, "submitblock", vec![json!(block_hex(&c2))]).unwrap(); + assert_eq!(again, "duplicate-invalid"); } + + // A block on the tip still takes the early input check. + let on_tip = rbitcoin_consensus::mine_regtest_paying( + b2.block_hash(), + t0 + 30, + height + 2, + op_true.clone(), + vec![spend(coinbase_at(2), 50_0000_0001)], + ); + let r = dispatch(&ctx, "submitblock", vec![json!(block_hex(&on_tip))]).unwrap(); + assert_eq!(r, "bad-txns-in-belowout"); + assert_eq!(hub.tip_hash(), Some(b2.block_hash())); let _ = std::fs::remove_dir_all(&dir); }