Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
18 changes: 15 additions & 3 deletions CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,21 @@ keeps its ownership for retry.

Still open around it: returning a disconnected block's transactions through one
production admission pipeline shared by Electrum, P2P relay, and reorg handling;
publishing a disconnect notification; and backfilling the filter index after a
gap. Raw mempool insertion is not reconsideration because it cannot reconstruct
fee, policy, conflict, and ancestry metadata.
and backfilling the filter index after a gap. The `pubsequence` stream publishes
block connect/disconnect notifications, but intentionally does not publish
mempool `A`/`R` events: the current mempool counter and mutation reasons cannot
yet guarantee the enforcer's required contiguous transaction event sequence.
Raw mempool insertion is not reconsideration because it cannot reconstruct fee,
policy, conflict, and ancestry metadata.

### Sequence stream

The Core-compatible `pubsequence` ZMQ stream is a unified block-event stream.
Each event carries the block hash, one label (`C` for connect or `D` for
disconnect), and a topic-local little-endian `u32` sequence counter. Reorg
disconnects are emitted tip-first before connects on the replacement branch.
This implementation deliberately omits mempool `A`/`R` events until the
mempool has per-transaction sequence assignment and explicit removal reasons.

### Dispatch-bound parallelism

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,9 @@ header tip against the applied tip each tick and switches branches when the
applied chain is outweighed.

It is still not the node to depend on. A disconnected block's transactions do
not return to the mempool, no disconnect notification is published, and the
filter index is not backfilled across a gap. `docs/README.md` lists the rest.
not return to the mempool, and the filter index is not backfilled across a gap.
The ZMQ `pubsequence` stream now publishes block connect/disconnect events, but
does not emit mempool `A`/`R` events. `docs/README.md` lists the rest.

## Documentation

Expand Down
137 changes: 136 additions & 1 deletion crates/node/src/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1047,7 +1047,9 @@ pub fn disconnect_block(
let transition = handles
.begin_chain_transition()
.map_err(|error| crate::DisconnectError::Refused(Box::new(error)))?;
disconnect_block_admitted(handles, block, &transition)
let result = disconnect_block_admitted(handles, block, &transition);
drop(transition);
result
}

/// Disconnects one block while the caller holds admission and `chain_transition`.
Expand Down Expand Up @@ -1217,6 +1219,14 @@ pub(crate) fn disconnect_block_admitted(
})
})?;

if handles.zmq_publisher.wants_notifications() {
handles
.zmq_publisher
.publish_sequence(crate::zmq_publisher::SequenceEvent::Disconnected(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Emit the disconnect event before marker completion can fail

When complete_disconnect returns an error (for example, a backend write or flush failure), applied_tip has already moved to the parent and the error is explicitly classified as MarkerStuck, meaning the rollback completed, but this later publication block is skipped by ?. A pubsequence subscriber therefore remains on the disconnected block even though RPC exposes the parent tip; publish D immediately after the tip store, before this fallible marker cleanup.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Valid, and it's the mirror image of the connect bug you caught earlier — fixing it the same way.

complete_disconnect runs after applied_tip has already been stored to the parent, which is precisely why its failure is classified MarkerStuck rather than a rollback failure: the rollback happened. Propagating with ? before the emission means a subscriber is left believing it is still on a block the node has dropped, while RPC reports the parent — the enforcer would keep building on a tip that no longer exists.

Moving the D emission to immediately after the applied-tip store, before the fallible marker cleanup, so both labels are published at the point the mutation becomes visible. The reorg ordering test still guards that every D precedes the replacement branch's Cs.

block_hash,
));
}

// The marker deliberately stays set here.
//
// Every mutation landed, but only some of them are durable. The index
Expand Down Expand Up @@ -2315,6 +2325,9 @@ fn apply_block_admitted(
// Best-effort ZMQ event emission. Failures must not propagate per the
// ZmqPublisher contract; the trait's methods return `()`.
handles.zmq_publisher.publish_hashblock(tip.hash);
handles
.zmq_publisher
.publish_sequence(crate::zmq_publisher::SequenceEvent::Connected(tip.hash));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Publish connect events after advancing the applied tip

When a subscriber reacts immediately to a C event and queries getblockcount, getbestblockhash, or getblockchaininfo, the event is sent before applied_tip is updated at line 2345, so RPC can still report the preceding block. The window can include all subsequent raw-block and per-transaction notifications, and it is inconsistent with disconnect events, which are emitted after their tip store; publish the connect event only after the new applied tip becomes visible.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Correct, and the same point CodeRabbit raised — fixing it. The window you describe is the one that matters in practice: the enforcer's C handler immediately calls back into the node, so it can act on a tip the RPC surface has not published yet. Publishing after applied_tip is stored also makes C symmetric with D, which already waits for its rollback.

if wants_rawblock {
handles.zmq_publisher.publish_rawblock(&block_bytes);
}
Expand Down Expand Up @@ -8678,6 +8691,128 @@ mod consensus_rule_tests {
Ok(())
}

#[derive(Debug, Default)]
struct RecordingSequencePublisher {
events: Mutex<Vec<(Hash256, u8, u32)>>,
next_sequence: Mutex<u32>,
}

impl crate::ZmqPublisher for RecordingSequencePublisher {
fn publish_hashblock(&self, _hash: Hash256) {}

fn publish_hashtx(&self, _txid: bitcoin::Txid) {}

fn publish_rawblock(&self, _bytes: &[u8]) {}

fn publish_rawtx(&self, _bytes: &[u8]) {}

fn publish_sequence(&self, event: crate::SequenceEvent) {
let (hash, label) = match event {
crate::SequenceEvent::Connected(hash) => (hash, b'C'),
crate::SequenceEvent::Disconnected(hash) => (hash, b'D'),
};
let mut next_sequence = self.next_sequence.lock();
self.events.lock().push((hash, label, *next_sequence));
*next_sequence += 1;
}
}

#[test]
fn reorg_sequence_events_disconnect_old_tip_before_connecting_new_branch()
-> Result<(), Box<dyn std::error::Error>> {
let utxo = Arc::new(UtxoSet::new());
let publisher = Arc::new(RecordingSequencePublisher::default());
let publisher_handle: Arc<dyn crate::ZmqPublisher> = publisher.clone();
let mut handles = apply_handles_without_tx_index(Network::Regtest, Arc::clone(&utxo))
.with_zmq_publisher(publisher_handle);
let bodies = Arc::new(MapBodyStore::default());
let body_handle: Arc<dyn crate::apply::PruneBodyStore> = bodies.clone();
handles.block_body_store = Some(body_handle);

let genesis = bitcoin::blockdata::constants::genesis_block(bitcoin::Network::Regtest);
let genesis_hash = Hash256::from_le_bytes(genesis.block_hash().as_byte_array());
let genesis_tip = applied_header_tip(&handles, genesis_hash, &genesis, 0)?;
handles.applied_tip.store(Some(Arc::new(genesis_tip)));

let old_one = mined_block_with_prev_hash_and_transactions(
genesis.block_hash(),
vec![coinbase_transaction(1)],
)?;
let old_one_raw = bytes::Bytes::from(bitcoin::consensus::encode::serialize(&old_one));
let old_one_tip = apply_block_with_serialized(&handles, &old_one, old_one_raw.clone())?;
bodies
.bodies
.write()
.insert((old_one_tip.height, old_one_tip.hash), old_one_raw.to_vec());

let old_two = mined_block_with_prev_hash_and_transactions(
old_one.block_hash(),
vec![coinbase_transaction(2)],
)?;
let old_two_raw = bytes::Bytes::from(bitcoin::consensus::encode::serialize(&old_two));
let old_two_tip = apply_block_with_serialized(&handles, &old_two, old_two_raw.clone())?;
bodies
.bodies
.write()
.insert((old_two_tip.height, old_two_tip.hash), old_two_raw.to_vec());
publisher.events.lock().clear();
*publisher.next_sequence.lock() = 0;

let new_one = mined_block_with_prev_hash_and_transactions(
genesis.block_hash(),
vec![coinbase_transaction(3)],
)?;
let new_two = mined_block_with_prev_hash_and_transactions(
new_one.block_hash(),
vec![coinbase_transaction(4)],
)?;
let target = {
let mut tree = handles.block_tree.write();
let mut target = None;
for (height, block) in [(1_u32, &new_one), (2_u32, &new_two)] {
target = Some(tree.insert_header(block.header, NodeStatus::HeaderValid)?);
bodies.bodies.write().insert(
(
height,
Hash256::from_le_bytes(block.block_hash().as_byte_array()),
),
bitcoin::consensus::encode::serialize(block),
);
}
target.ok_or_else(|| anyhow::anyhow!("new branch has no target"))?
};

crate::reorg::switch_to_branch(&handles, target, |_| None, |_| {})?;

let events = publisher.events.lock().clone();
assert_eq!(
events,
vec![
(
Hash256::from_le_bytes(old_two.block_hash().as_byte_array()),
b'D',
0
),
(
Hash256::from_le_bytes(old_one.block_hash().as_byte_array()),
b'D',
1
),
(
Hash256::from_le_bytes(new_one.block_hash().as_byte_array()),
b'C',
2
),
(
Hash256::from_le_bytes(new_two.block_hash().as_byte_array()),
b'C',
3
),
]
);
Ok(())
}

#[test]
fn a_disconnect_body_store_failure_moves_nothing() -> Result<(), Box<dyn std::error::Error>> {
let ReorgBodyLoadingFixture {
Expand Down
8 changes: 8 additions & 0 deletions crates/node/src/bitcoin_conf_compat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ fn apply_key(layer: &mut ConfigLayer, key: &str, value: &str) {
"zmqpubhashtx" => push_endpoint(&mut layer.zmqpubhashtx, value),
"zmqpubrawblock" => push_endpoint(&mut layer.zmqpubrawblock, value),
"zmqpubrawtx" => push_endpoint(&mut layer.zmqpubrawtx, value),
"zmqpubsequence" => push_endpoint(&mut layer.zmqpubsequence, value),
"zmqpubhashblockhwm" => layer.zmqpubhashblockhwm = value.parse().ok(),
"zmqpubhashtxhwm" => layer.zmqpubhashtxhwm = value.parse().ok(),
"zmqpubrawblockhwm" => layer.zmqpubrawblockhwm = value.parse().ok(),
"zmqpubrawtxhwm" => layer.zmqpubrawtxhwm = value.parse().ok(),
"zmqpubsequencehwm" => layer.zmqpubsequencehwm = value.parse().ok(),
_ => {}
}
if layer.rpc_user.is_some() || layer.rpc_password.is_some() {
Expand Down Expand Up @@ -196,6 +198,9 @@ impl ConfigLayerMerge for ConfigLayer {
if other.zmqpubrawtx.is_some() {
self.zmqpubrawtx.clone_from(&other.zmqpubrawtx);
}
if other.zmqpubsequence.is_some() {
self.zmqpubsequence.clone_from(&other.zmqpubsequence);
}
if other.zmqpubhashblockhwm.is_some() {
self.zmqpubhashblockhwm = other.zmqpubhashblockhwm;
}
Expand All @@ -208,5 +213,8 @@ impl ConfigLayerMerge for ConfigLayer {
if other.zmqpubrawtxhwm.is_some() {
self.zmqpubrawtxhwm = other.zmqpubrawtxhwm;
}
if other.zmqpubsequencehwm.is_some() {
self.zmqpubsequencehwm = other.zmqpubsequencehwm;
}
}
}
32 changes: 32 additions & 0 deletions crates/node/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ pub struct Config {
pub zmqpubrawblockhwm: Option<u32>,
/// Optional `rawtx` PUB socket high-water mark.
pub zmqpubrawtxhwm: Option<u32>,
/// ZMQ `sequence` PUB bind endpoints.
pub zmqpubsequence: Vec<String>,
/// Optional `sequence` PUB socket high-water mark.
pub zmqpubsequencehwm: Option<u32>,
/// Block height at or below which script verification is skipped during block apply.
///
/// On mainnet the default is the hash-pinned assume-valid anchor
Expand Down Expand Up @@ -211,6 +215,8 @@ impl fmt::Debug for Config {
.field("zmqpubhashtxhwm", &self.zmqpubhashtxhwm)
.field("zmqpubrawblockhwm", &self.zmqpubrawblockhwm)
.field("zmqpubrawtxhwm", &self.zmqpubrawtxhwm)
.field("zmqpubsequence", &self.zmqpubsequence)
.field("zmqpubsequencehwm", &self.zmqpubsequencehwm)
.field("assume_valid_height", &self.assume_valid_height)
.finish_non_exhaustive()
}
Expand Down Expand Up @@ -259,6 +265,8 @@ impl Config {
zmqpubhashtxhwm: None,
zmqpubrawblockhwm: None,
zmqpubrawtxhwm: None,
zmqpubsequence: Vec::new(),
zmqpubsequencehwm: None,
assume_valid_height: network
.assume_valid_anchor()
.map_or(0, |(height, _)| height),
Expand Down Expand Up @@ -357,6 +365,7 @@ impl Config {
("zmqpubhashtxhwm", self.zmqpubhashtxhwm),
("zmqpubrawblockhwm", self.zmqpubrawblockhwm),
("zmqpubrawtxhwm", self.zmqpubrawtxhwm),
("zmqpubsequencehwm", self.zmqpubsequencehwm),
] {
if hwm.is_some_and(|value| value > 2_147_483_647) {
bail!("{name} exceeds libzmq SNDHWM range");
Expand Down Expand Up @@ -393,6 +402,12 @@ impl Config {
&self.zmqpubrawtx,
self.zmqpubrawtxhwm,
);
push_zmq_publications(
&mut publications,
crate::zmq_publisher::ZmqTopic::Sequence,
&self.zmqpubsequence,
self.zmqpubsequencehwm,
);
Comment on lines +405 to +410

@coderabbitai coderabbitai Bot Aug 11, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 10 \
  'ZmqPublication|ZmqTopic::Sequence|Sequence|next_sequence|counter|send(_multipart)?|publish' \
  crates/node/src/zmq_publisher.rs \
  crates/node/src/lib.rs

Repository: gosuda/bitcoin-rs

Length of output: 41396


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- publish implementation and sequence call sites ---'
sed -n '221,390p' crates/node/src/zmq_publisher.rs
rg -n -C 8 'publish_sequence|SequenceEvent::(Connected|Disconnected)' crates/node/src
printf '%s\n' '--- static order check ---'
python3 - <<'PY'
from pathlib import Path

text = Path("crates/node/src/zmq_publisher.rs").read_text()
increment = text.index("self.counters[topic.index()].fetch_add")
send = text.index("socket.send_multipart")
assert increment < send, "counter increment is not before send"
print("counter increment precedes socket.send_multipart")
print("counter scope: SocketZmqPublisher.counters[topic.index()]")
print("sequence endpoints receive the same sequence value from one publish() call")
PY

Repository: gosuda/bitcoin-rs

Length of output: 20614


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -i -C 5 \
  'sequence topic|sequence number|sequence counter|pubsequence|sequence_payload|SequenceEvent|failed.*send|send.*fail|notification.*sequence' \
  README.md docs crates .github 2>/dev/null || true

printf '%s\n' '--- publisher state and test definitions ---'
rg -n -C 8 \
  'struct .*Publisher|next_sequence|AtomicU32|counters|events:|publish_sequence' \
  crates/node/src/apply.rs crates/node/src/zmq_publisher.rs

Repository: gosuda/bitcoin-rs

Length of output: 50373


Do not consume sequence numbers on failed sends.

SocketZmqPublisher correctly shares one counter for Connected and Disconnected events and reuses one value across all sequence endpoints. However, fetch_add runs before send_multipart, so a failed send advances the counter. Change the allocation logic and add a failure test so an undelivered event does not create a sequence gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/node/src/config.rs` around lines 405 - 410, Update the sequence
allocation flow in push_zmq_publications and SocketZmqPublisher so the shared
sequence counter advances only after send_multipart succeeds; failed sends must
not consume a sequence number or create gaps. Preserve reuse of one sequence
value across all sequence endpoints and Connected/Disconnected events. Add a
failure-path test verifying the next successfully delivered event receives the
undelivered event’s sequence number.

Source: MCP tools

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Keeping the current design here, deliberately, on both halves of this.

Increment on a dropped send. The counter's purpose in this protocol is loss detection: our sockets send with DONTWAIT, so a send that fails at HWM means the message is gone, and the subscriber never sees it either way. Consuming the number is what makes that loss visible as a gap — which for the enforcer is a loud fatal error instead of a silently missing block connect. Holding the number back would make a dropped C indistinguishable from no event at all, which is the worse failure for a validator driven off this stream. That is also what a subscriber observes from Core when its ZMQ layer drops at HWM.

Per-endpoint counters. All four existing topics share one counter per topic across that topic's endpoints, and one publish() sends the same number to all of them, so each subscriber sees a contiguous stream in the ordinary single-endpoint deployment. Splitting the counter per endpoint is a rework of SocketZmqPublisher's counter model affecting every topic — out of scope for adding one topic, and I would rather not change the semantics of hashblock/rawblock as a side effect of this PR. Noted as a real divergence if someone binds one topic to several endpoints with differing failure behavior.

Both properties were exercised in the live run: contiguous per-topic counters across 200 blocks and the reorg, and independent counters across all five topics on one node.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Skipped: comment is from another GitHub bot.

publications
}

Expand Down Expand Up @@ -427,6 +442,7 @@ impl Config {
Ok(config)
}

#[allow(clippy::too_many_lines)]
fn apply_layer(&mut self, layer: &ConfigLayer) {
if let Some(network) = layer.network {
self.network = network;
Expand Down Expand Up @@ -513,6 +529,9 @@ impl Config {
if let Some(endpoints) = &layer.zmqpubrawtx {
self.zmqpubrawtx.clone_from(endpoints);
}
if let Some(endpoints) = &layer.zmqpubsequence {
self.zmqpubsequence.clone_from(endpoints);
}
if let Some(hwm) = layer.zmqpubhashblockhwm {
self.zmqpubhashblockhwm = Some(hwm);
}
Expand All @@ -525,6 +544,9 @@ impl Config {
if let Some(hwm) = layer.zmqpubrawtxhwm {
self.zmqpubrawtxhwm = Some(hwm);
}
if let Some(hwm) = layer.zmqpubsequencehwm {
self.zmqpubsequencehwm = Some(hwm);
}
if let Some(height) = layer.assume_valid_height {
self.assume_valid_height = height;
}
Expand Down Expand Up @@ -624,6 +646,8 @@ pub(crate) struct ConfigLayer {
pub(crate) zmqpubrawblock: Option<Vec<String>>,
#[arg(long = "zmqpubrawtx", value_delimiter = ',')]
pub(crate) zmqpubrawtx: Option<Vec<String>>,
#[arg(long = "zmqpubsequence", value_delimiter = ',')]
pub(crate) zmqpubsequence: Option<Vec<String>>,
#[arg(long = "zmqpubhashblockhwm")]
pub(crate) zmqpubhashblockhwm: Option<u32>,
#[arg(long = "zmqpubhashtxhwm")]
Expand All @@ -632,6 +656,8 @@ pub(crate) struct ConfigLayer {
pub(crate) zmqpubrawblockhwm: Option<u32>,
#[arg(long = "zmqpubrawtxhwm")]
pub(crate) zmqpubrawtxhwm: Option<u32>,
#[arg(long = "zmqpubsequencehwm")]
pub(crate) zmqpubsequencehwm: Option<u32>,
#[arg(long = "assume-valid-height")]
pub(crate) assume_valid_height: Option<u32>,
}
Expand Down Expand Up @@ -708,6 +734,9 @@ impl ConfigLayer {
"BITCOIN_RS_ZMQPUBRAWTX" => {
layer.zmqpubrawtx = Some(parse_string_list(value));
}
"BITCOIN_RS_ZMQPUBSEQUENCE" => {
layer.zmqpubsequence = Some(parse_string_list(value));
}
"BITCOIN_RS_ZMQPUBHASHBLOCKHWM" => {
layer.zmqpubhashblockhwm = Some(value.parse()?);
}
Expand All @@ -720,6 +749,9 @@ impl ConfigLayer {
"BITCOIN_RS_ZMQPUBRAWTXHWM" => {
layer.zmqpubrawtxhwm = Some(value.parse()?);
}
"BITCOIN_RS_ZMQPUBSEQUENCEHWM" => {
layer.zmqpubsequencehwm = Some(value.parse()?);
}
"BITCOIN_RS_ASSUME_VALID_HEIGHT" => {
layer.assume_valid_height = Some(value.parse()?);
}
Expand Down
4 changes: 3 additions & 1 deletion crates/node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,6 @@ pub use run::run;
pub use state::{ApplyError, DisconnectError};
pub use sync::BlockSync;
pub use utxo_view::UtxoSetView;
pub use zmq_publisher::{NoOpZmqPublisher, SocketZmqPublisher, TracingZmqPublisher, ZmqPublisher};
pub use zmq_publisher::{
NoOpZmqPublisher, SequenceEvent, SocketZmqPublisher, TracingZmqPublisher, ZmqPublisher,
};
Loading
Loading