spv: parallel genesis header download over checkpoint segments - #378
Open
xanimo wants to merge 12 commits into
Open
spv: parallel genesis header download over checkpoint segments#378xanimo wants to merge 12 commits into
xanimo wants to merge 12 commits into
Conversation
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).
This was referenced Aug 2, 2026
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
force-pushed
the
0.1.5-dev-par-genesis-headers
branch
from
August 5, 2026 21:04
d1776fe to
711fdf8
Compare
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
marked this pull request as draft
August 6, 2026 20:28
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
force-pushed
the
0.1.5-dev-par-genesis-headers
branch
from
August 6, 2026 20:36
711fdf8 to
9ef2f7b
Compare
xanimo
marked this pull request as ready for review
August 6, 2026 20:36
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
force-pushed
the
0.1.5-dev-par-genesis-headers
branch
from
August 7, 2026 04:08
c097e02 to
6627282
Compare
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
force-pushed
the
0.1.5-dev-par-genesis-headers
branch
from
August 7, 2026 04:11
6627282 to
097866f
Compare
…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
force-pushed
the
0.1.5-dev-par-genesis-headers
branch
from
August 8, 2026 04:08
830a8f4 to
6134796
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.