Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/mining/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ pub struct BlockTemplate {
pub enum BlockValidationResult {
/// The block is valid and, for submission, was synchronously applied.
Accepted,
/// The block was already accepted (its body is on the applied chain).
/// The block's body was already connected (scripts-valid), including after a later reorg.
Duplicate,
/// The block duplicates one already known to be invalid.
///
Expand Down
35 changes: 22 additions & 13 deletions crates/node/src/mining.rs
Original file line number Diff line number Diff line change
Expand Up @@ -723,27 +723,37 @@ impl MiningCoordinator {

/// Core `LookupBlockIndex` / BIP22 proposal vocabulary.
///
/// A node on the applied chain has had its body connected (Core
/// `BLOCK_VALID_SCRIPTS`). `Invalid` is `BLOCK_FAILED_VALID`. Any other
/// tree entry, including a header-only `Active` tip, is still
/// inconclusive — `NodeStatus::Active` is the header chain, not scripts.
/// CONTRACT: docs/contracts/external-api.md#API-21
fn known_block_result(&self, block_hash: Hash256) -> Option<BlockValidationResult> {
let tree = self.block_tree.read();
let node_id = tree.lookup(block_hash)?;
let node = tree.node(node_id).ok()?;
if node.status == NodeStatus::Invalid {
return Some(BlockValidationResult::DuplicateInvalid);
}
let on_applied = self
.applied_tip
.load_full()
.is_some_and(|tip| tree.node_at_height_from(tip.tip_id, node.height) == Some(node_id));
if on_applied {
if self.scripts_valid(&tree, node_id, node.height, node.chain_tx_count) {
return Some(BlockValidationResult::Duplicate);
}
Some(BlockValidationResult::DuplicateInconclusive)
}

/// In-process apply leaves `chain_tx_count`; checkpoint restore writes it
/// only on the applied tip, so applied-chain membership covers ancestors.
fn scripts_valid(
&self,
tree: &BlockTree,
node_id: NodeId,
height: u32,
chain_tx_count: u64,
) -> bool {
if chain_tx_count != 0 {
return true;
}
self.applied_tip
.load_full()
.is_some_and(|tip| tree.node_at_height_from(tip.tip_id, height) == Some(node_id))
}

/// Admits `header` through [`accept_headers`], the same gate inbound P2P uses.
fn accept_submitted_header(&self, header: Header) -> Result<(), MiningControlError> {
let mut tree = self.block_tree.write();
Expand Down Expand Up @@ -781,10 +791,9 @@ impl MiningCoordinator {

fn submit(&self, block: &Block) -> Result<BlockValidationResult, MiningControlError> {
let block_hash: Hash256 = block.block_hash().into();
// Core v31 `submitblock` dropped the index pre-check. `ProcessNewBlock`
// returns `duplicate` only when the block was already accepted
// (`!new_block && accepted`). A header-only tree entry must still
// receive the body so `submitheader` then `submitblock` works.
// CONTRACT: docs/contracts/external-api.md#API-21
// Header-only tree entries are DuplicateInconclusive and still receive
// the body so `submitheader` then `submitblock` works.
if matches!(
self.known_block_result(block_hash),
Some(BlockValidationResult::Duplicate)
Expand Down
103 changes: 103 additions & 0 deletions crates/node/tests/mining.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1259,6 +1259,109 @@ fn proposal_of_a_header_only_block_is_duplicate_inconclusive() -> anyhow::Result
Ok(())
}

fn disconnect_applied(state: &NodeState, block: &Block) -> anyhow::Result<()> {
state
.chain_followers()
.apply_disconnect(&state.apply_handles(), block)
.map(|_| ())
.map_err(|error| anyhow::anyhow!("{error}"))
}

#[test]
// CONTRACT: docs/contracts/external-api.md#API-21
fn proposal_of_a_disconnected_scripts_valid_block_is_duplicate() -> anyhow::Result<()> {
Comment on lines +1270 to +1272

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

2. Tests omit api-21 annotation 📘 Rule violation ▣ Testability

The two new permanent compatibility tests do not identify API-21, BIP22, or another named contract
in their names or adjacent annotations. Although the contract catalog maps them externally, the
tests themselves do not exercise an explicitly documented contract as required.
Agent Prompt
## Issue description
The new permanent tests lack a test-local reference to their named current contract.

## Issue Context
`docs/contracts/external-api.md` maps both tests to active contract `API-21`. Add an adjacent contract/spec comment such as `// CONTRACT: API-21 (BIP22 duplicate behavior)` to each test.

## Fix Focus Areas
- crates/node/tests/mining.rs[1270-1271]
- crates/node/tests/mining.rs[1307-1308]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

let state = open_regtest()?;
apply_genesis(&state)?;
let mining = coordinator(&state);
mining.publish_generation();
let genesis = Network::Regtest.genesis_block();
let child = mined_child(genesis.block_hash())?;
let child_hash = Hash256::from(child.block_hash());
assert_eq!(
mining.submit_block(child.clone())?,
BlockValidationResult::Accepted
);
disconnect_applied(&state, &child)?;
let chain_tx_count = {
let tree = state.block_tree();
tree.read()
.node_by_hash(child_hash)
.ok_or_else(|| anyhow::anyhow!("disconnected child missing from tree"))?
.chain_tx_count
};
assert_ne!(
chain_tx_count, 0,
"disconnect must keep the scripts-valid chain_tx_count"
);
let tip = state
.applied_tip()
.load_full()
.unwrap_or_else(|| panic!("applied tip missing after disconnect"));
assert_eq!(tip.hash, Hash256::from(genesis.block_hash()));
assert_eq!(
propose_block(&mining, child)?,
BlockValidationResult::Duplicate
);
Ok(())
}

#[test]
// CONTRACT: docs/contracts/external-api.md#API-21
fn submit_of_a_disconnected_scripts_valid_block_is_duplicate() -> anyhow::Result<()> {
let state = open_regtest()?;
apply_genesis(&state)?;
let mining = coordinator(&state);
mining.publish_generation();
let genesis = Network::Regtest.genesis_block();
let genesis_hash = Hash256::from(genesis.block_hash());
let child = mined_child(genesis.block_hash())?;
assert_eq!(
mining.submit_block(child.clone())?,
BlockValidationResult::Accepted
);
disconnect_applied(&state, &child)?;
assert_eq!(
mining.submit_block(child)?,
BlockValidationResult::Duplicate
);
let tip = state
.applied_tip()
.load_full()
.unwrap_or_else(|| panic!("applied tip missing after duplicate submit"));
assert_eq!(tip.hash, genesis_hash);
Ok(())
}

#[test]
// CONTRACT: docs/contracts/external-api.md#API-21
fn applied_ancestor_with_unset_chain_tx_count_is_duplicate() -> anyhow::Result<()> {
let state = open_regtest()?;
apply_genesis(&state)?;
let mining = coordinator(&state);
mining.publish_generation();
let genesis = Network::Regtest.genesis_block();
let genesis_hash = Hash256::from(genesis.block_hash());
let child = mined_child(genesis.block_hash())?;
assert_eq!(mining.submit_block(child)?, BlockValidationResult::Accepted);
{
let tree = state.block_tree();
let mut tree = tree.write();
let genesis_id = tree
.lookup(genesis_hash)
.ok_or_else(|| anyhow::anyhow!("missing genesis"))?;
tree.restore_chain_tx_count(genesis_id, 0)?;
}
assert_eq!(
propose_block(&mining, genesis.clone())?,
BlockValidationResult::Duplicate
);
assert_eq!(
mining.submit_block(genesis)?,
BlockValidationResult::Duplicate
);
Ok(())
}

#[test]
fn submit_block_applies_a_header_already_in_the_tree() -> anyhow::Result<()> {
let state = open_regtest()?;
Expand Down
2 changes: 1 addition & 1 deletion crates/rpc/src/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ declare_rows! {
"getnodeaddresses", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "", "0.4.0", Some(network::getnodeaddresses);
"getblocktemplate", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "BIP22/BIP23 template: client must advertise segwit (and signet on signet); submitold after long-poll, signet_challenge on signet, capabilities proposal+longpoll, coinbaseaux.flags empty hex.", "0.4.0", Some(mining::getblocktemplate);
"getmininginfo", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "Pinned v30 shape including bits/target and next-block facts derived from the mining coordinator.", "0.4.0", Some(mining::getmininginfo);
"submitblock", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "Decode failures are -22 (Block decode failed). Extra bytes after a complete block and BIP22's dummy second argument are ignored. A header already admitted by submitheader still accepts the body; only a previously applied block is duplicate.", "0.4.0", Some(mining::submitblock);
"submitblock", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "Decode failures are -22 (Block decode failed). Extra bytes after a complete block and BIP22's dummy second argument are ignored. A header already admitted by submitheader still accepts the body; a previously connected body (including after reorg) is duplicate.", "0.4.0", Some(mining::submitblock);
"submitheader", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "Header-only admission through the same tree path as inbound P2P headers. Decode failures are -22; missing previous or invalid headers are -25.", "0.4.0", Some(mining::submitheader);
"prioritisetransaction", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "Dummy (params[1]) must be 0 or null; fee_delta is params[2]. Non-zero dummy is Core -8. Pooled dust outputs are -8 except on regtest.", "0.4.0", Some(mining::prioritisetransaction);
"generatetoaddress", SurfaceKind::Rpc, Status::Implemented, "", CORE_VERSION, "Assembles, solves, and submits n blocks paying the given address through the mining coordinator.", "0.4.0", Some(mining::generatetoaddress);
Expand Down
2 changes: 1 addition & 1 deletion docs/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ match a regression.
| [chain-events.md](chain-events.md) | `EVT-01`–`EVT-05` | `ChainSnapshot`, `ChainEventHint`, `ChainEventPublisher`, `ConsumerCursor`, `UndoStore`/`DisconnectMarker`, `ChainChangeProof`: the seam between the apply path and reconciliation consumers | `crates/node/src/txindex_worker.rs` (first consumer); any index mirroring the applied chain | `crates/node/src/state.rs` tests `record_publishes_snapshot_and_hints_in_commit_order`, `record_drops_hints_when_channel_full`, `active_chain_snapshot_anchors_at_restored_tip_after_restart`; `crates/node/src/txindex_worker_recovery_tests.rs` tests `shallow_reorg_rewinds_to_common_ancestor_then_replays`, `tip_change_during_rebuild_converges_on_new_tip`; `crates/node/src/apply.rs` tests `a_clean_disconnect_leaves_no_in_flight_marker`, `chain_change_proof_finish_restores_even_generation` |
| [mempool-mutations.md](mempool-mutations.md) | `MPL-01`–`MPL-04` | Gateway ordering invariant, `MutationEnvelope`/`MutationResult` semantics, ZMQ `A`/`R` payload bytes, generation-validated admission and chain-change fencing | apply path (`crates/node/src/apply.rs`), `sendrawtransaction` (`crates/rpc/src/handlers/tx.rs`), ZMQ `sequence` subscribers (enforcer `--enable-mempool`) | `crates/mempool/src/gateway.rs` test `accepted_and_block_inclusion_events_arrive_in_commit_order`; `crates/node/src/zmq_publisher.rs` tests `block_inclusion_suppresses_r_frames` and `mempool_event_payloads_carry_reversed_txid_label_and_le_sequence`; `crates/node/src/apply.rs` test `stable_generation_is_even_before_and_after_connect` |
| [mempool-policy.md](mempool-policy.md) | `POL-01` | Pointer: relay policy contract pinned to Core 31.1 | `sendrawtransaction`/`testmempoolaccept` (`crates/rpc/src/handlers/tx.rs`), P2P relay admission | `crates/mempool/tests/policy_contract.rs` and `crates/rpc/tests/policy_contract.rs` (`cargo test -p bitcoin-rs-mempool --test policy_contract` / `-p bitcoin-rs-rpc --test policy_contract`) |
| [external-api.md](external-api.md) | `API-01`–`API-20` | Pointer: JSON-RPC/REST/ZMQ manifest, generated reference, error code mappings, query budgeting, solo-mining generate, `getnetworkhashps` snapshot behavior, BIP22/BIP23 template extras, mainnet GBT operational gates, `submitheader`, GBT client-rule negotiation, `submitblock` decode, GBT proposal request parsing, `submitblock` uncommitted witness fill, Core v31 submit/proposal duplicate vocabulary, BIP22 reject-reason mapping, GBT `vbrequired` always 0, Core `CheckWitnessMalleation` reject reasons, GBT `coinbaseaux.flags` empty hex, `prioritisetransaction` dummy/`fee_delta` arity, and pooled-dust refusal | RPC/REST/ZMQ clients; `tools/bip300301-enforcer` | `crates/rpc/tests/manifest_coverage.rs` tests `rpc_rows_and_the_live_registry_agree_both_ways`, `generated_reference_matches_checked_in`; `crates/rpc/src/handlers/mining.rs` generate, GBT extra, GBT gate, `submitheader`, GBT rule-negotiation, `submitblock` decode, GBT proposal-parse, `coinbaseaux`, and `prioritisetransaction` dummy/dust tests; `crates/node/tests/mining.rs` generate, GBT extra, `submitheader`, uncommitted-witness submit, submit/proposal duplicate, BIP22 reject-reason, `vbrequired`, and witness-malleation proposal tests; `crates/mining/src/coinbase.rs` uncommitted-witness tests; `crates/consensus/src/verify_block.rs` CheckWitnessMalleation tests; `crates/node/src/mining.rs` test `hash_ps_at_rejects_a_height_the_tip_cannot_resolve`; `crates/node/tests/mining.rs` test `network_hash_ps_rejects_core_invalid_windows` |
| [external-api.md](external-api.md) | `API-01`–`API-21` | Pointer: JSON-RPC/REST/ZMQ manifest, generated reference, error code mappings, query budgeting, solo-mining generate, `getnetworkhashps` snapshot behavior, BIP22/BIP23 template extras, mainnet GBT operational gates, `submitheader`, GBT client-rule negotiation, `submitblock` decode, GBT proposal request parsing, `submitblock` uncommitted witness fill, Core v31 submit/proposal duplicate vocabulary, BIP22 reject-reason mapping, GBT `vbrequired` always 0, Core `CheckWitnessMalleation` reject reasons, GBT `coinbaseaux.flags` empty hex, `prioritisetransaction` dummy/`fee_delta` arity, pooled-dust refusal, and reorged scripts-valid duplicate | RPC/REST/ZMQ clients; `tools/bip300301-enforcer` | `crates/rpc/tests/manifest_coverage.rs` tests `rpc_rows_and_the_live_registry_agree_both_ways`, `generated_reference_matches_checked_in`; `crates/rpc/src/handlers/mining.rs` generate, GBT extra, GBT gate, `submitheader`, GBT rule-negotiation, `submitblock` decode, GBT proposal-parse, `coinbaseaux`, and `prioritisetransaction` dummy/dust tests; `crates/node/tests/mining.rs` generate, GBT extra, `submitheader`, uncommitted-witness submit, submit/proposal duplicate, BIP22 reject-reason, `vbrequired`, witness-malleation proposal, and reorged scripts-valid duplicate tests; `crates/mining/src/coinbase.rs` uncommitted-witness tests; `crates/consensus/src/verify_block.rs` CheckWitnessMalleation tests; `crates/node/src/mining.rs` test `hash_ps_at_rejects_a_height_the_tip_cannot_resolve`; `crates/node/tests/mining.rs` test `network_hash_ps_rejects_core_invalid_windows` |
| [wallet-facing.md](wallet-facing.md) | `WF-01`–`WF-03` | Public Esplora/JSON-RPC surface an external wallet may use; no `NodeState` / `UtxoSet` / index types | [bitcoin-wallet](https://github.com/gosuda/bitcoin-wallet) (`btcw`); any Esplora HTTP client | `bin/bitcoin-rs/tests/wallet_facing.rs` tests `external_wallet_can_scan_estimate_and_broadcast`, `source_does_not_import_node_internals`; `crates/rpc/src/esplora.rs` tests `esplora_lives_only_under_the_api_prefix`, `api_is_the_public_electrs_directory`, `esplora_is_the_mempool_backend_superset` |
| [p2p-wire.md](p2p-wire.md) | `P2P-01`–`P2P-02` | Pointer: command inventory in `crates/p2p/src/compat.rs`, handshake/reject/deviations pinned to Core 31.1 | `crates/p2p` peers; `crates/p2p/src/chain_query.rs` active-chain serving | `crates/p2p/tests/core_compat.rs` (`cargo test -p bitcoin-rs-p2p --test core_compat`); live lane `scripts/run-p2p-core-interop.sh` |
| [qa-corpus.md](qa-corpus.md) | `QAC-01` | Pointer: fuzz seed provenance and refresh rules | `fuzz/fuzz_targets/{p2p_message,block_decode,tx_decode,script_eval}.rs`; CI fuzz lanes | `fuzz/CORPUS_PROVENANCE.md` mapping table; targets run under `cargo fuzz run <target> -- -runs=10000` |
Expand Down
33 changes: 28 additions & 5 deletions docs/contracts/external-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ vocabulary. `API-15` is BIP22 reject-reason mapping. `API-16` is GBT
`vbrequired` always 0. `API-17` is Core `CheckWitnessMalleation`
reject reasons. `API-18` is GBT `coinbaseaux.flags`. `API-19` is
`prioritisetransaction` dummy/`fee_delta` arity. `API-20` is
`prioritisetransaction` dust-output refusal.
`prioritisetransaction` dust-output refusal. `API-21` is GBT proposal /
`submitblock` duplicate for reorged scripts-valid bodies.

## Clauses

Expand Down Expand Up @@ -190,12 +191,12 @@ reject reasons. `API-18` is GBT `coinbaseaux.flags`. `API-19` is
- **Owner**: `MiningCoordinator::known_block_result` in
`crates/node/src/mining.rs`.
- GBT proposal looks the block hash up first, matching Core
`LookupBlockIndex`: a node on the applied chain is `duplicate`,
`LookupBlockIndex`: a scripts-valid body is `duplicate` (`API-21`),
`Invalid` is `duplicate-invalid`, and any other tree entry (including a
header-only tip) is `duplicate-inconclusive`.
- `submitblock` matches Core v31 `ProcessNewBlock`: only an already
accepted block is `duplicate`. A header admitted by `submitheader` still
receives the body.
- `submitblock` matches Core v31 `ProcessNewBlock`: a scripts-valid body
is `duplicate`. A header admitted by `submitheader` still receives the
body.

### `API-15`: BIP22 reject reasons

Expand Down Expand Up @@ -259,6 +260,23 @@ reject reasons. `API-18` is GBT `coinbaseaux.flags`. `API-19` is
not checked. Dust classification uses the pool's dust-relay fee via
`tx_has_dust_outputs`.

### `API-21`: reorged scripts-valid bodies are `duplicate`

- **Owner**: `MiningCoordinator::known_block_result` in
`crates/node/src/mining.rs`.
- Core proposal `pindex->IsValid(BLOCK_VALID_SCRIPTS)` is true for a
body that was fully connected and later reorged. `chain_tx_count != 0`
is written by `record_applied_tx_count` after a successful apply and is
not cleared on disconnect. Header-only nodes stay 0.
- Checkpoint restore writes `chain_tx_count` only on the applied tip.
Applied-chain membership is the restore fallback so ancestors whose
count is still 0 stay `duplicate`.
- `NodeStatus::Active` and `Stale` are header-chain displacement,
including a `submitheader` tip, and are not the scripts-valid test.
- `submitblock` uses the same scripts-valid test. A stale scripts-valid
resubmit is `duplicate`, not `inconclusive-not-best-prevblk`. Core
`BLOCK_HAVE_DATA` after prune is not modeled separately.

The wallet-facing subset of this surface — tip, fees, address/script
queries, and broadcast over Esplora, plus the key-free node RPCs — is
owned by [wallet-facing.md](wallet-facing.md).
Expand Down Expand Up @@ -371,3 +389,8 @@ owned by [wallet-facing.md](wallet-facing.md).
`prioritisetransaction_allows_dust_overlay_on_regtest`,
`prioritisetransaction_allows_absent_txid_overlay`
- `crates/mempool/src/standardness.rs` test `dust_relay_fee_changes_the_boundary`
- `API-21`:
- `crates/node/tests/mining.rs` tests
`proposal_of_a_disconnected_scripts_valid_block_is_duplicate`,
`submit_of_a_disconnected_scripts_valid_block_is_duplicate`,
`applied_ancestor_with_unset_chain_tx_count_is_duplicate`
2 changes: 1 addition & 1 deletion docs/rpc-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Unimplemented-set derivation: audited against the Bitcoin Core v31.0 source comm
| `getnodeaddresses` | 0.4.0 | |
| `getblocktemplate` | 0.4.0 | BIP22/BIP23 template: client must advertise segwit (and signet on signet); submitold after long-poll, signet_challenge on signet, capabilities proposal+longpoll, coinbaseaux.flags empty hex. |
| `getmininginfo` | 0.4.0 | Pinned v30 shape including bits/target and next-block facts derived from the mining coordinator. |
| `submitblock` | 0.4.0 | Decode failures are -22 (Block decode failed). Extra bytes after a complete block and BIP22's dummy second argument are ignored. A header already admitted by submitheader still accepts the body; only a previously applied block is duplicate. |
| `submitblock` | 0.4.0 | Decode failures are -22 (Block decode failed). Extra bytes after a complete block and BIP22's dummy second argument are ignored. A header already admitted by submitheader still accepts the body; a previously connected body (including after reorg) is duplicate. |
| `submitheader` | 0.4.0 | Header-only admission through the same tree path as inbound P2P headers. Decode failures are -22; missing previous or invalid headers are -25. |
| `prioritisetransaction` | 0.4.0 | Dummy (params[1]) must be 0 or null; fee_delta is params[2]. Non-zero dummy is Core -8. Pooled dust outputs are -8 except on regtest. |
| `generatetoaddress` | 0.4.0 | Assembles, solves, and submits n blocks paying the given address through the mining coordinator. |
Expand Down
Loading