diff --git a/changelog.d/submitblock-witness-reserved-value.md b/changelog.d/submitblock-witness-reserved-value.md new file mode 100644 index 000000000..eef8678ee --- /dev/null +++ b/changelog.d/submitblock-witness-reserved-value.md @@ -0,0 +1,6 @@ +Fixed + +- **`submitblock` adds the coinbase witness reserved value like Core.** + A block with a witness commitment and no coinbase witness was rejected + as `bad-witness-nonce-size`. Core fills in 32 zero bytes once segwit is + active, and mining software may leave it out. diff --git a/crates/rbitcoin-net/src/chain.rs b/crates/rbitcoin-net/src/chain.rs index 7bfcabc55..4e9fc8b72 100644 --- a/crates/rbitcoin-net/src/chain.rs +++ b/crates/rbitcoin-net/src/chain.rs @@ -1557,7 +1557,9 @@ impl ChainHub { self.stored_header_height(hash) } - fn stored_header_height(&self, hash: &BlockHash) -> Option { + /// Height of a stored header, connected or not. Walks back to a known + /// height (at most 10_000 headers). + pub fn stored_header_height(&self, hash: &BlockHash) -> Option { if let Some(h) = self.header_height(hash) { return Some(h); } diff --git a/crates/rbitcoin-rpc/src/methods/mine.rs b/crates/rbitcoin-rpc/src/methods/mine.rs index 10d060bdb..9811c3d05 100644 --- a/crates/rbitcoin-rpc/src/methods/mine.rs +++ b/crates/rbitcoin-rpc/src/methods/mine.rs @@ -897,8 +897,29 @@ pub(crate) fn header_time(ctx: &RpcContext, h: u32) -> Option { Some(rec.timestamp) } +/// Core `UpdateUncommittedBlockStructures`: a coinbase that commits to +/// witnesses but carries no witness gets the 32 zero bytes, once segwit is +/// active for this block. Mining software may leave it out. +fn fill_witness_reserved_value(hub: &rbitcoin_net::ChainHub, block: &mut Block) { + let segwit = hub + .stored_header_height(&block.header.prev_blockhash) + .is_some_and(|h| hub.params.segwit_active_at(h.saturating_add(1))); + let Some(coinbase) = block.txdata.first_mut() else { + return; + }; + if !segwit + || rbitcoin_consensus::witness_commitment_vout_index(coinbase).is_none() + || coinbase.input.iter().any(|i| !i.witness.is_empty()) + { + return; + } + if let Some(input) = coinbase.input.first_mut() { + input.witness = bitcoin::Witness::from_slice(&[[0u8; 32]]); + } +} + /// Same receive path as P2P `block` (all networks). -pub fn submit_received_block(hub: &rbitcoin_net::ChainHub, block: Block) -> SubmitBlockOutcome { +pub fn submit_received_block(hub: &rbitcoin_net::ChainHub, mut block: Block) -> SubmitBlockOutcome { use bitcoin::Target; use rbitcoin_net::AcceptOutcome; let hash = block.block_hash(); @@ -922,6 +943,7 @@ pub fn submit_received_block(hub: &rbitcoin_net::ChainHub, block: Block) -> Subm if !known { return SubmitBlockOutcome::Rejected("prev-blk-not-found".into()); } + fill_witness_reserved_value(hub, &mut block); match cheap_submit_tx_reject( hub.query.as_ref(), &block, diff --git a/crates/rbitcoin-rpc/src/methods_tests.rs b/crates/rbitcoin-rpc/src/methods_tests.rs index c25fa6c6b..a77e6f280 100644 --- a/crates/rbitcoin-rpc/src/methods_tests.rs +++ b/crates/rbitcoin-rpc/src/methods_tests.rs @@ -4014,6 +4014,79 @@ fn submitblock_vout_past_n_out_is_missingorspent() { let _ = std::fs::remove_dir_all(&dir); } +/// Core `UpdateUncommittedBlockStructures`: a coinbase with a witness +/// commitment and no witness gets the 32 zero bytes once segwit is active. +/// Below segwit the commitment is plain data and the coinbase stays bare. +/// A reserved value the miner already set is kept, a block with no +/// commitment is left alone, and a parent known only as a header counts. +#[test] +fn submitblock_adds_the_witness_reserved_value_once_segwit_is_active() { + let mut params = rbitcoin_consensus::ChainParams::regtest(); + params.apply_test_activation_height("segwit", 2).unwrap(); + let (ctx, dir, hub) = ctx_regtest_hub_on(params, 300_000_000); + let mk = |prev: BlockHash, time: u32, height: u32, commit: Option<[u8; 32]>, own: bool| { + let mut b = rbitcoin_consensus::mine_regtest_paying( + prev, + time, + height, + ScriptBuf::from_bytes(vec![0x51]), + vec![], + ); + if let Some(reserved) = commit { + if own { + b.txdata[0].input[0].witness = bitcoin::Witness::from_slice(&[reserved]); + } + let commitment = rbitcoin_consensus::witness_commitment_script([], &reserved); + b.txdata[0].output.push(bitcoin::TxOut { + value: Amount::ZERO, + script_pubkey: ScriptBuf::from_bytes(commitment), + }); + b.header.merkle_root = b.compute_merkle_root().unwrap(); + regrind(&mut b); + } + b + }; + let submit = |b: &Block| dispatch(&ctx, "submitblock", vec![json!(block_hex(b))]).unwrap(); + let mut prev = (hub.tip_hash().unwrap(), hub.tip_header().unwrap().time); + let zero = Some([0u8; 32]); + for (height, commit, own) in [ + (1u32, zero, false), + (2, zero, false), + (3, Some([7u8; 32]), true), + (4, None, false), + ] { + let b = mk(prev.0, prev.1 + 1, height, commit, own); + let r = submit(&b); + assert!(r.is_null(), "height {height}: {r}"); + assert_eq!(hub.tip_hash(), Some(b.block_hash()), "height {height}"); + prev = (b.block_hash(), b.header.time); + } + // Headers first: once its child's header arrives, h5 is a stored header + // with no body and no longer a header tip. + let b5 = mk(prev.0, prev.1 + 1, 5, None, false); + let b6 = mk(b5.block_hash(), prev.1 + 2, 6, zero, false); + for b in [&b5, &b6] { + dispatch(&ctx, "submitheader", vec![json!(serialize_hex(&b.header))]).unwrap(); + } + assert_eq!(submit(&b6), "inconclusive", "held until h5's body"); + assert!(submit(&b5).is_null()); + assert_eq!(hub.tip_hash(), Some(b6.block_hash())); + + let coinbase_witness = |h: u32| { + let b = hub.query.reconstruct_block_at_height(Height(h)).unwrap(); + b.txdata[0].input[0].witness.to_vec() + }; + assert!(coinbase_witness(1).is_empty(), "no fill below segwit"); + assert_eq!(coinbase_witness(2), vec![vec![0u8; 32]]); + assert_eq!(coinbase_witness(3), vec![vec![7u8; 32]]); + assert!( + coinbase_witness(4).is_empty(), + "no fill without a commitment" + ); + assert_eq!(coinbase_witness(6), vec![vec![0u8; 32]]); + let _ = std::fs::remove_dir_all(&dir); +} + /// A store fault reading the parent header is not `prev-blk-not-found`, /// and on the tip-extend path it is not a reject reason that gets cached. #[test] diff --git a/docs/rpc.md b/docs/rpc.md index 3796cfc54..c89fbf69b 100644 --- a/docs/rpc.md +++ b/docs/rpc.md @@ -153,7 +153,7 @@ still wait for durable SH when shindex is on. | `getmempoolfeeratediagram` | All networks. Mining chunks as `{weight, fee}` points (decreasing feerate). | | `submitpackage` | All networks. `MempoolHub::submit_package_rpc` admits each tx with trim deferred, then package-evaluates a remainder of two or more that failed static min relay, the dynamic mempool min fee, or missing inputs. The package path is a child-with-parents tree; an in-mempool parent outside the remainder still supplies prevouts. When that remainder's combined fee meets static min relay, those members skip the dynamic floor. `fees.effective-feerate` / `effective-includes` for an individually admitted tx are that tx's modified fee and its own wtxid; for a package-eval tx they are the remainder's summed modified fees and vsizes, in package order. One unprotected trim runs after the package; a tx that was admitted and then dropped is `mempool full`. Package-admitted txs use the same relay-age clock as an individual admit. RPC `maxfeerate` / `maxburnamount` / `"version"` pre-checks. A member whose own feerate exceeds `maxfeerate`, including a parent that exists only in the package, is rejected (`max feerate exceeded`) and is not used to rescue the rest. An individual admit still lists the transactions it replaced. A 3-gen chain admits when fees/policy allow. A later member that failed missing-inputs and is not in a successful remainder stays `bad-txns-inputs-missingorspent`. `IsChildWithParents` `-25` applies only when `RBITCOIN_RPC_PACKAGE_DIALECT` is on ([`env-knobs.md`](./env-knobs.md)). `package_msg` / `tx-results` / `replaced-transactions`. Esplora `POST /txs/package` and Electrum `broadcast_package` still use atomic `accept_package` (all-or-nothing; child-fail rollback). | | `gettxspendingprevout` | All networks. Live mempool spender of each `{txid,vout}`. | -| `submitblock` | All networks. Same `ChainHub::accept_received_block` as a P2P `block` message: tip-extend, or hold by hash + most-work `accept_branch`. A consensus reject is a BIP22 reason string. A local store fault, including one reading the parent or tip header or a spent output, is RPC error `-25` (Core `state.IsError()`). A cancelled connect is `inconclusive`. Neither caches the block as invalid. | +| `submitblock` | All networks. Like Core, a coinbase with a witness commitment and no witness first gets the 32-byte zero reserved value once segwit is active. The parent's height comes from the connected chain, a header tip, or a walk of at most 10,000 stored headers. A parent past that walk is treated as before segwit, so the block is left as sent. Then the same `ChainHub::accept_received_block` as a P2P `block` message: tip-extend, or hold by hash + most-work `accept_branch`. A consensus reject is a BIP22 reason string. A local store fault, including one reading the parent or tip header or a spent output, is RPC error `-25` (Core `state.IsError()`). A cancelled connect is `inconclusive`. Neither caches the block as invalid. | | `getblockfilter` | All networks. Requires `--block-filter-index`. `filtertype` `basic` only (anything else is `-5`). Returns `filter` and `header` hex. A sealed best-chain height uses the index. One block whose parent filter header is already sealed (genesis uses the zero prev-header) is built from the stored body. A stale branch is rebuilt back to that sealed fork point. An unsealed best-chain gap is “Filter not found. Block filters are still in the process of being indexed.” Unknown hash is `-5` “Block not found”. Flag off is “Index is not enabled”. REST `/rest/blockfilter/` stays watermark-only (“still in the process of being indexed” past the watermark). | | `gettxout` | All networks. Connected Class A + mempool. Default `include_mempool=true` returns `null` for a confirmed out spent by a live mempool tx. `include_mempool=false` still returns the confirmed coin. A leftover still live in the hub (IBD / `-blocksonly`) uses the connected path, not `confirmations: 0`. A disconnected archive row is `null` (not tip+1 confirmations). A vout at or past the output count is `null`. The genesis coinbase output is `null` (Core never adds it to the coins view); REST `getutxos` follows. | | `getindexinfo` | All networks. Reports `txindex` synced at tip — we reconstruct by txid from Class A (no separate index flag). |