Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion configure.ac
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
AC_PREREQ([2.69])
define(_CLIENT_VERSION_MAJOR, 9)
define(_CLIENT_VERSION_MINOR, 26)
define(_CLIENT_VERSION_BUILD, 2)
define(_CLIENT_VERSION_BUILD, 3)
define(_CLIENT_VERSION_RC, 0)
define(_CLIENT_VERSION_SUFFIX, [])
define(_CLIENT_VERSION_IS_RELEASE, true)
Expand Down
168 changes: 168 additions & 0 deletions doc/HEADERS_SYNC_FIX_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Fresh-Node Headers Sync — Validated Root Cause & KISS Fix Plan

*Independent audit (2026-06-30) of `FRESH_NODE_HEADERS_PRESYNC_FIX.md`, validated against
code, the live mainnet node, and the failing fresh node `192.168.1.143`.*

**Branch note:** this was investigated on `groestl-mining-modded`, but the affected code
(`src/kernel/chainparams.cpp`) is identical on `develop`. **The fix must land on `develop`
(the v9.26.x release line), not on the Groestl mining branch.** This plan file travels with it.

---

## TL;DR

Fresh v9.26.2 nodes can't sync because the **2026-06-26 release commit `47b9ea3481`
("refresh mainnet chain metadata") set `nMinimumChainWork` from `0x00` to a value equal to
~98.68% of the *real* tip chainwork** — but DigiByte's headers **pre-sync measures
"contextless" work that only ever reaches ~28% of the real chainwork.** So pre-sync tops out
around 28% and can never cross the 98.68% gate → it resets forever (the 99.98% → 76% loop).

**KISS fix: revert mainnet `nMinimumChainWork` to `0x00`** (one line). That is exactly how
mainnet ran for its entire history; it restores fresh sync immediately, with zero risk and no
regression. The complex "contextual presync cursor" the other team proposed is **not needed**
for this bug and is risky in a consensus-adjacent path.

---

## Evidence

### Live fresh node (192.168.1.143, v9.26.2, fresh datadir)
```
blocks=0 headers=0 ibd=True
Pre-synchronizing blockheaders, height: 23740000 (~99.90%)
Pre-synchronizing blockheaders, height: 23760000 (~99.98%)
Pre-synchronizing blockheaders, height: 18220000 (~76.72%) <-- reset
```
RPC shows **zero headers accepted** — presync never reaches the work threshold, so nothing is
committed to the block index.

### The numbers (measured against the synced mainnet node)
| Quantity | Value | As % of real tip |
|---|---|---|
| Real tip chainwork | `0x…1d10a64f…` | 100% |
| `nMinimumChainWork` (set 2026-06-26) | `0x…1cae290e…` | **98.68%** |
| What contextless pre-sync actually accumulates | measured | **~28%** |

Contextless/real work ratio measured over the last 500 / 2000 / 5000 blocks: **0.258 / 0.286 /
0.280**. Fresh sync requires `contextless% > nMinimumChainWork%` → **28% > 98.68% is
impossible.**

### Git history (the "why now")
```
47b9ea3481 2026-06-26 "release: refresh mainnet chain metadata"
- consensus.nMinimumChainWork = uint256S("0x00");
+ consensus.nMinimumChainWork = uint256S("0x…1cae290ed41eb2efd4804c");
```
Mainnet `nMinimumChainWork` was **`0x00` for its entire prior history** (no work gate → fresh
sync always worked). v9.26.2 introduced the gate, computed from the **real** chainwork, which
the contextless pre-sync can't reach.

---

## Why the contextless work is only ~28% of real (validated in code)

Pre-sync sums work from a **dummy index** with no context
(`src/headerssync.cpp:208,244`):
```cpp
m_current_chain_work += GetBlockProof(CBlockIndex(current));
m_redownload_chain_work += GetBlockProof(CBlockIndex(header));
```
`CBlockIndex(const CBlockHeader&)` leaves `nHeight = 0` and `pprev = nullptr`
(`src/chain.cpp:28`). DigiByte's `GetBlockProof()` is **context-sensitive** (`src/chain.cpp`):
- `nHeight >= workComputationChangeTarget` (real headers): geometric mean of
`GetNextWorkRequired(block.pprev, …)` across all active algos, then **`<< 7` (×128)**.
- `nHeight == 0` (the dummy): falls into the legacy branch
`GetBlockProofBase(nBits) * GetAlgoWorkFactor(0, algo)`, and
`GetAlgoWorkFactor(0, …) == 1` for every algo. So the dummy returns only the block's own
single-algo `nBits` work — **no `<<7`, no multi-algo averaging.**

That structural difference is why the dummy sum is ~28% of the real multi-algo chainwork. This
is inherited from upstream Bitcoin, where `GetBlockProof()` depends **only** on `nBits`, so the
contextless dummy equals the real work. In DigiByte it does not.

The pre-sync threshold for a fresh node is `nMinimumChainWork` (confirmed:
`GetAntiDoSWorkThreshold()` = `max(near_chaintip_work, MinimumChainWork())`, and for a fresh
node `near_chaintip_work = 0`, `src/net_processing.cpp:2890`).

---

## Audit of `FRESH_NODE_HEADERS_PRESYNC_FIX.md`

| Their claim | Verdict |
|---|---|
| `GetBlockProof()` is context-sensitive; presync uses a contextless dummy | ✅ **Correct** — validated in code |
| The dummy's `nHeight=0/pprev=null` yields wrong work for modern headers | ✅ **Correct** (it's the legacy branch, ~28% of real) |
| The presync abort at the tip height is the observed failure | ✅ **Correct** — reproduced on 192.168.1.143 |
| "Do **not** lower `nMinimumChainWork` as the primary fix" | ❌ **Wrong** — the over-tight `nMinimumChainWork` set on 2026-06-26 is the proximate root cause; correcting it is the right KISS fix |
| The fix must be a contextual `HeadersSyncWorkCursor` (temp CBlockIndex state) | ❌ **Over-engineered** for this bug — complex, risky, and unnecessary to restore fresh sync |

They diagnosed the *mechanism* correctly but inverted the *fix*: they treated a latent,
long-tolerated quirk (contextless presync) as the thing to rewrite, and dismissed the actual
trigger (a one-line metadata value set 4 days ago).

---

## The fix

### Recommended (KISS, zero-risk): revert `nMinimumChainWork` to `0x00`
`src/kernel/chainparams.cpp` (mainnet, ~line 191):
```cpp
// FRESH-SYNC FIX: revert the 2026-06-26 metadata refresh. DigiByte's headers pre-sync
// measures contextless work (~28% of real chainwork), so a real-chainwork-derived
// nMinimumChainWork is unreachable and breaks fresh sync. Mainnet ran with 0x00 for its
// entire history.
consensus.nMinimumChainWork = uint256S("0x00");
```
- **Works:** restores the exact behavior mainnet had for years (fresh sync succeeds).
- **No regression:** it is the historical value; nothing that worked breaks.
- **Anti-DoS:** unchanged from mainnet's entire history (which never had a presync work gate).
Header pre-sync still uses commitment-based redownload + peer management.
- **Does not touch consensus, Groestl rules, the algolock, BIP9, Qt, or validation.**

Also verify the other networks in the same file (testnet `0x…01ad46be4862`, signet, regtest)
and revert/zero any whose `nMinimumChainWork` exceeds ~25% of that network's real tip work by
the same test.

### Optional — if a real anti-DoS floor is wanted (do NOT ship as the emergency fix)
A non-zero `nMinimumChainWork` is only safe if it is **below what contextless pre-sync can
reach** (~25% of real tip work, with margin). Two ways to get there:
1. **Pick a validated low value** (e.g. ≤15–20% of real tip work) and **prove it on a fresh
node** before release. Risk: the exact whole-chain contextless total isn't trivially
computable, so it must be tested, not guessed.
2. **Make pre-sync contextual (the other team's `HeadersSyncWorkCursor`)** so it measures real
work; then `nMinimumChainWork` can be set from real chainwork the normal way. This is the
architecturally complete fix but is **complex, touches the DoS-protection path, and carries
real regression risk** — schedule it deliberately, not as the emergency patch.

**Recommendation:** ship the `0x00` revert now to unblock fresh nodes; treat the contextual
pre-sync as a separate, carefully-reviewed improvement only if a presync work gate is actually
desired.

---

## Validation plan for the fix

1. Build the patched binary; fresh mainnet datadir; `-debug=net`.
2. Connect to current v9.26.2 peers.
3. Confirm the log shows headers being accepted (no more
`Pre-synchronizing … (99.98%) → … (76%)` reset loop).
4. `getblockchaininfo`: `headers` climbs above 0 and tracks `blocks`; `initialblockdownload`
eventually false.
5. Confirm block download proceeds and the node reaches tip.
6. Confirm **no** `bad-algo` / `InvalidChainFound` while syncing the grandfathered Groestl
history (independent of this fix; the algolock only activates at height 23,808,000).
7. Sanity: a synced node still follows the most-work chain (the `0x00` change does not affect
chain selection).

---

## Bottom line

- **Root cause:** v9.26.2's 2026-06-26 `nMinimumChainWork` refresh (`0x00` → 98.68% of real
tip) made the header pre-sync work gate unreachable, because DigiByte pre-sync measures
contextless work (~28% of real).
- **KISS fix:** revert mainnet `nMinimumChainWork` to `0x00` (one line). Restores years-proven
behavior, zero risk, no consensus impact.
- **The other team's contextual-cursor rewrite is correct in theory but the wrong call for this
bug** — unnecessary, complex, and risky. Keep it on the shelf as an optional future
enhancement if a presync work floor is ever wanted.
60 changes: 60 additions & 0 deletions doc/release-notes/release-notes-9.26.3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
DigiByte Core version 9.26.3
============================

DigiByte Core v9.26.3 is a patch release on top of v9.26.2. It fixes a
fresh-node header synchronization regression and makes the transaction index
on by default so DigiDollar nodes start cleanly. **All v9.26.2 users — and
anyone setting up a new node — should upgrade.** It contains no consensus rule
changes; v9.26.2's Groestl algolock and the DigiDollar BIP9 deployment are
carried forward unchanged.

How to Upgrade
==============

Shut down DigiByte Core, replace the binaries, and restart. A reindex is not
required for this release.

Notable changes
===============

Fresh-node header sync fixed (nMinimumChainWork)
------------------------------------------------

A fresh v9.26.2 node could pre-synchronize block headers to ~99.98% and then
reset back to a lower height, looping forever with `headers=0` and never leaving
initial block download.

Root cause: the v9.26.2 release metadata refresh set mainnet
`consensus.nMinimumChainWork` to a value derived from the node's *real* chainwork
(~98.68% of the tip). DigiByte's header pre-synchronization, however, measures
work from a contextless `CBlockIndex` (no height or previous-block context), which
in DigiByte's multi-algorithm work model only accumulates to roughly 28% of the
real chainwork. The pre-sync work total therefore could never cross the threshold,
so the node aborted and restarted pre-sync indefinitely.

Fix: mainnet `nMinimumChainWork` is reverted to `0x00` — the value mainnet used
for its entire history before the v9.26.2 refresh, and a value the pre-sync always
reaches. This is a chain-metadata fix only; it does not change consensus, chain
selection, or anti-DoS posture relative to mainnet's prior behavior. Testnet,
signet and regtest are unaffected (their values were already pre-sync-reachable).

Background and a full validation plan are in `HEADERS_SYNC_FIX_PLAN.md`.

Transaction index on by default
-------------------------------

`-txindex` now defaults to **on** (`DEFAULT_TXINDEX = true`). DigiDollar requires a
full transaction index (mint/transfer/redeem scanning, collateral lookups,
`getrawtransaction`), and the node already refuses to start with DigiDollar active
and `-txindex` off. Defaulting it on lets DigiDollar nodes start out of the box.
Operators who do not want a transaction index can still set `-txindex=0` on
non-DigiDollar configurations.

Note: the `-digidollar` startup flag is not required and is not enabled by
default. DigiDollar consensus, RPC, and P2P all follow the BIP9 deployment
automatically (`IsDigiDollarEnabled()`); the flag only affects regtest.

Credits
=======

Thanks to everyone who diagnosed the fresh-node sync issue against live nodes.
6 changes: 5 additions & 1 deletion src/index/txindex.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

#include <index/base.h>

static constexpr bool DEFAULT_TXINDEX{false};
// DigiByte: txindex defaults to ON. DigiDollar (mint/transfer/redeem scanning,
// collateral lookups) and the oracle/getrawtransaction RPC surface require a full
// transaction index, and IsDigiDollarTxIndexRequired() refuses to start without it.
// Defaulting on avoids a hard startup error for normal DigiDollar users.
static constexpr bool DEFAULT_TXINDEX{true};

/**
* TxIndex is used to look up transactions included in the blockchain by hash.
Expand Down
9 changes: 9 additions & 0 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,15 @@ void InitParameterInteraction(ArgsManager& args)
LogPrintf("%s: parameter interaction: -whitebind set -> setting -listen=1\n", __func__);
}

// DigiByte: -txindex defaults on (DigiDollar needs it), but prune is incompatible with
// txindex. If the node is pruning and the user did not explicitly choose -txindex, leave
// txindex off so the pruned node starts cleanly. An explicit "-prune=N -txindex=1" still
// errors in AppInitParameterInteraction.
if (args.GetIntArg("-prune", 0) > 0) {
if (args.SoftSetBoolArg("-txindex", false))
LogPrintf("%s: parameter interaction: -prune set -> setting -txindex=0\n", __func__);
}

if (args.IsArgSet("-connect") || args.GetIntArg("-maxconnections", DEFAULT_MAX_PEER_CONNECTIONS) <= 0) {
// when only connecting to trusted nodes, do not seed via DNS, or listen by default
if (args.SoftSetBoolArg("-dnsseed", false))
Expand Down
8 changes: 7 additions & 1 deletion src/kernel/chainparams.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,13 @@ class CMainParams : public CChainParams {
consensus.vDeployments[Consensus::DEPLOYMENT_ALGOLOCK].min_activation_height = 0; // may activate as soon as it locks in

// The best chain should have at least this much work.
consensus.nMinimumChainWork = uint256S("0x0000000000000000000000000000000000000000001cae290ed41eb2efd4804c");
// NOTE: must stay reachable by the headers pre-sync, which measures *contextless*
// work (~28% of real chainwork in DigiByte's multi-algo model). The 2026-06-26
// refresh set this to a real-chainwork value (~98.68% of tip), which pre-sync can
// never reach, breaking fresh-node sync (headers reset loop). Reverted to 0x00
// (mainnet's historical value) until pre-sync computes contextual work. See
// HEADERS_SYNC_FIX_PLAN.md.
consensus.nMinimumChainWork = uint256S("0x00");

// By default assume that the signatures in ancestors of this block are valid block 23,500,000.
consensus.defaultAssumeValid = uint256S("0xade47d5ccbb92cb1d965b97a187bdbf65bf74be6a3709cb6a01339f8c2856deb"); // Block 23,500,000
Expand Down
Binary file modified src/qt/res/icons/digibyte_wallet.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion test/functional/interface_rest.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ def filter_output_indices_by_value(vouts, value):
class RESTTest (DigiByteTestFramework):
def set_test_params(self):
self.num_nodes = 2
self.extra_args = [["-rest", "-blockfilterindex=1", "-dandelion=0"], ["-dandelion=0"]]
# DigiByte defaults -txindex on; this test asserts getindexinfo() contains only the
# block filter index, so disable txindex explicitly to match the upstream assumption.
self.extra_args = [["-rest", "-blockfilterindex=1", "-txindex=0", "-dandelion=0"], ["-txindex=0", "-dandelion=0"]]
# whitelist peers to speed up tx relay / mempool sync
for args in self.extra_args:
args.append("-whitelist=noban@127.0.0.1")
Expand Down
4 changes: 4 additions & 0 deletions test/functional/rpc_misc.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
class RpcMiscTest(DigiByteTestFramework):
def set_test_params(self):
self.num_nodes = 1
# DigiByte defaults -txindex on; this test first asserts no indexes are running,
# then restarts the node with indexes. Start with -txindex=0 so the initial
# getindexinfo() is empty.
self.extra_args = [["-txindex=0"]]
self.supports_cli = False

def run_test(self):
Expand Down
4 changes: 3 additions & 1 deletion test/functional/rpc_txoutproof.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
class MerkleBlockTest(DigiByteTestFramework):
def set_test_params(self):
self.num_nodes = 2
# DigiByte defaults -txindex on; node 0 is the "no txindex" node this test uses to
# check that a proof can't be fetched without -txindex, so disable it explicitly.
self.extra_args = [
["-dandelion=0"],
["-txindex=0", "-dandelion=0"],
["-txindex", "-dandelion=0"],
]

Expand Down
Loading