Skip to content

Commit 79fd214

Browse files
committed
Apply review fixes to architecture docs
Reviewer-confirmed corrections from evoskuil across 12 docs: - 02: milestone allows validation bypass (not chain-fixing); chaser_block skips milestones because blocks-first has no PoW DoS guard; debug-only checks are !NDEBUG; LRU eviction on tree_ would create a new DoS vector. - 03: get_inventory_size gates on candidate-chain currentness, with the weak-chain rationale (not "wait until caught up"). - 04: consensus is split across headers, block-receive, this chaser, and confirm; intro softened from "single source of consensus acceptance". - 05: !NDEBUG polarity fix; expanded block_confirmable to describe strong-tx association, maturity, and relative-locktime rules. - 06: session-template class diagram now shows all three instantiations; recent != current (max-height config for testing). - 08: superseded_ is atomic because superseded() is protected and read non-stranded from the base. - 09: unhandled channel messages are ignored, not protocol_violation. - 10: sub1/add1 was a real off-by-one bug, fixed in PR libbitcoin#1007. - 11: order-discipline is the same as headers-first; BIP130 typo fix. - 12: chaser_storage timer runs on the chaser's strand (network threadpool), not a separate pool. - 00, README: roll-up updates.
1 parent 2d9f6b9 commit 79fd214

12 files changed

Lines changed: 214 additions & 91 deletions

docs/architecture/00-overview.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,12 @@ its *own* mutations while still running in parallel with the other chasers
155155
on its own strand … allowing concurrent chaser operations to the extent that
156156
threads are available"*).
157157

158-
This is the **central source of parallelism** in the node. The chasers form
159-
a pipeline; each stage runs on its own strand and they communicate by
160-
publishing events.
158+
This is one of the two main axes of parallelism in the node. The chasers
159+
form a pipeline; each stage runs on its own strand and they communicate
160+
by publishing events. The other axis, equally important, is **per-channel
161+
strands**: every peer connection also runs on its own strand. Peers and
162+
chasers therefore execute concurrently with each other, bounded only by
163+
the shared threadpool size.
161164

162165
---
163166

docs/architecture/02-chaser-organize.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -469,8 +469,13 @@ Exit paths:
469469
470470
### 6.2 Header milestone tracking (`chaser_header` only)
471471

472-
A *milestone* is a configured `(hash, height)` pair that fixes the chain.
473-
Functionally similar to a checkpoint but mutable per node settings.
472+
A *milestone* is a configured `(hash, height)` pair. Unlike a
473+
checkpoint, a milestone does **not** fix the chain — the node can
474+
still reorganise around it. What a milestone *does* is allow the
475+
**bypass of validation** of all blocks up to the milestone height,
476+
*if* the milestone is found in the active candidate chain. So a
477+
milestone is an operational optimisation gated on the node's own
478+
configuration, not a consensus commitment.
474479

475480
State: `active_milestone_height_` is the height of the *most recent
476481
milestone observed on the current candidate*. Initialised by
@@ -493,8 +498,17 @@ and:
493498
> the candidate is reorganized below the milestone (a rare event), and
494499
> only via `update_milestone`.
495500
496-
`chaser_block` skips milestones entirely. The full block already carries
497-
enough state to validate without the heuristic.
501+
`chaser_block` skips milestones entirely, for a different reason than
502+
"the full block carries enough state". The blocks-first design has no
503+
PoW guard before archival: a peer can flood the node with full
504+
blocks, and without an upstream headers-first chain to gate which
505+
blocks are *worth* the work, the node has no cheap way to refuse
506+
malicious blocks short of validating them. (One could imagine running
507+
headers-first internally by downloading every block and stripping its
508+
txs — but that is prohibitively expensive and redundant with running
509+
headers-first directly.) So in blocks-first mode, **every block must
510+
be validated before archival**, full stop; bypass-on-milestone would
511+
defeat the only DoS guard the mode has.
498512

499513
---
500514

@@ -523,8 +537,8 @@ store-corruption error). For a formal model, each is a proof obligation:
523537
| `organize13` | `ipp:428-432` | disorganize: `get_candidate_chain_state(fork_point)` after rebuild returned null |
524538
| `organize14` | `ipp:515` (in `push_block`) | `set_organized` failed after a successful `set_code` (we archived but couldn't push to candidate) |
525539
| `organize15` | `ipp:521-523` (in `push_block(key)`)| Tree extract returned no handle (item was missing when expected) |
526-
| `stalled_channel` | `ipp:471-475` (in `set_organized`, NDEBUG-only check) | Candidate height isn't `top+1` (broken sequencing) |
527-
| `suspended_channel`| `ipp:477-483` (in `set_organized`, NDEBUG-only check) | Parent of new candidate isn't current top (broken sequencing) |
540+
| `stalled_channel` | `ipp:471-475` (in `set_organized`, debug-only `!NDEBUG` check) | Candidate height isn't `top+1` (broken sequencing). Redundant safety check; release builds skip it. |
541+
| `suspended_channel`| `ipp:477-483` (in `set_organized`, debug-only `!NDEBUG` check) | Parent of new candidate isn't current top (broken sequencing). Redundant safety check; release builds skip it. |
528542

529543
> **Spec obligation list.** A formal model should be able to discharge
530544
> `organize2` through `organize15` as unreachable, given:
@@ -570,8 +584,11 @@ store-corruption error). For a formal model, each is a proof obligation:
570584
factoring in §4.1.
571585

572586
- `tree_` is naturally a `hash-table` keyed by header hash. The DoS
573-
concern (`§6.1 TODO`) can be enforced by a size cap with a
574-
least-recently-used eviction.
587+
concern flagged at `§6.1 TODO` is real but the obvious mitigation
588+
(an LRU eviction cap) is **not** the right answer — that would open
589+
a new DoS vector where an attacker forces eviction of legitimate
590+
weak branches. The right fix is more subtle and not solved here;
591+
treat the unbounded-tree assumption as load-bearing.
575592

576593
- `update_milestone` walks `tree_` by parent-hash chain — straightforward
577594
recursion.

docs/architecture/03-chaser-check.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,19 @@ generation. It:
330330

331331
`get_inventory_size` (`chaser_check.cpp:534-543`):
332332

333-
- Returns 0 if no connections OR if the *confirmed* chain isn't current
334-
(so no inventory work issues until the node is reasonably caught up).
333+
- Returns 0 if no connections OR if `is_current(false)` — i.e. the
334+
**candidate** (header) chain isn't current.
335335
- Otherwise: `ceilinged_divide(unassociated_count_above(fork, step), connections)`.
336336

337+
The candidate-current gate is *not* "wait until caught up" — issuing
338+
zero work until the node is caught up would just stall (you can't get
339+
caught up without downloading). Instead it guards against dividing
340+
work over a *weak* header chain: until headers are current, the
341+
partitioning of unassociated heights into per-peer inventory chunks
342+
could be meaningless or wrong, so block download is paused until
343+
header sync has produced a current (and therefore presumed
344+
near-canonical) candidate.
345+
337346
> **Invariant (Check-Inventory-1).** `inventory_` is computed at most
338347
> once (latch on first nonzero result). Stored in
339348
> `chaser_check.cpp:496-498`. This is intentional: peer inventory size is

docs/architecture/04-chaser-validate.md

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,28 @@
1717
> - it emits `chase::valid(height)` on success or `chase::unvalid(link)`
1818
> on failure
1919
>
20-
> This is the chaser most directly relevant to **formal verification**: it
21-
> is the single source of consensus acceptance, and the only place script
22-
> execution and UTXO availability are checked before confirmation.
20+
> This is the chaser most directly relevant to **formal verification**:
21+
> it is where script execution and prevout (UTXO availability) checks
22+
> happen before confirmation. Consensus is **not** confined to this
23+
> chaser however — it is split across several stages:
24+
>
25+
> - **Headers** (`chaser_header::validate`) — context-free header
26+
> consensus (proof-of-work, version, etc.) plus limited-context checks.
27+
> - **Blocks at receive time** (`protocol_block_in_31800::check`,
28+
> `chaser_block::validate` in blocks-first mode) — context-free
29+
> block-level consensus (size, sigops, commitments) and limited
30+
> context checks (no prevouts yet).
31+
> - **This chaser** — full block consensus with prevouts populated:
32+
> `block.accept(ctx, …)` (block-wide rules) and `block.connect(ctx)`
33+
> (script execution per input).
34+
> - **`chaser_confirm`** — block-relative order-based consensus checks
35+
> via `query.block_confirmable(link)`: previous-output is confirmed in
36+
> a "strong" tx, maturity, relative-locktime rules (see
37+
> [`05 §10.4`](05-chaser-confirm.md#104-the-utxo-oracle)).
38+
> - **Transactions** (planned) — same shape, not yet implemented.
39+
>
40+
> So this chaser is the **largest single block of consensus work** and
41+
> the natural focal point of a formal model, but not the sole source.
2342
2443
| File | Role |
2544
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |

docs/architecture/05-chaser-confirm.md

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -442,8 +442,8 @@ All terminal (call `fault`, suspend network).
442442
| `confirm10` | `chaser_confirm.cpp:298` | `roll_back` failed |
443443
| `confirm11` | `chaser_confirm.cpp:308` | `set_filter_head` failed before `set_block_confirmable` |
444444
| `confirm12` | `chaser_confirm.cpp:314` | `set_block_confirmable` failed |
445-
| `suspended_channel` | `chaser_confirm.cpp:379` (NDEBUG-only)| `confirmed_height != top+1` in `set_organized` — sequencing bug |
446-
| `suspended_service` | `chaser_confirm.cpp:387` (NDEBUG-only)| `to_parent(link) != to_confirmed(previous_height)` — parent mismatch |
445+
| `suspended_channel` | `chaser_confirm.cpp:379` (debug-only check, `!NDEBUG`) | `confirmed_height != top+1` in `set_organized` — sequencing bug. Redundant safety check; no effect in release builds. |
446+
| `suspended_service` | `chaser_confirm.cpp:387` (debug-only check, `!NDEBUG`) | `to_parent(link) != to_confirmed(previous_height)` — parent mismatch. Redundant safety check; no effect in release builds. |
447447

448448
> **Spec obligation list.** As with organize/validate, every `confirmN`
449449
> is unreachable under store-consistency invariants plus the strand
@@ -528,23 +528,55 @@ chaser_confirm : Process
528528
no stall when `chase::valid` arrived during the in-progress
529529
iteration.
530530

531-
### 10.4 The UTXO oracle
531+
### 10.4 What `query.block_confirmable(link)` actually checks
532532

533-
`query.block_confirmable(link)` is the UTXO double-spend check. Its
534-
correctness is the responsibility of libbitcoin-database. For a formal
535-
model, treat it as:
533+
This is a **significant consensus operation**, not a narrow
534+
double-spend probe. It evaluates *all block-relative, order-based
535+
consensus constraints* on a block — everything except header chaining
536+
and the chain-summation rules (cumulative work, MTP). Specifically:
537+
538+
1. **Strong-tx association for every spent prevout.** For every input,
539+
the previous output must be in a *strong* transaction — i.e. a tx
540+
that is associated to a block which is either (a) confirmed, or (b)
541+
in the confirmable candidate fork at *lesser* height than the
542+
spending block. This is the property that subsumes the "is the
543+
prevout in the UTXO set?" question, expressed in a tx→block
544+
association model rather than a separate UTXO snapshot.
545+
546+
2. **Coinbase maturity** (BIP34 / 100-confirmation rule for spending
547+
coinbase outputs).
548+
549+
3. **Relative locktime rules** (BIP68 sequence locks).
550+
551+
These checks also exist on the `system::chain` objects (for
552+
completeness, e.g. for stand-alone validation tools), but driving them
553+
there requires populating each input's metadata first. The store
554+
optimizes by performing the queries that *would have populated that
555+
metadata*, directly — much more efficient than populate-then-check.
556+
557+
So `block_confirmable` is correctly read as: *"under the assumption
558+
that all lower-height blocks in this fork are confirmable, is this
559+
block consensus-valid against all order-sensitive rules?"* Its
560+
correctness is the joint responsibility of the libbitcoin-database
561+
query implementation and the consensus rules it encodes; a formal
562+
model should treat the call as a non-trivial proof obligation, not a
563+
thin UTXO oracle.
564+
565+
For a model:
536566

537567
```
538-
block_confirmable(link, store_state) →
539-
Right(()) if every input refers to a UTXO present in store_state,
540-
and double-spend checks pass
568+
block_confirmable(link, fork, store_state) →
569+
Right(()) if for every input in block(link):
570+
- prevout tx is strong under (store_state ∪ fork[< height])
571+
- coinbase maturity satisfied
572+
- relative-locktime constraints satisfied
573+
- no double-spends within fork[≤ height]
541574
Left(error_code) otherwise
542575
```
543576

544-
The chaser sequences calls so that `store_state` at the moment of
545-
`block_confirmable(link)` reflects all blocks confirmed below `link`'s
546-
height in this fork (because `set_block_confirmable` for prior heights
547-
has already run by the loop ordering at `chaser_confirm.cpp:230-275`).
577+
The chaser sequences calls so that prior fork blocks have already had
578+
`set_block_confirmable` written by the time `block_confirmable(link)`
579+
runs for this block (loop ordering at `chaser_confirm.cpp:230-275`).
548580

549581
---
550582

docs/architecture/06-sessions-and-protocols.md

Lines changed: 39 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ to protocols.
3939

4040
### 1.1 Session hierarchy
4141

42+
`session_peer<NetworkSession>` is a class template whose
43+
`NetworkSession` parameter is instantiated separately for each
44+
concrete session: `network::session_outbound`,
45+
`network::session_inbound`, and `network::session_manual`. The
46+
template inherits *from its parameter* (the network base) and *also*
47+
from `node::session` (the mixin) — so each instantiation produces a
48+
different concrete network-base parent. The class diagram below shows
49+
the outbound instantiation explicitly; the inbound and manual
50+
instantiations are structurally identical, parameterised on their
51+
respective `network::session_*` base.
52+
4253
```mermaid
4354
classDiagram
4455
class network_session["network::session"] {
@@ -58,11 +69,13 @@ classDiagram
5869
class network_session_outbound["network::session_outbound"]
5970
class network_session_inbound["network::session_inbound"]
6071
class network_session_manual["network::session_manual"]
61-
class session_peer["session_peer&lt;NetworkSession&gt; (template)"] {
72+
class session_peer_out["session_peer&lt;network::session_outbound&gt;"] {
6273
+create_channel (override)
6374
+attach_handshake (override)
6475
+attach_protocols (override)
6576
}
77+
class session_peer_in["session_peer&lt;network::session_inbound&gt;"]
78+
class session_peer_man["session_peer&lt;network::session_manual&gt;"]
6679
class session_outbound
6780
class session_inbound {
6881
+enabled() override
@@ -72,14 +85,21 @@ classDiagram
7285
network_session <|-- network_session_outbound
7386
network_session <|-- network_session_inbound
7487
network_session <|-- network_session_manual
75-
network_session_outbound <|-- session_peer
76-
node_session <|-- session_peer
77-
session_peer <|-- session_outbound
78-
session_peer <|-- session_inbound
79-
session_peer <|-- session_manual
80-
81-
note for session_peer "Multiply derived:\n• node::session for chaser/bus access\n• network::session_* for socket lifecycle"
82-
note for node_session "All methods forward to full_node"
88+
89+
network_session_outbound <|-- session_peer_out
90+
network_session_inbound <|-- session_peer_in
91+
network_session_manual <|-- session_peer_man
92+
93+
node_session <|-- session_peer_out
94+
node_session <|-- session_peer_in
95+
node_session <|-- session_peer_man
96+
97+
session_peer_out <|-- session_outbound
98+
session_peer_in <|-- session_inbound
99+
session_peer_man <|-- session_manual
100+
101+
note for session_peer_out "Three template instantiations,\nidentical except for which\nnetwork::session_* is the\nnetwork-side base."
102+
note for node_session "Mixin: all methods\nforward to full_node."
83103
```
84104

85105
The `node::session` mixin (`src/sessions/session.cpp:35-160`) is **pure
@@ -115,10 +135,16 @@ bool session_inbound::enabled() const NOEXCEPT
115135
> **Invariant (Session-Inbound-1).** Inbound connection attempts are
116136
> rejected (the network layer disables the listener) until either
117137
> `delay_inbound == false` *or* the confirmed chain is "recent". The
118-
> definition of "recent" is the same as `full_node::is_recent` — top
119-
> equals configured max height *or* top timestamp is within the
120-
> `currency_window` (`src/full_node.cpp:415-425`). This prevents a
121-
> not-yet-caught-up node from serving stale data.
138+
> definition of "recent" is `full_node::is_recent` — top equals
139+
> configured max height *or* top timestamp is within the
140+
> `currency_window` (`src/full_node.cpp:415-425`). Note that **recent
141+
> is weaker than current**: "recent" considers the configured
142+
> `node.maximum_height`, so a node deliberately limited to a fixed
143+
> height (typically for testing) can activate inbound service at that
144+
> ceiling even though it would never satisfy the time-based
145+
> "currentness" test. This prevents a not-yet-caught-up node from
146+
> serving stale data in normal deployments while still allowing
147+
> bounded-height test deployments.
122148
123149
This is implemented via `enabled()` rather than the bus
124150
`suspend`/`resume` mechanism so that the listener has independent

docs/architecture/08-block-out-protocols.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -324,14 +324,15 @@ When `superseded_` flips true:
324324
325325
### 3.3 Why `superseded_` is `std::atomic_bool`
326326

327-
`superseded_` is written in `handle_receive_send_headers` (channel
328-
strand) and read in `handle_event` (bus subscriber's strand, which
329-
posts back to channel strand for actual processing). In practice both
330-
are the channel strand — see
331-
[`06 §3.1`](06-sessions-and-protocols.md#31-event-subscription-protocol)
332-
on subscription posting back to channel strand. The atomic is
333-
defensive; a non-atomic `bool` would likely be sound, but the cost is
334-
negligible.
327+
`protocol_block_out_70012::superseded()` is **`protected`**, so the
328+
derived class (`protocol_block_out_70012`) exposes read access to
329+
its own base (`protocol_block_out_106::handle_event`) which uses it
330+
as the supersede gate. The base reads `superseded()` from its bus
331+
handler context; the derived class writes `superseded_` from its
332+
`handle_receive_send_headers` channel handler. Making the flag
333+
atomic allows that read to happen **without** posting through the
334+
channel strand — i.e. the gate is non-stranded by design, and the
335+
atomic is what makes that safe.
335336

336337
---
337338

docs/architecture/09-filter-out-70015.md

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -282,14 +282,19 @@ The pattern exists because of two constraints:
282282
create unbounded queueing.
283283

284284
Unsubscribing for the duration of the stream **serializes** requests
285-
without explicit locks: a peer's second `get_client_filters` arrives
286-
when there is no handler for it, which the libbitcoin-network channel
287-
treats as a *protocol violation* and drops the peer.
288-
289-
> **Invariant (Filter-Stream-4).** A peer that issues a second
290-
> `get_client_filters` before the first completes will be dropped by
291-
> the channel layer (not by this protocol). This effectively makes
292-
> `getcfilters` request/response exclusive per channel.
285+
without explicit locks. A peer's second `get_client_filters` arriving
286+
while the first is in flight has no handler registered; the
287+
libbitcoin-network channel currently **ignores** unhandled messages
288+
(this may be tightened in future). The serializing effect therefore
289+
comes from the protocol simply not seeing the second request until it
290+
re-subscribes — not from peer drops.
291+
292+
> **Invariant (Filter-Stream-4).** While streaming, any additional
293+
> `get_client_filters` from this peer is dropped on the floor (no
294+
> handler). The first request completes; only after re-subscription
295+
> can another arrive. `getcfilters` is therefore *effectively*
296+
> serialized per channel, even though no explicit lock is held and
297+
> no peer-drop policy enforces it.
293298
294299
---
295300

0 commit comments

Comments
 (0)