From f99ba0e15d9f6b2c6611714b5f01f4603eca9091 Mon Sep 17 00:00:00 2001 From: xanimo Date: Sun, 7 Jun 2026 19:11:42 -0700 Subject: [PATCH 01/15] =?UTF-8?q?chainparams:=20add=2054=20checkpoints=20a?= =?UTF-8?q?t=20100k-block=20intervals=20(33=E2=86=9287=20total)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 efd12f53d4a64416a641b30e136de591dcb2242e) --- include/dogecoin/chainparams.h | 2 +- src/chainparams.c | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/include/dogecoin/chainparams.h b/include/dogecoin/chainparams.h index 87d6a1b10..9f34e7082 100644 --- a/include/dogecoin/chainparams.h +++ b/include/dogecoin/chainparams.h @@ -68,7 +68,7 @@ extern const dogecoin_chainparams dogecoin_chainparams_test; extern const dogecoin_chainparams dogecoin_chainparams_regtest; // the mainnet checkpoints, needs a fix size -extern const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[33]; +extern const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[87]; extern const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[24]; LIBDOGECOIN_API const dogecoin_chainparams* chain_from_b58_prefix(const char* address); diff --git a/src/chainparams.c b/src/chainparams.c index d7183195e..058f2eb52 100644 --- a/src/chainparams.c +++ b/src/chainparams.c @@ -88,29 +88,83 @@ const dogecoin_chainparams dogecoin_chainparams_regtest = { const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[] = { {0, "1a91e3dace36e2be3bf030a65679fe821aa1d6ef92e7c9902eb318182c355691", 1386325540, 0x1e0ffff0}, + {100000, "13ab3b961fcc500c03f51279385c42e9f055d48a37dfa72d0073c0d3f595036b", 1392346781, 0x1b267eeb}, {104679, "35eb87ae90d44b98898fec8c39577b76cb1eb08e1261cfc10706c8ce9a1d01cf", 1392637497, 0x1b41676b}, {145000, "cc47cae70d7c5c92828d3214a266331dde59087d4a39071fa76ddfff9b7bde72", 1395094679, 0x1b499dfd}, + {200000, "092fd3e76db5ff35fbfefe48d5c53ca26e799f0654a4036ddd5fd78de77418c2", 1398695540, 0x1b42fb92}, + {300000, "7c2b3b8cb1629fa014a29e85cd7b0fe7876ce3def8899ba4c0945c0de2ad7902", 1405442852, 0x1b2e13bb}, {371337, "60323982f9c5ff1b5a954eac9dc1269352835f47c2c5222691d80f0d50dcf053", 1410464577, 0x1b364184}, {450000, "d279277f8f846a224d776450aa04da3cf978991a182c6f3075db4c48b173bbd7", 1415413000, 0x1b03efda}, + {500000, "92ae1083b7b3c22fd7b4ce2eae121d518f8e1aa81d0be9432ce5aa20a2954fcc", 1418570422, 0x1b04f203}, + {600000, "caa5446c05c8e51cf7985a6cb4da4dc2b730e4ec399dc95b2a094df6bdd4f268", 1424881308, 0x1b03f04d}, + {700000, "eac853ae22d59a498386241a3de69a36739ccc9e0a6acfd617b64c5ea4a0f4b3", 1431191204, 0x1b05d766}, {771275, "1b7d789ed82cbdc640952e7e7a54966c6488a32eaad54fc39dff83f310dbaaed", 1435666139, 0x1b0444d2}, + {800000, "773fbb34e1bfe82467eb24cda8769dfdcd13a5b4dac4c8f9f6534c40301f7fbf", 1437473213, 0x1b06b43a}, + {900000, "5235e97ba5ae95c04300daed8d75dca2ed6eef9be42d433d985d3203639a4a66", 1443761100, 0x1b028fe5}, {1000000, "6aae55bea74235f0c80bd066349d4440c31f2d0f27d54265ecd484d8c1d11b47", 1450031952, 0x1b02dcf8}, + {1100000, "d48fef240e48d9395d08c2ec4fe9c6d5554091a107e1c0fddedf5db3a435d96e", 1456288963, 0x1b033eb4}, + {1200000, "1f8ef813b31ec896e3f7f064d6637e251242ba95c1bb12ca339e55b0614a53db", 1462554308, 0x1b036f1b}, {1250000, "00c7a442055c1a990e11eea5371ca5c1c02a0677b33cc88ec728c45edc4ec060", 1465690401, 0x1b045d00}, + {1300000, "80be4067b5dc8e3db9b787ca4a69c09b3478b6434edc51bc8984db1a79fa620f", 1468822277, 0x1b02fe44}, + {1400000, "18c7078848109e3d95706f639f1501550765e4675a02ddb8bfe3a703bb49fb8f", 1475060460, 0x1b039462}, {1500000, "f1d32d6920de7b617d51e74bdf4e58adccaa582ffdc8657464454f16a952fca6", 1481313507, 0x1b0306ef}, + {1600000, "ca5eb72f1e0d160f1481f74d56d7cc4a27d91aa585ba012da8018a5fe934d61b", 1487574651, 0x1b01b66a}, + {1700000, "c151a40f121a4f0ee0078e0268563c8299ad12652f939d9c6880aab9a93c1969", 1493860971, 0x1b016434}, {1750000, "5c8e7327984f0d6f59447d89d143e5f6eafc524c82ad95d176c5cec082ae2001", 1496985750, 0x1b00d4d4}, + {1800000, "c90be6e4d4dfd4f6cfa37e17afe2cb423d82a1af726f402244b4607d155bd021", 1500113228, 0x1b009c6d}, + {1900000, "6ab174805b47f1c93d69fbd37876375cacf57c075b3039561ddb05759f0853c0", 1506361934, 0x1a46b9c8}, {2000000, "9914f0e82e39bbf21950792e8816620d71b9965bdbbc14e72a95e3ab9618fea8", 1512600918, 0x1a1c1225}, {2031142, "893297d89afb7599a3c571ca31a3b80e8353f4cf39872400ad0f57d26c4c5d42", 1514549787, 0x1a15d633}, + {2100000, "4f95b8f837f139f512dd8ba26fe4dd702271394daccb29007dc52938c96ccf85", 1518833207, 0x1a08f167}, + {2200000, "7027f0e32f0566f39367580dfb9d42157608fc7ce4483bf2471beb68b3b3f26e", 1525088854, 0x1a07034c}, {2250000, "0a87a8d4e40dca52763f93812a288741806380cd569537039ee927045c6bc338", 1528215255, 0x1a053477}, + {2300000, "2f391edf30851d6ab71e1100a907d314190a15764da600e478730369db422f93", 1531345450, 0x1a04439c}, + {2400000, "41a17417233ec887dfd5950c731e64abfab5fe230353d226a6a3de580e59c3bf", 1537593624, 0x1a059c04}, + {2500000, "3352c854b77d6946fabed44b4451bc998a48dbbcc2dbc1d313f47b0e8a782d18", 1543848050, 0x1a05d53a}, {2510150, "77e3f4a4bcb4a2c15e8015525e3d15b466f6c022f6ca82698f329edef7d9777e", 1544484077, 0x1a0868af}, + {2600000, "37bd4bb2b345ca3bc7035adf8850632159d965878f7ab59fbcb4ffd382895a03", 1550097077, 0x1a05f4f3}, + {2700000, "e76b5a769f05c868c831e067946ce32221ad3e163b4bfd96ae2745233587e7cd", 1556337998, 0x1a03ef42}, {2750000, "d4f8abb835930d3c4f92ca718aaa09bef545076bd872354e0b2b85deefacf2e3", 1559459044, 0x1a0292e2}, + {2800000, "5af3d9109a9c3a75179a9a85f385ca5e045a40dcb4f716ad907beae3d5ee662b", 1562584608, 0x1a0336c7}, + {2900000, "7bc6c24835317d4944a468a27a112eae5d564fc53cf366e34c8c7452f30e09e5", 1568837724, 0x1a045469}, {3000000, "195a83b091fb3ee7ecb56f2e63d01709293f57f971ccf373d93890c8dc1033db", 1575096781, 0x1a07da10}, + {3100000, "c7970179a8433e85b13a4930f80367a05a50c1f4b8878b31cb4b0ca7506f04c1", 1581365323, 0x1a0a5662}, + {3200000, "1026822a0313b2466ddc13c251dd98072430708cc53c56e43e71dd9ab0625437", 1587656622, 0x1a0786ac}, {3250000, "7f3e28bf9e309c4b57a4b70aa64d3b2ea5250ae797af84976ddc420d49684034", 1590799169, 0x1a05bfa3}, + {3300000, "76f7f333faf010117d9ca7dd0c6200456dcbffa03b31dee66541966347589b8b", 1593941349, 0x1a050d2a}, + {3400000, "20588e8f8ce89cab1f894ef80faf935c631df24229a1a42bafee781d9a57a454", 1600234275, 0x1a04bf31}, {3500000, "eaa303b93c1c64d2b3a2cdcf6ccf21b10cc36626965cc2619661e8e1879abdfb", 1606543340, 0x1a08d505}, + {3600000, "3319474fec2b5cbc57df8a8b976fbbb4907d5315b23943daf58bc6ce0bba9347", 1612832557, 0x1a03ecc0}, {3606083, "954c7c66dee51f0a3fb1edb26200b735f5275fe54d9505c76ebd2bcabac36f1e", 1613218169, 0x1a03d764}, + {3700000, "d0f0af23aadcf6b8d4a681ee930e39d1e64aca967187fa8a0c655c6dacfa22ce", 1619169876, 0x1a043028}, + {3800000, "0e1a1b524408f5d3e23fc461351daf9469976679e37cfc321ab2e06a3cb4f01a", 1625505238, 0x1a03d014}, {3854173, "e4b4ecda4c022406c502a247c0525480268ce7abbbef632796e8ca1646425e75", 1628934997, 0x1a03ca36}, + {3900000, "3815e66fe50a8c9770c04306626fa6f6be7a087a5e03c0b014ffee470dbb215e", 1631833148, 0x1a032198}, {3963597, "2b6927cfaa5e82353d45f02be8aadd3bfd165ece5ce24b9bfa4db20432befb5d", 1635884460, 0x1a037bc9}, + {4000000, "47f227cd54c270aa18c5136635fc003a11cb82d6f8319dd8dab180a2f5555a9e", 1638207091, 0x1a0350f2}, + {4100000, "8112b8a11dd3d1965cab6ccc0175abca62ec6b1225ad303e72e94efac635007e", 1644587549, 0x1a02450e}, + {4200000, "93da94c346ff5299ff400be4c1008aae99e9b70ef74db22820e42049ce14c6b4", 1650989758, 0x1a03efe7}, + {4300000, "337d3b4bf937e2f9513e064b58253c9d0f188b7b928d570b07c03651d47e250f", 1657392921, 0x1a0461ea}, {4303965, "ed7d266dcbd8bb8af80f9ccb8deb3e18f9cc3f6972912680feeb37b090f8cee0", 1657646310, 0x1a0344f5}, + {4400000, "367bdf79d7c527a3f79430d72cf62a8201ba52edd608410a6da1b1ab7311e04d", 1663792227, 0x1a028e13}, + {4500000, "66d27fdeb0f694d06d1276b2d3d72c4dbefe294651ddc1ade220951823243396", 1670200610, 0x1a020cfc}, + {4600000, "75323db271259899e66cbf4cfab33dcc034527e34bdc75b9a70e1b2deec1aad1", 1676603347, 0x1a016fd5}, + {4700000, "748844093ba9fd6ba002ed2d0c9857c0292f1fb4aeb949d2fa8f0d438a18387f", 1682991374, 0x1a014ec5}, + {4800000, "a1e58e27594bbc04f9c021c9f73771852260fce5e09e86071d34c74a8a116d01", 1689360368, 0x1a01b637}, + {4900000, "8bc08384ae8f17e0891159f3cd5783c07a62bd37c3a67d8d5e1bd292addced46", 1695779125, 0x1a02888f}, + {5000000, "5ae6eadfe5fd98fa7f388b671c4863db4b62e46eecc054465a276d419500e469", 1702188235, 0x1a010be6}, {5050000, "e7d4577405223918491477db725a393bcfc349d8ee63b0a4fde23cbfbfd81dea", 1705383360, 0x1a019541}, + {5100000, "a60110809b403aecd06185ec73753ddfd21dbc243e82c5ef76c88648d1b7b718", 1708571081, 0x1a01114a}, + {5200000, "06381a6241f120951a2f751851f50320e059b084ebc639c6b9d7d8b076d1f0bc", 1714956041, 0x1a01a709}, + {5300000, "8ccc7ebe3d4a97bfdf1993145114f654c537a9b06bc89bb12beabc954fb3110f", 1721347471, 0x1a00f17c}, {5400000, "cbb1f4ae807da83e13bdf9c28188982938c9ee6bf560c1023f51adac229eef87", 1727704957, 0x0106DAC9}, + {5500000, "a2c15e69513051e6c01cce388718efa7a803f52766e6dc91624812e32250c56e", 1734026997, 0x1a00d1c5}, + {5600000, "39c02091aa7b8ad483a87957b12bdf4c7c6ebcc5dfe2c4648c4d235b40961c8d", 1740360175, 0x1a0093c1}, + {5700000, "28ef0c3aa091c839048bba16bcee4df442961e2bc4e42bda407906f71e5b594d", 1746679266, 0x1a00adee}, + {5800000, "8d1540c92ec87451d73573fec3720ca7e835e630538096e3e11c56dec8205e2e", 1753007429, 0x1a00d815}, + {5900000, "9eb4809b6bf358a5bf9fbddf82da9ff4f047ef76577d2939efabefc7bd6ced07", 1759340677, 0x1a00801a}, + {6000000, "7af46caeb390c15e5d92b4aa58854b55351b43fe5fc714e359b31d0ce019a187", 1765676839, 0x192fa398}, + {6100000, "bdeff3efe99a8203ad2baae74e50c29f5158227ff03d0bb7ffa3ff9fbd25dee3", 1772013218, 0x1a0082ee}, {6148124, "0f4d009c402553dbec50012ace7e224018595e4b788d1af78cea72eb6401d959", 1775065954, 0x1a00b3ec}, {6154988, "8ca6972c34a2154b6ae6f15996de8d1a794477ec0add439ee1279bd9a21b2eed", 1775506602, 0x1a008107}, {6156000, "e8257a8929faef98929dc4e006bf405ec0fdfa93cb29b6d336a04e6b4d7bd02e", 1775571096, 0x1a0088a2}, From 0169e8e83264dc99b453c0c73b5f8b97d755b8f2 Mon Sep 17 00:00:00 2001 From: bluezr Date: Sat, 1 Aug 2026 12:12:23 -0700 Subject: [PATCH 02/15] spv: parallel genesis header download over checkpoint segments 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). --- include/dogecoin/headersdb_file.h | 2 + include/dogecoin/spv.h | 36 +++ src/cli/spvnode.c | 17 +- src/headersdb_file.c | 21 +- src/spv.c | 381 +++++++++++++++++++++++++++++- 5 files changed, 445 insertions(+), 12 deletions(-) diff --git a/include/dogecoin/headersdb_file.h b/include/dogecoin/headersdb_file.h index cc38e8d7b..3dd9e771c 100644 --- a/include/dogecoin/headersdb_file.h +++ b/include/dogecoin/headersdb_file.h @@ -55,6 +55,8 @@ typedef struct dogecoin_headers_db_ dogecoin_blockindex genesis; dogecoin_blockindex *chaintip; dogecoin_blockindex *chainbottom; + dogecoin_bool batch_write; /**< Suppress per-record fdatasync; caller must commit after */ + dogecoin_bool skip_pow; /**< Skip scrypt PoW verify (for checkpoint-anchored bulk loads) */ } dogecoin_headers_db; dogecoin_headers_db *dogecoin_headers_db_new(const dogecoin_chainparams* chainparams, dogecoin_bool inmem_only); diff --git a/include/dogecoin/spv.h b/include/dogecoin/spv.h index ba2bbdf49..f2437ba9b 100644 --- a/include/dogecoin/spv.h +++ b/include/dogecoin/spv.h @@ -54,12 +54,46 @@ typedef struct spv_block_sample_ uint64_t fees; // total fees } spv_block_sample; +#define PAR_HDR_RAW_LEN 80 + +/* One parallel header-download segment. Each segment spans the open-closed + * height interval (start_height, stop_height] and is assigned to one node. */ +typedef struct par_hdr_seg_ { + uint32_t start_height; /* height of start_hash (exclusive lower bound) */ + uint256_t start_hash; /* block hash at start_height — getheaders locator */ + uint32_t stop_height; /* height of stop_hash (inclusive upper bound) */ + uint256_t stop_hash; /* block hash at stop_height — getheaders hash_stop */ + + /* download progress */ + int node_id; /* assigned node (-1 = unassigned) */ + uint32_t tip_height; /* highest header received so far in this segment */ + uint256_t tip_hash; /* hash of that header (next-batch locator) */ + + /* buffered raw 80-byte block headers in ascending height order */ + uint8_t *buf; + uint32_t count; /* headers buffered */ + uint32_t cap; /* buffer capacity */ + + dogecoin_bool complete; /* all stop_height - start_height headers received */ + dogecoin_bool flushed; /* segment has been flushed into the primary DB */ +} par_hdr_seg; + +/* Top-level state for a parallel genesis header download. */ +typedef struct par_hdr_state_ { + par_hdr_seg *segs; /* ordered array of segments */ + uint32_t num_segs; /* total segment count */ + uint32_t next_assign; /* index of the next unassigned segment */ + uint32_t flush_idx; /* index of the next segment pending flush */ + dogecoin_bool active; /* download in progress */ +} par_hdr_state; + typedef struct dogecoin_spv_client_ { dogecoin_node_group *nodegroup; uint64_t last_headersrequest_time; uint64_t oldest_item_of_interest; dogecoin_bool use_checkpoints; + struct par_hdr_state_ *par_hdr; /* parallel genesis header sync state */ const dogecoin_chainparams *chainparams; int stateflags; uint64_t last_statecheck_time; @@ -130,6 +164,8 @@ typedef struct dogecoin_spv_client_ void* zk_pending_commits; } dogecoin_spv_client; +LIBDOGECOIN_API dogecoin_bool dogecoin_spv_client_enable_genesis_headers(dogecoin_spv_client *client); + LIBDOGECOIN_API dogecoin_spv_client* dogecoin_spv_client_new(const dogecoin_chainparams *params, dogecoin_bool debug, dogecoin_bool headers_memonly, dogecoin_bool use_checkpoints, dogecoin_bool full_sync, int maxnodes, const char *http_server); LIBDOGECOIN_API void dogecoin_spv_client_free(dogecoin_spv_client *client); LIBDOGECOIN_API dogecoin_bool dogecoin_spv_client_load(dogecoin_spv_client *client, const char *file_path, dogecoin_bool prompt); diff --git a/src/cli/spvnode.c b/src/cli/spvnode.c index e21e82051..e8d4c245d 100644 --- a/src/cli/spvnode.c +++ b/src/cli/spvnode.c @@ -200,6 +200,7 @@ static struct option long_options[] = { {"filtered_blocks", no_argument, NULL, 'g'}, {"select_checkpoint", no_argument, NULL, 'q'}, {"daemon", no_argument, NULL, 'z'}, + {"genesis_headers", no_argument, NULL, 'H'}, {NULL, 0, NULL, 0} }; /** @@ -216,7 +217,7 @@ static void print_usage() { print_version(); printf("Usage: spvnode (-c|continuous) (-i|--ips ) (-m[--maxpeers] ) (-f ) \ (-a|--address
) (-n|--mnemonic ) (-s|[--pass_phrase]) (-y|--encrypted_file ) \ -(-w|--wallet_file ) (-h|--headers_file ) (-l|[--no_prompt]) (-b[--full_sync]) (-p[--checkpoint]) (-k[--master_key]) (-j[--use_tpm]) \ +(-w|--wallet_file ) (-h|--headers_file ) (-l|[--no_prompt]) (-b[--full_sync]) (-H[--genesis_headers]) (-p[--checkpoint]) (-k[--master_key]) (-j[--use_tpm]) \ (-u|--http_server ) (-x|--smpv) (-g|--filtered_blocks) (-q|--select_checkpoint) (-t|--testnet) (-r|--regtest) (-d|--debug) \n"); printf("Supported commands:\n"); printf(" scan (scan blocks up to the tip, creates header.db file)\n"); @@ -470,6 +471,7 @@ int main(int argc, char* argv[]) { char* name = 0; char* headers_name = 0; dogecoin_bool full_sync = false; + dogecoin_bool genesis_headers = false; dogecoin_bool have_decl_daemon = false; dogecoin_bool prompt = true; dogecoin_bool encrypted = false; @@ -487,7 +489,7 @@ int main(int argc, char* argv[]) { data = argv[argc - 1]; /* get arguments */ - while ((opt = getopt_long_only(argc, argv, "i:ctrdsm:n:f:y:u:w:h:a:lbpzkjxgq", long_options, &long_index)) != -1) { + while ((opt = getopt_long_only(argc, argv, "i:ctrdsm:n:f:y:u:w:h:a:lbpzkjxgqH", long_options, &long_index)) != -1) { switch (opt) { case 'c': quit_when_synced = false; @@ -522,6 +524,9 @@ int main(int argc, char* argv[]) { case 'b': full_sync = true; break; + case 'H': + genesis_headers = true; + break; case 'p': use_checkpoint = true; break; @@ -715,6 +720,14 @@ int main(int argc, char* argv[]) { printf("Filtered block mode disabled (use -g/--filtered_blocks to enable)\n"); } + if (genesis_headers) { + if (!dogecoin_spv_client_enable_genesis_headers(client)) + printf("[par-hdr] warning: no checkpoints for this chain; genesis header sync unavailable\n"); + else + printf("[par-hdr] parallel genesis header download enabled (%u segments)\n", + (unsigned int)client->par_hdr->num_segs); + } + client->sync_transaction = dogecoin_wallet_check_transaction; client->sync_transaction_ctx = wallet; #endif diff --git a/src/headersdb_file.c b/src/headersdb_file.c index a851c413c..c9f07deb3 100644 --- a/src/headersdb_file.c +++ b/src/headersdb_file.c @@ -285,7 +285,8 @@ dogecoin_bool dogecoin_headers_db_write(dogecoin_headers_db* db, dogecoin_blocki ser_u256(rec, arith_to_uint256(&blockindex->chainwork)); dogecoin_block_header_serialize(rec, &blockindex->header); size_t res = fwrite(rec->str, rec->len, 1, db->headers_tree_file); - dogecoin_file_commit(db->headers_tree_file); + if (!db->batch_write) + dogecoin_file_commit(db->headers_tree_file); cstr_free(rec, true); return (res == 1); } @@ -340,14 +341,16 @@ dogecoin_blockindex * dogecoin_headers_db_connect_hdr(dogecoin_headers_db* db, s if (connect_at != NULL) { // Check the proof of work if (!is_auxpow(blockindex->header.version)) { - uint256_t hash = {0}; - cstring* s = cstr_new_sz(64); - dogecoin_block_header_serialize(s, (const dogecoin_block_header*) &blockindex->header); - dogecoin_block_header_scrypt_hash(s, &hash); - cstr_free(s, true); - if (!check_pow(&hash, blockindex->header.bits, db->params, &blockindex->chainwork)) { - printf("%s:%d:%s : non-AUX proof of work failed : %s\n", __FILE__, __LINE__, __func__, strerror(errno)); - return blockindex; + if (!db->skip_pow) { + uint256_t hash = {0}; + cstring* s = cstr_new_sz(64); + dogecoin_block_header_serialize(s, (const dogecoin_block_header*) &blockindex->header); + dogecoin_block_header_scrypt_hash(s, &hash); + cstr_free(s, true); + if (!check_pow(&hash, blockindex->header.bits, db->params, &blockindex->chainwork)) { + printf("%s:%d:%s : non-AUX proof of work failed : %s\n", __FILE__, __LINE__, __func__, strerror(errno)); + return blockindex; + } } } diff --git a/src/spv.c b/src/spv.c index ffab9d4a9..378d52bf3 100644 --- a/src/spv.c +++ b/src/spv.c @@ -426,6 +426,10 @@ static int spv_zk_buffer_pending(dogecoin_spv_client* client, static dogecoin_bool dogecoin_net_spv_node_timer_callback(dogecoin_node *node, uint64_t *now); void dogecoin_net_spv_post_cmd(dogecoin_node *node, dogecoin_p2p_msg_hdr *hdr, struct const_buffer *buf); void dogecoin_net_spv_node_handshake_done(dogecoin_node *node); +void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node *node); +void par_hdr_free(dogecoin_spv_client *client); +static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, + struct const_buffer *buf, uint32_t count); void dogecoin_node_connection_state_changed_cb(dogecoin_node *node) { if (node->nodegroup->should_connect_to_more_nodes_cb) { @@ -653,6 +657,8 @@ void dogecoin_spv_client_free(dogecoin_spv_client *client) client->merkle_match_active = false; client->merkle_match_blockindex = NULL; + + par_hdr_free(client); if (client->headers_db) { if (client->headers_db_ctx) @@ -893,6 +899,9 @@ void dogecoin_net_spv_node_request_headers_or_blocks(dogecoin_node *node, dogeco */ dogecoin_bool dogecoin_net_spv_request_headers(dogecoin_spv_client *client) { + /* Parallel genesis headers in progress -- don't interfere. */ + if (client->par_hdr && client->par_hdr->active) return true; + size_t i; dogecoin_bool new_headers_available = false; for(i = 0; i < client->nodegroup->nodes->len; ++i) @@ -992,7 +1001,10 @@ void dogecoin_net_spv_node_handshake_done(dogecoin_node *node) } } - dogecoin_net_spv_request_headers((dogecoin_spv_client*)node->nodegroup->ctx); + if (client && client->par_hdr && client->par_hdr->active) + par_hdr_assign(client, node); + else + dogecoin_net_spv_request_headers((dogecoin_spv_client*)node->nodegroup->ctx); } /** @@ -1694,6 +1706,12 @@ void dogecoin_net_spv_post_cmd(dogecoin_node *node, dogecoin_p2p_msg_hdr *hdr, s uint64_t now = time(NULL); client->nodegroup->log_write_cb("Got %d headers (took %d s) from node %d\n", amount_of_headers, now - client->last_headersrequest_time, node->nodeid); + /* Parallel genesis headers mode -- buffer into the owning segment. */ + if (client->par_hdr && client->par_hdr->active) { + par_hdr_recv(client, node, buf, amount_of_headers); + return; + } + // flag off the request stall check client->last_headersrequest_time = 0; @@ -2563,3 +2581,364 @@ LIBDOGECOIN_API dogecoin_bool dogecoin_spv_client_filterclear(dogecoin_spv_clien return true; } + + + +static uint32_t spv_elapsed(const dogecoin_spv_client *client) { + return (uint32_t)((uint64_t)time(NULL) - client->start_ts); +} + +/* ================================================================ */ +/* Parallel genesis header download */ +/* ================================================================ */ + +/* Build segments from the chainparams block-header checkpoint array. + * Segment i covers the open-closed height range (prev_checkpoint, checkpoint[i]]. + * Segment 0 starts from the genesis block hash (height 0). + * Returns a newly allocated par_hdr_state, or NULL for chains with no checkpoints. */ +static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) +{ + const dogecoin_checkpoint *arr = NULL; + size_t cnt = 0; + if (strcmp(params->chainname, "main") == 0) { + arr = dogecoin_mainnet_checkpoint_array; + cnt = sizeof(dogecoin_mainnet_checkpoint_array) / + sizeof(dogecoin_mainnet_checkpoint_array[0]); + } else if (strcmp(params->chainname, "test") == 0) { + arr = dogecoin_testnet_checkpoint_array; + cnt = sizeof(dogecoin_testnet_checkpoint_array) / + sizeof(dogecoin_testnet_checkpoint_array[0]); + } + if (!cnt) return NULL; + + /* arr[0] is the genesis entry (height=0) — it is the start anchor for the + * first segment, not an endpoint to download toward. Skip it so segment 0 + * covers the range genesis..arr[1] instead of the degenerate 0..0 range. */ + size_t start_i = (cnt > 0 && arr[0].height == 0) ? 1 : 0; + size_t num_segs = cnt - start_i; + if (!num_segs) return NULL; + + par_hdr_state *s = dogecoin_calloc(1, sizeof(par_hdr_state)); + s->segs = dogecoin_calloc(num_segs, sizeof(par_hdr_seg)); + s->num_segs = (uint32_t)num_segs; + s->active = true; + + for (uint32_t i = 0; i < (uint32_t)num_segs; i++) { + uint32_t arr_i = (uint32_t)(start_i + i); + par_hdr_seg *seg = &s->segs[i]; + + /* stop = checkpoint[arr_i] */ + seg->stop_height = arr[arr_i].height; + utils_uint256_sethex((char *)arr[arr_i].hash, seg->stop_hash); + + /* start = genesis when i==0, else checkpoint[arr_i-1] */ + if (i == 0) { + seg->start_height = 0; + memcpy(seg->start_hash, params->genesisblockhash, sizeof(uint256_t)); + } else { + seg->start_height = arr[arr_i - 1].height; + utils_uint256_sethex((char *)arr[arr_i - 1].hash, seg->start_hash); + } + + seg->node_id = -1; + seg->tip_height = seg->start_height; + memcpy(seg->tip_hash, seg->start_hash, sizeof(uint256_t)); + + seg->cap = 2048; + seg->buf = dogecoin_malloc((size_t)seg->cap * PAR_HDR_RAW_LEN); + } + return s; +} + +/* Send a getheaders request to @node for the next batch in @seg. */ +static void par_hdr_send_getheaders(dogecoin_node *node, const par_hdr_seg *seg) +{ + vector_t *locators = vector_new(1, free); + uint256_t *loc = dogecoin_calloc(1, sizeof(uint256_t)); + memcpy(loc, seg->tip_hash, sizeof(uint256_t)); + vector_add(locators, loc); + + cstring *msg = cstr_new_sz(512); + dogecoin_p2p_msg_getheaders(locators, (uint8_t *)seg->stop_hash, msg); + vector_free(locators, true); + + cstring *p2p = dogecoin_p2p_message_new( + node->nodegroup->chainparams->netmagic, + DOGECOIN_MSG_GETHEADERS, msg->str, msg->len); + cstr_free(msg, true); + dogecoin_node_send(node, p2p); + cstr_free(p2p, true); + + node->state |= NODE_HEADERSYNC; +} + +/* Assign the next unassigned segment to @node and send the first getheaders. */ +LIBDOGECOIN_API void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node *node) +{ + par_hdr_state *s = client->par_hdr; + if (!s || !s->active) return; + if (!(node->state & NODE_CONNECTED) || !node->version_handshake) return; + + /* Skip nodes already working on a segment */ + for (uint32_t i = 0; i < s->num_segs; i++) { + if (s->segs[i].node_id == (int)node->nodeid) return; + } + + if (s->next_assign >= s->num_segs) return; /* all assigned */ + + par_hdr_seg *seg = &s->segs[s->next_assign]; + seg->node_id = (int)node->nodeid; + s->next_assign++; + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] assigned node %d to segment %u (heights %u..%u)\n", + node->nodeid, s->next_assign - 1, + seg->start_height + 1, seg->stop_height); + + par_hdr_send_getheaders(node, seg); +} + +/* Flush completed segments (in order) into the primary headers DB. + * Returns the number of segments flushed. */ +static uint32_t par_hdr_flush(dogecoin_spv_client *client) +{ + par_hdr_state *s = client->par_hdr; + uint32_t flushed = 0; + + /* Batch-optimise writes: suppress per-record fdatasync and skip scrypt + * PoW verification. Segments are checkpoint-anchored so the chain is + * implicitly validated. A single fsync at the end suffices. */ + dogecoin_headers_db *hdb = (dogecoin_headers_db *)client->headers_db_ctx; + dogecoin_bool prev_batch = hdb ? hdb->batch_write : false; + dogecoin_bool prev_skip = hdb ? hdb->skip_pow : false; + if (hdb) { hdb->batch_write = true; hdb->skip_pow = true; } + + while (s->flush_idx < s->num_segs && s->segs[s->flush_idx].complete && + !s->segs[s->flush_idx].flushed) { + par_hdr_seg *seg = &s->segs[s->flush_idx]; + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] flushing segment %u (%u headers, heights %u..%u)\n", + s->flush_idx, seg->count, + seg->start_height + 1, seg->stop_height); + + uint32_t bad = 0; + for (uint32_t j = 0; j < seg->count; j++) { + struct const_buffer cbuf = { + (const void *)(seg->buf + (size_t)j * PAR_HDR_RAW_LEN), + PAR_HDR_RAW_LEN + }; + dogecoin_bool connected; + dogecoin_blockindex *pindex = + client->headers_db->connect_hdr(client->headers_db_ctx, &cbuf, false, &connected); + if (!connected) { + if (bad == 0 && client->nodegroup && client->nodegroup->log_write_cb) { + /* Decode prev_block from the raw header for diagnostics */ + const uint8_t *raw = seg->buf + (size_t)j * PAR_HDR_RAW_LEN; + /* Standard header layout: version(4) + prev_block(32) */ + char prev_hex[65] = {0}; + for (int _k = 0; _k < 32; _k++) + snprintf(prev_hex + _k*2, 3, "%02x", raw[4 + (31-_k)]); + char tip_hex[65] = {0}; + if (hdb && hdb->chaintip) + for (int _k = 0; _k < 32; _k++) + snprintf(tip_hex + _k*2, 3, "%02x", ((uint8_t*)hdb->chaintip->hash)[_k]); + client->nodegroup->log_write_cb( + "[par-hdr] segment %u: first connect failure at j=%u\n" + " chaintip height=%d hash=%s\n" + " prev_block in hdr=%s\n", + s->flush_idx, j, + hdb && hdb->chaintip ? (int)hdb->chaintip->height : -1, tip_hex, + prev_hex); + } + bad++; + dogecoin_free(pindex); /* orphan — not in DB */ + } else { + if (pindex && client->header_connected) + client->header_connected(client); + /* pindex is now db->chaintip — owned by the DB, do NOT free */ + } + } + + seg->flushed = true; + s->flush_idx++; + flushed++; + + if (bad) break; /* stop flushing if chain broke; caller handles */ + } + + if (hdb) { + hdb->batch_write = prev_batch; + hdb->skip_pow = prev_skip; + if (flushed > 0 && hdb->headers_tree_file) + dogecoin_file_commit(hdb->headers_tree_file); + } + return flushed; +} + +/* Advance @buf past the AUXPoW chain data that follows the 80-byte standard + * header in a P2P headers message for AUXPoW blocks (version & 0x100). + * Mirrors deserialize_dogecoin_auxpow_block's buffer consumption but skips + * check_auxpow — PoW is guaranteed by checkpoint anchors at segment boundaries. */ +static dogecoin_bool par_hdr_skip_auxpow(struct const_buffer *buf) { + /* parent coinbase tx (variable length) */ + size_t cb_len = 0; + dogecoin_tx *dummy = dogecoin_tx_new(); + dogecoin_bool ok = (dogecoin_bool)dogecoin_tx_deserialize(buf->p, buf->len, dummy, &cb_len); + dogecoin_tx_free(dummy); + if (!ok || cb_len == 0 || !deser_skip(buf, cb_len)) return false; + + /* parent_hash (32 bytes) */ + if (!deser_skip(buf, 32)) return false; + + /* parent merkle branch: count (varint) + count×32 bytes */ + uint32_t merkle_count = 0; + if (!deser_varlen(&merkle_count, buf)) return false; + if (merkle_count > 0 && !deser_skip(buf, (size_t)merkle_count * 32)) return false; + + /* parent_merkle_index (uint32) */ + if (!deser_skip(buf, 4)) return false; + + /* aux merkle branch: count (varint) + count×32 bytes */ + uint32_t aux_count = 0; + if (!deser_varlen(&aux_count, buf)) return false; + if (aux_count > 0 && !deser_skip(buf, (size_t)aux_count * 32)) return false; + + /* aux_merkle_index (uint32) */ + if (!deser_skip(buf, 4)) return false; + + /* parent block header: version(4) + prev_block(32) + merkle_root(32) + time(4) + bits(4) + nonce(4) */ + if (!deser_skip(buf, 80)) return false; + + return true; +} + +/* Called from the DOGECOIN_MSG_HEADERS handler when par_hdr is active. + * @buf points just past the varint that gave @count. */ +static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, + struct const_buffer *buf, uint32_t count) +{ + par_hdr_state *s = client->par_hdr; + + /* Find the segment assigned to this node */ + par_hdr_seg *seg = NULL; + uint32_t seg_idx = 0; + for (uint32_t i = 0; i < s->num_segs; i++) { + if (s->segs[i].node_id == (int)node->nodeid && !s->segs[i].complete) { + seg = &s->segs[i]; + seg_idx = i; + break; + } + } + if (!seg) { + /* Unsolicited response — discard */ + node->state &= ~NODE_HEADERSYNC; + return; + } + + /* Buffer each raw 80-byte standard header. + * + * AUXPoW blocks (version & 0x100) carry variable-length AUXPoW chain data + * between the standard 80-byte header and the 1-byte tx_count varint in the + * P2P headers message. We copy the standard 80 bytes, advance buf by 80, + * then call par_hdr_skip_auxpow to consume the AUXPoW data without running + * check_auxpow (checkpoint anchors at segment boundaries guarantee validity). */ + for (uint32_t i = 0; i < count; i++) { + if (buf->len < PAR_HDR_RAW_LEN) break; + + /* Peek at version to detect AUXPoW (wire format: little-endian int32) */ + uint32_t wire_ver; + memcpy(&wire_ver, buf->p, 4); + const dogecoin_bool is_aux = (le32toh(wire_ver) & 0x100) != 0; + + /* Grow segment buffer if needed */ + if (seg->count >= seg->cap) { + seg->cap *= 2; + seg->buf = dogecoin_realloc(seg->buf, (size_t)seg->cap * PAR_HDR_RAW_LEN); + } + + /* Copy the standard 80-byte header and advance past it */ + memcpy(seg->buf + (size_t)seg->count * PAR_HDR_RAW_LEN, buf->p, PAR_HDR_RAW_LEN); + buf->p = (const uint8_t *)buf->p + PAR_HDR_RAW_LEN; + buf->len -= PAR_HDR_RAW_LEN; + + /* For AUXPoW blocks, skip the variable-length chain data */ + if (is_aux && !par_hdr_skip_auxpow(buf)) break; + + /* Compute header hash from the buffered 80 bytes */ + dogecoin_block_header hdr; + struct const_buffer hbuf = { seg->buf + (size_t)seg->count * PAR_HDR_RAW_LEN, + PAR_HDR_RAW_LEN }; + if (dogecoin_block_header_deserialize(&hdr, &hbuf, client->chainparams, NULL)) { + dogecoin_block_header_hash(&hdr, (uint8_t *)seg->tip_hash); + seg->tip_height++; + } + seg->count++; + + /* skip tx_count varint (always 0x00 in headers messages) */ + if (buf->len > 0) { buf->p = (const uint8_t *)buf->p + 1; buf->len--; } + } + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] seg %u: buffered %u, tip_height=%u, stop=%u\n", + seg_idx, seg->count, seg->tip_height, seg->stop_height); + + if (seg->tip_height >= seg->stop_height) { + /* Segment complete */ + seg->complete = true; + seg->node_id = -1; + node->state &= ~NODE_HEADERSYNC; + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u complete (%u headers)\n", seg_idx, seg->count); + + /* Try to flush as many ordered completed segments as possible */ + par_hdr_flush(client); + + /* Re-assign this node to the next segment */ + par_hdr_assign(client, node); + + /* Check if all segments are done */ + if (s->flush_idx >= s->num_segs) { + s->active = false; + dogecoin_blockindex *tip = + client->headers_db->getchaintip(client->headers_db_ctx); + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] all segments complete — primary DB height %d [%us elapsed]\n", + tip ? tip->height : -1, spv_elapsed(client)); + } + } else { + /* More headers needed for this segment */ + par_hdr_send_getheaders(node, seg); + } +} + +/* Release all par_hdr memory. */ +LIBDOGECOIN_API void par_hdr_free(dogecoin_spv_client *client) +{ + if (!client || !client->par_hdr) return; + par_hdr_state *s = client->par_hdr; + for (uint32_t i = 0; i < s->num_segs; i++) + dogecoin_free(s->segs[i].buf); + dogecoin_free(s->segs); + dogecoin_free(s); + client->par_hdr = NULL; +} + +/* Initialise parallel genesis header download for @client. + * Builds segments from the chainparams block checkpoint array. + * Returns true on success, false if the chain has no checkpoints. */ +LIBDOGECOIN_API dogecoin_bool dogecoin_spv_client_enable_genesis_headers( + dogecoin_spv_client *client) +{ + if (!client || !client->chainparams) return false; + par_hdr_free(client); + client->par_hdr = par_hdr_init(client->chainparams); + if (!client->par_hdr) return false; + return true; +} From b898bc7a9cd1baa68ae106ad7484b042166c70b1 Mon Sep 17 00:00:00 2001 From: xanimo Date: Thu, 6 Aug 2026 20:31:25 +0000 Subject: [PATCH 03/15] spv: reclaim parallel header segments from dead or silent peers 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. --- include/dogecoin/spv.h | 12 ++-- src/spv.c | 128 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/include/dogecoin/spv.h b/include/dogecoin/spv.h index f2437ba9b..faddc2961 100644 --- a/include/dogecoin/spv.h +++ b/include/dogecoin/spv.h @@ -66,6 +66,7 @@ typedef struct par_hdr_seg_ { /* download progress */ int node_id; /* assigned node (-1 = unassigned) */ + uint64_t requested_at; /* time of the last getheaders sent for this seg */ uint32_t tip_height; /* highest header received so far in this segment */ uint256_t tip_hash; /* hash of that header (next-batch locator) */ @@ -80,11 +81,12 @@ typedef struct par_hdr_seg_ { /* Top-level state for a parallel genesis header download. */ typedef struct par_hdr_state_ { - par_hdr_seg *segs; /* ordered array of segments */ - uint32_t num_segs; /* total segment count */ - uint32_t next_assign; /* index of the next unassigned segment */ - uint32_t flush_idx; /* index of the next segment pending flush */ - dogecoin_bool active; /* download in progress */ + par_hdr_seg *segs; /* ordered array of segments */ + uint32_t num_segs; /* total segment count */ + uint32_t flush_idx; /* index of the next segment pending flush */ + uint32_t last_flush_idx; /* flush_idx at the last observed progress */ + uint64_t last_progress_time; /* time of that progress */ + dogecoin_bool active; /* download in progress */ } par_hdr_state; typedef struct dogecoin_spv_client_ diff --git a/src/spv.c b/src/spv.c index 378d52bf3..3def64b7f 100644 --- a/src/spv.c +++ b/src/spv.c @@ -187,6 +187,11 @@ static dogecoin_bool spv_log_merkle_match(const uint8_t txid[32], uint32_t pos, } static const unsigned int HEADERS_MAX_RESPONSE_TIME = 120; +/* Seconds a parallel header segment may sit without a getheaders response + before it is released back to the pool for another peer to pick up. */ +static const uint64_t PAR_HDR_SEG_TIMEOUT = 120; +/* Seconds without the ordered flush advancing before we log loudly. */ +static const uint64_t PAR_HDR_STALL_WARN = 300; static const unsigned int MIN_TIME_DELTA_FOR_STATE_CHECK = 5; static const unsigned int BLOCK_GAP_TO_DEDUCT_TO_START_SCAN_FROM = 5; static const unsigned int BLOCKS_DELTA_IN_S = 60; @@ -427,6 +432,7 @@ static dogecoin_bool dogecoin_net_spv_node_timer_callback(dogecoin_node *node, u void dogecoin_net_spv_post_cmd(dogecoin_node *node, dogecoin_p2p_msg_hdr *hdr, struct const_buffer *buf); void dogecoin_net_spv_node_handshake_done(dogecoin_node *node); void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node *node); +void par_hdr_reclaim(dogecoin_spv_client *client, uint64_t now); void par_hdr_free(dogecoin_spv_client *client); static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, struct const_buffer *buf, uint32_t count); @@ -776,6 +782,10 @@ void dogecoin_net_spv_periodic_statecheck(dogecoin_node *node, uint64_t *now) // tick regardless of how much time had actually elapsed. They are updated // only when an actual request is sent (see dogecoin_net_spv_request_headers // and the getheaders/getdata send paths). + /* Parallel genesis header sync: reclaim segments from dead or silent + peers and top up idle peers before the normal request path runs. */ + par_hdr_reclaim(client, *now); + if ((client->stateflags & SPV_HEADER_SYNC_FLAG) == SPV_HEADER_SYNC_FLAG) { dogecoin_net_spv_request_headers(client); @@ -2622,6 +2632,8 @@ static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) s->segs = dogecoin_calloc(num_segs, sizeof(par_hdr_seg)); s->num_segs = (uint32_t)num_segs; s->active = true; + s->last_flush_idx = 0; + s->last_progress_time = (uint64_t)time(NULL); for (uint32_t i = 0; i < (uint32_t)num_segs; i++) { uint32_t arr_i = (uint32_t)(start_i + i); @@ -2651,8 +2663,10 @@ static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) } /* Send a getheaders request to @node for the next batch in @seg. */ -static void par_hdr_send_getheaders(dogecoin_node *node, const par_hdr_seg *seg) +static void par_hdr_send_getheaders(dogecoin_node *node, par_hdr_seg *seg) { + seg->requested_at = (uint64_t)time(NULL); + vector_t *locators = vector_new(1, free); uint256_t *loc = dogecoin_calloc(1, sizeof(uint256_t)); memcpy(loc, seg->tip_hash, sizeof(uint256_t)); @@ -2684,17 +2698,27 @@ LIBDOGECOIN_API void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node * if (s->segs[i].node_id == (int)node->nodeid) return; } - if (s->next_assign >= s->num_segs) return; /* all assigned */ + /* Lowest-index segment that is neither complete nor currently owned. A + released segment keeps its buffered headers and tip_hash, so a new owner + resumes where the previous one stopped rather than restarting. */ + par_hdr_seg *seg = NULL; + uint32_t seg_idx = 0; + for (uint32_t i = 0; i < s->num_segs; i++) { + if (!s->segs[i].complete && s->segs[i].node_id == -1) { + seg = &s->segs[i]; + seg_idx = i; + break; + } + } + if (!seg) return; /* nothing outstanding */ - par_hdr_seg *seg = &s->segs[s->next_assign]; seg->node_id = (int)node->nodeid; - s->next_assign++; if (client->nodegroup && client->nodegroup->log_write_cb) client->nodegroup->log_write_cb( "[par-hdr] assigned node %d to segment %u (heights %u..%u)\n", - node->nodeid, s->next_assign - 1, - seg->start_height + 1, seg->stop_height); + node->nodeid, seg_idx, + seg->tip_height + 1, seg->stop_height); par_hdr_send_getheaders(node, seg); } @@ -2714,6 +2738,8 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) dogecoin_bool prev_skip = hdb ? hdb->skip_pow : false; if (hdb) { hdb->batch_write = true; hdb->skip_pow = true; } + uint32_t flush_idx_before = s->flush_idx; + while (s->flush_idx < s->num_segs && s->segs[s->flush_idx].complete && !s->segs[s->flush_idx].flushed) { par_hdr_seg *seg = &s->segs[s->flush_idx]; @@ -2775,6 +2801,11 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) if (flushed > 0 && hdb->headers_tree_file) dogecoin_file_commit(hdb->headers_tree_file); } + if (s->flush_idx != flush_idx_before) { + s->last_flush_idx = s->flush_idx; + s->last_progress_time = (uint64_t)time(NULL); + } + return flushed; } @@ -2918,6 +2949,91 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, } } +/* Look up a connected node by its nodeid, or NULL if it is gone. */ +static dogecoin_node *par_hdr_node_by_id(dogecoin_spv_client *client, int nodeid) +{ + if (!client || !client->nodegroup || !client->nodegroup->nodes) return NULL; + for (size_t i = 0; i < client->nodegroup->nodes->len; i++) { + dogecoin_node *n = vector_idx(client->nodegroup->nodes, i); + if (n && (int)n->nodeid == nodeid) return n; + } + return NULL; +} + +/* Periodic maintenance for a parallel header sync. + * + * Segments are handed out one per peer and are only cleared on completion, so + * a peer that disconnects or goes silent mid-segment would otherwise hold it + * forever. Because par_hdr_flush only advances across contiguously complete + * segments, a single stuck segment blocks every finished segment above it and + * the sync parks with no getheaders on the wire. + * + * This reclaims those segments, hands free segments to idle peers, and warns + * if the ordered flush stops advancing. + */ +LIBDOGECOIN_API void par_hdr_reclaim(dogecoin_spv_client *client, uint64_t now) +{ + if (!client || !client->par_hdr || !client->par_hdr->active) return; + par_hdr_state *s = client->par_hdr; + + /* 1. Release segments whose owner disconnected or stopped responding. */ + for (uint32_t i = 0; i < s->num_segs; i++) { + par_hdr_seg *seg = &s->segs[i]; + if (seg->complete || seg->node_id == -1) continue; + + dogecoin_node *owner = par_hdr_node_by_id(client, seg->node_id); + dogecoin_bool gone = (owner == NULL) || + !(owner->state & NODE_CONNECTED) || + !owner->version_handshake; + dogecoin_bool stalled = !gone && seg->requested_at > 0 && + now > seg->requested_at && + (now - seg->requested_at) > PAR_HDR_SEG_TIMEOUT; + + if (!gone && !stalled) continue; + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] releasing segment %u from node %d (%s); " + "%u headers kept, will resume at %u\n", + i, seg->node_id, gone ? "disconnected" : "timed out", + seg->count, seg->tip_height + 1); + + if (owner) owner->state &= ~NODE_HEADERSYNC; + seg->node_id = -1; + seg->requested_at = 0; + } + + /* 2. Hand any free segment to a peer that is not already working one. */ + if (client->nodegroup && client->nodegroup->nodes) { + for (size_t i = 0; i < client->nodegroup->nodes->len; i++) { + dogecoin_node *n = vector_idx(client->nodegroup->nodes, i); + if (!n) continue; + if (!(n->state & NODE_CONNECTED) || !n->version_handshake) continue; + par_hdr_assign(client, n); /* no-ops if n already owns a segment */ + } + } + + /* 3. Warn if the ordered flush has stopped advancing. */ + if (s->flush_idx != s->last_flush_idx) { + s->last_flush_idx = s->flush_idx; + s->last_progress_time = now; + } else if (s->last_progress_time > 0 && now > s->last_progress_time && + (now - s->last_progress_time) > PAR_HDR_STALL_WARN) { + uint32_t assigned = 0, blocked = 0; + for (uint32_t i = 0; i < s->num_segs; i++) { + if (s->segs[i].node_id != -1) assigned++; + if (s->segs[i].complete && !s->segs[i].flushed) blocked++; + } + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] WARNING: no flush progress for %us — " + "flush_idx=%u/%u, %u segments assigned, %u complete but blocked\n", + (unsigned int)(now - s->last_progress_time), + s->flush_idx, s->num_segs, assigned, blocked); + s->last_progress_time = now; /* rate-limit the warning */ + } +} + /* Release all par_hdr memory. */ LIBDOGECOIN_API void par_hdr_free(dogecoin_spv_client *client) { From 92c72169ca94a3da3732e649aade3af94ef57917 Mon Sep 17 00:00:00 2001 From: xanimo Date: Thu, 6 Aug 2026 20:34:08 +0000 Subject: [PATCH 04/15] spv: release flushed segment buffers and bound the assignment lead 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. --- src/spv.c | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/spv.c b/src/spv.c index 3def64b7f..752a2886b 100644 --- a/src/spv.c +++ b/src/spv.c @@ -192,6 +192,10 @@ static const unsigned int HEADERS_MAX_RESPONSE_TIME = 120; static const uint64_t PAR_HDR_SEG_TIMEOUT = 120; /* Seconds without the ordered flush advancing before we log loudly. */ static const uint64_t PAR_HDR_STALL_WARN = 300; +/* How far ahead of the flush point segments may be handed out. Segments are + staged in memory until the ordered flush reaches them, so an unbounded lead + lets a single slow segment pin the rest of the chain in RAM. */ +static const uint32_t PAR_HDR_MAX_LEAD = 24; static const unsigned int MIN_TIME_DELTA_FOR_STATE_CHECK = 5; static const unsigned int BLOCK_GAP_TO_DEDUCT_TO_START_SCAN_FROM = 5; static const unsigned int BLOCKS_DELTA_IN_S = 60; @@ -1714,7 +1718,15 @@ void dogecoin_net_spv_post_cmd(dogecoin_node *node, dogecoin_p2p_msg_hdr *hdr, s uint32_t amount_of_headers; if (!deser_varlen(&amount_of_headers, buf)) return; uint64_t now = time(NULL); - client->nodegroup->log_write_cb("Got %d headers (took %d s) from node %d\n", amount_of_headers, now - client->last_headersrequest_time, node->nodeid); + /* last_headersrequest_time is only armed by the serial path; in + parallel mode it stays 0 and the subtraction printed a raw epoch. + Deliberately not armed here — it drives a per-node stall check that + would misbehave every peer at once if the whole sync went quiet. */ + if (client->par_hdr && client->par_hdr->active) + client->nodegroup->log_write_cb("Got %d headers from node %d\n", + amount_of_headers, node->nodeid); + else + client->nodegroup->log_write_cb("Got %d headers (took %d s) from node %d\n", amount_of_headers, now - client->last_headersrequest_time, node->nodeid); /* Parallel genesis headers mode -- buffer into the owning segment. */ if (client->par_hdr && client->par_hdr->active) { @@ -2701,16 +2713,19 @@ LIBDOGECOIN_API void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node * /* Lowest-index segment that is neither complete nor currently owned. A released segment keeps its buffered headers and tip_hash, so a new owner resumes where the previous one stopped rather than restarting. */ + uint32_t limit = s->flush_idx + PAR_HDR_MAX_LEAD; + if (limit > s->num_segs) limit = s->num_segs; + par_hdr_seg *seg = NULL; uint32_t seg_idx = 0; - for (uint32_t i = 0; i < s->num_segs; i++) { + for (uint32_t i = s->flush_idx; i < limit; i++) { if (!s->segs[i].complete && s->segs[i].node_id == -1) { seg = &s->segs[i]; seg_idx = i; break; } } - if (!seg) return; /* nothing outstanding */ + if (!seg) return; /* nothing outstanding inside the window */ seg->node_id = (int)node->nodeid; @@ -2792,6 +2807,13 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) s->flush_idx++; flushed++; + /* The headers are on disk now — release the staging buffer. Without + this every flushed segment keeps its raw headers resident until + par_hdr_free at teardown, which on mainnet is the whole chain. */ + dogecoin_free(seg->buf); + seg->buf = NULL; + seg->cap = 0; + if (bad) break; /* stop flushing if chain broke; caller handles */ } @@ -2886,7 +2908,10 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, /* Grow segment buffer if needed */ if (seg->count >= seg->cap) { - seg->cap *= 2; + /* cap can be 0 if the buffer was released after a flush; a + flushed segment should never be written to again, but doubling + 0 would silently never grow. */ + seg->cap = seg->cap ? seg->cap * 2 : 2048; seg->buf = dogecoin_realloc(seg->buf, (size_t)seg->cap * PAR_HDR_RAW_LEN); } From 646a423581e9e8016466d590b1fbca8f82b39732 Mon Sep 17 00:00:00 2001 From: xanimo Date: Thu, 6 Aug 2026 21:29:16 +0000 Subject: [PATCH 05/15] spv: preempt a slow flush head and budget staging by bytes 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. --- include/dogecoin/spv.h | 3 + src/spv.c | 125 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/include/dogecoin/spv.h b/include/dogecoin/spv.h index faddc2961..3a8b485e3 100644 --- a/include/dogecoin/spv.h +++ b/include/dogecoin/spv.h @@ -67,6 +67,8 @@ typedef struct par_hdr_seg_ { /* download progress */ int node_id; /* assigned node (-1 = unassigned) */ uint64_t requested_at; /* time of the last getheaders sent for this seg */ + uint64_t assigned_at; /* time the current owner took this segment */ + uint32_t count_at_assign;/* headers already buffered when it took it */ uint32_t tip_height; /* highest header received so far in this segment */ uint256_t tip_hash; /* hash of that header (next-batch locator) */ @@ -86,6 +88,7 @@ typedef struct par_hdr_state_ { uint32_t flush_idx; /* index of the next segment pending flush */ uint32_t last_flush_idx; /* flush_idx at the last observed progress */ uint64_t last_progress_time; /* time of that progress */ + uint64_t buffered_bytes; /* raw header bytes staged across segments */ dogecoin_bool active; /* download in progress */ } par_hdr_state; diff --git a/src/spv.c b/src/spv.c index 752a2886b..101ab5b33 100644 --- a/src/spv.c +++ b/src/spv.c @@ -192,10 +192,17 @@ static const unsigned int HEADERS_MAX_RESPONSE_TIME = 120; static const uint64_t PAR_HDR_SEG_TIMEOUT = 120; /* Seconds without the ordered flush advancing before we log loudly. */ static const uint64_t PAR_HDR_STALL_WARN = 300; -/* How far ahead of the flush point segments may be handed out. Segments are - staged in memory until the ordered flush reaches them, so an unbounded lead - lets a single slow segment pin the rest of the chain in RAM. */ -static const uint32_t PAR_HDR_MAX_LEAD = 24; +/* Ceiling on raw header bytes staged in memory ahead of the ordered flush. + Segment sizes are uneven (the checkpoint arrays were merged at different + intervals), so a fixed segment count bounds neither memory nor peer + utilisation well; budget the thing that actually grows. */ +static const uint64_t PAR_HDR_MAX_BUFFERED = 256ull * 1024 * 1024; +/* A segment must have been owned this long before its download rate is judged; + below this the sample is noise. */ +static const uint64_t PAR_HDR_RATE_GRACE = 30; +/* The flush-head owner is preempted when its rate falls below this fraction of + the median rate across the other active segments, expressed as a divisor. */ +static const uint32_t PAR_HDR_SLOW_FACTOR = 4; static const unsigned int MIN_TIME_DELTA_FOR_STATE_CHECK = 5; static const unsigned int BLOCK_GAP_TO_DEDUCT_TO_START_SCAN_FROM = 5; static const unsigned int BLOCKS_DELTA_IN_S = 60; @@ -2713,21 +2720,24 @@ LIBDOGECOIN_API void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node * /* Lowest-index segment that is neither complete nor currently owned. A released segment keeps its buffered headers and tip_hash, so a new owner resumes where the previous one stopped rather than restarting. */ - uint32_t limit = s->flush_idx + PAR_HDR_MAX_LEAD; - if (limit > s->num_segs) limit = s->num_segs; - par_hdr_seg *seg = NULL; uint32_t seg_idx = 0; - for (uint32_t i = s->flush_idx; i < limit; i++) { + for (uint32_t i = s->flush_idx; i < s->num_segs; i++) { if (!s->segs[i].complete && s->segs[i].node_id == -1) { + /* The flush head is always allowed: refusing it would deadlock, + since nothing can drain while it is unowned. */ + if (i != s->flush_idx && s->buffered_bytes >= PAR_HDR_MAX_BUFFERED) + return; seg = &s->segs[i]; seg_idx = i; break; } } - if (!seg) return; /* nothing outstanding inside the window */ + if (!seg) return; /* nothing outstanding */ - seg->node_id = (int)node->nodeid; + seg->node_id = (int)node->nodeid; + seg->assigned_at = (uint64_t)time(NULL); + seg->count_at_assign = seg->count; if (client->nodegroup && client->nodegroup->log_write_cb) client->nodegroup->log_write_cb( @@ -2810,6 +2820,9 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) /* The headers are on disk now — release the staging buffer. Without this every flushed segment keeps its raw headers resident until par_hdr_free at teardown, which on mainnet is the whole chain. */ + uint64_t freed = (uint64_t)seg->count * PAR_HDR_RAW_LEN; + s->buffered_bytes = (s->buffered_bytes > freed) + ? s->buffered_bytes - freed : 0; dogecoin_free(seg->buf); seg->buf = NULL; seg->cap = 0; @@ -2916,6 +2929,7 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, } /* Copy the standard 80-byte header and advance past it */ + s->buffered_bytes += PAR_HDR_RAW_LEN; memcpy(seg->buf + (size_t)seg->count * PAR_HDR_RAW_LEN, buf->p, PAR_HDR_RAW_LEN); buf->p = (const uint8_t *)buf->p + PAR_HDR_RAW_LEN; buf->len -= PAR_HDR_RAW_LEN; @@ -2985,6 +2999,92 @@ static dogecoin_node *par_hdr_node_by_id(dogecoin_spv_client *client, int nodeid return NULL; } +/* Headers per second delivered by the current owner of @seg, or 0 if the + * segment has not been owned long enough for the sample to mean anything. */ +static uint32_t par_hdr_seg_rate(const par_hdr_seg *seg, uint64_t now) +{ + if (seg->node_id == -1 || seg->assigned_at == 0) return 0; + if (now <= seg->assigned_at) return 0; + uint64_t elapsed = now - seg->assigned_at; + if (elapsed < PAR_HDR_RATE_GRACE) return 0; + if (seg->count <= seg->count_at_assign) return 0; + return (uint32_t)((seg->count - seg->count_at_assign) / elapsed); +} + +/* Release the flush-head segment when its owner is alive but far slower than + * its peers and someone else is free to take over. + * + * A dead owner is handled by the timeout in par_hdr_reclaim. A merely slow + * one is not: it keeps answering inside the deadline while every segment above + * it finishes into memory and their owners go idle, because the ordered flush + * cannot advance past the head. One slow peer therefore throttles the whole + * sync. Resuming from tip_hash means preemption costs nothing already + * downloaded. + */ +static void par_hdr_preempt_head(dogecoin_spv_client *client, uint64_t now) +{ + par_hdr_state *s = client->par_hdr; + if (s->flush_idx >= s->num_segs) return; + + par_hdr_seg *head = &s->segs[s->flush_idx]; + if (head->complete || head->node_id == -1) return; + + uint32_t head_rate = par_hdr_seg_rate(head, now); + if (head_rate == 0) return; /* still inside the grace window */ + + /* Median rate across the other segments currently being downloaded. */ + uint32_t rates[64]; + uint32_t n = 0; + for (uint32_t i = 0; i < s->num_segs && n < 64; i++) { + if (i == s->flush_idx) continue; + uint32_t r = par_hdr_seg_rate(&s->segs[i], now); + if (r > 0) rates[n++] = r; + } + if (n < 3) return; /* too few samples to call anything an outlier */ + + for (uint32_t i = 1; i < n; i++) { /* insertion sort, n <= 64 */ + uint32_t v = rates[i], j = i; + while (j > 0 && rates[j - 1] > v) { rates[j] = rates[j - 1]; j--; } + rates[j] = v; + } + uint32_t median = rates[n / 2]; + + if (head_rate * PAR_HDR_SLOW_FACTOR >= median) return; /* not an outlier */ + + /* Only preempt if someone is actually free to pick it up. */ + dogecoin_node *idle = NULL; + if (client->nodegroup && client->nodegroup->nodes) { + for (size_t i = 0; i < client->nodegroup->nodes->len && !idle; i++) { + dogecoin_node *cand = vector_idx(client->nodegroup->nodes, i); + if (!cand) continue; + if (!(cand->state & NODE_CONNECTED) || !cand->version_handshake) continue; + if ((int)cand->nodeid == head->node_id) continue; + dogecoin_bool busy = false; + for (uint32_t k = 0; k < s->num_segs; k++) { + if (s->segs[k].node_id == (int)cand->nodeid) { busy = true; break; } + } + if (!busy) idle = cand; + } + } + if (!idle) return; + + dogecoin_node *owner = par_hdr_node_by_id(client, head->node_id); + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] preempting segment %u from node %d " + "(%u hdr/s vs median %u); %u headers kept, resuming at %u\n", + s->flush_idx, head->node_id, head_rate, median, + head->count, head->tip_height + 1); + + if (owner) owner->state &= ~NODE_HEADERSYNC; + head->node_id = -1; + head->requested_at = 0; + head->assigned_at = 0; + + par_hdr_assign(client, idle); +} + /* Periodic maintenance for a parallel header sync. * * Segments are handed out one per peer and are only cleared on completion, so @@ -3038,7 +3138,10 @@ LIBDOGECOIN_API void par_hdr_reclaim(dogecoin_spv_client *client, uint64_t now) } } - /* 3. Warn if the ordered flush has stopped advancing. */ + /* 3. A live but slow owner of the flush head throttles everything above it. */ + par_hdr_preempt_head(client, now); + + /* 4. Warn if the ordered flush has stopped advancing. */ if (s->flush_idx != s->last_flush_idx) { s->last_flush_idx = s->flush_idx; s->last_progress_time = now; From 3268cad1c35312a20a490d20695408a17d9fa8b2 Mon Sep 17 00:00:00 2001 From: bluezr Date: Thu, 6 Aug 2026 17:27:07 -0700 Subject: [PATCH 06/15] spv: let the parallel header tail use idle peers 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. --- include/dogecoin/spv.h | 5 ++ src/spv.c | 130 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/include/dogecoin/spv.h b/include/dogecoin/spv.h index 3a8b485e3..ca86dee48 100644 --- a/include/dogecoin/spv.h +++ b/include/dogecoin/spv.h @@ -66,6 +66,8 @@ typedef struct par_hdr_seg_ { /* download progress */ int node_id; /* assigned node (-1 = unassigned) */ + int shadow_id; /* second node racing this segment (-1 = none) */ + uint64_t shadow_at; /* time the shadow was attached */ uint64_t requested_at; /* time of the last getheaders sent for this seg */ uint64_t assigned_at; /* time the current owner took this segment */ uint32_t count_at_assign;/* headers already buffered when it took it */ @@ -89,6 +91,9 @@ typedef struct par_hdr_state_ { uint32_t last_flush_idx; /* flush_idx at the last observed progress */ uint64_t last_progress_time; /* time of that progress */ uint64_t buffered_bytes; /* raw header bytes staged across segments */ + uint32_t rate_ref; /* last median peer rate seen while >=3 segs + * were in flight; the tail has no crowd to + * compare against, so it compares to this */ dogecoin_bool active; /* download in progress */ } par_hdr_state; diff --git a/src/spv.c b/src/spv.c index 101ab5b33..6befc06a6 100644 --- a/src/spv.c +++ b/src/spv.c @@ -2672,6 +2672,8 @@ static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) } seg->node_id = -1; + seg->shadow_id = -1; + seg->shadow_at = 0; seg->tip_height = seg->start_height; memcpy(seg->tip_hash, seg->start_hash, sizeof(uint256_t)); @@ -2682,6 +2684,8 @@ static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) } /* Send a getheaders request to @node for the next batch in @seg. */ +static dogecoin_node *par_hdr_node_by_id(dogecoin_spv_client *client, int node_id); + static void par_hdr_send_getheaders(dogecoin_node *node, par_hdr_seg *seg) { seg->requested_at = (uint64_t)time(NULL); @@ -2714,7 +2718,8 @@ LIBDOGECOIN_API void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node * /* Skip nodes already working on a segment */ for (uint32_t i = 0; i < s->num_segs; i++) { - if (s->segs[i].node_id == (int)node->nodeid) return; + if (s->segs[i].node_id == (int)node->nodeid || + s->segs[i].shadow_id == (int)node->nodeid) return; } /* Lowest-index segment that is neither complete nor currently owned. A @@ -2892,7 +2897,28 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, par_hdr_seg *seg = NULL; uint32_t seg_idx = 0; for (uint32_t i = 0; i < s->num_segs; i++) { - if (s->segs[i].node_id == (int)node->nodeid && !s->segs[i].complete) { + if (s->segs[i].complete) continue; + if (s->segs[i].node_id == (int)node->nodeid) { + seg = &s->segs[i]; + seg_idx = i; + break; + } + /* A racing shadow delivering before the owner takes the segment over. + * Whichever peer answers first is by definition the faster one on this + * range, and the buffered headers are shared, so the swap costs + * nothing already downloaded. */ + if (s->segs[i].shadow_id == (int)node->nodeid) { + dogecoin_node *old_owner = par_hdr_node_by_id(client, s->segs[i].node_id); + if (old_owner) old_owner->state &= ~NODE_HEADERSYNC; + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u: shadow node %d won the race from node %d\n", + i, (int)node->nodeid, s->segs[i].node_id); + s->segs[i].node_id = (int)node->nodeid; + s->segs[i].shadow_id = -1; + s->segs[i].shadow_at = 0; + s->segs[i].assigned_at = (uint64_t)time(NULL); + s->segs[i].count_at_assign = s->segs[i].count; seg = &s->segs[i]; seg_idx = i; break; @@ -2960,6 +2986,8 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, /* Segment complete */ seg->complete = true; seg->node_id = -1; + seg->shadow_id = -1; + seg->shadow_at = 0; node->state &= ~NODE_HEADERSYNC; if (client->nodegroup && client->nodegroup->log_write_cb) @@ -3021,6 +3049,76 @@ static uint32_t par_hdr_seg_rate(const par_hdr_seg *seg, uint64_t now) * sync. Resuming from tip_hash means preemption costs nothing already * downloaded. */ + +/* Attach a second peer to an already-owned segment. + * + * Segments are the unit of parallelism and cannot be subdivided in flight, so + * once fewer segments remain than there are peers, the extra peers idle and + * the sync runs at the speed of whoever holds the last one. Preemption helps + * only if the owner is a clear outlier; a merely mediocre peer keeps the + * segment while faster ones sit unused. + * + * When there is genuine spare capacity -- strictly more idle peers than + * incomplete segments -- hand the segment to a second peer as well and take + * whichever reaches the stop height first. The cost is duplicated bandwidth on + * the last few segments; the benefit is that tail latency stops depending on + * which peer happened to draw the final segment. + */ +static void par_hdr_race_tail(dogecoin_spv_client *client, uint64_t now) +{ + par_hdr_state *s = client->par_hdr; + if (!client->nodegroup || !client->nodegroup->nodes) return; + + uint32_t incomplete = 0; + for (uint32_t i = 0; i < s->num_segs; i++) + if (!s->segs[i].complete) incomplete++; + if (incomplete == 0) return; + + /* Count peers not currently owning or shadowing anything. */ + uint32_t idle_count = 0; + for (size_t i = 0; i < client->nodegroup->nodes->len; i++) { + dogecoin_node *n = vector_idx(client->nodegroup->nodes, i); + if (!n || !(n->state & NODE_CONNECTED) || !n->version_handshake) continue; + dogecoin_bool busy = false; + for (uint32_t k = 0; k < s->num_segs && !busy; k++) + if (s->segs[k].node_id == (int)n->nodeid || + s->segs[k].shadow_id == (int)n->nodeid) busy = true; + if (!busy) idle_count++; + } + if (idle_count <= incomplete) return; /* no genuine spare capacity */ + + /* Shadow the segments nearest the flush head first: those gate everything. */ + for (uint32_t i = s->flush_idx; i < s->num_segs && idle_count > 0; i++) { + par_hdr_seg *seg = &s->segs[i]; + if (seg->complete || seg->node_id == -1 || seg->shadow_id != -1) continue; + + dogecoin_node *cand = NULL; + for (size_t j = 0; j < client->nodegroup->nodes->len && !cand; j++) { + dogecoin_node *n = vector_idx(client->nodegroup->nodes, j); + if (!n || !(n->state & NODE_CONNECTED) || !n->version_handshake) continue; + if ((int)n->nodeid == seg->node_id) continue; + dogecoin_bool busy = false; + for (uint32_t k = 0; k < s->num_segs && !busy; k++) + if (s->segs[k].node_id == (int)n->nodeid || + s->segs[k].shadow_id == (int)n->nodeid) busy = true; + if (!busy) cand = n; + } + if (!cand) return; + + seg->shadow_id = (int)cand->nodeid; + seg->shadow_at = now; + idle_count--; + + if (client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] racing segment %u: node %d shadowing node %d " + "(resuming at %u)\n", i, seg->shadow_id, seg->node_id, + seg->tip_height + 1); + + par_hdr_send_getheaders(cand, seg); + } +} + static void par_hdr_preempt_head(dogecoin_spv_client *client, uint64_t now) { par_hdr_state *s = client->par_hdr; @@ -3040,14 +3138,25 @@ static void par_hdr_preempt_head(dogecoin_spv_client *client, uint64_t now) uint32_t r = par_hdr_seg_rate(&s->segs[i], now); if (r > 0) rates[n++] = r; } - if (n < 3) return; /* too few samples to call anything an outlier */ - - for (uint32_t i = 1; i < n; i++) { /* insertion sort, n <= 64 */ - uint32_t v = rates[i], j = i; - while (j > 0 && rates[j - 1] > v) { rates[j] = rates[j - 1]; j--; } - rates[j] = v; + /* Remember the median while a crowd exists, because the tail has none: + * once every other segment has completed their node_id is -1 and + * par_hdr_seg_rate() reports 0 for all of them, so n falls to 0 exactly + * when the head is the only thing left and preemption matters most. + * Fall back to the last crowd-derived median in that case. */ + uint32_t median; + if (n >= 3) { + for (uint32_t i = 1; i < n; i++) { + uint32_t v = rates[i], j = i; + while (j > 0 && rates[j - 1] > v) { rates[j] = rates[j - 1]; j--; } + rates[j] = v; + } + median = rates[n / 2]; + s->rate_ref = median; + } else if (s->rate_ref > 0) { + median = s->rate_ref; + } else { + return; /* never saw a crowd; nothing to call an outlier against */ } - uint32_t median = rates[n / 2]; if (head_rate * PAR_HDR_SLOW_FACTOR >= median) return; /* not an outlier */ @@ -3126,6 +3235,8 @@ LIBDOGECOIN_API void par_hdr_reclaim(dogecoin_spv_client *client, uint64_t now) if (owner) owner->state &= ~NODE_HEADERSYNC; seg->node_id = -1; seg->requested_at = 0; + seg->shadow_id = -1; + seg->shadow_at = 0; } /* 2. Hand any free segment to a peer that is not already working one. */ @@ -3140,6 +3251,7 @@ LIBDOGECOIN_API void par_hdr_reclaim(dogecoin_spv_client *client, uint64_t now) /* 3. A live but slow owner of the flush head throttles everything above it. */ par_hdr_preempt_head(client, now); + par_hdr_race_tail(client, now); /* 4. Warn if the ordered flush has stopped advancing. */ if (s->flush_idx != s->last_flush_idx) { From e873dbf732de57c36a6f01a2188f23608b44b5f1 Mon Sep 17 00:00:00 2001 From: bluezr Date: Thu, 6 Aug 2026 17:49:46 -0700 Subject: [PATCH 07/15] spv: require a header batch to continue from the segment it lands in 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. --- src/spv.c | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/spv.c b/src/spv.c index 6befc06a6..2a8d3157a 100644 --- a/src/spv.c +++ b/src/spv.c @@ -2937,6 +2937,33 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, * P2P headers message. We copy the standard 80 bytes, advance buf by 80, * then call par_hdr_skip_auxpow to consume the AUXPoW data without running * check_auxpow (checkpoint anchors at segment boundaries guarantee validity). */ + /* A batch must continue from what this segment already holds. + * + * Two peers can be attached to one segment while a race is resolving, and + * a getheaders is answered relative to the locator sent at request time. + * The loser's reply can therefore arrive after the winner has advanced the + * segment, carrying headers that start below seg->tip_height. Appending + * those at seg->count leaves a discontinuity in the middle of the buffer + * that only surfaces at flush, as a connect failure. + * + * Compare the first header's prev_block against the hash this segment + * expects next and drop the whole batch if it does not match. Worth doing + * even without racing: it also rejects a peer that answers with something + * other than the continuation it was asked for. */ + if (count > 0 && buf->len >= PAR_HDR_RAW_LEN) { + const uint8_t *first_prev = (const uint8_t *)buf->p + 4; /* version(4) */ + const uint8_t *expected = seg->count ? (const uint8_t *)seg->tip_hash + : (const uint8_t *)seg->start_hash; + if (memcmp(first_prev, expected, DOGECOIN_HASH_LENGTH) != 0) { + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u: dropping %u headers from node %d, " + "does not continue from height %u\n", + seg_idx, count, (int)node->nodeid, seg->tip_height); + return; + } + } + for (uint32_t i = 0; i < count; i++) { if (buf->len < PAR_HDR_RAW_LEN) break; From 4574b0014cd577f69301e8e3832647cea61052f1 Mon Sep 17 00:00:00 2001 From: bluezr Date: Thu, 6 Aug 2026 18:15:48 -0700 Subject: [PATCH 08/15] spv: race the tail whenever a peer is idle, not only at the very end 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. --- src/spv.c | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/spv.c b/src/spv.c index 2a8d3157a..d91fb813a 100644 --- a/src/spv.c +++ b/src/spv.c @@ -3112,13 +3112,43 @@ static void par_hdr_race_tail(dogecoin_spv_client *client, uint64_t now) s->segs[k].shadow_id == (int)n->nodeid) busy = true; if (!busy) idle_count++; } - if (idle_count <= incomplete) return; /* no genuine spare capacity */ + if (idle_count == 0) return; /* nobody free to help */ + + /* Racing used to require idle_count > incomplete, which meant it only + * engaged once nearly every segment was done. A full mainnet run showed + * that is far too late: the first race fired about three quarters of the + * way through the log, so every peer that finished early sat idle while + * mid-run segments crawled. Any idle peer is spare capacity; the question + * is only which segment is worth spending it on. + * + * Shadow a segment only when its owner is running below the reference + * rate. A peer keeping pace does not need help, and shadowing it would + * just duplicate bandwidth. Being merely below median is enough here -- + * preemption already handles the clear outliers, and this is the cheaper + * intervention because the original owner keeps working. */ + uint32_t ref = s->rate_ref; + if (ref == 0) { + /* No reference yet: sample the segments currently in flight. */ + uint32_t sum = 0, n = 0; + for (uint32_t i = 0; i < s->num_segs; i++) { + uint32_t r = par_hdr_seg_rate(&s->segs[i], now); + if (r > 0) { sum += r; n++; } + } + if (n == 0) return; + ref = sum / n; + } /* Shadow the segments nearest the flush head first: those gate everything. */ for (uint32_t i = s->flush_idx; i < s->num_segs && idle_count > 0; i++) { par_hdr_seg *seg = &s->segs[i]; if (seg->complete || seg->node_id == -1 || seg->shadow_id != -1) continue; + /* Leave owners that are keeping up alone. A rate of 0 means the owner + * is still inside the grace window, which also counts as no evidence + * of trouble. */ + uint32_t rate = par_hdr_seg_rate(seg, now); + if (rate == 0 || rate >= ref) continue; + dogecoin_node *cand = NULL; for (size_t j = 0; j < client->nodegroup->nodes->len && !cand; j++) { dogecoin_node *n = vector_idx(client->nodegroup->nodes, j); @@ -3139,7 +3169,8 @@ static void par_hdr_race_tail(dogecoin_spv_client *client, uint64_t now) if (client->nodegroup->log_write_cb) client->nodegroup->log_write_cb( "[par-hdr] racing segment %u: node %d shadowing node %d " - "(resuming at %u)\n", i, seg->shadow_id, seg->node_id, + "(%u hdr/s vs ref %u, resuming at %u)\n", + i, seg->shadow_id, seg->node_id, rate, ref, seg->tip_height + 1); par_hdr_send_getheaders(cand, seg); From 20c87da15c2f304ea8f424bff0255be7e77c27be Mon Sep 17 00:00:00 2001 From: bluezr Date: Thu, 6 Aug 2026 20:44:29 -0700 Subject: [PATCH 09/15] chainparams: extend mainnet checkpoints to 6,300,000 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. --- src/chainparams.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/chainparams.c b/src/chainparams.c index 058f2eb52..200b513ad 100644 --- a/src/chainparams.c +++ b/src/chainparams.c @@ -173,7 +173,10 @@ const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[] = { {6173706, "1a59af827274d479127434728f9e1885a45b9a252bab246d039045822e671aa5", 1776698026, 0x197935DD}, {6189700, "a6ed6d6b03668d0bb9baddfbc52565b2514d02f118d6d0298a6ca48d511d7804", 1777713485, 0x19429592}, {6191600, "a8eeeadc81e8def69167f0d82c50c4d05fdb0c6ffd17a6af955f80d6925e0f0d", 1777835169, 0x197c3bc0}, - {6201360, "0bb1dbb971969f89f1c4c6305396042de1d6a390d71a44b066255c844da9256c", 1778454835, 0x19555f03}}; + {6201360, "0bb1dbb971969f89f1c4c6305396042de1d6a390d71a44b066255c844da9256c", 1778454835, 0x19555f03}, + {6250000, "197f31497b1e3b506da36eb6126738e3206e42fdff240844f8b75ae4ebc29519", 1781539362, 0x19614D63}, + {6275000, "63c89f44fe3ce35d6abaa297d32d75826ce4368027e098f816432f68c2bedbd6", 1783120876, 0x1979F333}, + {6300000, "e35560d7a6dda44da8fd5d3fea4025319ac5d6287dc3c1308cde650e51e12e58", 1784704739, 0x19671029}}; const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[] = { {0, "bb0a78264637406b6360aad926284d544d7049f45189db5664f3c4d07350559e", 1391503289, 0x1e0ffff0}, From e6f0450381a56f76f12ba64a99e2a57a5e9f2c4a Mon Sep 17 00:00:00 2001 From: bluezr Date: Thu, 6 Aug 2026 21:11:02 -0700 Subject: [PATCH 10/15] chainparams: export checkpoint counts instead of sizeof on extern arrays 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 #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. --- include/dogecoin/chainparams.h | 6 ++++-- include/dogecoin/libdogecoin.h | 6 ++++-- src/chainparams.c | 10 ++++++++++ src/cli/spvnode.c | 8 ++++---- src/spv.c | 11 +++++------ 5 files changed, 27 insertions(+), 14 deletions(-) diff --git a/include/dogecoin/chainparams.h b/include/dogecoin/chainparams.h index 9f34e7082..f39d96500 100644 --- a/include/dogecoin/chainparams.h +++ b/include/dogecoin/chainparams.h @@ -68,8 +68,10 @@ extern const dogecoin_chainparams dogecoin_chainparams_test; extern const dogecoin_chainparams dogecoin_chainparams_regtest; // the mainnet checkpoints, needs a fix size -extern const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[87]; -extern const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[24]; +extern const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[]; +extern const size_t dogecoin_mainnet_checkpoint_count; +extern const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[]; +extern const size_t dogecoin_testnet_checkpoint_count; LIBDOGECOIN_API const dogecoin_chainparams* chain_from_b58_prefix(const char* address); LIBDOGECOIN_API int chain_from_b58_prefix_bool(char* address); diff --git a/include/dogecoin/libdogecoin.h b/include/dogecoin/libdogecoin.h index 89a3ba56b..16ae60051 100644 --- a/include/dogecoin/libdogecoin.h +++ b/include/dogecoin/libdogecoin.h @@ -112,8 +112,10 @@ extern const dogecoin_chainparams dogecoin_chainparams_test; extern const dogecoin_chainparams dogecoin_chainparams_regtest; // the mainnet checkpoints, needs a fix size -extern const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[33]; -extern const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[24]; +extern const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[]; +extern const size_t dogecoin_mainnet_checkpoint_count; +extern const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[]; +extern const size_t dogecoin_testnet_checkpoint_count; const dogecoin_chainparams* chain_from_b58_prefix(const char* address); int chain_from_b58_prefix_bool(char* address); diff --git a/src/chainparams.c b/src/chainparams.c index 200b513ad..f56ca1528 100644 --- a/src/chainparams.c +++ b/src/chainparams.c @@ -178,6 +178,13 @@ const dogecoin_checkpoint dogecoin_mainnet_checkpoint_array[] = { {6275000, "63c89f44fe3ce35d6abaa297d32d75826ce4368027e098f816432f68c2bedbd6", 1783120876, 0x1979F333}, {6300000, "e35560d7a6dda44da8fd5d3fea4025319ac5d6287dc3c1308cde650e51e12e58", 1784704739, 0x19671029}}; +/* Counts defined next to the arrays. sizeof() on the extern declarations + yields whatever bound the header states, not the real length: chainparams.h + said [87] and libdogecoin.h said [33] while this file defined 89, so callers + silently saw a truncated array. */ +const size_t dogecoin_mainnet_checkpoint_count = + sizeof(dogecoin_mainnet_checkpoint_array) / sizeof(dogecoin_mainnet_checkpoint_array[0]); + const dogecoin_checkpoint dogecoin_testnet_checkpoint_array[] = { {0, "bb0a78264637406b6360aad926284d544d7049f45189db5664f3c4d07350559e", 1391503289, 0x1e0ffff0}, {483173, "a804201ca0aceb7e937ef7a3c613a9b7589245b10cc095148c4ce4965b0b73b5", 1427629321, 0x1e0fffff}, @@ -266,3 +273,6 @@ dogecoin_bool isMainnetFromB58Prefix(const char address[P2PKHLEN]) { /* Check if chainparams is mainnet */ return (chainparams == &dogecoin_chainparams_main); } + +const size_t dogecoin_testnet_checkpoint_count = + sizeof(dogecoin_testnet_checkpoint_array) / sizeof(dogecoin_testnet_checkpoint_array[0]); diff --git a/src/cli/spvnode.c b/src/cli/spvnode.c index e8d4c245d..a83dd511d 100644 --- a/src/cli/spvnode.c +++ b/src/cli/spvnode.c @@ -297,10 +297,10 @@ static int spv_choose_checkpoint_index(const dogecoin_chainparams* chain, dogeco if (chain == &dogecoin_chainparams_main) { checkpoints = dogecoin_mainnet_checkpoint_array; - count = (int)(sizeof(dogecoin_mainnet_checkpoint_array) / sizeof(dogecoin_mainnet_checkpoint_array[0])); + count = (int)(dogecoin_mainnet_checkpoint_count); } else if (chain == &dogecoin_chainparams_test) { checkpoints = dogecoin_testnet_checkpoint_array; - count = (int)(sizeof(dogecoin_testnet_checkpoint_array) / sizeof(dogecoin_testnet_checkpoint_array[0])); + count = (int)(dogecoin_testnet_checkpoint_count); } else { return -1; } @@ -405,10 +405,10 @@ void spv_sync_completed(dogecoin_spv_client* client) { if (client->chainparams == &dogecoin_chainparams_main) { checkpoints = dogecoin_mainnet_checkpoint_array; - checkpoint_count = (int)(sizeof(dogecoin_mainnet_checkpoint_array) / sizeof(dogecoin_mainnet_checkpoint_array[0])); + checkpoint_count = (int)(dogecoin_mainnet_checkpoint_count); } else if (client->chainparams == &dogecoin_chainparams_test) { checkpoints = dogecoin_testnet_checkpoint_array; - checkpoint_count = (int)(sizeof(dogecoin_testnet_checkpoint_array) / sizeof(dogecoin_testnet_checkpoint_array[0])); + checkpoint_count = (int)(dogecoin_testnet_checkpoint_count); } if (checkpoints && checkpoint_count > 0) { suggested_checkpoint_height = (int)checkpoints[0].height; diff --git a/src/spv.c b/src/spv.c index d91fb813a..fe9f9c9f1 100644 --- a/src/spv.c +++ b/src/spv.c @@ -850,8 +850,8 @@ void dogecoin_net_spv_fill_block_locator(dogecoin_spv_client *client, vector_t * if (client->use_checkpoints && client->oldest_item_of_interest > BLOCK_GAP_TO_DEDUCT_TO_START_SCAN_FROM * BLOCKS_DELTA_IN_S) { dogecoin_bool is_main = (client->chainparams && strcmp(client->chainparams->chainname, "main") == 0); const dogecoin_checkpoint *checkpoint = is_main ? dogecoin_mainnet_checkpoint_array : dogecoin_testnet_checkpoint_array; - size_t mainnet_checkpoint_size = sizeof(dogecoin_mainnet_checkpoint_array) / sizeof(dogecoin_mainnet_checkpoint_array[0]); - size_t testnet_checkpoint_size = sizeof(dogecoin_testnet_checkpoint_array) / sizeof(dogecoin_testnet_checkpoint_array[0]); + size_t mainnet_checkpoint_size = dogecoin_mainnet_checkpoint_count; + size_t testnet_checkpoint_size = dogecoin_testnet_checkpoint_count; size_t length = is_main ? mainnet_checkpoint_size : testnet_checkpoint_size; int i; for (i = (int)length - 1; i >= 0; i--) { @@ -884,6 +884,7 @@ void dogecoin_net_spv_fill_block_locator(dogecoin_spv_client *client, vector_t * * @param node The node that is requesting headers or blocks. * @param blocks boolean, true if we want to request blocks, false if we want to request headers */ + void dogecoin_net_spv_node_request_headers_or_blocks(dogecoin_node *node, dogecoin_bool blocks) { // request next headers @@ -2631,12 +2632,10 @@ static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) size_t cnt = 0; if (strcmp(params->chainname, "main") == 0) { arr = dogecoin_mainnet_checkpoint_array; - cnt = sizeof(dogecoin_mainnet_checkpoint_array) / - sizeof(dogecoin_mainnet_checkpoint_array[0]); + cnt = dogecoin_mainnet_checkpoint_count; } else if (strcmp(params->chainname, "test") == 0) { arr = dogecoin_testnet_checkpoint_array; - cnt = sizeof(dogecoin_testnet_checkpoint_array) / - sizeof(dogecoin_testnet_checkpoint_array[0]); + cnt = dogecoin_testnet_checkpoint_count; } if (!cnt) return NULL; From 563ebb7b88d2ad07426fbbd55c98a448d3c48ea3 Mon Sep 17 00:00:00 2001 From: bluezr Date: Fri, 7 Aug 2026 12:40:34 -0700 Subject: [PATCH 11/15] spv: don't count headers that fail to deserialize, size rate samples 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. --- src/spv.c | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/spv.c b/src/spv.c index fe9f9c9f1..9c29ce46e 100644 --- a/src/spv.c +++ b/src/spv.c @@ -2993,10 +2993,24 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, dogecoin_block_header hdr; struct const_buffer hbuf = { seg->buf + (size_t)seg->count * PAR_HDR_RAW_LEN, PAR_HDR_RAW_LEN }; - if (dogecoin_block_header_deserialize(&hdr, &hbuf, client->chainparams, NULL)) { - dogecoin_block_header_hash(&hdr, (uint8_t *)seg->tip_hash); - seg->tip_height++; + if (!dogecoin_block_header_deserialize(&hdr, &hbuf, client->chainparams, NULL)) { + /* An 80-byte header that will not deserialize cannot advance the + * continuity anchor. Counting it anyway would leave tip_hash and + * tip_height describing header n-1 while seg->count moved to n, so + * the next batch's prev_block check would compare against the wrong + * hash and the discontinuity would only surface at flush, as a + * connect failure well away from its cause. Drop the remainder of + * the batch and let the segment be re-requested from tip_hash. */ + s->buffered_bytes -= PAR_HDR_RAW_LEN; + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u: undeserializable header at index %u " + "from node %d, dropping rest of batch\n", + seg_idx, seg->count, (int)node->nodeid); + break; } + dogecoin_block_header_hash(&hdr, (uint8_t *)seg->tip_hash); + seg->tip_height++; seg->count++; /* skip tx_count varint (always 0x00 in headers messages) */ @@ -3187,10 +3201,18 @@ static void par_hdr_preempt_head(dogecoin_spv_client *client, uint64_t now) uint32_t head_rate = par_hdr_seg_rate(head, now); if (head_rate == 0) return; /* still inside the grace window */ - /* Median rate across the other segments currently being downloaded. */ - uint32_t rates[64]; + /* Median rate across the other segments currently being downloaded. + * + * Sized to num_segs rather than a fixed 64. The old bound stopped sampling + * at 64 without saying so, and mainnet is already at 89 segments, so once + * more than 64 are in flight the median is drawn from an arbitrary prefix + * of the peer set -- and that median is the reference every preemption and + * racing decision keys off. Truncation here is invisible in the logs and + * would look like a tuning problem, not a sampling one. */ + uint32_t *rates = dogecoin_malloc((size_t)s->num_segs * sizeof(*rates)); + if (!rates) return; uint32_t n = 0; - for (uint32_t i = 0; i < s->num_segs && n < 64; i++) { + for (uint32_t i = 0; i < s->num_segs; i++) { if (i == s->flush_idx) continue; uint32_t r = par_hdr_seg_rate(&s->segs[i], now); if (r > 0) rates[n++] = r; @@ -3212,8 +3234,10 @@ static void par_hdr_preempt_head(dogecoin_spv_client *client, uint64_t now) } else if (s->rate_ref > 0) { median = s->rate_ref; } else { + dogecoin_free(rates); return; /* never saw a crowd; nothing to call an outlier against */ } + dogecoin_free(rates); if (head_rate * PAR_HDR_SLOW_FACTOR >= median) return; /* not an outlier */ From d70cc8404b9482ef47e54069c94b500d81e50824 Mon Sep 17 00:00:00 2001 From: bluezr Date: Fri, 7 Aug 2026 14:17:13 -0700 Subject: [PATCH 12/15] spv: retry a segment that fails to connect, fall back to sequential after 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. --- include/dogecoin/spv.h | 7 ++++ src/spv.c | 92 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/include/dogecoin/spv.h b/include/dogecoin/spv.h index ca86dee48..42ff74b60 100644 --- a/include/dogecoin/spv.h +++ b/include/dogecoin/spv.h @@ -56,6 +56,12 @@ typedef struct spv_block_sample_ #define PAR_HDR_RAW_LEN 80 +/* How many times a segment may fail to connect at flush before the parallel + * downloader gives up entirely and lets the sequential path take over. Each + * retry re-requests the segment from whatever the primary DB actually reached, + * usually from a different peer. */ +#define PAR_HDR_MAX_FLUSH_FAILS 3 + /* One parallel header-download segment. Each segment spans the open-closed * height interval (start_height, stop_height] and is assigned to one node. */ typedef struct par_hdr_seg_ { @@ -81,6 +87,7 @@ typedef struct par_hdr_seg_ { dogecoin_bool complete; /* all stop_height - start_height headers received */ dogecoin_bool flushed; /* segment has been flushed into the primary DB */ + uint32_t flush_fails; /* times this segment failed to connect at flush */ } par_hdr_seg; /* Top-level state for a parallel genesis header download. */ diff --git a/src/spv.c b/src/spv.c index 9c29ce46e..7b8b086b6 100644 --- a/src/spv.c +++ b/src/spv.c @@ -2810,6 +2810,11 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) } bad++; dogecoin_free(pindex); /* orphan — not in DB */ + /* Stop at the first failure. Header j+1 chains off header j, + * which is not in the DB, so nothing after this point can + * connect either -- continuing would allocate and free an + * orphan per remaining header and blur where the chain broke. */ + break; } else { if (pindex && client->header_connected) client->header_connected(client); @@ -2817,6 +2822,81 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) } } + if (bad) { + /* Do NOT mark this segment flushed or advance flush_idx. The old + * code did both unconditionally and freed the staging buffer, so a + * mid-segment connect failure lost those headers permanently, left + * a hole in the chain, and let every later segment fail the same + * way until flush_idx ran off the end and the sync reported "all + * segments complete" over a broken chain. + * + * Instead, resume the segment from what the DB actually reached. + * Headers 0..j-1 did connect, so the chaintip is the correct + * locator; re-requesting from start_hash would replay those and + * fail immediately. The buffer is dropped and the segment is put + * back up for assignment, usually landing on a different peer. */ + seg->flush_fails++; + + uint64_t staged = (uint64_t)seg->count * PAR_HDR_RAW_LEN; + s->buffered_bytes = (s->buffered_bytes > staged) + ? s->buffered_bytes - staged : 0; + dogecoin_free(seg->buf); + seg->buf = NULL; + seg->cap = 0; + seg->count = 0; + + if (hdb && hdb->chaintip) { + memcpy(seg->tip_hash, hdb->chaintip->hash, DOGECOIN_HASH_LENGTH); + seg->tip_height = (uint32_t)hdb->chaintip->height; + } else { + memcpy(seg->tip_hash, seg->start_hash, DOGECOIN_HASH_LENGTH); + seg->tip_height = seg->start_height; + } + + seg->complete = false; + seg->node_id = -1; + seg->shadow_id = -1; + seg->shadow_at = 0; + seg->count_at_assign = 0; + + if (seg->flush_fails >= PAR_HDR_MAX_FLUSH_FAILS) { + /* Retrying has not helped, so the range is not merely a bad + * peer. Hand the rest of the chain to the sequential path, + * which verifies AUXPoW and can make progress from the real + * chaintip without trusting segment boundaries. */ + s->active = false; + + /* Segments past flush_idx may be complete and still holding + * their staging buffers. Nothing will flush them now, and on + * mainnet that is most of the chain resident for the rest of + * the run, so release them here rather than at teardown. */ + for (uint32_t k = s->flush_idx + 1; k < s->num_segs; k++) { + if (!s->segs[k].buf) continue; + uint64_t held = (uint64_t)s->segs[k].count * PAR_HDR_RAW_LEN; + s->buffered_bytes = (s->buffered_bytes > held) + ? s->buffered_bytes - held : 0; + dogecoin_free(s->segs[k].buf); + s->segs[k].buf = NULL; + s->segs[k].cap = 0; + s->segs[k].count = 0; + } + + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u failed to connect %u times — " + "disabling parallel download, falling back to " + "sequential from height %u\n", + s->flush_idx, seg->flush_fails, seg->tip_height); + } else if (client->nodegroup && client->nodegroup->log_write_cb) { + client->nodegroup->log_write_cb( + "[par-hdr] segment %u: connect failed, re-requesting from " + "height %u (attempt %u of %u)\n", + s->flush_idx, seg->tip_height, + seg->flush_fails, (uint32_t)PAR_HDR_MAX_FLUSH_FAILS); + } + break; + } + seg->flushed = true; s->flush_idx++; flushed++; @@ -2830,8 +2910,6 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) dogecoin_free(seg->buf); seg->buf = NULL; seg->cap = 0; - - if (bad) break; /* stop flushing if chain broke; caller handles */ } if (hdb) { @@ -2951,8 +3029,14 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, * other than the continuation it was asked for. */ if (count > 0 && buf->len >= PAR_HDR_RAW_LEN) { const uint8_t *first_prev = (const uint8_t *)buf->p + 4; /* version(4) */ - const uint8_t *expected = seg->count ? (const uint8_t *)seg->tip_hash - : (const uint8_t *)seg->start_hash; + /* tip_hash is always the right anchor: it is seeded from start_hash when + * the segment is built, advanced per header received, and re-seeded from + * the primary DB chaintip when a flush fails. Selecting start_hash on + * count == 0 instead would be wrong in that last case -- the segment has + * no buffered headers but the DB is already past start_height, so every + * correctly-served batch would be rejected and the segment would be + * re-requested forever. */ + const uint8_t *expected = (const uint8_t *)seg->tip_hash; if (memcmp(first_prev, expected, DOGECOIN_HASH_LENGTH) != 0) { if (client->nodegroup && client->nodegroup->log_write_cb) client->nodegroup->log_write_cb( From 3a7944417de95b7b035eafcb050075d9c0c78b45 Mon Sep 17 00:00:00 2001 From: bluezr Date: Fri, 7 Aug 2026 22:42:58 -0700 Subject: [PATCH 13/15] spv: verify each segment lands on its checkpoint before accepting it stop_hash was set from the checkpoint array and sent as the getheaders hash_stop, but nothing ever compared it against the headers that came back. A segment was accepted purely because it had reached stop_height. Safety was emergent rather than stated. A divergent segment left a chaintip the next segment could not extend, so it was caught one segment late, as a connect failure some distance from its cause. The final segment has no next segment, so nothing caught it at all -- a bad one surfaced later in the sequential tail, as a failure to extend from a chaintip that should never have been there. That matters more here than it would elsewhere, because checkpoint anchoring is the entire justification for skipping proof-of-work between checkpoints. The parallel path keeps only the 80-byte header and discards the AUXPoW payload, so check_auxpow never runs across the checkpointed range: linkage plus anchoring is the whole guarantee. Leaving the anchor half of it unchecked is not a gap worth inferring around. A segment that does not land on its checkpoint is now rejected outright. It has never been flushed at that point -- flush only touches complete && !flushed segments, in order -- so nothing already written is at risk, and it is put back for assignment. It resumes from the checkpoint anchor, or from the primary DB chaintip when an earlier flush failure left the chaintip mid-range. Three failures disable the parallel downloader and hand over to the sequential path, matching the flush-failure policy. Verified both directions, since a check that never fires is indistinguishable from one that cannot. Positive: a full mainnet sync puts all 89 segments through it with zero mismatches, so the comparison is not tripping on byte order between utils_uint256_sethex and dogecoin_block_header_hash. Negative: corrupting one segment's stop_hash produces three mismatches naming the expected and received hashes, then the fallback, then a correct chain from the sequential path. 412s to all segments, 506s total, 6,323,071. 78/78. --- src/spv.c | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/spv.c b/src/spv.c index 7b8b086b6..01096c7f7 100644 --- a/src/spv.c +++ b/src/spv.c @@ -2661,6 +2661,7 @@ static par_hdr_state *par_hdr_init(const dogecoin_chainparams *params) seg->stop_height = arr[arr_i].height; utils_uint256_sethex((char *)arr[arr_i].hash, seg->stop_hash); + /* start = genesis when i==0, else checkpoint[arr_i-1] */ if (i == 0) { seg->start_height = 0; @@ -3107,6 +3108,94 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, seg_idx, seg->count, seg->tip_height, seg->stop_height); if (seg->tip_height >= seg->stop_height) { + /* The segment claims to have reached its checkpoint. Verify that it + * actually landed on it before trusting the range. + * + * stop_hash comes from the checkpoint array and is sent as getheaders + * hash_stop, but nothing compared it against what arrived. Safety was + * emergent: a divergent segment left a chaintip the *next* segment + * could not extend, so it surfaced one segment late, as a connect + * failure away from its cause -- and the final segment has no next + * segment, so nothing caught it at all. + * + * This is the whole justification for skipping proof-of-work between + * checkpoints, so it is worth checking directly rather than inferring. + * The segment has never been flushed at this point (flush only touches + * complete && !flushed segments, in order), so rejecting it costs + * nothing already written. */ + if (memcmp(seg->tip_hash, seg->stop_hash, DOGECOIN_HASH_LENGTH) != 0) { + dogecoin_headers_db *hdb = + (dogecoin_headers_db *)client->headers_db_ctx; + + seg->flush_fails++; + + char got_hex[65] = {0}, want_hex[65] = {0}; + for (int _k = 0; _k < 32; _k++) { + snprintf(got_hex + _k*2, 3, "%02x", ((const uint8_t *)seg->tip_hash)[31-_k]); + snprintf(want_hex + _k*2, 3, "%02x", ((const uint8_t *)seg->stop_hash)[31-_k]); + } + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u: terminal hash mismatch at height %u " + "from node %d (attempt %u)\n" + " expected %s\n" + " got %s\n", + seg_idx, seg->tip_height, (int)node->nodeid, + seg->flush_fails, want_hex, got_hex); + + uint64_t staged = (uint64_t)seg->count * PAR_HDR_RAW_LEN; + s->buffered_bytes = (s->buffered_bytes > staged) + ? s->buffered_bytes - staged : 0; + dogecoin_free(seg->buf); + seg->buf = NULL; + seg->cap = 0; + seg->count = 0; + + /* Resume from the primary DB if it is already inside this segment + * -- that only happens when an earlier flush failed partway and + * left the chaintip mid-range -- otherwise from the checkpoint + * anchor, since nothing from this segment reached the DB. */ + if (hdb && hdb->chaintip && + (uint32_t)hdb->chaintip->height > seg->start_height && + (uint32_t)hdb->chaintip->height <= seg->stop_height) { + memcpy(seg->tip_hash, hdb->chaintip->hash, DOGECOIN_HASH_LENGTH); + seg->tip_height = (uint32_t)hdb->chaintip->height; + } else { + memcpy(seg->tip_hash, seg->start_hash, DOGECOIN_HASH_LENGTH); + seg->tip_height = seg->start_height; + } + + seg->complete = false; + seg->node_id = -1; + seg->shadow_id = -1; + seg->shadow_at = 0; + seg->count_at_assign = 0; + node->state &= ~NODE_HEADERSYNC; + + if (seg->flush_fails >= PAR_HDR_MAX_FLUSH_FAILS) { + s->active = false; + for (uint32_t k = 0; k < s->num_segs; k++) { + if (k == seg_idx || !s->segs[k].buf) continue; + uint64_t held = (uint64_t)s->segs[k].count * PAR_HDR_RAW_LEN; + s->buffered_bytes = (s->buffered_bytes > held) + ? s->buffered_bytes - held : 0; + dogecoin_free(s->segs[k].buf); + s->segs[k].buf = NULL; + s->segs[k].cap = 0; + s->segs[k].count = 0; + } + if (client->nodegroup && client->nodegroup->log_write_cb) + client->nodegroup->log_write_cb( + "[par-hdr] segment %u failed terminal check %u times — " + "disabling parallel download, falling back to " + "sequential from height %u\n", + seg_idx, seg->flush_fails, seg->tip_height); + } else { + par_hdr_assign(client, node); + } + return; + } + /* Segment complete */ seg->complete = true; seg->node_id = -1; From 75562c52b9f2d6c5f14897ba3746a58551ff89ad Mon Sep 17 00:00:00 2001 From: bluezr Date: Fri, 7 Aug 2026 23:38:30 -0700 Subject: [PATCH 14/15] test: assert the checkpoint tables are well-formed The parallel downloader builds one segment per adjacent pair of checkpoints and treats each hash as a trust anchor: it skips proof-of-work between them and verifies only that a segment lands on its terminal hash. Segment count, ordering, non-overlap and the anchors themselves all come from these two arrays, and nothing checked them. Per entry: the hash is 64 hex characters, because utils_uint256_sethex reads a fixed width and a short string leaves the tail of the anchor uninitialised rather than failing; timestamp and target are non-zero, since both are carried into the header DB for the anchor. Across entries: heights strictly increase, so no pair can produce a zero-length segment or one whose stop is below its start -- the downloader would wait forever for headers that cannot arrive -- and no two entries share a hash, which would let a segment satisfy its terminal check against the wrong block. Also pins two things the segment builder depends on and neither header states: the counts are non-zero, which would otherwise make every loop here vacuous, and entry 0 is genesis on both chains, which is why N checkpoints yield N-1 segments. That last one is asserted rather than assumed because this test first claimed the opposite and failed on its own tree. Verified against a broken table rather than only a good one: renumbering one mainnet checkpoint out of order fails the strictly-increasing check. 79/79. --- CMakeLists.txt | 1 + Makefile.am | 1 + test/checkpoints_tests.c | 102 +++++++++++++++++++++++++++++++++++++++ test/unittester.c | 2 + 4 files changed, 106 insertions(+) create mode 100644 test/checkpoints_tests.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 72dc941aa..a8127ceea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -553,6 +553,7 @@ IF(USE_TESTS) test/block_tests.c test/buffer_tests.c test/chacha20_tests.c + test/checkpoints_tests.c test/context_tests.c test/cstr_tests.c test/ecc_tests.c diff --git a/Makefile.am b/Makefile.am index 14dc6d8ca..f3a483dc8 100644 --- a/Makefile.am +++ b/Makefile.am @@ -280,6 +280,7 @@ tests_SOURCES = \ test/block_tests.c \ test/buffer_tests.c \ test/chacha20_tests.c \ + test/checkpoints_tests.c \ test/context_tests.c \ test/cstr_tests.c \ test/ecc_tests.c \ diff --git a/test/checkpoints_tests.c b/test/checkpoints_tests.c new file mode 100644 index 000000000..d3c3fa708 --- /dev/null +++ b/test/checkpoints_tests.c @@ -0,0 +1,102 @@ +/********************************************************************** + * Copyright (c) 2026 bluezr * + * Copyright (c) 2026 The Dogecoin Foundation * + * Distributed under the MIT software license, see the accompanying * + * file COPYING or http://www.opensource.org/licenses/mit-license.php.* + **********************************************************************/ + +#include + +#include +#include + +#include + +/* The parallel header downloader builds one segment per adjacent pair of + * checkpoints and treats each checkpoint hash as a trust anchor: it skips + * proof-of-work between them and verifies only that each segment lands on its + * terminal hash. Every property that relies on -- segment count, ordering, + * non-overlap, the anchors themselves -- comes from these arrays, and nothing + * checked them. + * + * The exported counts are load-bearing rather than cosmetic. They exist + * because callers used to derive the length with sizeof(array)/sizeof(*array), + * which on an extern array yields whatever bound the header declares. The + * headers said [87] and [33] against 90 real entries, so a checkpoint + * extension compiled, passed the suite, and was silently ignored. */ + +static void check_array(const char *name, + const dogecoin_checkpoint *arr, + size_t count) +{ + size_t i; + + /* A zero count would make every loop below vacuous, which is the failure + * mode this file exists to prevent. */ + u_assert_true(count > 0); + + for (i = 0; i < count; i++) { + size_t hexlen = strlen(arr[i].hash); + + /* 32 bytes, hex-encoded. utils_uint256_sethex reads a fixed width, so + * a short string leaves the tail of the anchor uninitialised rather + * than failing. */ + u_assert_int_eq((int)hexlen, 64); + u_assert_true(strspn(arr[i].hash, "0123456789abcdefABCDEF") == hexlen); + + /* Timestamp and bits are carried into the header DB for the anchor, so + * a zeroed entry would seed it with something meaningless. */ + u_assert_true(arr[i].timestamp != 0); + u_assert_true(arr[i].target != 0); + + if (i == 0) continue; + + /* Strictly increasing. Equal heights would produce a zero-length + * segment; decreasing heights would produce one whose stop is below + * its start, and the downloader would wait forever for headers that + * cannot arrive. */ + u_assert_true(arr[i].height > arr[i - 1].height); + + /* Distinct anchors. Two checkpoints sharing a hash would let a + * segment satisfy its terminal check against the wrong block. */ + u_assert_true(strcmp(arr[i].hash, arr[i - 1].hash) != 0); + } + + (void)name; +} + +void test_checkpoints() +{ + check_array("mainnet", + dogecoin_mainnet_checkpoint_array, + dogecoin_mainnet_checkpoint_count); + + check_array("testnet", + dogecoin_testnet_checkpoint_array, + dogecoin_testnet_checkpoint_count); + + /* Guard the bug that motivated exporting the counts: if anyone reverts to + * sizeof() on the extern array, this stops matching the real length. + * sizeof on an array of unknown bound is a constraint violation, so this + * has to compare against something the definition controls. */ + u_assert_true(dogecoin_mainnet_checkpoint_count > + dogecoin_testnet_checkpoint_count); + + /* Entry 0 is genesis, on both chains. The downloader relies on this: it + * starts building segments at index 1 and supplies genesis separately as + * the first segment's start anchor, so N checkpoints yield N-1 segments. + * If a non-genesis checkpoint were ever prepended, segment 0 would span + * genesis..that height while claiming to span genesis..checkpoint[1], and + * the terminal-hash check would reject it. + * + * Asserted rather than assumed because the author of this test assumed the + * opposite and was wrong. */ + u_assert_int_eq((int)dogecoin_mainnet_checkpoint_array[0].height, 0); + u_assert_int_eq((int)dogecoin_testnet_checkpoint_array[0].height, 0); + + /* Only entry 0 may be genesis; a second height-0 entry would produce a + * zero-length segment. Covered by the strictly-increasing check above, but + * stated here because it is the property the segment builder depends on. */ + u_assert_true(dogecoin_mainnet_checkpoint_array[1].height > 0); + u_assert_true(dogecoin_testnet_checkpoint_array[1].height > 0); +} diff --git a/test/unittester.c b/test/unittester.c index 96dd35fae..64539901e 100644 --- a/test/unittester.c +++ b/test/unittester.c @@ -48,6 +48,7 @@ extern void test_bip44(); extern void test_block_header(); extern void test_buffer(); extern void test_chacha20(); +extern void test_checkpoints(); extern void test_cstr(); extern void test_ecc(); extern void test_hash(); @@ -179,6 +180,7 @@ int main() u_run_test(test_block_header); u_run_test(test_buffer); u_run_test(test_chacha20); + u_run_test(test_checkpoints); u_run_test(test_cstr); u_run_test(test_ecc); u_run_test(test_hash); From eddcb5c6808a939f44b66db4e6200619dc893721 Mon Sep 17 00:00:00 2001 From: bluezr Date: Sat, 8 Aug 2026 11:39:31 -0700 Subject: [PATCH 15/15] spv: check the segment-array invariants at runtime The parallel downloader now carries enough interacting state -- node_id, shadow_id, complete, flushed, flush_idx, count, cap, buf, and the height triple -- that the relationships between those fields are easier to break than to notice. Every bug found in this area so far was silent: a segment retired with headers missing, a segment re-requested forever against the wrong anchor, a count advanced past a header that never parsed. None of them announced themselves, and the suite passed through all of them. par_hdr_check() states the relationships and reports the ones that do not hold: ordered flushing (below flush_idx implies flushed, and the converse), flushed implies complete and implies the staging buffer is released, a complete segment is owned by nobody, a shadow implies an owner and is not the owner, heights bracket the range, count is bounded by capacity while the buffer is live, and no peer owns two incomplete segments -- which par_hdr_recv cannot disambiguate, since it matches on node_id and takes the first. Deliberately not assert(). Release builds define NDEBUG, so assert would document these while checking them only where nobody runs. The cost is one pass over ~89 segments after operations that already do real work, and it reports rather than aborts: a violation means a bug in this file, and killing a node mid-sync is worse than finishing with a diagnostic in the log. Verified both ways, because a check that never fires and one that cannot are indistinguishable from a passing run. Positive: a full mainnet sync puts 89 segments and 89 flushes through it with zero violations. Negative: removing the `seg->flushed = true` assignment in the flush path produces 1008, correctly naming the injected fault -- "segment 0 is below flush_idx 6 but not flushed". One invariant was wrong on its first draft and caught by the positive run rather than by review: count is retained after a flush as the record of how many headers went in, while buf is released, so the count/buffer pair only describes unflushed segments. It fired 6,372 times before that was fixed. 79/79. --- src/spv.c | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/src/spv.c b/src/spv.c index 01096c7f7..079f1d81e 100644 --- a/src/spv.c +++ b/src/spv.c @@ -2755,6 +2755,120 @@ LIBDOGECOIN_API void par_hdr_assign(dogecoin_spv_client *client, dogecoin_node * /* Flush completed segments (in order) into the primary headers DB. * Returns the number of segments flushed. */ +/* Check the invariants the segment array is supposed to hold, and log any that + * do not. Returns the number of violations, 0 when healthy. + * + * Deliberately not assert(). This codebase compiles with NDEBUG in release, so + * an assert here would document the invariants while checking them only in + * builds nobody ships. These conditions are cheap -- one pass over ~89 + * segments -- and the failures they describe are silent ones, which is exactly + * the case for paying at runtime. + * + * Called after the operations that mutate segment ownership or flush state. + * It reports rather than aborts: a violation means a bug in this file, and + * killing a node mid-sync is a worse outcome than finishing with a diagnostic + * in the log. */ +static uint32_t par_hdr_check(dogecoin_spv_client *client, const char *where) +{ + par_hdr_state *s = client->par_hdr; + uint32_t bad = 0; + uint32_t i; + + if (!s) return 0; + +#define PAR_HDR_BAD(fmt, ...) \ + do { \ + bad++; \ + if (client->nodegroup && client->nodegroup->log_write_cb) \ + client->nodegroup->log_write_cb("[par-hdr][INVARIANT] %s: " fmt, \ + where, __VA_ARGS__); \ + } while (0) + + /* flush_idx only ever moves forward, and never past the end. */ + if (s->flush_idx > s->num_segs) + PAR_HDR_BAD("flush_idx %u exceeds num_segs %u\n", + s->flush_idx, s->num_segs); + + for (i = 0; i < s->num_segs; i++) { + const par_hdr_seg *seg = &s->segs[i]; + + /* A segment below flush_idx has been written to the DB; one at or + * above it has not. Ordered flushing is what lets the downloader skip + * proof-of-work, so a hole here breaks the anchoring argument. */ + if (i < s->flush_idx && !seg->flushed) + PAR_HDR_BAD("segment %u is below flush_idx %u but not flushed\n", + i, s->flush_idx); + if (i >= s->flush_idx && seg->flushed) + PAR_HDR_BAD("segment %u is at/above flush_idx %u but flushed\n", + i, s->flush_idx); + + /* Flushed implies complete, and implies the staging buffer is gone -- + * that release is what keeps peak RSS off the size of the chain. */ + if (seg->flushed && !seg->complete) + PAR_HDR_BAD("segment %u flushed but not complete\n", i); + if (seg->flushed && seg->buf) + PAR_HDR_BAD("segment %u flushed but still holds its buffer\n", i); + + /* A completed segment is owned by nobody: par_hdr_assign only + * considers !complete && node_id == -1, so a complete segment with an + * owner leaks that peer out of the assignment pool for the rest of the + * run. */ + if (seg->complete && seg->node_id != -1) + PAR_HDR_BAD("segment %u complete but still owned by node %d\n", + i, seg->node_id); + if (seg->complete && seg->shadow_id != -1) + PAR_HDR_BAD("segment %u complete but still shadowed by node %d\n", + i, seg->shadow_id); + + /* A shadow races the owner; it is meaningless without one, and a peer + * cannot race itself. */ + if (seg->shadow_id != -1 && seg->node_id == -1) + PAR_HDR_BAD("segment %u has shadow %d but no owner\n", + i, seg->shadow_id); + if (seg->shadow_id != -1 && seg->shadow_id == seg->node_id) + PAR_HDR_BAD("segment %u shadowed by its own owner %d\n", + i, seg->node_id); + + /* Heights bracket the range this segment is responsible for. */ + if (seg->stop_height <= seg->start_height) + PAR_HDR_BAD("segment %u stop %u is not above start %u\n", + i, seg->stop_height, seg->start_height); + if (seg->tip_height < seg->start_height) + PAR_HDR_BAD("segment %u tip %u is below start %u\n", + i, seg->tip_height, seg->start_height); + + /* count is the number of headers staged in buf -- but only until the + * segment is flushed, after which the buffer is released and count is + * kept as the record of how many headers went in. So this pair only + * describes unflushed segments. */ + if (!seg->flushed) { + if (seg->count > 0 && !seg->buf) + PAR_HDR_BAD("segment %u claims %u staged headers with no buffer\n", + i, seg->count); + if (seg->count > seg->cap) + PAR_HDR_BAD("segment %u count %u exceeds capacity %u\n", + i, seg->count, seg->cap); + } + + /* One peer, one segment. Two segments owned by the same node means + * par_hdr_recv cannot tell which one a batch belongs to -- it matches + * on node_id and takes the first. */ + if (seg->node_id != -1) { + uint32_t j; + for (j = i + 1; j < s->num_segs; j++) { + if (s->segs[j].complete) continue; + if (s->segs[j].node_id == seg->node_id) + PAR_HDR_BAD("node %d owns both segment %u and %u\n", + seg->node_id, i, j); + } + } + } + +#undef PAR_HDR_BAD + + return bad; +} + static uint32_t par_hdr_flush(dogecoin_spv_client *client) { par_hdr_state *s = client->par_hdr; @@ -2924,6 +3038,8 @@ static uint32_t par_hdr_flush(dogecoin_spv_client *client) s->last_progress_time = (uint64_t)time(NULL); } + par_hdr_check(client, "after flush"); + return flushed; } @@ -3213,6 +3329,8 @@ static void par_hdr_recv(dogecoin_spv_client *client, dogecoin_node *node, /* Re-assign this node to the next segment */ par_hdr_assign(client, node); + par_hdr_check(client, "after segment complete"); + /* Check if all segments are done */ if (s->flush_idx >= s->num_segs) { s->active = false;