Skip to content

spv: parallel genesis header download over checkpoint segments - #378

Open
xanimo wants to merge 12 commits into
dogecoinfoundation:0.1.5-devfrom
xanimo:0.1.5-dev-par-genesis-headers
Open

spv: parallel genesis header download over checkpoint segments#378
xanimo wants to merge 12 commits into
dogecoinfoundation:0.1.5-devfrom
xanimo:0.1.5-dev-par-genesis-headers

Conversation

@xanimo

@xanimo xanimo commented Aug 2, 2026

Copy link
Copy Markdown
Member

Initial header sync requests headers serially from a single peer, so a genesis
sync is bounded by one peer's latency for the whole chain.

This splits the range into checkpoint-delimited segments and downloads them
concurrently across connected peers, then stitches them in order. Segments are
validated against the checkpoint that bounds them, so a peer that lies about a
segment is caught at the boundary rather than after the fact.

To make the segmentation useful the checkpoint table is densified from 33 to 87
entries at 100k-block intervals. Sparse checkpoints mean long unbounded
segments, which is exactly where a malicious peer has room to work.

Serial sync remains the fallback when fewer peers are connected than segments.

xanimo added 2 commits August 1, 2026 11:28
Fill all gaps larger than 100k blocks in the mainnet checkpoint array
using block hashes queried from local Dogecoin Core 1.14.99 via RPC.

Previous latest checkpoint: height 5,400,000 (Sep 2024).
New latest checkpoint:      height 6,100,000 (Jan 2026).

New entries cover heights 100k–6.1M at 100k intervals, reducing the
maximum headers-sync range on a fresh -p (checkpoint) sync from
~840k blocks to ~140k blocks.

(cherry picked from commit efd12f5)
Header sync from genesis is serial: one peer, 2000 headers per round
trip, several million headers. The chainparams checkpoint array already
partitions the chain into intervals whose endpoints are known-good block
hashes, so those intervals can be fetched independently and stitched back
together in order.

Segment i covers the open-closed height range (checkpoint[i-1],
checkpoint[i]], with segment 0 anchored at the genesis hash. Each is
assigned to a different peer, buffered as raw 80-byte headers, and
flushed into the primary headers DB strictly in ascending segment order
so the on-disk chain is never written out of sequence. On mainnet the
current array yields 86 segments.

Because every segment is bounded by a checkpoint at both ends, the chain
is validated by construction, so the flush path sets two new headers DB
flags: skip_pow, which skips the per-header scrypt verification that the
checkpoint anchors already guarantee, and batch_write, which suppresses
the per-record fdatasync in favour of a single commit at the end. Both
default off and are only set for the duration of a segment flush.

Enabled with -H/--genesis_headers; without it nothing changes, since
every hook is guarded on par_hdr->active. dogecoin_net_spv_request_headers
returns early while a parallel sync is running so the serial path cannot
interleave, handshake completion assigns a segment instead of issuing a
getheaders, and incoming headers are routed to the owning segment.

par_hdr_skip_auxpow advances past the AUXPoW chain data that follows the
standard header for merge-mined blocks. It deliberately does not call
check_auxpow: the segment endpoints are checkpoints, so the work is
already accounted for, and running scrypt over millions of headers is the
cost this feature exists to avoid.

Independent of compact block filters -- the implementation makes no
reference to filter state.

78/78, and -H yields 86 segments with the expected height ranges
(0: 1..100000, 1: 100001..104679).
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 2, 2026
Wires the BIP157 message flow into the SPV client: getcfcheckpt to pin
the peer's checkpoints, getcfheaders to build and validate the filter
header chain, getcfilters to fetch filters, GCS matching against watched
scripts, and a getdata for the blocks that matched.

Filter headers are persisted through the cfheaders/cfilters DBs added in
the previous layer, so a restart resumes from the stored tip rather than
re-downloading. Cached filters are rescanned at startup, which covers
scripts registered after the filters were stored.

BIP37 and BIP157 are mutually exclusive here, by design and for privacy:
a bloom filter tells the peer which scripts are being watched, which is
the disclosure compact filters exist to avoid. Compact filters are on by
default and dogecoin_spv_client_filterload fails closed while they are,
so a BIP37 consumer opts out explicitly via
dogecoin_spv_enable_compact_filters(client, false). The test asserts both
halves of that.

headersdb gains a block-hash-by-height lookup with a resume cursor.
cfheaders batches request ascending heights, and rescanning the whole
file per lookup is O(N^2) over a 6.2M-record file; resuming from the last
match keeps it linear.

Deliberately not included, to keep this reviewable and because each
belongs elsewhere:

  - parallel cfilter/cfheaders download. The state fields live in the
    compact_filter layer; the logic lands in its own PR. Every path here
    takes the sequential branch.
  - the par_hdr parallel genesis header download, which is dogecoinfoundation#378.
  - skip_pow and the stored-hash-on-load shortcut. Both were in the
    prototype branch, both weaken what header loading verifies, and
    neither is needed by filter sync. They belong with the bulk-load work
    where their trust assumptions can be reviewed as a unit. Header
    loading here is unchanged from 0.1.5-dev.

Report the height range actually covered on completion. cf_scan_start_height
was only set on one of the two scan-start paths, and the log falls back to 1
when it is unset, so a run that covered the checkpoint tail reported
"scanned heights 1..tip" -- overstating coverage in exactly the way that
hides a gap.

Verified against a local 1.14.99 node serving BIP157 with the corrected
cfilter wire order, run with an isolated HOME so the real filter cache was
untouched: 1442 cfilters parsed off the wire, full
getcfcheckpt/getcfheaders/getcfilters round trip, zero validation errors or
misbehaviour, and the completion line now reports 6314395..6315836 rather
than 1..6315836.

That run does not exercise GCS matching: -a is not wired to the filter
state until the CLI layer, so no scripts were watched. The matching block
is byte-identical to the prototype's sequential arm, which was separately
confirmed to match at a predicted height over the wire, and the unit tests
cover GCS matching directly -- but this layer's own end-to-end run proves
the wire path, not the match path.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 2, 2026
Connects the BIP157 client to the command line, which is what makes the
feature reachable by a user rather than only by the library API.

  -a/--address        registers the address as a watched script with the
                      filter state, so cfilters are matched against it
  -e/--no_cfilters    disables compact filters, the documented opt-out
                      for a consumer that wants BIP37 instead
  -o                  logs filter header checkpoints during sync
  --cfheaders_path    override cfheaders.dat location
  --cfilters_path     override cfilters.dat location

wallet gains dogecoin_wallet_add_watchonly_addr so a watched address is
persisted without a private key. It follows the existing add-address path
exactly, including that dogecoin_address_to_pubkey_hash returns a pointer
into a static buffer and is not freed by its existing caller either.

Deliberately excluded, because each is the seam with another feature and
not the CLI's to own:

  --cf_from_genesis, --genesis_headers, --filter_hash_db, --cf_workers

--genesis_headers is the clearest case. It calls
dogecoin_spv_client_enable_genesis_headers, which initialises par_hdr
*and* resets cf_start_height and clears the CF databases -- one function
spanning parallel header download and compact filters. Exposing it here
would pull dogecoinfoundation#378 into this PR. The peer-list replication that gave each
parallel worker its own TCP connection goes with them.

End to end against a local 1.14.99 node serving BIP157, run with an
isolated HOME so the real filter cache was untouched:

  [bip157] watching address: DJoQKLWJtM251NhzsR7DmbgT88TwqBbPX7
  [bip157] MATCH at height 6315000
  [bip157] all filters processed: scanned heights 6314418..6315859,
           1 matched blocks
  [bip157] requesting 1 matched full blocks across 1 peers
  [bip157] processing 2 txs from matched block height=6315000

1442 cfilters parsed off the wire, zero validation errors. The address was
taken from block 6315000 via getrawtransaction before the run, so the
matched height was predicted rather than read back afterwards.

This closes the gap left open in the spv layer, where -a was not yet wired
to the filter state and so GCS matching was never exercised end to end.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 3, 2026
Wires the BIP157 message flow into the SPV client: getcfcheckpt to pin
the peer's checkpoints, getcfheaders to build and validate the filter
header chain, getcfilters to fetch filters, GCS matching against watched
scripts, and a getdata for the blocks that matched.

Filter headers are persisted through the cfheaders/cfilters DBs added in
the previous layer, so a restart resumes from the stored tip rather than
re-downloading. Cached filters are rescanned at startup, which covers
scripts registered after the filters were stored.

BIP37 and BIP157 are mutually exclusive here, by design and for privacy:
a bloom filter tells the peer which scripts are being watched, which is
the disclosure compact filters exist to avoid. Compact filters are on by
default and dogecoin_spv_client_filterload fails closed while they are,
so a BIP37 consumer opts out explicitly via
dogecoin_spv_enable_compact_filters(client, false). The test asserts both
halves of that.

headersdb gains a block-hash-by-height lookup with a resume cursor.
cfheaders batches request ascending heights, and rescanning the whole
file per lookup is O(N^2) over a 6.2M-record file; resuming from the last
match keeps it linear.

Deliberately not included, to keep this reviewable and because each
belongs elsewhere:

  - parallel cfilter/cfheaders download. The state fields live in the
    compact_filter layer; the logic lands in its own PR. Every path here
    takes the sequential branch.
  - the par_hdr parallel genesis header download, which is dogecoinfoundation#378.
  - skip_pow and the stored-hash-on-load shortcut. Both were in the
    prototype branch, both weaken what header loading verifies, and
    neither is needed by filter sync. They belong with the bulk-load work
    where their trust assumptions can be reviewed as a unit. Header
    loading here is unchanged from 0.1.5-dev.

Report the height range actually covered on completion. cf_scan_start_height
was only set on one of the two scan-start paths, and the log falls back to 1
when it is unset, so a run that covered the checkpoint tail reported
"scanned heights 1..tip" -- overstating coverage in exactly the way that
hides a gap.

Verified against a local 1.14.99 node serving BIP157 with the corrected
cfilter wire order, run with an isolated HOME so the real filter cache was
untouched: 1442 cfilters parsed off the wire, full
getcfcheckpt/getcfheaders/getcfilters round trip, zero validation errors or
misbehaviour, and the completion line now reports 6314395..6315836 rather
than 1..6315836.

That run does not exercise GCS matching: -a is not wired to the filter
state until the CLI layer, so no scripts were watched. The matching block
is byte-identical to the prototype's sequential arm, which was separately
confirmed to match at a predicted height over the wire, and the unit tests
cover GCS matching directly -- but this layer's own end-to-end run proves
the wire path, not the match path.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 3, 2026
Connects the BIP157 client to the command line, which is what makes the
feature reachable by a user rather than only by the library API.

  -a/--address        registers the address as a watched script with the
                      filter state, so cfilters are matched against it
  -e/--no_cfilters    disables compact filters, the documented opt-out
                      for a consumer that wants BIP37 instead
  -o                  logs filter header checkpoints during sync
  --cfheaders_path    override cfheaders.dat location
  --cfilters_path     override cfilters.dat location

wallet gains dogecoin_wallet_add_watchonly_addr so a watched address is
persisted without a private key. It follows the existing add-address path
exactly, including that dogecoin_address_to_pubkey_hash returns a pointer
into a static buffer and is not freed by its existing caller either.

Deliberately excluded, because each is the seam with another feature and
not the CLI's to own:

  --cf_from_genesis, --genesis_headers, --filter_hash_db, --cf_workers

--genesis_headers is the clearest case. It calls
dogecoin_spv_client_enable_genesis_headers, which initialises par_hdr
*and* resets cf_start_height and clears the CF databases -- one function
spanning parallel header download and compact filters. Exposing it here
would pull dogecoinfoundation#378 into this PR. The peer-list replication that gave each
parallel worker its own TCP connection goes with them.

End to end against a local 1.14.99 node serving BIP157, run with an
isolated HOME so the real filter cache was untouched:

  [bip157] watching address: DJoQKLWJtM251NhzsR7DmbgT88TwqBbPX7
  [bip157] MATCH at height 6315000
  [bip157] all filters processed: scanned heights 6314418..6315859,
           1 matched blocks
  [bip157] requesting 1 matched full blocks across 1 peers
  [bip157] processing 2 txs from matched block height=6315000

1442 cfilters parsed off the wire, zero validation errors. The address was
taken from block 6315000 via getrawtransaction before the run, so the
matched height was predicted rather than read back afterwards.

This closes the gap left open in the spv layer, where -a was not yet wired
to the filter state and so GCS matching was never exercised end to end.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
@xanimo
xanimo force-pushed the 0.1.5-dev-par-genesis-headers branch from d1776fe to 711fdf8 Compare August 5, 2026 21:04
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Wires the BIP157 message flow into the SPV client: getcfcheckpt to pin
the peer's checkpoints, getcfheaders to build and validate the filter
header chain, getcfilters to fetch filters, GCS matching against watched
scripts, and a getdata for the blocks that matched.

Filter headers are persisted through the cfheaders/cfilters DBs added in
the previous layer, so a restart resumes from the stored tip rather than
re-downloading. Cached filters are rescanned at startup, which covers
scripts registered after the filters were stored.

BIP37 and BIP157 are mutually exclusive here, by design and for privacy:
a bloom filter tells the peer which scripts are being watched, which is
the disclosure compact filters exist to avoid. Compact filters are on by
default and dogecoin_spv_client_filterload fails closed while they are,
so a BIP37 consumer opts out explicitly via
dogecoin_spv_enable_compact_filters(client, false). The test asserts both
halves of that.

headersdb gains a block-hash-by-height lookup with a resume cursor.
cfheaders batches request ascending heights, and rescanning the whole
file per lookup is O(N^2) over a 6.2M-record file; resuming from the last
match keeps it linear.

Deliberately not included, to keep this reviewable and because each
belongs elsewhere:

  - parallel cfilter/cfheaders download. The state fields live in the
    compact_filter layer; the logic lands in its own PR. Every path here
    takes the sequential branch.
  - the par_hdr parallel genesis header download, which is dogecoinfoundation#378.
  - skip_pow and the stored-hash-on-load shortcut. Both were in the
    prototype branch, both weaken what header loading verifies, and
    neither is needed by filter sync. They belong with the bulk-load work
    where their trust assumptions can be reviewed as a unit. Header
    loading here is unchanged from 0.1.5-dev.

Report the height range actually covered on completion. cf_scan_start_height
was only set on one of the two scan-start paths, and the log falls back to 1
when it is unset, so a run that covered the checkpoint tail reported
"scanned heights 1..tip" -- overstating coverage in exactly the way that
hides a gap.

Verified against a local 1.14.99 node serving BIP157 with the corrected
cfilter wire order, run with an isolated HOME so the real filter cache was
untouched: 1442 cfilters parsed off the wire, full
getcfcheckpt/getcfheaders/getcfilters round trip, zero validation errors or
misbehaviour, and the completion line now reports 6314395..6315836 rather
than 1..6315836.

That run does not exercise GCS matching: -a is not wired to the filter
state until the CLI layer, so no scripts were watched. The matching block
is byte-identical to the prototype's sequential arm, which was separately
confirmed to match at a predicted height over the wire, and the unit tests
cover GCS matching directly -- but this layer's own end-to-end run proves
the wire path, not the match path.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Wires the BIP157 message flow into the SPV client: getcfcheckpt to pin
the peer's checkpoints, getcfheaders to build and validate the filter
header chain, getcfilters to fetch filters, GCS matching against watched
scripts, and a getdata for the blocks that matched.

Filter headers are persisted through the cfheaders/cfilters DBs added in
the previous layer, so a restart resumes from the stored tip rather than
re-downloading. Cached filters are rescanned at startup, which covers
scripts registered after the filters were stored.

BIP37 and BIP157 are mutually exclusive here, by design and for privacy:
a bloom filter tells the peer which scripts are being watched, which is
the disclosure compact filters exist to avoid. Compact filters are on by
default and dogecoin_spv_client_filterload fails closed while they are,
so a BIP37 consumer opts out explicitly via
dogecoin_spv_enable_compact_filters(client, false). The test asserts both
halves of that.

headersdb gains a block-hash-by-height lookup with a resume cursor.
cfheaders batches request ascending heights, and rescanning the whole
file per lookup is O(N^2) over a 6.2M-record file; resuming from the last
match keeps it linear.

Deliberately not included, to keep this reviewable and because each
belongs elsewhere:

  - parallel cfilter/cfheaders download. The state fields live in the
    compact_filter layer; the logic lands in its own PR. Every path here
    takes the sequential branch.
  - the par_hdr parallel genesis header download, which is dogecoinfoundation#378.
  - skip_pow and the stored-hash-on-load shortcut. Both were in the
    prototype branch, both weaken what header loading verifies, and
    neither is needed by filter sync. They belong with the bulk-load work
    where their trust assumptions can be reviewed as a unit. Header
    loading here is unchanged from 0.1.5-dev.

Report the height range actually covered on completion. cf_scan_start_height
was only set on one of the two scan-start paths, and the log falls back to 1
when it is unset, so a run that covered the checkpoint tail reported
"scanned heights 1..tip" -- overstating coverage in exactly the way that
hides a gap.

Verified against a local 1.14.99 node serving BIP157 with the corrected
cfilter wire order, run with an isolated HOME so the real filter cache was
untouched: 1442 cfilters parsed off the wire, full
getcfcheckpt/getcfheaders/getcfilters round trip, zero validation errors or
misbehaviour, and the completion line now reports 6314395..6315836 rather
than 1..6315836.

That run does not exercise GCS matching: -a is not wired to the filter
state until the CLI layer, so no scripts were watched. The matching block
is byte-identical to the prototype's sequential arm, which was separately
confirmed to match at a predicted height over the wire, and the unit tests
cover GCS matching directly -- but this layer's own end-to-end run proves
the wire path, not the match path.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Connects the BIP157 client to the command line, which is what makes the
feature reachable by a user rather than only by the library API.

  -a/--address        registers the address as a watched script with the
                      filter state, so cfilters are matched against it
  -e/--no_cfilters    disables compact filters, the documented opt-out
                      for a consumer that wants BIP37 instead
  -o                  logs filter header checkpoints during sync
  --cfheaders_path    override cfheaders.dat location
  --cfilters_path     override cfilters.dat location

wallet gains dogecoin_wallet_add_watchonly_addr so a watched address is
persisted without a private key. It follows the existing add-address path
exactly, including that dogecoin_address_to_pubkey_hash returns a pointer
into a static buffer and is not freed by its existing caller either.

Deliberately excluded, because each is the seam with another feature and
not the CLI's to own:

  --cf_from_genesis, --genesis_headers, --filter_hash_db, --cf_workers

--genesis_headers is the clearest case. It calls
dogecoin_spv_client_enable_genesis_headers, which initialises par_hdr
*and* resets cf_start_height and clears the CF databases -- one function
spanning parallel header download and compact filters. Exposing it here
would pull dogecoinfoundation#378 into this PR. The peer-list replication that gave each
parallel worker its own TCP connection goes with them.

End to end against a local 1.14.99 node serving BIP157, run with an
isolated HOME so the real filter cache was untouched:

  [bip157] watching address: DJoQKLWJtM251NhzsR7DmbgT88TwqBbPX7
  [bip157] MATCH at height 6315000
  [bip157] all filters processed: scanned heights 6314418..6315859,
           1 matched blocks
  [bip157] requesting 1 matched full blocks across 1 peers
  [bip157] processing 2 txs from matched block height=6315000

1442 cfilters parsed off the wire, zero validation errors. The address was
taken from block 6315000 via getrawtransaction before the run, so the
matched height was predicted rather than read back afterwards.

This closes the gap left open in the spv layer, where -a was not yet wired
to the filter state and so GCS matching was never exercised end to end.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Wires the BIP157 message flow into the SPV client: getcfcheckpt to pin
the peer's checkpoints, getcfheaders to build and validate the filter
header chain, getcfilters to fetch filters, GCS matching against watched
scripts, and a getdata for the blocks that matched.

Filter headers are persisted through the cfheaders/cfilters DBs added in
the previous layer, so a restart resumes from the stored tip rather than
re-downloading. Cached filters are rescanned at startup, which covers
scripts registered after the filters were stored.

BIP37 and BIP157 are mutually exclusive here, by design and for privacy:
a bloom filter tells the peer which scripts are being watched, which is
the disclosure compact filters exist to avoid. Compact filters are on by
default and dogecoin_spv_client_filterload fails closed while they are,
so a BIP37 consumer opts out explicitly via
dogecoin_spv_enable_compact_filters(client, false). The test asserts both
halves of that.

headersdb gains a block-hash-by-height lookup with a resume cursor.
cfheaders batches request ascending heights, and rescanning the whole
file per lookup is O(N^2) over a 6.2M-record file; resuming from the last
match keeps it linear.

Deliberately not included, to keep this reviewable and because each
belongs elsewhere:

  - parallel cfilter/cfheaders download. The state fields live in the
    compact_filter layer; the logic lands in its own PR. Every path here
    takes the sequential branch.
  - the par_hdr parallel genesis header download, which is dogecoinfoundation#378.
  - skip_pow and the stored-hash-on-load shortcut. Both were in the
    prototype branch, both weaken what header loading verifies, and
    neither is needed by filter sync. They belong with the bulk-load work
    where their trust assumptions can be reviewed as a unit. Header
    loading here is unchanged from 0.1.5-dev.

Report the height range actually covered on completion. cf_scan_start_height
was only set on one of the two scan-start paths, and the log falls back to 1
when it is unset, so a run that covered the checkpoint tail reported
"scanned heights 1..tip" -- overstating coverage in exactly the way that
hides a gap.

Verified against a local 1.14.99 node serving BIP157 with the corrected
cfilter wire order, run with an isolated HOME so the real filter cache was
untouched: 1442 cfilters parsed off the wire, full
getcfcheckpt/getcfheaders/getcfilters round trip, zero validation errors or
misbehaviour, and the completion line now reports 6314395..6315836 rather
than 1..6315836.

That run does not exercise GCS matching: -a is not wired to the filter
state until the CLI layer, so no scripts were watched. The matching block
is byte-identical to the prototype's sequential arm, which was separately
confirmed to match at a predicted height over the wire, and the unit tests
cover GCS matching directly -- but this layer's own end-to-end run proves
the wire path, not the match path.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Connects the BIP157 client to the command line, which is what makes the
feature reachable by a user rather than only by the library API.

  -a/--address        registers the address as a watched script with the
                      filter state, so cfilters are matched against it
  -e/--no_cfilters    disables compact filters, the documented opt-out
                      for a consumer that wants BIP37 instead
  -o                  logs filter header checkpoints during sync
  --cfheaders_path    override cfheaders.dat location
  --cfilters_path     override cfilters.dat location

wallet gains dogecoin_wallet_add_watchonly_addr so a watched address is
persisted without a private key. It follows the existing add-address path
exactly, including that dogecoin_address_to_pubkey_hash returns a pointer
into a static buffer and is not freed by its existing caller either.

Deliberately excluded, because each is the seam with another feature and
not the CLI's to own:

  --cf_from_genesis, --genesis_headers, --filter_hash_db, --cf_workers

--genesis_headers is the clearest case. It calls
dogecoin_spv_client_enable_genesis_headers, which initialises par_hdr
*and* resets cf_start_height and clears the CF databases -- one function
spanning parallel header download and compact filters. Exposing it here
would pull dogecoinfoundation#378 into this PR. The peer-list replication that gave each
parallel worker its own TCP connection goes with them.

End to end against a local 1.14.99 node serving BIP157, run with an
isolated HOME so the real filter cache was untouched:

  [bip157] watching address: DJoQKLWJtM251NhzsR7DmbgT88TwqBbPX7
  [bip157] MATCH at height 6315000
  [bip157] all filters processed: scanned heights 6314418..6315859,
           1 matched blocks
  [bip157] requesting 1 matched full blocks across 1 peers
  [bip157] processing 2 txs from matched block height=6315000

1442 cfilters parsed off the wire, zero validation errors. The address was
taken from block 6315000 via getrawtransaction before the run, so the
matched height was predicted rather than read back afterwards.

This closes the gap left open in the spv layer, where -a was not yet wired
to the filter state and so GCS matching was never exercised end to end.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Wires the BIP157 message flow into the SPV client: getcfcheckpt to pin
the peer's checkpoints, getcfheaders to build and validate the filter
header chain, getcfilters to fetch filters, GCS matching against watched
scripts, and a getdata for the blocks that matched.

Filter headers are persisted through the cfheaders/cfilters DBs added in
the previous layer, so a restart resumes from the stored tip rather than
re-downloading. Cached filters are rescanned at startup, which covers
scripts registered after the filters were stored.

BIP37 and BIP157 are mutually exclusive here, by design and for privacy:
a bloom filter tells the peer which scripts are being watched, which is
the disclosure compact filters exist to avoid. Compact filters are on by
default and dogecoin_spv_client_filterload fails closed while they are,
so a BIP37 consumer opts out explicitly via
dogecoin_spv_enable_compact_filters(client, false). The test asserts both
halves of that.

headersdb gains a block-hash-by-height lookup with a resume cursor.
cfheaders batches request ascending heights, and rescanning the whole
file per lookup is O(N^2) over a 6.2M-record file; resuming from the last
match keeps it linear.

Deliberately not included, to keep this reviewable and because each
belongs elsewhere:

  - parallel cfilter/cfheaders download. The state fields live in the
    compact_filter layer; the logic lands in its own PR. Every path here
    takes the sequential branch.
  - the par_hdr parallel genesis header download, which is dogecoinfoundation#378.
  - skip_pow and the stored-hash-on-load shortcut. Both were in the
    prototype branch, both weaken what header loading verifies, and
    neither is needed by filter sync. They belong with the bulk-load work
    where their trust assumptions can be reviewed as a unit. Header
    loading here is unchanged from 0.1.5-dev.

Report the height range actually covered on completion. cf_scan_start_height
was only set on one of the two scan-start paths, and the log falls back to 1
when it is unset, so a run that covered the checkpoint tail reported
"scanned heights 1..tip" -- overstating coverage in exactly the way that
hides a gap.

Verified against a local 1.14.99 node serving BIP157 with the corrected
cfilter wire order, run with an isolated HOME so the real filter cache was
untouched: 1442 cfilters parsed off the wire, full
getcfcheckpt/getcfheaders/getcfilters round trip, zero validation errors or
misbehaviour, and the completion line now reports 6314395..6315836 rather
than 1..6315836.

That run does not exercise GCS matching: -a is not wired to the filter
state until the CLI layer, so no scripts were watched. The matching block
is byte-identical to the prototype's sequential arm, which was separately
confirmed to match at a predicted height over the wire, and the unit tests
cover GCS matching directly -- but this layer's own end-to-end run proves
the wire path, not the match path.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
xanimo added a commit to xanimo/libdogecoin that referenced this pull request Aug 5, 2026
Connects the BIP157 client to the command line, which is what makes the
feature reachable by a user rather than only by the library API.

  -a/--address        registers the address as a watched script with the
                      filter state, so cfilters are matched against it
  -e/--no_cfilters    disables compact filters, the documented opt-out
                      for a consumer that wants BIP37 instead
  -o                  logs filter header checkpoints during sync
  --cfheaders_path    override cfheaders.dat location
  --cfilters_path     override cfilters.dat location

wallet gains dogecoin_wallet_add_watchonly_addr so a watched address is
persisted without a private key. It follows the existing add-address path
exactly, including that dogecoin_address_to_pubkey_hash returns a pointer
into a static buffer and is not freed by its existing caller either.

Deliberately excluded, because each is the seam with another feature and
not the CLI's to own:

  --cf_from_genesis, --genesis_headers, --filter_hash_db, --cf_workers

--genesis_headers is the clearest case. It calls
dogecoin_spv_client_enable_genesis_headers, which initialises par_hdr
*and* resets cf_start_height and clears the CF databases -- one function
spanning parallel header download and compact filters. Exposing it here
would pull dogecoinfoundation#378 into this PR. The peer-list replication that gave each
parallel worker its own TCP connection goes with them.

End to end against a local 1.14.99 node serving BIP157, run with an
isolated HOME so the real filter cache was untouched:

  [bip157] watching address: DJoQKLWJtM251NhzsR7DmbgT88TwqBbPX7
  [bip157] MATCH at height 6315000
  [bip157] all filters processed: scanned heights 6314418..6315859,
           1 matched blocks
  [bip157] requesting 1 matched full blocks across 1 peers
  [bip157] processing 2 txs from matched block height=6315000

1442 cfilters parsed off the wire, zero validation errors. The address was
taken from block 6315000 via getrawtransaction before the run, so the
matched height was predicted rather than read back afterwards.

This closes the gap left open in the spv layer, where -a was not yet wired
to the filter state and so GCS matching was never exercised end to end.

WITH_NET=ON with -DBUILD_SHARED_LIBS=1: 81/81. WITH_NET=OFF: 74/74.
@xanimo
xanimo marked this pull request as draft August 6, 2026 20:28
xanimo added 2 commits August 6, 2026 13:35
Segments were handed out by a monotonic next_assign cursor and node_id was
only cleared on completion, so a peer that disconnected or went quiet
mid-segment held it permanently. Once the cursor reached num_segs no new
peer could be given work, and because par_hdr_flush only advances across
contiguously complete segments, one stuck segment blocked every finished
segment above it. dogecoin_net_spv_request_headers stays short-circuited
while par_hdr->active, so there was no fallback either: the sync parked
silently with no getheaders on the wire.

Observed on mainnet: started across several peers, decayed to one, stopped
at height 500000 with no further requests.

Segments now carry requested_at, stamped in par_hdr_send_getheaders. A new
par_hdr_reclaim, called from the periodic statecheck, releases any
incomplete segment whose owner is no longer connected or has not answered
within PAR_HDR_SEG_TIMEOUT, then offers free segments to every connected
peer that is not already working one. A released segment keeps its buffered
headers and tip_hash, so the next owner resumes from the last header
received rather than restarting the range.

par_hdr_assign now selects the lowest-index segment that is neither
complete nor owned instead of advancing a cursor, which is what makes
released segments reachable again; next_assign is dropped.

Adds a stall warning: if flush_idx has not advanced in PAR_HDR_STALL_WARN
seconds, log flush position, assigned count, and how many segments are
complete but blocked, so a stuck ordered flush is visible rather than
looking like an idle node.
par_hdr_flush marked a segment flushed and moved on, leaving seg->buf
resident until par_hdr_free at teardown, and par_hdr_assign handed out
segments without regard to how far ahead of flush_idx it had run. A mainnet
log with one stalled segment shows the consequence: the flush parked at
segment 5 while peers were being assigned segment 45, every completed
segment in between staged in memory. In the limit that is the entire header
chain resident, roughly 470 MB of raw headers plus allocator slack.

Free the staging buffer as soon as its headers are on disk, and only hand
out segments within PAR_HDR_MAX_LEAD of the flush point so a single slow or
released segment cannot pin the rest of the chain in RAM. A flushed segment
is complete, so neither par_hdr_recv nor par_hdr_assign can select it
again; the growth path is hardened anyway so a zero cap cannot silently
fail to grow.

Also stop printing a raw epoch in the headers log line. It subtracts
client->last_headersrequest_time, which only the serial path arms, so in
parallel mode it rendered as "took 1786047392 s". The timestamp is
deliberately left unarmed: it drives a per-node stall check that would
misbehave every connected peer at once if a parallel sync went quiet.
@xanimo
xanimo force-pushed the 0.1.5-dev-par-genesis-headers branch from 711fdf8 to 9ef2f7b Compare August 6, 2026 20:36
@xanimo
xanimo marked this pull request as ready for review August 6, 2026 20:36
xanimo added 5 commits August 6, 2026 14:31
Two problems left after the previous commit, both visible in a mainnet run
that stalled at height 4303965.

The reclaim added earlier only releases a segment whose owner is gone or
past the response deadline. An owner that is alive and answering, just far
slower than its peers, is left alone -- correctly by that rule, and yet it
is the worst case. Because the flush is strictly ordered, the flush-head
owner gates everything: in the stalled run node 16 held segment 58 at
roughly a sixth of the rate the other peers were managing, four segments
above it completed during a single capture window, and their owners went
idle with 22 peers connected and three doing work.

Track headers per second per segment from assignment, and once past a grace
window compare the flush head against the median of the other active
segments. If it is slower by PAR_HDR_SLOW_FACTOR and some connected peer is
free, release it and hand it over. Resuming from tip_hash means nothing
already downloaded is refetched. Preemption is limited to the flush head
because that is the only position where one peer's rate bounds the whole
sync, and requires an idle peer so a release cannot make things worse.

The lead limit introduced with the buffer free was a fixed segment count,
which is the wrong unit: the mainnet checkpoint arrays were merged at
different intervals, so segments run from a couple thousand headers to a
hundred thousand. Twenty-four segments is somewhere between 10 MB and
190 MB depending where in the chain the flush sits, so it bounds neither
memory nor peer utilisation. Track staged bytes directly and refuse new
assignments past PAR_HDR_MAX_BUFFERED instead. The flush head is exempt --
refusing it would deadlock, since nothing can drain while it is unowned.
A full mainnet sync on this branch finished all 86 segments in 1786s, but
85 of them took about 10 minutes and the 86th took 20. One peer held the
last segment at ~39 hdr/s against an aggregate of ~8700 while seventeen
others sat idle.

Two things caused that.

par_hdr_preempt_head already judges the flush head against its peers, and
it works -- it fired four times early in the run. But it starts with

    if (n < 3) return;   /* too few samples to call anything an outlier */

where n counts other segments currently downloading. At the tail those
are all complete, their node_id is -1, par_hdr_seg_rate() returns 0 for
each, and n falls to 0. The check disables itself exactly when the head
is the only segment left, which is when it is the only thing that would
help. The same run shows node 16 preempted off segment 6 for running at
222 hdr/s against a median of 1400, then later taking segment 70 and
keeping it for twenty minutes, because by then nothing could judge it.

The median seen while a crowd exists is now kept in rate_ref and used as
the comparison when fewer than three segments are in flight.

Second, a segment has one owner and cannot be subdivided once assigned,
so parallelism is bounded by remaining segments rather than by peers.
When idle peers strictly outnumber incomplete segments there is real
spare capacity going unused: par_hdr_race_tail attaches a second peer to
a segment nearest the flush head, and whichever answers first takes
ownership. The buffered headers belong to the segment, not the peer, so
the swap costs nothing already downloaded, and racing only starts once
there is genuinely nothing else for those peers to do.

Not addressed here: splitting a straggler's remaining range across
several peers. That means inserting into the segment array, and
flush_idx, the per-segment buffers and the node-to-segment lookups all
key off those indices, so it wants its own change.
Racing two peers on one segment exposed a gap in the ingest path: it
appended every batch at seg->count without checking that the batch
actually continues from what the segment already holds.

A getheaders is answered relative to the locator sent at request time. So
when a shadow wins a race and takes the segment forward, the loser's
reply is still in flight, carrying headers from below the new tip. Those
were appended anyway, leaving a discontinuity in the middle of the buffer
that only surfaced at flush:

    [par-hdr] racing segment 63: node 2 shadowing node 4 (resuming at 4830001)
    [par-hdr] segment 63: shadow node 2 won the race from node 4
    [par-hdr] segment 63: first connect failure at j=32000

Three segments failed that way in a full mainnet run.

Compare the first header's prev_block against the hash the segment
expects next -- tip_hash, or start_hash when nothing is buffered yet --
and drop the whole batch on a mismatch. The next getheaders re-requests
from the right place, so nothing is lost.

Worth having even without racing. It also rejects a peer that answers
with something other than the continuation it was asked for, which the
ingest path previously accepted on trust.

Re-run with the guard: 86/86 segments, 33 races, 28 won by the shadow,
11 batches dropped as stale, and zero connect failures.
The first racing implementation required idle_count > incomplete before
it would shadow anything. A full mainnet run showed how late that is: the
first race fired at log line 14397 of 19653, roughly three quarters of
the way through. Until then every peer that finished its segment early
sat idle while mid-run segments crawled, because with twenty segments
outstanding and twenty peers the condition could not be satisfied.

Any idle peer is spare capacity. The question is only which segment is
worth spending it on, so the trigger is now a single idle peer plus a
segment whose owner is running below the reference rate.

Below the reference, not far below it: preemption already handles clear
outliers by taking the segment away, and racing is the cheaper
intervention because the original owner keeps working. Owners keeping
pace are left alone, so this does not duplicate bandwidth on segments
that are progressing normally. Where no reference rate exists yet the
in-flight segments are averaged, so racing can engage early in a run
rather than waiting for the first crowd-derived median.

The log line now carries the observed rate and the reference that
justified the decision, so a run shows whether the threshold is picking
sensible targets.
The parallel header download is anchored on checkpoints, so the chain
past the last one is fetched sequentially with PoW verification on. That
tail is now the dominant cost of a full sync: measured at 120,027 headers
taking anywhere from 168s to 1,280s across runs, against 300-500s for the
6.2M headers ahead of it.

Adds three entries, continuing the array's existing spacing:

  6,250,000  2026-06-15  ~50 days behind the tip at time of writing
  6,275,000  2026-07-04  ~32 days behind
  6,300,000  2026-07-22  ~15 days behind

That shortens the sequential tail from 120,027 headers to about 21,600,
and takes the downloader from 86 segments to 89.

On recency. The array's previous last entry was 87 days old, which is
roughly the release cadence, so periodic extension is the established
practice here rather than a new one. The bound worth keeping is not to
pin blocks recent enough that a legitimate reorg would be refused: a
client that rejects the honest chain is a worse outcome than a slow sync.
6,300,000 sits about two weeks back, which keeps that margin on a
merge-mined chain while removing most of the tail.

Every entry was checked against two independent sources before being
trusted. The first two were read from a locally synced chain whose
headers were PoW-verified during the sync, since they lie beyond the
previous last checkpoint; the extraction was validated by reading the
same fields for 6,201,360 and 6,191,600 and reproducing the hashes,
timestamps and nBits already in this array exactly. All three were then
confirmed against a third-party explorer, using 6,300,000 and 6,201,360
as controls -- both returned byte-identical hashes and nBits.
@xanimo
xanimo force-pushed the 0.1.5-dev-par-genesis-headers branch from c097e02 to 6627282 Compare August 7, 2026 04:08
The checkpoint arrays were declared with hardcoded bounds that did not
match what chainparams.c defines:

    chainparams.h    dogecoin_mainnet_checkpoint_array[87]
    libdogecoin.h    dogecoin_mainnet_checkpoint_array[33]
    chainparams.c    90 entries

Every caller counted them with sizeof(array)/sizeof(array[0]). On an
extern array that yields whatever bound the header states, not the real
length, and it does so silently. So the parallel header downloader built
its segments from the first 87 checkpoints, and a consumer including
libdogecoin.h would have seen 33.

This was not theoretical: the checkpoints added in the previous commit
had no effect at all. The downloader still built 86 segments. It
compiled, the suite passed, and the data was simply ignored. With counts
exported from the translation unit that defines the arrays, it builds 89.

Both headers now declare the arrays unsized and export
dogecoin_mainnet_checkpoint_count / dogecoin_testnet_checkpoint_count
alongside them. Removing the bogus bounds turned every remaining misuse
into a compile error rather than leaving it silently wrong -- four more
sites in src/cli/spvnode.c, eight expressions in total, which is a fair
argument for the unsized declaration on its own.

Same shape as the chainparams struct in dogecoinfoundation#400: one object, two
declarations, drift nobody could see. sizeof on an extern array with a
declared bound cannot be trusted, and there is no such expression left in
the tree.

78/78, and the downloader reports 89 segments.
@xanimo
xanimo force-pushed the 0.1.5-dev-par-genesis-headers branch from 6627282 to 097866f Compare August 7, 2026 04:11
…to segments

par_hdr_recv() incremented seg->count for every 80-byte header it copied,
including ones dogecoin_block_header_deserialize() rejected. tip_hash and
tip_height are only advanced on success, so a bad header left the segment
describing header n-1 while count moved to n. The next batch's prev_block
check then compared against the wrong hash, and the discontinuity surfaced
at flush as a connect failure well away from its cause. The header is now
dropped along with the rest of the batch, buffered_bytes is given back, and
the segment is re-requested from tip_hash.

par_hdr_preempt_head() collected peer rates into a fixed uint32_t[64] with
the loop bounded at 64. That is not an overflow -- the bound holds -- but it
stops sampling silently, and mainnet is already at 89 segments. Above 64 in
flight the median comes from an arbitrary prefix of the peer set, and that
median is the reference every preemption and racing decision keys off, so
truncation would read as a tuning problem rather than a sampling one. Sized
to num_segs instead.

Neither is reachable in the runs measured so far: batches deserialized
cleanly, and concurrency stayed near 20 peers, well under the old bound.
Both are latent rather than observed.

78/78.
…fter 3

par_hdr_flush() marked a segment flushed, advanced flush_idx and freed its
staging buffer unconditionally, before testing whether any header in it had
failed to connect. A mid-segment connect failure therefore lost those headers
permanently -- the buffer was gone and the segment was recorded as done, so it
could never be re-requested.

The consequences compounded. With a hole in the chain the next segment's first
header could not connect either, so it took the same path, and so on until
flush_idx ran off the end and the run logged "all segments complete" over a
chain with a gap. The trailing `if (bad) break` only stopped later segments; it
did not undo the current one. Its comment said the caller handles the failure,
but `bad` was local and never returned, and the sole caller discards the return
value, so nothing upstream could tell a clean flush from a broken one.

Now a connect failure stops the inner loop at the first bad header -- everything
after it chains off a header that is not in the DB and cannot connect either --
and the segment is put back for assignment instead of being retired. It resumes
from the primary DB's chaintip rather than from start_hash, because headers
0..j-1 did connect and replaying them would fail immediately.

After PAR_HDR_MAX_FLUSH_FAILS attempts the range is not just an unlucky peer,
so the parallel downloader disables itself and the sequential path continues
from the real chaintip, verifying AUXPoW as it goes. That transition is the one
already taken at normal completion, so the handover is not a new code path.
Completed-but-unflushed segments release their buffers at that point; they will
never be flushed now, and on mainnet holding them is most of the chain.

The batch-continuity guard had to change with it. It chose its expected hash
with `seg->count ? tip_hash : start_hash`, which is correct while a segment
only ever moves forward, but wrong for a segment reset after a flush failure:
count is 0 and the DB is already past start_height, so every correctly-served
batch was compared against the wrong anchor and dropped, and the segment was
re-requested forever. tip_hash is seeded from start_hash when segments are
built and re-seeded from the chaintip on failure, so it is always the right
anchor and the ternary was redundant to begin with.

Exercised by fault injection rather than by argument: corrupting a header at
flush drives one retry that recovers and completes the chain, and a persistent
corruption drives three failures, the fallback, and a correct chain from the
sequential path. Without the anchor change the first of those livelocks.

78/78.
@xanimo
xanimo force-pushed the 0.1.5-dev-par-genesis-headers branch from 830a8f4 to 6134796 Compare August 8, 2026 04:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant