Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ heap-profile-*
core.*
/tmp/
/.bitcoin-rs/
/.env
/data/
.env

# Re-include project plan tracked in-repo (global ~/.gitignore drops PLAN.md).
!PLAN.md
Expand Down
19 changes: 18 additions & 1 deletion CONCEPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ The mainnet consensus checkpoint (height 938343, block `00000000000000000000cceb
The standard node operational configuration tuned for mainnet sync: `fjall` storage backend, multi-peer block download active (outbound peer target 8, pending block budget 128, 16 in-flight requests per peer), hash-pinned assume-valid active on mainnet (height 938343), 450 MiB database cache (`dbcache`, matching Bitcoin Core parity), with secondary indexes (`txindex`, `blockfilterindex`), pruning, and `utreexo` stateless validation disabled by default.

### Container deployment posture
The checked-in Docker Compose specialization of the optimized default posture. The image compiles only the production `fjall` storage and `bitcoinkernel` verifier features and runs as an unprivileged user. Compose publishes P2P on the configured host port, keeps JSON-RPC on the host loopback interface, requires an explicit non-empty RPC password, and gives every selected Bitcoin network its own named data volume so incompatible checkpoints are never reused across a network switch. Shutdown allows up to 5 minutes because the bounded subsystem drain is followed by an unbounded, synchronous full-UTXO clean checkpoint; this is an operational SIGKILL guard, not a checkpoint-duration guarantee.
The checked-in Docker Compose specialization of the optimized default posture. The image compiles only the production `fjall` storage and `bitcoinkernel` verifier features and runs as an unprivileged user. The BIP300/301 integration Compose publishes P2P on the configured host port, keeps JSON-RPC on the host loopback interface, leaves `txindex` and the optional Electrum service disabled, supplies local-development RPC credential fallbacks that deployments should override, and namespaces node and enforcer data by `BITCOIN_RS_NETWORK` so incompatible P2P networks never reuse runtime state. Shutdown allows up to 5 minutes because the bounded subsystem drain is followed by an unbounded, synchronous full-UTXO clean checkpoint; this is an operational SIGKILL guard, not a checkpoint-duration guarantee.

### Node network selection
The user-facing `BITCOIN_RS_NETWORK`/`--network` selection that atomically supplies consensus rules and P2P bootstrap identity while preserving later, low-level overrides. Standard Bitcoin names use their matching consensus `Network`, message start, and DNS bootstrap. `drynet4` uses mainnet consensus history with message start `eca5d404`, disables Bitcoin DNS seeds, and connects to `drynet4.drivechain.dev:8533`. Compose passes the same selection to bitcoin-rs and the BIP300/301 enforcer and uses it to namespace their data directories. The internal consensus `Network` remains `mainnet` for drynet4.

### Sync regimes (download-bound vs processing-bound)
The two distinct cost regimes any sync measurement must name before its numbers mean anything. **Download-bound:** wall-clock is decided by the network path (peer scheduling, per-peer bandwidth, staller handling) — the regime of live IBD. **Processing-bound:** blocks are already local and wall-clock is decided by validation plus storage commit — the regime of reindex and offline replay. A node can rank differently in the two regimes, so a faster-than-X claim is meaningless without stating which regime was measured and with what validation posture. Within a regime the comparison is only as good as its least-matched input — see *Matched-harness comparison*.
Expand Down Expand Up @@ -177,6 +180,20 @@ 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.

### Chain control

Consensus-affecting RPCs do not mutate the RPC context's block-tree handle
directly. They delegate through the node-owned `ChainControl` boundary so the
same apply-admission and chain-transition locks protect RPC-triggered and
sync-triggered reorganizations. `invalidateblock` marks the named subtree
invalid, republishes the best remaining header tip, and moves applied
chainstate to it through the normal disconnect path. Before changing header
status it previews the replacement tip and loads every body required by the
complete disconnect/connect plan. The same chain-transition witness remains
held from that preflight through header invalidation and branch switching, so
another apply or reorg cannot enter between them; successful disconnects emit
the same `pubsequence` `D` events as an organic reorg.

### Dispatch-bound parallelism

A stage that is parallel in shape but serial in effect because each dispatch is
Expand Down
25 changes: 15 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,26 @@ That starts a mainnet node storing state in `.bitcoin-rs` and serving JSON-RPC
on `127.0.0.1:8332`. See [docs/getting-started.md](docs/getting-started.md) for
backend selection, RPC authentication, and checking sync progress.

### Docker Compose
### Enforcer integration

The included Compose configuration builds the production `fjall` +
`bitcoinkernel` profile, keeps each network's chain state in a separate named
volume, exposes P2P on port 8333, and binds RPC to the Docker host's loopback
interface only.
The Compose example under `tools/bip300301-enforcer` is specifically for
running bitcoin-rs together with the BIP300/301 enforcer; it is not the general
bitcoin-rs deployment path. It builds the production `fjall` + `bitcoinkernel`
node profile, starts both services, stores their data under `data/`, exposes
P2P, and binds RPC to the Docker host's loopback interface only. One
`BITCOIN_RS_NETWORK` selects the matching network for both services. The
`drynet4` selection derives its mainnet consensus rules, custom P2P magic,
fixed peer, and disabled DNS seeding inside bitcoin-rs.

Set explicit RPC credentials in `.env`, then start the node:
Set explicit RPC credentials in `.env`, then start bitcoin-rs and the enforcer:

```sh
cd tools/bip300301-enforcer
cp .env.example .env
# Edit .env and set BITCOIN_RS_RPC_PASSWORD before starting the node.

docker compose up --build -d
docker compose logs -f node
docker compose logs -f
```

Check sync progress inside the container. This uses the credentials already
Expand All @@ -64,10 +69,10 @@ docker compose exec node sh -c \
http://127.0.0.1:8332/'
```

Stop the process without deleting its chain data with `docker compose down`.
Stop the process without deleting its chain data with
`docker compose down`.
Compose allows up to 5 minutes for the full clean checkpoint before forcing
termination. Deleting the selected network's named volume requires the explicit
`docker compose down -v` form.
termination. Chain data remains under `data/` until it is explicitly removed.

## Measured performance

Expand Down
1 change: 0 additions & 1 deletion crash-1efd99623c772a1dd3aca1178fdafc93c933dec0

This file was deleted.

87 changes: 52 additions & 35 deletions crates/chain/src/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,52 @@ impl BlockTree {
/// chain-transition witness. Equal-work valid tips retain insertion order, matching
/// normal tip publication.
pub fn invalidate_subtree(&mut self, root: NodeId) -> Result<Vec<Hash256>, ChainError> {
let (invalid, best) = self.invalidation_plan(root)?;

// Demote the previous active tip to Stale if it is not the new best and is not
// about to be marked invalid.
if let Some(old_tip) = self.tip_id() {
if let Some(best) = best {
if best != old_tip {
let old_index = old_tip
.index()
.ok_or(ChainError::UnknownNode { id: old_tip })?;
if !invalid[old_index] {
self.node_mut_without_index_invalidation(old_tip)?.status =
NodeStatus::Stale;
}
}
}
}

// Wipe the published tip and active index before republishing.
self.tip.store(None);
self.active_by_height.clear_tainted();

// Mark the subtree invalid and collect the hashes in deterministic slab order.
let mut hashes = Vec::with_capacity(invalid.iter().filter(|&&b| b).count());
for (index, node) in &mut self.nodes {
if invalid[index] {
node.status = NodeStatus::Invalid;
hashes.push(node.hash);
}
}

// Republish the best valid tip (if any), which also rebuilds the active index.
if let Some(best) = best {
self.publish_tip_if_best(best)?;
}

Ok(hashes)
}

/// Returns the tip that would become active after invalidating `root` and
/// its descendants, without changing the tree.
pub fn tip_after_invalidation(&self, root: NodeId) -> Result<Option<NodeId>, ChainError> {
self.invalidation_plan(root).map(|(_, best)| best)
}

fn invalidation_plan(&self, root: NodeId) -> Result<(Vec<bool>, Option<NodeId>), ChainError> {
let root_index = root.index().ok_or(ChainError::UnknownNode { id: root })?;
self.node(root)?;

Expand Down Expand Up @@ -680,41 +726,7 @@ impl BlockTree {
})
.transpose()?;

// Demote the previous active tip to Stale if it is not the new best and is not
// about to be marked invalid.
if let Some(old_tip) = self.tip_id() {
if let Some(best) = best {
if best != old_tip {
let old_index = old_tip
.index()
.ok_or(ChainError::UnknownNode { id: old_tip })?;
if !invalid[old_index] {
self.node_mut_without_index_invalidation(old_tip)?.status =
NodeStatus::Stale;
}
}
}
}

// Wipe the published tip and active index before republishing.
self.tip.store(None);
self.active_by_height.clear_tainted();

// Mark the subtree invalid and collect the hashes in deterministic slab order.
let mut hashes = Vec::with_capacity(invalid.iter().filter(|&&b| b).count());
for (index, node) in &mut self.nodes {
if invalid[index] {
node.status = NodeStatus::Invalid;
hashes.push(node.hash);
}
}

// Republish the best valid tip (if any), which also rebuilds the active index.
if let Some(best) = best {
self.publish_tip_if_best(best)?;
}

Ok(hashes)
Ok((invalid, best))
}
/// Returns all ancestors from `start` down to the root, including `start`.
pub fn ancestor_chain(&self, start: NodeId) -> Result<Vec<NodeId>, ChainError> {
Expand Down Expand Up @@ -1907,6 +1919,11 @@ mod tests {
assert_eq!(tree.tip_id(), Some(side_ids[2]));
assert_eq!(tree.active_by_height.get(1), Some(side_ids[0]));

// Previewing the invalidation selects a2 without mutating status or tip.
assert_eq!(tree.tip_after_invalidation(side_ids[0])?, Some(a2_id));
assert_eq!(tree.tip_id(), Some(side_ids[2]));
assert_eq!(tree.node(side_ids[0])?.status, NodeStatus::HeaderValid);

// Invalidate the side root (b1). This must mark b1..b3 invalid and reselect a2.
let invalid_hashes = tree.invalidate_subtree(side_ids[0])?;
assert_eq!(invalid_hashes.len(), 3);
Expand Down
Loading
Loading