diff --git a/DIGIDOLLAR_ARCHITECTURE.md b/DIGIDOLLAR_ARCHITECTURE.md index 15879ea821..fdf86bc756 100644 --- a/DIGIDOLLAR_ARCHITECTURE.md +++ b/DIGIDOLLAR_ARCHITECTURE.md @@ -711,7 +711,9 @@ DigiDollar implements true network-wide tracking by scanning the **entire blockc void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, - const CTxMemPool* mempool) + const CTxMemPool* mempool, + const CChain* chain, + const Consensus::Params* consensus) { // Create cursor to iterate ALL UTXOs (similar to gettxoutsetinfo) std::unique_ptr pcursor(view->Cursor()); @@ -724,8 +726,14 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, // Find DigiDollar vault outputs (P2TR with value > 0 at output 0) if (key.n == 0 && coin.out.scriptPubKey[0] == OP_1 && coin.out.nValue > 0) { - // Fetch FULL transaction from block storage - CTransactionRef tx = node::GetTransaction(nullptr, mempool, txid, + // v9.26.4: coins created below the DigiDollar activation floor can + // never be DD vaults — skip them (their blocks may be pruned away) + + // Fetch FULL transaction: txindex when available, otherwise read it + // straight from the retained block at the coin's height (works on + // pruned nodes, which keep every block at/above the DD floor) + const CBlockIndex* creating_block = chain ? (*chain)[coin.nHeight] : nullptr; + CTransactionRef tx = node::GetTransaction(creating_block, mempool, txid, hashBlock, *blockman); // Validate DD mint structure: diff --git a/V9.26.4_MAINNET_VALIDATION.md b/V9.26.4_MAINNET_VALIDATION.md new file mode 100644 index 0000000000..b90366ab51 --- /dev/null +++ b/V9.26.4_MAINNET_VALIDATION.md @@ -0,0 +1,172 @@ +# v9.26.4 Mainnet Prune Validation — Live Test Record + +Date: 2026-07-02 (UTC) · Binary: `DigiByte version v9.26.4 (release build)` from +branch `release/v9.26.4` · Host: Jared's workstation, live mainnet P2P. + +## Purpose + +Prove on **real mainnet data** — not just regtest — that a v9.26.4 node with +`prune=N` and **no txindex**: + +1. boots, registers the DigiDollar prune lock at the activation floor + (23,627,520), and prunes the ~12-year pre-DigiDollar history; +2. keeps validating and following the live chain while pruned; +3. can never prune a DigiDollar-era block, even when explicitly asked to; +4. reports correct DigiDollar deployment state pre-activation. + +Context: mainnet tip was ~23,774,300 during the test — already **past** the +minimum activation height (23,627,520) while DigiDollar BIP9 is still +signaling (`status: started`). So the retained DigiDollar-era window is live +and real: the lock protects blocks that exist today. + +## Setup (repeatable) + +```bash +# 1. Cleanly stop the primary node (Qt has no mainnet RPC in this config): +kill -TERM # wait for "Shutdown: done" in ~/.digibyte/debug.log + +# 2. Copy chain data to a disposable test datadir (~39 GB, a few minutes). +# The untouched ~/.digibyte is the pristine backup — re-copy to reset the +# test bed for reindex/rescan experiments at any time. +mkdir -p ~/.digibyte-prunetest +cp -a ~/.digibyte/blocks ~/.digibyte-prunetest/blocks +cp -a ~/.digibyte/chainstate ~/.digibyte-prunetest/chainstate +# deliberately NOT copied: indexes/ (txindex + DD stats index — the pruned +# node must not need them) and wallets/ (test runs walletless). + +# 3. Test-node config (~/.digibyte-prunetest/digibyte.conf) — distinct ports +# so it can coexist with the primary node: +# prune=2000 +# server=1 +# rpcport=14022 +# port=12124 +# rpcbind=127.0.0.1 +# rpcallowip=127.0.0.1 +# dbcache=1024 +# # no txindex line on purpose: prune must auto-disable the default txindex + +# 4. Launch the release Qt against it: +DISPLAY=:1 src/qt/digibyte-qt -datadir=$HOME/.digibyte-prunetest -splash=0 + +# 5. Query it: +src/digibyte-cli -datadir=$HOME/.digibyte-prunetest -rpcport=14022 +``` + +## Observed results + +### Startup (debug.log) + +``` +DigiByte version v9.26.4 (release build) +InitParameterInteraction: parameter interaction: -prune set -> setting -txindex=0 +InitParameterInteraction: parameter interaction: -prune set -> setting -digidollarstatsindex=0 +Prune configured to target 2000 MiB on disk for block and undo files. +DigiDollar: pruning enabled; retaining all blocks at/above height 23627520 (DigiDollar activation floor) +Oracle: DigiDollar not yet active (BIP9) at height 23774290, skipping price loading +``` + +All four wiring points fired on mainnet: txindex softset off, DD stats index +softset off, prune lock registered at the activation floor, and the +BIP9-gated oracle startup skip. Startup includes one long single-core step +(~8 minutes) after "Pruning blockstore…": the DigiDollar health seeding scan +over the ~24M-entry mainnet UTXO set (present since v9.26.3; not +prune-specific). + +### Pruned steady state + +| Check | Result | +|---|---| +| `getblockchaininfo.pruned` | `true` | +| Disk (`size_on_disk`) after initial prune | **2.07 GB** (from 38 GB blocks + 4.2 GB indexes) | +| First `pruneheight` | 21,195,663 — below the floor, because the 2000 MiB size target was satisfied before the lock had to bind | +| Chain following | tip advanced live with the network while pruned (23,774,274 → 23,774,383+) | +| `getnetworkinfo.localservicesnames` | `['WITNESS', 'NETWORK_LIMITED']` — no `NODE_NETWORK`, correct for a pruned node | +| `getindexinfo` | `{}` — no txindex, nothing else | +| `getdigidollardeploymentinfo` | `status: started`, bit 23, `min_activation_height: 23627520`, live signaling tally (4,130 of 28,224 threshold at test time), MuSig2 session telemetry present | + +### The DigiDollar prune lock, proven on mainnet + +``` +$ digibyte-cli pruneblockchain 23774000 # deliberately above the floor +23511221 # clamped — refused to cross the lock +``` + +- Requested prune height 23,774,000 (near tip, far **above** the floor). +- Actual prune stopped at **23,511,221** — the last whole block-file boundary + below the lock (`floor − PRUNE_LOCK_BUFFER − 1` = 23,627,509; the block file + containing the floor spans ~116k blocks and is retained whole). +- Floor block 23,627,520: **still readable** (`getblock` succeeds). +- Block 23,000,000 (below the floor): pruned, `getblock` errors cleanly. +- Disk after the aggressive prune: **0.21 GB**. + +This is the release guarantee in one command: a pruned v9.26.4 node **cannot** +be talked into deleting a DigiDollar-era block — automatic pruning and the +`pruneblockchain` RPC both clamp below the activation floor. + +### getdigidollarstats on a pruned mainnet node + +Pre-activation it returns instantly with `DigiDollar is not yet active on +this blockchain` — the standard activation gate at the top of every +DigiDollar RPC. The no-index UTXO-scan fallback (and its "don't poll it +tightly" caveat from the release notes) only comes into play after +activation. + +### First-prune cost, and restart with m_have_pruned=true + +- The **first** prune of a 23.7M-block chain took ~8 minutes of one core + between "Pruning blockstore…" and "Done loading": pruning ~21 million + blocks marks each one's block-index entry as data-less and flushes that to + LevelDB once. (The primary node's unpruned startups show no such gap, and + neither does the pruned node afterward.) +- **Restart after pruning**: total boot ~112 s (block-index load, same as an + unpruned node), "Pruning blockstore… → Done loading" in the same second. + The data-availability guard (`CheckBlockDataAvailability` from tip down to + the floor, `m_have_pruned=true`) ran against the pruned datadir, found the + DigiDollar-era window intact, and booted normally with the prune lock + re-registered. (The refuse-to-start path for a *damaged* window is pinned + by `feature_digidollar_pruning.py` F9 on regtest — it cannot be triggered + with a v9.26.4 binary because the lock prevents creating the damage.) + +## Reset / reindex / rescan procedure + +- **Reset the test bed** (fresh un-pruned copy): stop test Qt, then + `rm -rf ~/.digibyte-prunetest/{blocks,chainstate}` and re-copy from + `~/.digibyte` (the pristine backup). ~3 minutes. +- **Reindex test**: with the copy in place, start with `-reindex` to rebuild + the chainstate from local block files. On a *pruned* copy, `-reindex` + re-downloads missing history from the network and re-prunes (this is the + recovery path the startup guard prescribes; exercised end-to-end in + `feature_digidollar_pruning.py` F9 on regtest). +- **Rescan test**: wallet rescans work within the retained window and fail + cleanly with "Can't rescan beyond pruned data" below it (pinned in + `feature_digidollar_pruning.py` F6). +- The primary node's datadir `~/.digibyte` was never modified by this test. + +## Re-validation on the tagged binary (consensus fix included) + +Re-run on the recompiled `v9.26.4` tag (`c3c785443f`, which adds the redeem +collateral floor gate) against a fresh 42 GB copy of `~/.digibyte` +(`~/.dgb-mainprune`, `prune=2000`, no txindex): + +- **Boot:** both softsets fired; `DigiDollar: pruning enabled; retaining all + blocks at/above height 23627520`; DigiDollar status `started` (mainnet not yet + active); `pruned=true`; services `['WITNESS','NETWORK_LIMITED']`; `getindexinfo` + empty. Initial prune to the 2000 MiB target: `size_on_disk` 1.77 GB. +- **Clamp:** `pruneblockchain(23778861)` returned **23,511,221** (below the + floor), floor block 23,627,520 remained readable, block 23,000,000 was gone + (`error -1`), and `size_on_disk` fell to **209,887,284 bytes (0.21 GB)**. +- **Restart:** the data-availability guard passed, `pruned=true`, the floor block + was still available, and the node resumed syncing the live chain. + +Identical to the original run — the consensus change does not affect mainnet +(DigiDollar is not active yet, so no redeem path runs) and does not change the +prune/lock/guard mechanics. + +## Verdict + +Every mainnet observation — on both the pre-fix and the tagged binary — matched +the regtest functional suite (`feature_digidollar_pruning.py`, 15 phases), the +live testnet26 battery, and the code review. The pool config in the release +notes (`prune=2000`, no txindex) runs today on mainnet, keeps the full +DigiDollar-era window, and will carry a pool through DigiDollar activation +without a further upgrade. diff --git a/V9.26.4_PRUNING_EXPLAINER.md b/V9.26.4_PRUNING_EXPLAINER.md new file mode 100644 index 0000000000..c4b847963e --- /dev/null +++ b/V9.26.4_PRUNING_EXPLAINER.md @@ -0,0 +1,99 @@ +# DigiByte v9.26.4 — DigiDollar-Compatible Pruning: how it works, and how we proved it 🍃 + +*— DigiSwarm, DGB AI dev team* + +**The problem.** Since v9.26.3, a DigiDollar node needed `txindex=1` and the full +12-year block history (~38 GB blocks + ~4 GB index, growing). Pools said that's too +heavy. `-prune` was impossible: prune and txindex are mutually exclusive, and +DigiDollar validation used the txindex to resolve the amount/lock-term of spent DD +outputs. + +**The insight.** A DigiDollar output can only be *created* once DigiDollar activates, +and activation cannot happen below the deployment's minimum activation height: block +**23,627,520**. So every block DigiDollar validation will *ever* need lives between +that height and the tip. Everything below is plain DGB history — safe to delete. + +**How v9.26.4 does it** (one narrowly scoped consensus rule — the redeem collateral +floor gate that keeps pruned and full nodes in agreement — everything else opt-in +behind `-prune`): + +1. **Prune lock at the floor.** Startup registers a "digidollar" prune lock at + 23,627,520. Automatic pruning *and* the `pruneblockchain` RPC are clamped below + it — a pruned node physically cannot delete a DigiDollar-era block. +2. **No txindex needed.** DD lookups read the creating transaction straight from the + retained block at the coin's height. Every lookup path fails *closed* — reject, + never accept. +3. **Startup guard.** A pruned datadir missing a DD-era block refuses to start and + asks for `-reindex`, rather than validating with incomplete data. + +Nodes that don't set `prune=` behave exactly like v9.26.3 — txindex still defaults +ON, and `-prune` + `-txindex=1` still errors. + +**Testing (TDD).** 3,404 unit tests green; full 380-test functional suite green, +including the Groestl algolock boundary (accept at 600 / reject at 601, reindex-safe) +and every DigiDollar activation test. New 14-phase `feature_digidollar_pruning.py`: + +- a pruned node crosses BIP9 activation, **mines a DD block a full node accepts**, + runs mint → send → redeem, prunes, and restarts to identical stats; +- the prune lock is proven to be the *binding* constraint (not the generic + keep-the-last-288 window); +- a fresh pruned node cold-syncs the entire DD-era chain over P2P; +- a full node migrates in place to pruned (the exact pool path); +- reorgs across DD blocks on the pruned node; +- a deliberately damaged datadir triggers the guard, then recovers with `-reindex`. + +**The real-world mainnet test.** We copied a synced mainnet datadir and ran the +release v9.26.4 Qt on **live mainnet** with `prune=2000` and no txindex: + +- Log: `DigiDollar: pruning enabled; retaining all blocks at/above height 23627520` — + while DD is still *signaling* (bit 23, `started`, ~14.6%). Mainnet's tip + (~23,774,400) is already past the floor, so the retained DD window is live + **today**. +- Disk fell **38 GB → 2.1 GB** at the prune target. We then deliberately ran + `pruneblockchain 23774000` (above the floor): it **clamped to 23,511,221**, the + floor block stayed readable, everything older was cleanly gone — final footprint + **0.21 GB**. +- The node kept validating live mainnet blocks while pruned, advertised + `NODE_NETWORK_LIMITED`, and a restart passed the data-availability guard and booted + normally in ~2 min (the first prune only has a one-time ~8-min step marking 21M old + block-index entries). + +Full test record: `V9.26.4_MAINNET_VALIDATION.md`. Design + test plan: +`V9.26.4_PRUNING_PLAN.md`. + +**For pools — upgrade once. How to set up `digibyte.conf`:** + +Existing v9.26.3 node (the pool upgrade path): + +1. Shut the node down. +2. Edit `digibyte.conf`: + - **add**: `prune=2000` + - **remove** any `txindex=1` line (prune and txindex are incompatible; with no + txindex line, v9.26.4 auto-disables it under prune) +3. Replace the binaries with v9.26.4 and start. The node prunes **in place** — no + resync, no reindex. The first start spends a few extra minutes (one-time) + marking the old blocks as pruned; confirm in debug.log: + + ``` + DigiDollar: pruning enabled; retaining all blocks at/above height 23627520 (DigiDollar activation floor) + ``` + +Fresh node: same conf — it syncs from the network and prunes as it goes. + +Notes: + +- `prune=N` is a disk target in **MiB** (minimum 550); 2000 gives comfortable + headroom. `prune=1` selects manual mode (pruning only via the `pruneblockchain` + RPC). +- Wallets and `getblocktemplate` mining work normally. Don't set `txindex=1` or + `-reindex-chainstate` together with prune (both error, by design). +- To go back to an archival node later: remove `prune=`, set `txindex=1`, restart + with `-reindex` (the node re-downloads the full chain). + +That node validates today, automatically keeps every DigiDollar-era block, and +carries you through DigiDollar activation with no further upgrade. + +*Fine print: pruned nodes don't serve deep history to new peers (archival nodes +still do that); `getrawtransaction` for pre-DD history needs an archival node; and +after activation the retained DD-era window grows with the chain — `prune=N` deletes +the 12 years of history, it isn't a permanent size cap.* diff --git a/V9.26.4_PRUNING_PLAN.md b/V9.26.4_PRUNING_PLAN.md new file mode 100644 index 0000000000..36ac48dcf3 --- /dev/null +++ b/V9.26.4_PRUNING_PLAN.md @@ -0,0 +1,566 @@ +# v9.26.4 — Prune 12 Years of History, Keep DigiDollar Working +### The one-upgrade release for mining pools · 24-hour execution plan + +**Mission:** ship a v9.26.4 that lets pools run **pruned** mainnet nodes — deleting the +~23.6 million pre-DigiDollar blocks (12 years, ~32 GB) while keeping the small +DigiDollar-era window — so pools **upgrade once**, enforce the Groestl algolock at +block 23,808,000, signal and validate DigiDollar, and never need txindex. +**One narrowly scoped consensus change (the redeem collateral floor gate, added by the +ship audit); every other new behavior is opt-in behind `-prune`.** The whole job +is plumbing: make sure a pruned node has every piece of data DigiDollar reads, and that +each piece is wired to the right source so the node computes the same answers as a full +node. + +--- + +## 0. Timing — DigiDollar is NOT active yet (this makes it low-risk) + +DigiDollar follows BIP9 signaling and **has not activated** (v9.26.3 release notes: +"DigiDollar consensus, RPC, and P2P all follow the BIP9 deployment automatically"). +Activation is weeks away and on a **different clock** from the ~6-day Groestl algolock. +Why that matters: + +- **Today the chain has zero DD transactions, zero DD coins, zero oracle bundles.** A + pruned v9.26.4 node validates **plain DigiByte blocks** — ordinary pruning, none of + the DigiDollar plumbing is even exercised yet. +- **The only thing stopping pruning today is one over-strict check.** + `IsDigiDollarTxIndexRequired` triggers when DigiDollar is *configured* + (`HasDigiDollarDeployment`, always true on mainnet) — **not** when it's *active* + (`IsDigiDollarEnabled`). So the node demands txindex now, before any DD tx can exist. + The v9.26.3 release notes describe the intent as "refuses to start with DigiDollar + **active**" — the code is stricter than that. v9.26.4 lines the code up with the + intent. +- **The DigiDollar-specific plumbing is dormant until activation** and fully + exercisable on **regtest** (where DD is always-active) before we ship. The health seed + (§4 #5) reads zero today because there are zero DD coins — which is *correct* — and + only starts doing real work at activation. So if any wire were connected wrong, it + (a) can't affect mainnet for weeks, and (b) shows up immediately in the regtest tests. +- **Upgrade-once still means shipping the whole thing now** — pools install one binary + that must carry them *through* activation without a second upgrade — but doing so is + low-risk precisely because the DigiDollar paths don't run on mainnet yet. +- **The flip side — and the real point:** because mainnet cannot exercise DigiDollar + yet, every guarantee this release makes is about the **post-activation future**, and + the proof must come from the regtest **activation rehearsal** (TDD plan **F0**): a + pruned node driven through BIP9 signaling → LOCKED_IN → ACTIVE *while pruning*, then + mining the first DD blocks, a user minting/sending/redeeming, a deep redeem thousands + of blocks after its mint with pruning in between, and a late restart that rebuilds + health and volatility state from retained blocks. "Not active yet" lowers today's + shipping risk; it **raises** the bar on proving tomorrow's behavior — F0 is that bar. + +--- + +## 1. What pools do (the whole ask) + +```ini +# digibyte.conf +prune=2000 +``` + +Install v9.26.4, keep (or add) `prune=N`, start. That's the entire migration. +Steady-state disk: **~2–3 GB** today (vs ~32 GB + txindex). One upgrade, done: +algolock ✓, DigiDollar signaling ✓, DigiDollar validation ✓, pruning ✓. + +--- + +## 2. How it works — in simple terms + +DigiByte's chain is 23.77M blocks, but DigiDollar can only ever reference data created +**after its activation floor (block 23,627,520)** — a DD coin cannot exist before the +deployment's minimum activation height, and even a 10-year-locked mint redeemed in 2036 +was *created* inside the DD era. So the chain divides cleanly: + +| Region | Blocks | DigiDollar needs it? | v9.26.4 pruned node | +|---|---|---|---| +| 12 years of history | ~23.6M (≈32 GB) | **Never** | **Deleted** | +| DigiDollar era (floor → tip) | ~145K today (≈0.2–0.4 GB) | Yes — forever | **Kept, permanently** | +| UTXO set (chainstate) | — | Yes (which coins exist) | Kept (pruning never touches it) | + +Everything DigiDollar validation ever reads lives in the kept region — the plan just +makes sure each reader is wired to it: + +1. **Which DD coins exist** → UTXO set (never pruned). +2. **Each coin's dollar amount + lock tier** → OP_RETURN of its creating tx → in a + DD-era block → on disk. The lookup is "read block at `coin.nHeight`" — **no txindex**. +3. **Oracle price / volatility freeze state** → coinbase bundles in recent blocks → in + the kept window; the startup scan already skips pre-DD blocks *before* touching disk + (`bundle_manager.cpp:1825, 1881-1897` — confirmed). +4. **System health totals** (DD supply/collateral → DCA/ERR) → rebuilt from #1 + #2 + (after the health-reader is reconnected in §4). + +The keep-forever rule uses the **prune-lock plumbing that already exists** in our Bitcoin +v26 base: one named lock `"digidollar"` at the floor, honored by every prune path — +automatic pruning *and* the `pruneblockchain` RPC both funnel through the same clamp +(`validation.cpp:3477-3501`, confirmed: nothing bypasses it). + +--- + +## 3. Making sure a pruned node is always complete — the reindex guard + +> **A v9.26.4 pruned node either has EVERY DigiDollar-era block and computes exactly +> what a full node computes — or it refuses to start. There is no in-between.** + +Three pieces make that true: + +**Piece 1 — the prune lock (no future holes).** Registered during chainstate load +(`CompleteChainstateInitialization`, `src/node/chainstate.cpp` ~:148, `cs_main` held) — +hooked in provably before the first prune event can run (init Step 10; pruning is +suspended during reindex until import completes). The lock map is in-memory, so it is +re-registered on every startup — same pattern the block indexes already use. + +**Piece 2 — the reindex guard (no existing holes).** At every startup of a pruned node +with DigiDollar configured: + +``` +if tip->nHeight < floor_height: # fresh/partial sync — nothing to check yet + skip (the prune lock keeps the window intact going forward) +else: + floor_block = ActiveChain()[floor_height] # guaranteed active-chain ancestor + if !CheckBlockDataAvailability(*tip, *floor_block): # blockstorage.cpp:755-759 + InitError("DigiDollar block window incomplete. Restart with -reindex + to rebuild it (the node will redownload and re-prune).") +``` + +> **Wiring correction (required):** the guard must be conditioned on `tip->nHeight >= +> floor_height` **and** take `floor_block` from `ActiveChain()` (a guaranteed ancestor). +> A bare `CheckBlockDataAvailability(tip, floor_block)` would **crash** on a fresh pruned +> IBD (tip below the floor → `ActiveChain()[floor]` is null → null-deref; or an assert at +> `blockstorage.cpp:747` if a non-ancestor index at height > tip is passed). This mirrors +> the way the existing index guard sources its start block via `FindFork` +> (`init.cpp:2280-2288`). For a genuine in-window hole (tip ≥ floor, floor an active +> ancestor) `CheckBlockDataAvailability` returns cleanly `false` and the guard raises +> `InitError` — no crash. + +This is what keeps a half-hooked-up node from ever running: a datadir pruned under old +software, a hand-deleted blk file, any gap — all become a **refused boot with clear +instructions**, never a node that runs and computes different DD health/amounts than the +network. It mirrors the existing `NeedsRedownload` check +(`src/node/chainstate.cpp:148-155`). `-reindex` on a pruned datadir already does the +right thing (`CleanupBlockRevFiles` → clean redownload, pruning as it goes, ending at the +~2–3 GB steady state, with the lock now keeping the DD era intact). + +**Piece 3 — a backstop check (make doubly sure).** The oracle startup scan currently +`continue`s silently if a block read fails (`bundle_manager.cpp:1833-1836`). For a +**post-activation** block that read can't fail under Pieces 1–2 — so make it a hard +startup error. If it ever fires, the data is corrupt and the node must not run. + +--- + +## 4. Exact change set (~200 LOC, all node-local, all behind `-prune`) + +| # | File | Change | Why | +|---|---|---|---| +| 1 | `src/node/chainstate.cpp` | Register `"digidollar"` prune lock, `height_first = EarliestDigiDollarActivationHeight` = `min(nDDActivationHeight, min_activation_height)` = 23,627,520 mainnet (matches consensus's own floor, `digidollar/validation.cpp:70-78`; `PRUNE_LOCK_BUFFER=10` → effective 23,627,509). Name must NOT collide with `"digidollarstatsindex"` (that BaseIndex lock moves upward). | Keep the DD era forever | +| 2 | `src/node/chainstate.cpp` | **Reindex guard** (§3 Piece 2) | No half-hooked-up nodes | +| 3 | `src/init.cpp:904` | `IsDigiDollarTxIndexRequired` → `false` when `-prune>0` (main/testnet). The `-prune -txindex=1` error stays. | Pruned nodes don't need txindex | +| 4 | `src/init.cpp` (param interaction) | When `-prune>0` and `-digidollarstatsindex` not explicitly set → SoftSet it to `0` (same pattern as the existing prune→txindex SoftSet at `:787-793`). | Index is default-ON (`:1900`) and RPC-only (never read by consensus — confirmed); without this, the generic pruned-index check (`:2284-2291`) would demand a reindex and break "upgrade once" | +| 5 | `src/digidollar/health.cpp:404` | `ScanUTXOSet`: pass `ActiveChain()[coin.nHeight]` as `block_index` to `GetTransaction` (confirmed fallback path, `node/transaction.cpp:268-298`) + skip coins with `nHeight < floor`. | **The one wiring gap.** Today this reads only through the txindex; a pruned node (no txindex) would seed DD supply = 0 and then compute different DCA/ERR health than the rest of the network. Reconnect it to read the same transaction from the retained block — identical result on a full node, correct result on a pruned one. | +| 6 | `src/oracle/bundle_manager.cpp:1833-1836` | Post-activation read failure → startup error (§3 Piece 3). | Turn a can't-happen state into a refused boot | + +**Deliberately NOT in this release:** an extra floor check on the redeem-collateral input +(consensus-adjacent, and only reachable by a class of coins we can show doesn't exist). +Instead, a **one-time data check** before we tag: on an archival node, scan the UTXO set +for any pre-floor P2TR coin (`nHeight < 23,627,520`) that happens to parse as a DD vault +output. Expected: **0** → the pruned read path is never exercised on such a coin and the +release stays 100 % node-local. If > 0: **stop and reconvene** (do not ship). + +--- + +## 5. Why a pruned node always stays in step with a full node + +Every place a pruned node could have computed something differently — and how each is +wired to match: + +| Where they could differ | How it's wired to match | +|---|---| +| DD amount lookups | Both read the same creating-tx bytes; pruned reads the retained block, full reads txindex/block. DD coins have `nHeight ≥ floor` → block always retained (Piece 1) | +| Startup health seed (DCA/ERR) | Reconnected by #5; identical inputs on both node classes | +| Volatility freeze state on restart | Startup scan inputs = post-activation blocks only (header-check confirmed) = fully retained; Piece 3 refuses to boot otherwise | +| A datadir with a missing DD block | **Reindex guard** refuses boot (Piece 2) | +| `pruneblockchain` RPC | Cannot cross the floor — it funnels through the same lock clamp (confirmed, no bypass) | +| Reorg needing pruned undo data | rev files prune in lockstep with blk files → retained window has undo; reorgs only *lower* locks (the safe direction); DD window is ~145K blocks deep | +| Pre-floor coin that looks like a DD vault | Covered by the one-time data check (§4); expected empty; must be 0 before we tag | +| P2P | `NODE_NETWORK_LIMITED` — existing upstream behavior, accepted by v9.26.2/.3 and the v8 line; deep `getdata` correctly refused | +| Mining | `getblocktemplate`/`CreateNewBlock` read only mempool + tip + the in-memory oracle manager (zero txindex references — confirmed); pruned pool blocks are built under identical rules | +| DigiDollar activation | v9.26.4 **signals bit 23 automatically** (`ComputeBlockVersion`) and validates all DD rules — unlike any v8-based alternative, which would both stall activation (no signaling) and stop validating DD (hashpower not checking the rules) | +| Full-node users | **Nothing changes for them.** Without `-prune`, v9.26.4 behaves exactly like v9.26.3 (txindex default on, same requirements); change #5 is result-identical on full nodes | + +Deployed-network note: the live releases are **v9.26.2 and v9.26.3** +(consensus-identical). "v9.26.1" exists only as pre-release tags with pre-launch +chainparams — not a compatibility target. + +Assumeutxo note: `loadtxoutset` snapshots are **not applicable on mainnet** — no +snapshot data is configured (`m_assumeutxo_data.clear()`, `kernel/chainparams.cpp:292`) +— so snapshot sync introduces no extra case for this plan. + +--- + +## 6. Upgrade-once matrix + +| Coming from | What the pool does | Resync? | +|---|---|---| +| **v8.26.x pruned** (the common pool case) | Install v9.26.4, keep `prune=N` | **Maybe none**: if the node's retained window already covers the floor (likely — the DD window is only ~0.2–0.4 GB), the guard passes and it just runs. Otherwise the guard asks for **one** `-reindex` (hours; prunes as it goes; ends at ~2–3 GB). No txindex hangover (v8 default was off) | +| v8.26.x / v9.26.2 / v9.26.3 **full** | Install v9.26.4, add `prune=N` | **None** — node prunes down in place (all blocks present → guard passes trivially). Stale `indexes/txindex` dir can be deleted manually | +| Fresh install | `prune=N` from first start | One IBD (downloads all blocks once — bandwidth, not disk; prunes during sync) | + +Either way: **one upgrade**, and the same binary serves full nodes unchanged. + +--- + +## 7. What a pruned pool node can / cannot do + +- **Full capability:** mining (`getblocktemplate`/`submitblock`), complete DD consensus + validation, DD wallet ops (`mintdigidollar`, `senddigidollar`, `redeemdigidollar`, + positions/balances/UTXO listings), oracle RPCs (all tip-bounded — confirmed), BIP9 + signaling for DigiDollar. +- **Limited:** `getrawtransaction` for pre-DD-era txs (no txindex, block deleted); + `getdigidollarstats` **historical** per-height queries (those need the stats index, + off by default under prune) — **current** stats still work: the RPC has a built-in + fallback that scans the UTXO set (`rpc/digidollar.cpp:678-705`), and that fallback is + the same `ScanUTXOSet` that change #5 rewires, so it reads retained blocks correctly + (just slower than the index); serving deep history to peers (`NODE_NETWORK_LIMITED`, + last ~288 blocks) — pruned pools don't seed IBD; the network's full nodes do. +- **Wallet notes (the "user in prune mode" story):** + - **Any wallet created after DigiDollar activation restores and rescans fine on a + pruned node** — its entire relevant history is inside the retained DD-era window. + - A wallet whose birthday is *before* the floor hits the standard upstream rule + ("last wallet synchronisation goes beyond pruned data… -reindex", + `wallet.cpp:3593-3602`) — restore those on a full node or accept one `-reindex`. + - Simplest for pools: create the pool wallet fresh on the pruned node. + - `mintdigidollar` / `senddigidollar` / `redeemdigidollar` and the Qt DD tab use + wallet storage + tip-bounded oracle reads + retained-block amount lookups — all + present on a pruned node. + +**Disk (estimates):** today ~2–3 GB total; DD-era window grows ~3–5 GB/yr. `prune=N` +(min 550) bounds pre-DD pruning aggressiveness; steady-state equals the DD window +regardless of N. A startup log line states the DigiDollar floor explicitly. + +--- + +## 8. 24-hour execution schedule + +| Hour | Work | Gate | +|---|---|---| +| 0–6 | Implement #1–#6 + unit tests (TDD; all sites pre-pinned). Tests: txindex-not-required-under-prune; lock registered at load; guard reacts correctly to a synthetic hole; health seed correct with `g_txindex == nullptr`; stats-index SoftSet | Unit suite green | +| 4–10 ∥ | `test/functional/feature_digidollar_pruning.py` (regtest): pruned txindex-less node mints/transfers/redeems; **restart** → identical health/volatility state vs full peer; GBT with DD txs in mempool; guard → `InitError`; `pruneblockchain` cannot cross the floor | Functional green | +| 6–10 ∥ | **One-time data check** on an archival mainnet node (pre-floor P2TR coins that parse as DD vaults) | **Must be 0 before tag** | +| 10–16 | Full test suite; testnet pruned sync; two mainnet canaries: (a) v9.26.3-full → v9.26.4 `prune=2000` in place, (b) v8.26.x-pruned datadir → v9.26.4 (exercises the guard path live) | Canaries validate + mine templates | +| 16–20 | Tag `v9.26.4`, deterministic builds, release notes (§6 matrix + §7 table verbatim) | Builds reproduce | +| 20–24 | Pool announcement + install support | Pools installing | + +**Timeline note:** the algolock is ~5–6 days out. The 24-hour target is for the +*release*; pools then have days to install one binary + one config line. Even a +24 → 48 h slip keeps the margin. + +**Rollback story:** all new behavior sits behind `-prune`. Any pool (or we) can revert to +full-node behavior at any time by removing `prune=` — a v9.26.4 node without it is +functionally a v9.26.3. + +--- + +## 9. Alternatives considered (for the record) + +- **v8.26.3 (algolock-only, prunable):** set aside. It doesn't signal bit 23, so pools + parked on it would stall DigiDollar activation entirely; and once DigiDollar activates + those nodes wouldn't validate DD rules at all, so a single invalid DD block would put + their hashpower on a different chain than the exchanges and full nodes — the exact + split we're trying to avoid, just moved to activation day. It also leaves a second + consensus line to maintain forever. +- **`blocksdir=` / full-node bridge:** pools declined; moot. +- **Redeem floor check in consensus now:** deferred behind the one-time data check (§4) + to keep this release 100 % node-local under the 24 h clock. + +## 10. After this release (not now) +- Stats index starting at the DD floor (restores `getdigidollarstats` on pruned nodes). +- "Phase 2" DD-amount store in the UTXO database (bounds the retained window's growth). +- Redeem-collateral floor check, added across node types together as an extra + correctness check. + + +--- + +# Part II — TDD Test Plan + + +Companion to `V9.26.4_PRUNING_PLAN.md`. This is the test blueprint that must be **100% +green before tag**. It follows strict TDD: every test is written **RED first** (fails on +today's v9.26.3), then the implementation makes it **GREEN**, and the whole suite is the +release gate. The job the tests verify is plumbing — that every reader on a pruned node +is hooked to the right data and produces the same answer a full node produces. + +## The three things every test must ultimately prove +1. **Everything is wired right** — a pruned node produces *byte-identical* validation + results to a full node, and non-pruned behavior is unchanged. +2. **Pruning is allowed** — a DigiDollar-configured mainnet/testnet node starts pruned, + deletes pre-DigiDollar history, and never deletes the DigiDollar era. +3. **Mining DigiDollar blocks works** — a pruned node builds a valid DD block that a full + node accepts. + +--- + +## 0. The core property: **a pruned node matches a full node** (two-node cross-check) + +Everything reduces to one property, and we test it directly by running **two nodes side +by side through the same blocks**: + +> **node_A** = full (`txindex=1`, no prune) — the reference. +> **node_P** = pruned (`prune=…`, no txindex, DD era retained) — the node under test. +> +> For every block, reorg, restart, RPC, and malformed/invalid tx: **node_P must agree +> with node_A** on best-block hash, accept/reject result, and every DigiDollar quantity +> (supply, collateral, DCA multiplier, volatility-freeze state). + +If they *ever* disagree, the test fails and we do not ship. This one cross-check is worth +more than any number of one-sided assertions — it makes "a pruned node stays in step with +the network" a machine-checked fact, not a claim. (Because DigiDollar is **not active +yet**, on mainnet today node_A and node_P are validating plain blocks — the DigiDollar +plumbing is exercised entirely on regtest, where we control activation, well before it can +matter on mainnet.) + +--- + +## 1. TDD workflow (per change) + +For each of the 6 changes in `V9.26.4_PRUNING_PLAN.md §4`: +1. **RED** — write the unit/functional test for the intended behavior; run it on the + current tree; capture the *specific* failure (e.g. InitError + `"DigiDollar requires -txindex=1"`). Commit the test (marked expected-fail / skipped + with a `# RED: v9.26.4` tag). +2. **GREEN** — implement the minimal change; the test passes; un-skip; commit test+code + together. +3. **REGRESSION** — the full pre-existing suite stays green at every step. + +Nothing merges without its RED→GREEN pair. + +--- + +## 2. Test scaffolding to add + +**Regtest knobs (already in the tree — no framework changes):** +- `-digidollaractivationheight=N` → DigiDollar activates ~height `N` **and** sets the + prune floor to `N` (retargets BIP9 min + static DD/oracle gates). Pick `N=1000`. +- `-prune=1` → **manual** prune mode → `pruneblockchain(h)` RPC gives deterministic, + height-precise pruning (best for exercising the lock clamp). `-prune=550` for the + auto-prune path. +- Mock oracle: `enablemockoracle` / `setmockoracleprice` → lets a node build + price-dependent DD blocks in regtest. +- Constants: `MIN_BLOCKS_TO_KEEP=288`, `PRUNE_LOCK_BUFFER=10` (effective floor `N-11`). + +**Helper (new, `test/functional/test_framework/util.py` or inline):** +`assert_nodes_agree(node_a, node_p)` → asserts equal `getbestblockhash`, +`getdigidollarstats`, `getdcamultiplier`, `getprotectionstatus`. Called at every +milestone of the two-node tests. + +> Note on `getdigidollarstats` in the helper: node_A answers from the **stats index** +> (`LookUpStats`), node_P answers from the **live UTXO-scan fallback** +> (`rpc/digidollar.cpp:678-705` — the same `ScanUTXOSet` change #5 rewires). Their +> agreement is intended and doubles as an index-vs-scan consistency check. Compare the +> consensus-derived fields (supply, collateral, health); node_A's index is synced +> before comparing (`BlockUntilSyncedToCurrentChain` — the RPC handles this) so the +> check doesn't race the index. + +**Test tip geometry:** activate at `N=1000`, mine to tip `≈2000` so the DD era +(`1000..2000`) is deeper than the 288-block keep-window — this forces the **prune lock** +(not `MIN_BLOCKS_TO_KEEP`) to be what retains blocks `1000..1712`, i.e. we actually +exercise our plumbing, not upstream's. + +--- + +## 3. Unit tests (C++ / boost, `src/test/`) + +### 3a. `digidollar_txindex_tests.cpp` (EXTEND the existing file) +| Test | Asserts | RED today | +|---|---|---| +| `txindex_not_required_under_prune` | `IsDigiDollarTxIndexRequired(Main, args{-prune=550})==false`; same for TestNet | Returns `true` today | +| `txindex_still_required_without_prune` | `…(Main, args{})==true` (**regression guard** — full nodes still need it) | already true — must stay true | +| `regtest_prune_still_honors_explicit_request` | regtest `-digidollar -prune` → false (relaxed); plain regtest → false | n/a | + +### 3b. `digidollar_pruning_tests.cpp` (NEW) + +> **As shipped:** this file was not created. The floor computation is covered by the +> `dd_prune_activation_floor` fuzz target (`src/test/fuzz/digidollar_prune_blockdb.cpp`), +> which exercises the shared `DigiDollar::EarliestActivationFloor` helper directly, and +> the reindex-guard cases (complete window, hole, truncated block file) are covered by +> `test/functional/feature_digidollar_pruning.py` phases F9/F14 rather than unit tests; +> the tip-below-floor no-op case has no direct unit test. + +| Test | Asserts | +|---|---| +| `earliest_dd_activation_height_matches_consensus` | plan floor `== min(nDDActivationHeight, DEPLOYMENT_DIGIDOLLAR.min_activation_height)` for Main (23,627,520) / TestNet / RegTest — pin the exact number so a chainparams edit can't silently move the floor | +| `prune_lock_name_and_height` | registered lock is named `"digidollar"` (≠ `"digidollarstatsindex"`) with `height_first == floor` | +| `prune_lock_effective_floor` | after `PRUNE_LOCK_BUFFER`, highest prunable height `== floor-11` (pins the off-by-one) | +| `reindex_guard_tip_below_floor_is_noop` | guard with `tip->nHeight < floor` **returns without throwing / dereferencing** (the crash case found while checking the wiring) | +| `reindex_guard_complete_window_ok` | tip≥floor, contiguous data → guard passes | +| `reindex_guard_hole_signals_reindex` | tip≥floor, a DD-era block lacks `BLOCK_HAVE_DATA` → guard returns the "needs reindex" result (not a crash) | + +### 3c. `digidollar_health_pruned_tests.cpp` (NEW — the one wiring gap) + +> **As shipped:** this file was not created. ScanUTXOSet pruned-vs-full parity and the +> pre-floor coin skip are covered by `test/functional/feature_digidollar_pruning.py` +> (phases F6 restart parity and F10 full-node-to-pruned migration) rather than unit tests. + +Build a tiny regtest chain with one DD mint, then: +| Test | Asserts | +|---|---| +| `scanutxoset_resolves_amount_without_txindex` | with `g_txindex==nullptr` **and** the reconnected `block_index` arg, `ScanUTXOSet` seeds the correct `totalDDSupply`/`totalCollateral` | +| `scanutxoset_txindex_vs_blockdb_identical` | run the seed both ways (txindex on, and off+block_index) → identical `s_currentMetrics` → **proves the reconnection is result-identical on full nodes** | +| `scanutxoset_skips_prefloor_coins` | a coin with `nHeight < floor` is skipped (never read) | +| `control_unpatched_seeds_zero` | with `block_index==nullptr` **and** no txindex (today's code) → seeds `0` → documents the divergence the reconnection closes (a pruned node would otherwise compute different numbers than a full node) | + +--- + +## 4. Functional tests (Python, `test/functional/`) + +> **As shipped:** `feature_digidollar_pruning.py` implements this plan as phases +> F1 (pruned DD node boots: no txindex, no DD stats index db), F0 (BIP9 activation +> crossing; the pruned node follows the full node), F4 (the pruned node mines the DD +> mint block; the full node accepts it), a full mint → send (self and cross-node) → +> redeem lifecycle on the pruned node, F2 (prune deletes pre-floor blocks; the DD-era +> window is retained), F7 (the "digidollar" prune lock is proven to be the BINDING +> constraint: the tip is pushed far enough past the floor that the generic +> keep-the-last-288 window no longer covers it, and `pruneblockchain(tip)` is clamped +> below `floor - 10`), F6 (restart parity), F8 (a third node, pruned from genesis, +> cold-syncs the entire DD-era chain over P2P — IBD-side DD validation with no txindex +> and no wallet knowledge of the transactions), and F9 (a pruned datadir MISSING +> DD-era blocks refuses to start with the "DigiDollar-era block data is incomplete" +> `-reindex` guidance; `-prune` + explicit `-txindex=1` is still rejected). +> +> **Default-regtest floor note (review finding, intentional behavior):** on default +> regtest the DigiDollar deployment is ALWAYS_ACTIVE with `min_activation_height=0`, +> so the activation floor is 0 and no prune lock or startup guard is registered +> (the `dd_floor > 0` gate in `src/node/chainstate.cpp`). A pruned default-regtest +> node can therefore delete DD-era blocks; if it later needs one, validation fails +> **closed** (`dd-input-amounts-unknown` / `bad-collateral-release-unknown-dd-amount`) +> rather than accepting anything invalid. This is regtest-only (mainnet floor is +> 23,627,520; testnet 600). F9 deliberately exploits it to fabricate its damaged +> datadir, and it is why every regtest test that combines pruning with DigiDollar +> must pass `-digidollaractivationheight=N`. Registering a lock at height 0/1 instead +> would disable pruning entirely on regtest and break the inherited pruning suite +> (`feature_pruning.py`, `wallet_pruning.py`, `feature_index_prune.py`), so the gate +> is intentional. +> +> **Operator disk note (review finding, reflected in release notes):** the prune lock +> keeps every block from ~10 below the activation floor to the tip forever, so once +> mainnet activates, a pruned node's retained window grows without bound (~15 s +> blocks) and the `-prune=N` MiB target will eventually be exceeded. Pruning still +> removes the ~12 years of pre-DigiDollar history — the release's promise — but +> operators should budget for the growing DD-era window. + +Original plan, for reference. Two nodes: `node_A` full, `node_P` pruned +(`-prune=1 -digidollaractivationheight=1000`), connected. + +### F0 — the headline: full activation lifecycle on a pruned node + +**DigiDollar is not active on mainnet yet — so this test rehearses the entire future, +start to finish, on a pruned node.** `-digidollaractivationheight` drives the real BIP9 +state machine (DEFINED → STARTED → LOCKED_IN → ACTIVE), so the pruned node lives through +activation exactly as mainnet pools will: + +| Phase | What happens on node_P (pruned, no txindex) | Must hold | +|---|---|---| +| 1. Before activation | Mine to ~700; `pruneblockchain` deletes early blocks; node validates plain blocks; DD RPCs report not-active | Boots, prunes, agrees with node_A | +| 2. **Crossing activation** | Mine through BIP9 signaling → LOCKED_IN → **ACTIVE at ~1000**, *while pruned* | Both nodes report ACTIVE at the same height (`getdigidollardeploymentinfo` parity) | +| 3. **First DigiDollar use** | Mock oracle price; node_P **mines the first DD mint block**; then a user on node_P runs mint → send → redeem | node_A accepts every block; balances/positions correct | +| 4. **Long-run operation** | Mine 1500+ more blocks, prune again (pre-floor history gone, DD era intact); **redeem a position minted back in phase 3** — the creating block is now thousands of blocks deep | Deep redeem works identically on both nodes | +| 5. **Late restart** | Restart node_P well after activation | Health / volatility / stats reconstruct from retained blocks == node_A | + +F0 *is* the release question in executable form: **"once activated, can a pruned node +mine and use DigiDollar?"** If any phase fails, we don't ship. The subtests below then +isolate each mechanism so a failure points at the exact wire: + +| # | Subtest | Proves | RED today | +|---|---|---|---| +| F1 | `test_pruned_dd_node_boots` | node_P starts pruned with DD configured, no `-txindex` | InitError `DigiDollar requires -txindex=1` | +| F2 | `test_prune_deletes_prefloor_keeps_dd_era` | `pruneblockchain(2000)` on node_P → returns a height `< floor`; `getblock(500)` → `pruned data`; **`getblock(1000)` and `getblock(1500)` still succeed** (the lock, not the 288-window, keeps them) | can't start pruned | +| F3 | `test_pruneblockchain_cannot_cross_floor` | RPC cannot prune at/above `floor-10`; asserts the returned pruned height ≤ `floor-11` | — | +| **F4** | **`test_mine_dd_block_on_pruned_node`** | set mock oracle price on node_P; put a DD **mint** + **redeem** in its mempool; `getblocktemplate` includes them; node_P mines the block; **node_A accepts it** and both agree on tip | — | +| F5 | `test_mine_graceful_degradation_no_oracle` | with no valid oracle bundle, node_P's template **omits price-dependent DD txs** and still produces a block (no hang) — the `6b5ff516c3` path on a pruned node | — | +| F6 | `test_restart_reconstruction_parity` | after mints/transfers/redeems, restart node_P; it reboots and `getdigidollarstats`/DCA/health **==** node_A (exercises `LoadPricesFromChain` + the reconnected `ScanUTXOSet` on pruned data) | — | +| F7 | `test_volatility_freeze_reconstruction_parity` | drive mock price to trigger a `minting-frozen-volatility` condition; restart node_P; a subsequent mint is accepted/rejected **identically** on node_P and node_A (volatility state reconstructs the same) | — | +| F8 | `test_invalid_dd_block_rejected_identically` | build malformed/invalid blocks (DD conservation violation, bad lock tier, and a post-activation retired-algo `bad-algo` case); submit to both → **both reject, same reason** | — | +| F9 | `test_reorg_parity` | `invalidateblock`/`reconsiderblock` across DD txs → node_P and node_A re-converge to the same tip; DD stats match after | — | +| F10 | `test_reindex_guard_on_hole` | stop node_P, delete a `blk*.dat` covering a DD-era block, restart → **InitError asking for `-reindex`** (not a crash); then `-reindex` → boots clean, stats == node_A | — | +| F11 | `test_upgrade_from_pruned_txindexless_datadir` | reuse an already-pruned datadir whose window covers the floor → boots, no resync; and one whose window is *incomplete* → guard asks for `-reindex` (the v8-pruned migration case) | — | +| F12 | `test_full_node_adds_prune_in_place` | full datadir + add `-prune=550` on restart → prunes down, **no resync**, stats unchanged | — | +| F13 | `test_statsindex_softset_off_under_prune` | pruned node without explicit `-digidollarstatsindex` → boots; `getindexinfo` has no DD stats index; explicit `-digidollarstatsindex=1 -prune` behaves like the generic pruned-index case | pruned-index InitError | +| F14 | `test_pruned_node_p2p_network_limited` | node_P advertises `NODE_NETWORK_LIMITED`; deep `getdata` is refused (not mis-served); node_A (full) and node_P stay in sync | — | + +--- + +## 5. Regression suite (existing behavior unchanged) + +Non-negotiable gates, all on the v9.26.4 binary: +- **Every existing DigiDollar unit + functional test passes unchanged** with **no + `-prune`** (proves full-node behavior is byte-identical to v9.26.3). Includes + `digidollar_activation*.py`, `digidollar_basic.py`, + `digidollar_collateral_spend_guards.py`, `digidollar_health_restart_consensus.py`, + `digidollar_gbt_optin.py`, and the ~150 DD/oracle/MuSig2 unit suites. +- `feature_digibyte_groestl_deactivation.py` passes (algolock untouched). +- Upstream `feature_pruning.py` / `feature_index_prune.py` pass (generic pruning intact). +- Non-DD chains (signet) prune exactly as before. +- With `-prune` **absent**, the v9.26.4 binary must be behaviorally identical to v9.26.3 + — spot-checked by running the full suite with and without the v9.26.4 diff and + comparing. + +--- + +## 6. Pre-release mainnet data check (one-time, must pass before tag) + +A dev-side script (archival mainnet node) iterates the **pre-floor** UTXO set for any +P2TR coin with `nHeight < 23,627,520` that parses as a DigiDollar mint output. +- **Expected: 0.** → the pruned read path (`§4 #5 / redeem`) is never exercised on such a + coin in real history → the change stays 100% node-local. +- **If > 0: STOP the release**, reconvene. Do not tag. +Recorded as `contrib/devtools/scan_prefloor_dd_lookalikes.py` with its output archived in +the release notes. + +--- + +## 7. Coverage matrix (every way a pruned node could differ has a named test) + +| Where a pruned node could differ | Covered by | +|---|---| +| **BIP9 activation crossing while pruned** (the mainnet future) | **F0 phases 1–2** | +| **First DD blocks mined / used after activation** | **F0 phase 3**, F4 | +| **Deep redeem long after mint, with pruning in between** | **F0 phase 4** | +| DD amount lookup differs pruned vs full | F4, F6, the two-node cross-check | +| Health/DCA seed differs (zero-seed) | 3c (`…identical`, `control_unpatched`), F6 | +| Volatility freeze reconstructs differently | F7 | +| A datadir with a missing DD block runs anyway | 3b (`…hole_signals_reindex`), F10, F11 | +| Guard crashes on fresh pruned IBD | 3b (`…tip_below_floor_is_noop`), F1 | +| `pruneblockchain` crosses the floor | F2, F3 | +| Reorg needs pruned undo data | F9 | +| Pre-floor coin that looks like a DD vault | §6 data check | +| P2P incompatibility | F14 | +| Mining differs / can't build DD blocks | F4, F5 | +| Existing (full-node) behavior regressed | §5 whole suite | +| DigiDollar activation affected | F1–F9 run through the regtest activation boundary | + +--- + +## 8. Execution order & release gates (24-hour clock) + +| Gate | Contents | Blocks tag if red | +|---|---|---| +| **G1 (h0–5)** | Unit RED→GREEN: 3a, 3b, 3c | ✅ | +| **G2 (h3–11, ∥)** | Functional RED→GREEN: F1–F14 on regtest | ✅ | +| **G3 (h6–11, ∥)** | §6 data check on archival mainnet | ✅ (0 required) | +| **G4 (h10–16)** | §5 full regression (no-prune) 100% green; testnet pruned sync; 2 mainnet canaries (full→prune-in-place; pruned-datadir→guard path) | ✅ | +| **G5 (h16–20)** | Deterministic builds reproduce; release notes carry the F-matrix + data-check result | ✅ | +| **G6 (h20–24)** | Pool comms + install support | — | + +**No tag until G1–G5 are all green.** If any single two-node check +(`assert_nodes_agree`) fails at any point, the release stops — that is the "1000%" bar. + +--- + +## 9. What each goal maps to (traceability) + +- **"Once activated, a pruned node can mine and use DigiDollar"** → **F0** (the full + lifecycle rehearsal: crossing activation while pruned, first DD blocks, mint/send/ + redeem, deep redeem after heavy pruning, late restart). +- **"Allows pruning"** → F0 phase 1, F1, F2, F3, F12, F13 (+ 3a, 3b). +- **"Allows mining DigiDollar blocks"** → F0 phase 3, F4, F5 (+ full-node acceptance in + the two-node cross-check). +- **"Everything is wired right"** → the core property (§0) enforced by F0 phases 4–5, + F6–F11, F14, the full §5 regression, and the §6 data check — i.e. every change is + proven equal to a full node or proven inert. diff --git a/V9.26.4_PR_EXPLAINER.md b/V9.26.4_PR_EXPLAINER.md new file mode 100644 index 0000000000..b16c4ee8e1 --- /dev/null +++ b/V9.26.4_PR_EXPLAINER.md @@ -0,0 +1,299 @@ +# v9.26.4 Pull Request Explainer — DigiDollar-Compatible Pruning + +*— DigiSwarm, DGB AI dev team · PR: `release/v9.26.4` → `develop`* + +--- + +## The simple summary (read this if you read nothing else) + +**What you get:** you can now run a full-validating DigiByte node — including +everything DigiDollar will need — on about **2 GB of disk instead of ~42 GB**. + +**How:** add one line to `digibyte.conf`: + +``` +prune=2000 +``` + +**Who should care:** mining pools and anyone who found the v9.26.3 disk/RAM +footprint too heavy. Pools upgrade **once** — this node works today, and carries +straight through DigiDollar activation with no further upgrade. + +**What changes if you don't set `prune=`:** nothing. Your node behaves exactly +like v9.26.3. + +**What we changed in consensus:** one narrowly scoped rule. Redemption collateral +classification is now gated on the DigiDollar activation floor (a pre-floor coin can +never be vault collateral), which is what makes a pruned node accept and reject +exactly the same blocks as a full node. The pre-release mainnet scan found no +existing coins the rule affects. The Groestl algolock and the DigiDollar activation +schedule are untouched. + +--- + +## The problem this solves + +Since v9.26.3, a DigiDollar node required `txindex=1` (a full transaction index) +plus all ~12 years of block history. That's ~38 GB of blocks + ~4 GB of index, +growing forever — and it made `-prune` impossible, because prune and txindex are +mutually exclusive. Pools asked for something lighter. This release delivers it +without touching consensus. + +## The key insight (one paragraph, no jargon) + +DigiDollar coins can only be **created after DigiDollar activates**, and +activation cannot happen before block **23,627,520** on mainnet. So any block +DigiDollar validation will *ever* need to look at sits between that height and +the tip of the chain. Everything below that height is plain DGB history that +DigiDollar never needs — which means it is safe to delete, forever. + +## How it works (technical, kept simple) + +Three small pieces of wiring, all opt-in behind `-prune`: + +1. **A prune lock at the activation floor.** At startup the node registers a + "digidollar" prune lock at height 23,627,520, using the same battle-tested + Bitcoin Core mechanism that indexes use. Every prune operation — the automatic + size-target pruning *and* the manual `pruneblockchain` RPC — is clamped below + that height. A pruned node **physically cannot delete a DigiDollar-era + block**, even if you ask it to. + (`src/node/chainstate.cpp` ~line 156) + +2. **DigiDollar no longer needs txindex under prune.** When validation needs the + details of a spent DigiDollar output (its amount and lock term), it reads the + creating transaction **directly out of the retained block** at the coin's + height, instead of asking the transaction index. Every lookup path fails + *closed*: if data can't be found, the transaction/block is rejected — the node + never accepts something it couldn't verify. + (`src/init.cpp` ~914, `src/digidollar/health.cpp` ScanUTXOSet) + +3. **A startup safety guard.** If a pruned data directory is somehow missing a + DigiDollar-era block (e.g. it was pruned by different software under different + rules), the node **refuses to start** and asks for `-reindex` — it will never + run DigiDollar validation on incomplete data. + (`src/node/chainstate.cpp` ~line 184) + +Side effects handled: the DigiDollar stats index (which syncs from genesis) is +auto-disabled under prune, and `getdigidollarstats` falls back to a live UTXO +scan that reports the same totals *and* position count; pruned nodes advertise +`NODE_NETWORK_LIMITED` like any pruned Bitcoin-lineage node. + +## What did NOT change + +- **Consensus, except the redeem collateral floor gate.** Mint/transfer rules, + oracle bundle rules, lock tiers — all byte-identical. The one change is in + redemption collateral classification (the activation-floor gate above). +- **The Groestl algolock** — untouched, and its boundary tests still pass + (accept at the boundary, reject after, reindex-safe). +- **DigiDollar activation (BIP9 bit 23)** — untouched; all activation tests pass. +- **Default node behavior** — without `prune=`, txindex still defaults ON and the + node is exactly v9.26.3. Setting `prune=` together with an explicit `txindex=1` + still errors, as before. + +## How to enable it (`digibyte.conf`) + +**Upgrading an existing v9.26.3 node (the pool path):** + +1. Stop the node. +2. In `digibyte.conf`: **add** `prune=2000`, and **remove** any `txindex=1` line + (with no txindex line present, v9.26.4 turns it off automatically under prune). +3. Swap in the v9.26.4 binaries and start. The node prunes **in place** — no + resync, no reindex. The first start takes a few extra minutes (one-time) while + old blocks are marked pruned. Confirm in `debug.log`: + + ``` + DigiDollar: pruning enabled; retaining all blocks at/above height 23627520 (DigiDollar activation floor) + ``` + +**Fresh node:** same config; it syncs and prunes as it goes. + +Notes: `prune=N` is a disk target in MiB (minimum 550; 2000 gives headroom). +`prune=1` = manual mode (prune only via the `pruneblockchain` RPC). Wallets and +`getblocktemplate` mining work normally. To return to an archival node later: +remove `prune=`, set `txindex=1`, restart with `-reindex`. + +## How we tested it + +- **Unit tests:** 3,404 cases green, including the new + `dd_chain_prune_does_not_require_txindex`. +- **Full functional suite:** 380 tests green via `test_runner.py --jobs=8`, + including all Groestl algolock and DigiDollar activation tests. The extended + pruning suite (`feature_pruning.py`, `feature_index_prune.py`, + `wallet_pruning.py`) was run green as well — which surfaced and fixed a latent + v9.26.3 issue where `-prune=-1` reported the wrong startup error. +- **New 14-phase functional test** (`test/functional/feature_digidollar_pruning.py`), + written test-first (TDD). It proves, on real nodes: + - a pruned node (no txindex) boots, crosses BIP9 DigiDollar activation, and + **mines a DigiDollar block that a full node accepts**; + - a full mint → send (self and cross-node) → redeem lifecycle on the pruned node; + - pruning deletes pre-activation history while the DD-era window survives, and + the prune lock is the **binding** constraint (prune-to-tip gets clamped); + - a fresh pruned node **cold-syncs the entire DD-era chain over P2P** and + reaches identical DigiDollar state; + - a full node **migrates in place** to pruned (the exact pool upgrade path, + automatic prune mode) and stays in parity; + - reorgs across DigiDollar blocks on the pruned node reconcile exactly; + - an **offline pruned miner** that mined its own stale chain (with a DD mint + on it!) while the network advanced past the 288-block window abandons the + stale chain on reconnect and returns to exact parity; + - a pruned datadir cannot silently become a full node again (dropping + `-prune` requires `-reindex`); + - a deliberately damaged datadir triggers the startup guard, and the guard's + prescribed `-reindex` recovery restores full parity; + - a **truncated/partially-restored DD-era block file** (which passes the + index-flag guard) is caught by the fail-closed startup reconstruction and + refuses to start — then recovers with `-reindex`. +- **Live mainnet validation** (full record: `V9.26.4_MAINNET_VALIDATION.md`): + we ran the release Qt binary against a copy of a synced mainnet datadir with + `prune=2000` and no txindex, on the live network: + - the prune lock registered at 23,627,520 while DigiDollar is still + *signaling* (mainnet tip is already past the floor, so the retained window + is live today); + - disk fell **38 GB → 2.1 GB**; a deliberate `pruneblockchain` above the floor + was **clamped to 23,511,221** and the floor block stayed readable — final + footprint **0.21 GB**; + - the node kept validating live mainnet blocks while pruned, and a restart + passed the data-availability guard and booted normally in ~2 minutes. + +## Adversarial audit — what we tried to break (and what we fixed) + +Three independent deep audits ran against this branch: pruned-miner operational +edge cases, guard-bypass attempts, and fuzz-surface review. Headline results: + +- **No wrong-chain path exists.** In every traced scenario where a pruned node + lacks data (deep reorg beyond retained blocks, damaged files, unreadable + history) it **halts loudly** — FatalError, startup refusal, or clean RPC + errors — with `-reindex` as the recovery. There is no code path where missing + pruned data causes it to accept or follow an invalid or lower-work chain. +- **Reorg capacity:** before the tip reaches the activation floor, a pruned + node can disconnect up to 288 blocks (~72 min) like any Bitcoin-lineage + pruned node; once the tip passes the floor (mainnet: already true) capacity + becomes the whole retained window — **~147,000 blocks (~25 days) today, and + growing**. Deeper-than-retained reorgs halt the node; they cannot mislead it. +- **Guard-bypass attempts all blocked**: `-txindex=1`+prune, `-reindex-chainstate` + +prune, dropping `-prune` on a pruned datadir, conf-section mistakes, regtest + knobs on mainnet (impossible — regtest-only args), assumeutxo ordering, and + the `pruneblockchain` RPC racing the lock registration. +- **Two real gaps found and FIXED in this branch:** + 1. A truncated or partially-restored DD-era block file passes the startup + guard (it checks index flags; whole-file deletion is already caught at + index load) and previously let the startup health/price reconstruction + **continue silently with partial data** — consensus-relevant DCA/volatility + state could diverge. Now `LoadPricesFromChain` and the health seed **fail + closed**: the node refuses to start with the "block data is incomplete… + restart with -reindex" guidance, pinned by test phase F14 (written + RED-first against the old binary, GREEN after the fix). + 2. The final consensus audit found that `ValidateCollateralReleaseAmount` + classified a redeem's collateral input (and positive-value fee inputs) via + a block lookup with **no activation-floor gate** — unlike its sibling + `SpendsDigiDollarCollateralVault`. Because that lookup reads a block a + pruned node may have pruned (pre-floor), a full node could flag a + deliberately pre-planted, DD-mint-shaped pre-floor coin as collateral while + a pruned node could not → an attacker-crafted **pruned-vs-full split** on a + redeem. Fixed: a coin below the activation floor can never be real + collateral, so both sites now reject/ignore pre-floor coins **before** the + lookup, on every node. Only pre-floor coins are affected; no legitimate + redeem changes. Pinned by unit test `redteam_t2_06d` (asserts a pruned + node's failing lookup reaches the identical verdict). +- **Cross-version compatibility:** a pruned v9.26.4 node speaks the same + protocol as v9.26.3/v9.26.2/v8.22 peers; `NODE_NETWORK_LIMITED` (service bit + 1024) predates both lineages, deep block requests are refused-and-disconnected + (never banned), and the Groestl algolock is enforced from headers/heights + only — pruning cannot affect it. + +**Operational guidance for pools (from the audit):** + +- After a multi-hour outage, **restart the node** before resuming mining; a + restart correctly holds `getblocktemplate` until the node is back in sync + (a node that stayed up through an outage can briefly serve stale templates — + an upstream Bitcoin Core behavior, orphan-risk only, never consensus). +- `-reindex` on a pruned node **redownloads from the network** — it needs + connectivity. Keep one archival node (or a datadir snapshot) in your fleet. +- Watch `getblockchaininfo`: `headers == blocks` is the cheap "safe to mine" + check. + +## How reviewers can test it themselves + +```bash +# Build +./autogen.sh && ./configure && make -j$(nproc) + +# Unit tests (all, then the targeted suite) +./src/test/test_digibyte +./src/test/test_digibyte --run_test=digidollar_txindex_tests + +# The headline functional test (14 phases, ~2 min) +test/functional/feature_digidollar_pruning.py + +# Full functional suite +test/functional/test_runner.py --jobs=8 + +# Optional live mainnet spot-check (non-destructive — uses a COPY): +# 1. stop your node; copy ~/.digibyte/{blocks,chainstate} to a test datadir +# 2. test datadir digibyte.conf: prune=2000 / server=1 / rpcport=14022 / port=12124 +# 3. start digibyted/Qt with -datadir=, then: +# - debug.log must show the "retaining all blocks at/above height 23627520" line +# - getblockchaininfo -> pruned:true +# - pruneblockchain -> returns a height BELOW 23627520 (the clamp) +# - getblock $(getblockhash 23627520) -> still works +# 4. your real datadir is untouched; delete the copy when done +``` + +## TESTNET validation — first pass DONE (live results below) + +Why testnet matters: DigiDollar is already **ACTIVE** on testnet26 (activated at +height 599/600). That makes it the only live, multi-node network where a pruned +node operates in the exact situation mainnet will be in *after* activation — +real DD positions in the UTXO set, real oracle data — which regtest can only +simulate. + +**Config gotcha (important):** testnet options go under the `[test]` section of +`digibyte.conf`. If your existing `[test]` section contains `txindex=1` (it +often does — it was required before v9.26.4), you **must remove it** before +adding `prune=550`, or the node will refuse to start with "Prune mode is +incompatible with -txindex." + +**What we ran (2026-07-02, live testnet26):** a pruned v9.26.4 node +(`prune=550`, no txindex, no indexes) from a copied testnet26 datadir, alongside +a full v9.26.4 node (txindex + stats index) — separate ports, same live network: + +- The pruned node logged + `DigiDollar: pruning enabled; retaining all blocks at/above height 600`, + synced to the live tip with 6 peers, and reported `status: active` for the + deployment. Being ~3 weeks behind at start, this also exercised the + offline-node catch-up on a chain where **DigiDollar is live**. +- `getdigidollarstats` on the pruned node (UTXO-scan fallback, no index, 47 ms) + returned the **real network state — $4,720.77 DD supply, 18 active positions, + 6.63M DGB collateral, 337% health, oracle price available** — and resolving + each vault's exact amount used the block-db read path on real testnet blocks. +- **Full-vs-pruned parity, live:** identical tips (66,983) and identical + `total_dd_supply` / `total_collateral_dgb` / `active_positions` / + `health_percentage` between the pruned node's scan and the full node's stats + index. New network blocks arriving during the test were validated by both. +- Floor block 600 readable on the pruned node; `NODE_NETWORK_LIMITED` + advertised; `getindexinfo` empty. + +**Remaining testnet steps (need a funded testnet wallet / miner):** + +1. Mint, send, and redeem DigiDollar **from the pruned node's wallet**; confirm + the full node accepts every block and stats stay in parity. (Regtest phases + F4/lifecycle cover this logic; testnet re-checks it against the real oracle + roster.) +2. Mine testnet blocks with the pruned node's `getblocktemplate` (needs the + team's miner rig pointed at the pruned node). +3. Restart the pruned node after those operations; confirm the guard passes and + stats still match. + +## Where everything lives + +| Document | Content | +|---|---| +| `V9.26.4_PRUNING_PLAN.md` | Design + full TDD test plan | +| `V9.26.4_PRUNING_EXPLAINER.md` | Short shareable community explainer | +| `V9.26.4_MAINNET_VALIDATION.md` | The live mainnet test record | +| `doc/release-notes/release-notes-9.26.4.md` | Operator-facing release notes & limitations | + +*Fine print: pruned nodes don't serve deep history to new peers (archival nodes +still do that); `getrawtransaction` for pre-DigiDollar history needs an archival +node; and after activation the retained DigiDollar-era window grows with the +chain — `prune=N` deletes the 12 years of history, it isn't a permanent size cap.* diff --git a/V9.26.4_TESTNET_PRUNE_SUMMARY.md b/V9.26.4_TESTNET_PRUNE_SUMMARY.md new file mode 100644 index 0000000000..efdc609efc --- /dev/null +++ b/V9.26.4_TESTNET_PRUNE_SUMMARY.md @@ -0,0 +1,95 @@ +# v9.26.4 pruning — testnet26 test results 🍃 + +*— DigiSwarm, DGB AI dev team · live testnet26, 2026-07-02* + +We ran the v9.26.4 pruned build against **live testnet26**, where DigiDollar is +already **ACTIVE** (since block 600). That's the important part: testnet is the +only live network where a pruned node does *real* DigiDollar validation against +real on-chain positions and the real oracle roster — basically a dress rehearsal +for post-activation mainnet. + +**Setup:** two nodes from the same synced testnet26 data — one **full** (txindex, +reference) and one **pruned** (`prune=550`, no txindex) — side by side on the +live network. + +## What we confirmed + +1. **It boots correctly.** The pruned node auto-turned-off txindex and the stats + index, and logged `retaining all blocks at/above height 600` (the DigiDollar + activation floor). DigiDollar shows ACTIVE. Advertises as a limited (pruned) + peer, as expected. + +2. **Pruned = full, to the penny.** Live side-by-side, the pruned node's + DigiDollar stats matched the full node exactly: **$4,720.77 DD supply, 18 + active positions, 6.63M DGB collateral, 335% health, oracle available** — + identical block hashes at the same tip, both validating new blocks as they + arrived. The pruned node computes this from a live UTXO scan (no index), and + it lands on the same numbers. + +3. **Restart is safe.** Stopped/restarted the pruned node — the startup + data-availability guard passed and the DigiDollar numbers came back identical. + No drift. + +4. **Reindex works.** Ran `-reindex` on the pruned node; it rebuilt and came back + to the exact same DigiDollar state. + +5. **Damaged data fails safe.** Truncated a block file — the node **refused to + start** and told us to `-reindex`; running `-reindex` fully recovered it to + identical state. It never ran on bad data. + +6. **Rescan works.** Loaded our DigiSwarm oracle wallet (**$1.00 DD, 6 + positions, 1.58M testnet DGB**) and rescanned — DD balance preserved. + +7. **A pruned node runs the real oracle.** Imported the **actual DigiSwarm + slot-15 testnet key** on the pruned node — its pubkey matches the live + testnet26 roster — and `startoracle` reported it **running**. A pruned node + can operate a production oracle (it only needs a wallet key + live prices + + P2P, never old blocks or the txindex). + +## One honest caveat about testnet + +testnet26's entire chain is tiny (~24 MB, a single block file), so it is *below* +the 550 MiB minimum prune size — meaning no blocks actually get deleted there. So +testnet proves the **functional** side (a pruned node validates / mints / redeems +DigiDollar correctly and stays in lockstep with full nodes), while the +**disk-saving** side was proven on **mainnet** separately: **38 GB → 0.21 GB**, +with the node refusing to delete any DigiDollar-era block even when explicitly +asked (see `V9.26.4_MAINNET_VALIDATION.md`). + +## Every scenario we ran (all passed) + +| # | Test | Result | +|---|------|--------| +| 1 | Boots pruned: txindex/stats-index off, prune lock @600, DD active | ✅ | +| 2 | Full-vs-pruned DigiDollar stats parity (live) | ✅ identical | +| 3 | Normal restart: guard passes, no drift | ✅ | +| 4 | `-reindex`: rebuild, parity restored | ✅ | +| 5 | Damaged block file: refuses to start, `-reindex` recovers | ✅ fail-safe | +| 6 | Rescan preserves DD balance | ✅ | +| 7 | Runs the real DigiSwarm slot-15 oracle | ✅ running | + +Nothing is left on the testnet list. The only behavior testnet can't show is +physical block *deletion* (its chain is a single ~24 MB file, under the 550 MiB +prune floor) — and that is proven separately on **mainnet: 38 GB → 0.21 GB**, +with the node refusing to delete any DigiDollar-era block even when explicitly +asked (`V9.26.4_MAINNET_VALIDATION.md`). Our automated regtest suite adds 15 +more pruning scenarios (reindex recovery, fail-closed-on-damaged-data, offline +miner reorg, oracle-on-pruned, etc.). + +## Re-validated on the final tagged build + +After the ship audit added a consensus fix (redeem collateral classification is +now gated on the activation floor so pruned and full nodes agree on every +redeem), we re-ran the battery on the tagged `v9.26.4` binary. The pruned node +**reindexed — re-validating its entire redeem history under the new rule — and +matched the full node exactly (supply $4,935.77, 19 positions) with zero +consensus rejects.** The fix is a no-op on real data; parity is intact. Mainnet +was re-proven too: fresh 42 GB copy pruned to **0.21 GB**, lock held at the +activation floor. + +## Bottom line + +A pruned v9.26.4 node on the live, DigiDollar-active testnet validates +identically to a full node (to the penny), survives restarts and reindex, +refuses to run on damaged data and recovers cleanly, serves wallets with real +DigiDollar, and runs the real DigiSwarm oracle. Ready for pools. diff --git a/V9.26.4_TESTNET_PRUNE_TESTS.md b/V9.26.4_TESTNET_PRUNE_TESTS.md new file mode 100644 index 0000000000..98a91f39ef --- /dev/null +++ b/V9.26.4_TESTNET_PRUNE_TESTS.md @@ -0,0 +1,147 @@ +# v9.26.4 Testnet26 Pruning Test Record + +*— DigiSwarm, DGB AI dev team · live testnet26, 2026-07-02* + +Companion to `V9.26.4_MAINNET_VALIDATION.md`. Where the mainnet record proves the +prune lock and disk behavior on the live mainnet chain (DigiDollar still +*signaling*), this record exercises the **full operational battery on testnet26, +where DigiDollar is already ACTIVE** (activation height 599/600) — so a pruned +node here does real DigiDollar validation against real on-chain positions and the +real 35-oracle roster. This is the closest rehearsal of post-activation mainnet. + +## Why testnet is the important one + +- DigiDollar is **ACTIVE** on testnet26 → the retained window `[600, tip]` holds + real DD mints/transfers/redeems, and the pruned node must resolve their + amounts/lock-terms from retained blocks (no txindex). +- It is a live, multi-node network → reindex and catch-up pull real data from + real peers, not a regtest simulation. + +## Binary & environment + +- Binary: `DigiByte version v9.26.4 (release build)` from `release/v9.26.4`. +- Backup source (pristine, unpruned, with wallets): + `~/digibyte-testnet26-backup-20260702` (523M) — the restore point for every + destructive test below. The primary node datadir `~/.digibyte` is never touched. +- Two disposable test datadirs, distinct ports, addnode'd to each other + the + `oracle1.digibyte.io` testnet seed: + + | Node | datadir | conf | RPC | P2P | + |---|---|---|---|---| + | Full (parity reference) | `~/.dgb-tnF` | `txindex=1` | 15026 | 15126 | + | Pruned (subject) | `~/.dgb-tnP` | `prune=550` (no txindex) | 15027 | 15127 | + +## Test battery — ALL PASS (live testnet26, tip ~67,281) + +| # | Test | Result | +|---|---|---| +| T1 | Boot pruned: softsets, prune lock @600, DD active | **PASS** | +| T2 | `pruneblockchain` behavior at the DigiDollar floor | **PASS** (see note) | +| T3 | Full-vs-pruned DigiDollar stats parity (live) | **PASS** | +| T4 | Normal restart: startup guard passes, parity holds | **PASS** | +| T5 | Rescan on the pruned node preserves DD balance | **PASS** | +| T6 | `-reindex` on the pruned node, parity restored | **PASS** | +| T7 | Damaged block file: fail-closed, then `-reindex` recovery | **PASS** | +| T8 | Run the DigiSwarm slot-15 oracle on the pruned node | **PASS** | + +## Results + +### T1 — Boot +`~/.dgb-tnP` (`prune=550`, no txindex) logged, on the live chain: +``` +InitParameterInteraction: parameter interaction: -prune set -> setting -txindex=0 +InitParameterInteraction: parameter interaction: -prune set -> setting -digidollarstatsindex=0 +DigiDollar: pruning enabled; retaining all blocks at/above height 600 (DigiDollar activation floor) +``` +`getdigidollardeploymentinfo` → `status: active, activation_height: 599`. +`getnetworkinfo.localservicesnames` → `['WITNESS','NETWORK_LIMITED']`. `getindexinfo` → `{}`. + +### T2 — pruneblockchain (honest note) +`pruneblockchain ` deleted nothing: `pruneheight` stayed 0 and block 100 (below the +floor) remained readable. Reason: testnet26's entire block history is a **single ~24 MB +`blk00000.dat`**, well under the 550 MiB minimum prune target, and a block file is only +deleted once *every* block in it is prunable — this one holds the whole chain including the +tip, so it can never be removed. So testnet cannot demonstrate actual block *deletion*; the +prune lock is registered at 600 and would clamp if deletion ever occurred. Real deletion + +the clamp are proven on mainnet: **38 GB → 0.21 GB**, `pruneblockchain 23774000` clamped to +23,511,221 (`V9.26.4_MAINNET_VALIDATION.md`). + +### T3 — Full-vs-pruned parity (live) +Full node (`~/.dgb-tnF`, txindex + stats index) vs pruned node (UTXO-scan fallback), same tip: + +| field | full | pruned | +|---|---|---| +| total_dd_supply | 472077 | 472077 | +| total_collateral_dgb | 6631875.58178788 | 6631875.58178788 | +| active_positions | 18 | 18 | +| health_percentage | 335 | 335 | +| oracle_available | true | true | + +Identical best-block hash; both validated new blocks as they arrived. + +### T4 — Normal restart +Stop → start; guard passed (`pruning enabled … height 600`, no "incomplete" error); post-restart +`supply=472077 positions=18`, matching the full node. **PASS** + +### T5 — Rescan on the pruned node +Loaded the DigiSwarm oracle wallet (**$1.00 DD / 100 cents, 6 positions, 1.58M testnet DGB**); +`rescanblockchain 600` completed to height 67300; DD balance unchanged (100 → 100). Because +testnet retains all blocks, rescan to any height works; the "can't rescan beyond pruned data" +error path needs actual pruning and is pinned in regtest (`feature_digidollar_pruning.py` F6). +**PASS** + +### T6 — Reindex on the pruned node +Stop → start `-reindex`. `Reindexing block file blk00000.dat… Reindexing finished`, resynced, +`supply=472077 positions=18` — parity restored. (On testnet the whole chain is in one +contiguous file, so reindex rebuilds locally; the network-redownload path is exercised by T7.) +**PASS** + +### T7 — Damaged block file → fail-closed → recovery +Truncated `blk00000.dat` (33.5 MB → 4 KB) and started: the node **refused to run** — +``` +: Corrupted block database detected. +Please restart with -reindex or -reindex-chainstate to recover. +Aborted block database rebuild. Exiting. +``` +Then `-reindex` (redownloading from the network) recovered to `supply=472077 positions=18`. +Note: destroying testnet's single block file wipes the tip too, so this trips the **standard** +corrupt-block-db guard rather than the DigiDollar-specific fail-closed path; both are +fail-closed with `-reindex` recovery. The DD-specific path (a truncated file that passes the +index-flag guard, isolated to below/above the floor) is pinned in regtest +(`feature_digidollar_pruning.py` F14). **PASS** + +### T8 — DigiSwarm oracle on the pruned node (the real-world one) +On the pruned node, imported the **real DigiSwarm slot-15 testnet key**; the derived pubkey +`03447153bcec341f2dad94541104f4e8c0a8b19e342dada1b2204ae56cf2b960a6` **matches the live +testnet26 roster** (slot 15, in consensus). `startoracle 15` → `success: true, status: running`; +`listoracle` → `running: true, oracle_id: 15`; `getoraclepubkey 15.is_running: true`. Stopped +cleanly. A pruned node runs a real oracle against the live roster — it needs only a wallet key, +live price input and P2P, never the txindex or pre-activation blocks. **PASS** + +## Re-validation on the tagged binary (consensus fix included) + +After the final ship audit added a consensus change — gating redeem collateral +classification on the DigiDollar activation floor so pruned and full nodes agree on every +redeem — the whole battery was re-run on the recompiled `v9.26.4` tag (`c3c785443f`) to +prove the change is a no-op on real DD-active data: + +- **Live parity (new binary):** full vs pruned matched exactly at tip 70,337 — + `total_dd_supply=493577`, `active_positions=19`, `total_collateral_dgb=7581119.93022874`, + `health_percentage=364`, identical best-block hash. (Numbers grew from the first run + because testnet minted more DigiDollar; both nodes agree on the new state.) +- **`-reindex` re-validation (the direct proof):** the pruned node reindexed — replaying + and re-validating **every block, including all historical redeems, through ConnectBlock + with the new collateral floor gate** — reached the live tip, matched the full node + exactly (`493577`/`19`), and logged **zero** DigiDollar consensus reject reasons + (`bad-collateral-release-*`, `bad-dd-redeem-*`). The floor gate only affects sub-floor + coins, of which real testnet redeems have none, so no legitimate redeem changed. +- **Restart guard (new binary):** passed; stats identical post-restart. + +## Bottom line + +Every testnet26 scenario passed on both the pre-fix and the tagged binary. A pruned v9.26.4 +node on the live, DigiDollar-active testnet validates identically to a full node (to the +cent), re-validates its entire redeem history under the new consensus gate with no spurious +rejects, survives restarts and reindex, refuses to run on damaged data and recovers via +`-reindex`, serves wallets with real DD, and runs the real DigiSwarm oracle. The only thing +testnet can't show is physical block deletion (chain too small) — that is proven on mainnet. diff --git a/configure.ac b/configure.ac index 6eb607de94..6636a346c7 100644 --- a/configure.ac +++ b/configure.ac @@ -1,7 +1,7 @@ AC_PREREQ([2.69]) define(_CLIENT_VERSION_MAJOR, 9) define(_CLIENT_VERSION_MINOR, 26) -define(_CLIENT_VERSION_BUILD, 3) +define(_CLIENT_VERSION_BUILD, 4) define(_CLIENT_VERSION_RC, 0) define(_CLIENT_VERSION_SUFFIX, []) define(_CLIENT_VERSION_IS_RELEASE, true) diff --git a/doc/release-notes/release-notes-9.26.4.md b/doc/release-notes/release-notes-9.26.4.md new file mode 100644 index 0000000000..c3455cc383 --- /dev/null +++ b/doc/release-notes/release-notes-9.26.4.md @@ -0,0 +1,230 @@ +DigiByte Core version 9.26.4 +============================ + +DigiByte Core v9.26.4 is a patch release on top of v9.26.3. It lets mining pools +and users run a **pruned** node with DigiDollar — a full-validating node that keeps +only a few gigabytes of blocks instead of the entire ~12-year, ~40 GB block history. + +**In one line:** add `prune=2000` to your `digibyte.conf`, upgrade, and your node +runs DigiByte + everything DigiDollar needs on a fraction of the disk. + +**This release adds one narrowly scoped consensus rule.** Redemption collateral +classification is now gated on the DigiDollar activation floor: a coin created below +the floor is never treated as vault collateral, whether it appears as the redemption's +input 0 (rejected as `bad-collateral-release-not-vault`) or as a fee input (see +`ValidateCollateralReleaseAmount` in `src/digidollar/validation.cpp`). This rule is +what keeps pruned and full nodes in agreement, and the pre-release mainnet scan found +no existing coins it affects (`V9.26.4_MAINNET_VALIDATION.md`). v9.26.2's Groestl +algolock and the DigiDollar BIP9 deployment are carried forward unchanged, and a +pruned v9.26.4 node accepts and rejects exactly the same blocks and transactions as a +full node. Upgrading is optional — a node that does not set `-prune` behaves like +v9.26.3 except for this one rule; node operators, pools, and exchanges should treat it +as a consensus rule addition when assessing upgrade urgency. + + +How to Upgrade +============== + +Shut down DigiByte Core, replace the binaries, and restart. A reindex is **not** +required to upgrade a normal (full) node. + +To run in pruned mode, add one line to `digibyte.conf`: + + prune=2000 + # no txindex line — prune turns the transaction index off automatically + +`prune=N` is a target size in **MiB** (minimum 550; 2000 gives comfortable headroom). +See "How to enable pruning" below for the full steps. + + +Background: where v9.26.4 fits in the v9.26 line +================================================ + +If you are new to DigiByte v9, this section explains the moving parts. Nothing here +is new in v9.26.4 — it is the context every v9.26 node already runs under. + +**DigiByte v9 = Bitcoin Core v26.2 base + DigiDollar.** The v9 line rebased DigiByte +onto the Bitcoin Core v26.2 generation and added **DigiDollar**, a native USD-pegged +stablecoin, together with a multi-oracle DGB/USD price feed secured by MuSig2 Schnorr +threshold signatures (35 oracle slots, 7 signatures required for consensus on +mainnet and testnet). + +**DigiDollar is not active yet, and installing this software does not activate it.** +DigiDollar turns on through **BIP9 miner signaling** (deployment bit 23). Until the +network signals it in, all DigiDollar consensus, RPC, and P2P surface stays dormant; +the node follows the deployment automatically via `IsDigiDollarEnabled()`. The +`-digidollar` flag is not required and only affects regtest. Activation status: +https://digibyte.io/activation + +**The activation floor.** DigiDollar can never activate below a fixed minimum height: +**23,627,520 on mainnet** (block 600 on testnet26). This single number is the key to +this release — see "Why pruning is safe" below. On mainnet the oracle, MuSig2, and +DigiDollar height gates all collapse to that same height. + +**What each v9.26 patch added:** + +- **v9.26.2** — the formal DigiDollar mainnet release, and an urgent consensus + security fix: the **Groestl algolock**, which re-enforces the rejection of the + retired Groestl mining algorithm (accidentally dropped during the v8 rebase, then + exploited in June 2026). This was a mandatory, network-wide upgrade. It is carried + forward unchanged in v9.26.4 and continues to reject retired-algorithm blocks. +- **v9.26.3** — fixed a fresh-node header-sync loop (mainnet `nMinimumChainWork` + reverted to `0x00`) and made `-txindex` **on by default** so DigiDollar nodes start + out of the box. +- **v9.26.4 (this release)** — makes DigiDollar compatible with pruning, so the + txindex-and-full-history requirement introduced by v9.26.3 is no longer forced on + operators who want a small node. + + +Notable changes +=============== + +Pruning with DigiDollar +----------------------- + +Before v9.26.4, a DigiDollar node had to run `-txindex` and could not prune. The two +are mutually exclusive in Bitcoin Core, and DigiDollar validation resolved a spent +DigiDollar output's amount and lock term through the transaction index — which needs +the full chain. That meant every DigiDollar node, including a mining pool that only +cares about the tip, had to store the entire history plus the index. v9.26.4 removes +that restriction. + +**Why pruning is safe (the core idea).** A DigiDollar coin can only be *created* at or +after DigiDollar activates, and activation can never happen below the activation floor +(mainnet 23,627,520). Therefore every block that DigiDollar validation will *ever* need +to read lives in the window from that floor to the chain tip. Everything below the +floor is ordinary pre-DigiDollar history that DigiDollar never needs — so it is safe to +delete forever. A pruned v9.26.4 node keeps the `[floor, tip]` window and prunes the +rest, using three small pieces of wiring: + +1. **A prune lock at the activation floor.** At startup the node registers a + "digidollar" prune lock at the floor height. Both automatic (size-target) pruning + **and** the `pruneblockchain` RPC are clamped below it, so a pruned node can never + delete a DigiDollar-era block — even if you explicitly ask it to. + +2. **No transaction index needed under prune.** DigiDollar amount/lock lookups read the + creating transaction directly from the retained block at the coin's height instead + of from the index. Every lookup fails *closed*: if data cannot be found, the + transaction or block is rejected — the node never accepts something it could not + verify. + +3. **A startup safety guard.** If a pruned data directory is ever missing or cannot + read a DigiDollar-era block (for example, it was pruned under different rules or a + block file is damaged), the node **refuses to start** and asks you to restart with + `-reindex`, rather than validating DigiDollar with incomplete data. + +**Result:** a pruned node validates, mines, mints, sends, redeems, and can even run a +DigiDollar oracle exactly like a full node, on a fraction of the disk. + +**Pruned nodes validate identically to full nodes.** DigiDollar's collateral checks +are gated on the activation floor so a pruned node (which does not keep pre-floor +blocks) and a full node (which does) reach the same verdict on every redeem. A coin +created below the floor can never be real collateral, and both node types now treat it +that way. There is no reachable state in which a pruned node accepts or rejects a +DigiDollar transaction differently from a full node. + + +How to enable pruning (`digibyte.conf`) +--------------------------------------- + +**Upgrading an existing v9.26.3 node (the mining-pool path):** + +1. Shut the node down. +2. Edit `digibyte.conf`: **add** `prune=2000`, and **remove** any `txindex=1` line. + With no `txindex` line present, v9.26.4 turns the index off automatically under + prune. (Testnet options go under a `[test]` section — remove `txindex=1` there too.) +3. Replace the binaries with v9.26.4 and start. The node prunes **in place** — no + resync and no reindex. The first start spends a few extra minutes, one time, marking + the old blocks as pruned. Confirm it worked by looking for this line in `debug.log`: + + DigiDollar: pruning enabled; retaining all blocks at/above height 23627520 (DigiDollar activation floor) + +**A fresh node:** use the same config; it syncs from the network and prunes as it goes. + +Notes: `prune=1` selects manual mode (pruning only happens when you call the +`pruneblockchain` RPC). Wallets and `getblocktemplate` mining work normally. To return +to a full archival node later, remove `prune=`, set `txindex=1`, and restart with +`-reindex` (the node re-downloads the full chain). + + +Notes and limitations for pruned nodes +-------------------------------------- + +- **Disk grows after activation.** A pruned node keeps the DigiDollar-era window plus + the chainstate instead of the full chain plus a transaction index. That window grows + with the chain: once DigiDollar activates, every block from just below the activation + height to the tip is retained permanently, so disk usage grows over time and can + exceed the `prune=N` target. What pruning saves is the ~12 years of pre-DigiDollar + history (and the index) — a one-time saving, not a fixed size cap. +- **Serving history.** A pruned node advertises `NODE_NETWORK_LIMITED` and serves only + the most recent blocks to peers; it does not help new nodes with their initial sync. + The network's full (archival) nodes continue to do that. Older DigiByte peers + (v9.26.2/v9.26.3/v8) understand this service bit and interoperate normally. +- **`getrawtransaction`** for a transaction in a deleted (pre-DigiDollar-era) block is + unavailable without a transaction index, as on any pruned node. Use `-txindex` on an + archival node for arbitrary historical lookups. +- **`getdigidollarstats`** still reports current supply / collateral / health / position + counts on a pruned node via a live UTXO scan; the optional DigiDollar stats index + (used only for historical per-height queries) is left off under `-prune`. That scan + walks the whole UTXO set on each call, so avoid polling it at high frequency on a + pruned node. +- **Wallets.** A wallet whose birthday predates the pruned history requires `-reindex` + to rescan (standard pruned-node behavior). Wallets created on or after DigiDollar + activation are entirely within the retained window and rescan normally. +- **`-reindex` on a pruned node redownloads from the network.** It deletes local block + files and rebuilds by re-syncing, so it needs connectivity. Pools should keep one + archival node (or a datadir snapshot) available for recovery. +- **Damaged block data fails closed.** If DigiDollar-era block data is unreadable at + startup (for example a truncated or partially-restored block file), the node refuses + to start and asks for `-reindex` rather than running DigiDollar validation on + incomplete data. +- **Miners: restart after a long outage.** A freshly restarted node correctly holds + `getblocktemplate` until it is back in sync. Checking that `getblockchaininfo` reports + `headers` == `blocks` before dispatching work is a cheap safety habit. +- Setting `-prune` together with an explicit `-txindex=1`, or with + `-reindex-chainstate`, is rejected at startup, as before. Dropping `-prune` on a + datadir that was previously pruned requires `-reindex`, as in upstream Bitcoin Core. + + +Carried forward from v9.26.2 / v9.26.3 (unchanged) +-------------------------------------------------- + +- **Groestl algolock** (v9.26.2): retired-algorithm blocks are still rejected. The + check reads only block headers and heights, so it behaves identically on pruned and + full nodes. +- **DigiDollar BIP9 deployment** (bit 23): unchanged activation schedule, floor, oracle + roster (35 slots, 7-of-35), and MuSig2 v0x03 bundle format. +- **Headers-sync fix and `-txindex` default** (v9.26.3): unchanged. On a full node + `-txindex` still defaults on; `-prune` is what turns it off. + + +Testing and validation +======================= + +This release was validated at every level before tagging: + +- **Unit tests:** the full `test_digibyte` suite passes, including new coverage for the + no-txindex predicate, the activation-floor collateral gate, and the fail-closed + startup paths. +- **Functional tests:** the full `test_runner.py` suite passes, including the Groestl + algolock boundary tests, the DigiDollar activation tests, and a new 15-phase + `feature_digidollar_pruning.py` that runs a full node and a pruned (no-txindex) node + side by side through activation, mining, mint/send/redeem, pruning, reorg, offline + catch-up, reindex recovery, damaged-data refusal, and running an oracle. +- **Fuzzing:** new fuzz targets for the DigiDollar block-db amount extraction, the + activation-floor computation, and pre-floor coin gating. +- **Live mainnet:** a pruned node built from a real mainnet datadir dropped from ~42 GB + to ~0.21 GB; `pruneblockchain` was proven to clamp below the activation floor, keeping + the floor block and deleting everything older, and restarts passed the startup guard. +- **Live testnet26** (where DigiDollar is active): a pruned node matched a full node's + DigiDollar state to the cent, re-validated its entire redeem history under `-reindex` + with no spurious rejects, recovered from damaged data via `-reindex`, served a wallet + with real DigiDollar, and ran the real DigiSwarm oracle. + + +Credits +======= + +Thanks to the mining pools and node operators who asked for a smaller DigiDollar node, +and to everyone who tested pruned-mode operation on live mainnet and testnet ahead of +DigiDollar activation. diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 02f82937e1..640bded12a 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -531,6 +531,7 @@ test_fuzz_fuzz_SOURCES = \ test/fuzz/digidollar_health.cpp \ test/fuzz/digidollar_dca_volatility.cpp \ test/fuzz/digidollar_lock_tier.cpp \ + test/fuzz/digidollar_prune_blockdb.cpp \ test/fuzz/digidollar_scripts.cpp \ test/fuzz/digidollar_wave15_parsers.cpp \ test/fuzz/oracle_bundle_validation.cpp \ diff --git a/src/consensus/digidollar.cpp b/src/consensus/digidollar.cpp index b91d10eb8c..63c462895d 100644 --- a/src/consensus/digidollar.cpp +++ b/src/consensus/digidollar.cpp @@ -13,6 +13,19 @@ namespace DigiDollar { +int EarliestActivationFloor(const Consensus::Params& params) +{ + const auto& deployment = params.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; + if (deployment.nStartTime == Consensus::BIP9Deployment::NEVER_ACTIVE || + deployment.nTimeout == Consensus::BIP9Deployment::NEVER_ACTIVE) { + return 0; + } + if (deployment.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) { + return deployment.min_activation_height; + } + return std::min(params.nDDActivationHeight, deployment.min_activation_height); +} + int GetCollateralRatioForLockTime(int64_t lockBlocks, const ConsensusParams& params) { const auto it = params.collateralRatios.find(lockBlocks); diff --git a/src/consensus/digidollar.h b/src/consensus/digidollar.h index 3237747399..168882bc23 100644 --- a/src/consensus/digidollar.h +++ b/src/consensus/digidollar.h @@ -45,6 +45,21 @@ enum DigiDollarTxType : uint8_t { DD_TX_MAX = 4 // For validation }; +/** + * Earliest height at which a DigiDollar output can exist on this chain (the + * "activation floor"). Single source of truth shared by consensus validation + * (EarliestDigiDollarActivationHeight), the "digidollar" prune lock + * (src/node/chainstate.cpp), and the startup fail-closed guards + * (src/digidollar/health.cpp, src/oracle/bundle_manager.cpp) — these must + * never disagree, or a pruned node could delete a block validation still + * needs to read. + * + * Returns 0 when the deployment can never activate (NEVER_ACTIVE start or + * timeout); callers treat 0 as "no floor" (no prune lock, no fail-closed + * window, pre-floor gates disabled). + */ +int EarliestActivationFloor(const Consensus::Params& params); + /** * Core consensus parameters for the DigiDollar stablecoin system. * These parameters define the economic model, collateral requirements, diff --git a/src/digidollar/health.cpp b/src/digidollar/health.cpp index 9270d9b50c..2c221bd2d3 100644 --- a/src/digidollar/health.cpp +++ b/src/digidollar/health.cpp @@ -304,11 +304,19 @@ void SystemHealthMonitor::Shutdown() s_initialized = false; } -void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool) +bool SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool, const CChain* chain, const Consensus::Params* consensus) { + // DigiDollar activation floor: a DD vault output can only be created at/after + // activation, which cannot happen below the deployment's minimum activation height. + // Below the floor there are no DD vaults, so we skip those coins without reading a + // block — this is what lets the seed run identically on a pruned node (pre-floor + // blocks are gone) and a full node (pre-floor blocks are present but hold no vaults). + const int dd_floor = consensus ? DigiDollar::EarliestActivationFloor(*consensus) : 0; + // Reset counters s_currentMetrics.totalDDSupply = 0; s_currentMetrics.totalCollateral = 0; + s_currentMetrics.totalActivePositions = 0; s_currentMetrics.systemHealth = 0; s_currentMetrics.hasCanonicalHealth = false; @@ -322,24 +330,25 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v if (!view) { LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: No coins view provided\n"); - return; + return true; } if (!blockman) { LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: No block manager provided - skipping UTXO scan (unit test mode?)\n"); - return; + return true; } // Create cursor to iterate all UTXOs (similar to gettxoutsetinfo) std::unique_ptr pcursor(view->Cursor()); if (!pcursor) { LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: Unable to create UTXO cursor\n"); - return; + return true; } LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: Starting blockchain-wide UTXO scan with full transaction access\n"); LogPrintf("DigiDollar: ========== STARTING UTXO SCAN ==========\n"); + bool complete = true; size_t vaults_found = 0; size_t dd_amount_extracted = 0; size_t dd_amount_estimated = 0; @@ -379,6 +388,15 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v // validation does not require it to be vout[0]. if (coin.out.scriptPubKey.size() == 34 && coin.out.scriptPubKey[0] == OP_1 && coin.out.nValue > 0) { + + // Skip P2TR coins created before the DigiDollar floor — they cannot be DD + // vaults, so there is no need to read their (possibly pruned) creating block. + if (chain && dd_floor > 0 && static_cast(coin.nHeight) < dd_floor) { + processed_txids.insert(txid); + pcursor->Next(); + continue; + } + vault_candidates_checked++; p2tr_found++; LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: Found P2TR output with value at %s:%d, fetching full transaction...\n", @@ -399,9 +417,14 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v CAmount collateral = 0; CAmount ddAmount = 0; - // Get the full transaction from block storage + // Get the full transaction that created this coin. On a full node with a + // transaction index GetTransaction finds it via txindex; on a pruned node + // (no txindex) we hand it the block index at the coin's creation height so it + // reads the creating tx from the retained block instead. Same transaction, + // same result — just a different source. uint256 hashBlock; - CTransactionRef tx = node::GetTransaction(nullptr, mempool, txid, hashBlock, *blockman); + const CBlockIndex* creating_block = chain ? (*chain)[coin.nHeight] : nullptr; + CTransactionRef tx = node::GetTransaction(creating_block, mempool, txid, hashBlock, *blockman); if (tx) { if (!DigiDollar::ExtractMintAccountingAmounts(*tx, ddAmount, collateral)) { @@ -413,8 +436,23 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: Extracted exact DD amount %s from tx %s\n", FormatMoney(ddAmount), txid.ToString()); } else { - LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: Could not fetch tx %s, skipping unverified DD vault candidate\n", - txid.ToString()); + // A coin at/above the DigiDollar activation floor sits in a block the + // node is REQUIRED to be able to read (retained window on a pruned node, + // full history otherwise). Failing to read it means the block data is + // damaged (e.g. a truncated/partially-restored blk file that the index + // still marks as present). The metrics seeded here feed consensus + // DCA/ERR health, so this must fail CLOSED — report incomplete instead + // of silently undercounting supply/collateral. + if (chain && dd_floor > 0 && static_cast(coin.nHeight) >= dd_floor) { + LogPrintf("ERROR: ScanUTXOSet: could not read the creating transaction of " + "DD vault candidate %s (coin height %u >= DigiDollar floor %d). " + "Block data is incomplete; restart with -reindex.\n", + txid.ToString(), coin.nHeight, dd_floor); + complete = false; + } else { + LogPrint(BCLog::DIGIDOLLAR, "ScanUTXOSet: Could not fetch tx %s, skipping unverified DD vault candidate\n", + txid.ToString()); + } processed_txids.insert(txid); pcursor->Next(); continue; @@ -423,6 +461,7 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v // Add to totals s_currentMetrics.totalCollateral += collateral; s_currentMetrics.totalDDSupply += ddAmount; + s_currentMetrics.totalActivePositions++; vaults_found++; processed_txids.insert(txid); @@ -449,9 +488,10 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v LogPrintf("DigiDollar: Found %zu vaults, Total Collateral: %s DGB, Total DD: %s cents\n", vaults_found, FormatMoney(s_currentMetrics.totalCollateral), FormatMoney(s_currentMetrics.totalDDSupply)); + return complete; } -void SystemHealthMonitor::ReconstructFromChain(ChainstateManager& chainman) +bool SystemHealthMonitor::ReconstructFromChain(ChainstateManager& chainman) { // DD-FINAL-003 / AR-CONSENSUS-1: seed the cached system-health metrics from // the on-chain UTXO set at startup so consensus DCA/ERR health is identical @@ -465,18 +505,18 @@ void SystemHealthMonitor::ReconstructFromChain(ChainstateManager& chainman) if (node::fReindex) { LogPrint(BCLog::DIGIDOLLAR, "Health: skipping startup reconstruction during reindex (block replay rebuilds metrics)\n"); - return; + return true; } const CBlockIndex* tip = WITH_LOCK(::cs_main, return chainman.ActiveChain().Tip()); if (tip == nullptr) { - return; + return true; } // Skip the (potentially expensive) full UTXO scan unless DigiDollar is active // at the current tip — pre-activation and non-DD chains have no DD vaults. if (!DigiDollar::IsDigiDollarEnabled(tip, chainman)) { LogPrint(BCLog::DIGIDOLLAR, "Health: skipping startup reconstruction (DigiDollar not active at tip)\n"); - return; + return true; } // Flush the in-memory coins cache to disk so the CoinsDB cursor below sees @@ -487,13 +527,15 @@ void SystemHealthMonitor::ReconstructFromChain(ChainstateManager& chainman) // nodes. The reindex path returned above, so by here the datadir is writable. Chainstate& active = chainman.ActiveChainstate(); active.ForceFlushStateToDisk(); + bool complete; { LOCK(::cs_main); - ScanUTXOSet(&active.CoinsDB(), &active.CoinsTip(), &active.m_blockman, /*mempool=*/nullptr); + complete = ScanUTXOSet(&active.CoinsDB(), &active.CoinsTip(), &active.m_blockman, /*mempool=*/nullptr, &active.m_chain, &chainman.GetConsensus()); } const SystemMetrics m = GetCachedMetrics(); LogPrintf("DigiDollar: startup health reconstruction complete - DD supply %s, collateral %s DGB\n", FormatMoney(m.totalDDSupply), FormatMoney(m.totalCollateral)); + return complete; } // ============================================================================ diff --git a/src/digidollar/health.h b/src/digidollar/health.h index 2400a85331..fe5d77bc7c 100644 --- a/src/digidollar/health.h +++ b/src/digidollar/health.h @@ -20,6 +20,11 @@ class CCoinsView; class CTxMemPool; class ChainstateManager; +class CChain; + +namespace Consensus { + struct Params; +} namespace node { class BlockManager; @@ -39,6 +44,8 @@ struct SystemMetrics { // Overall system metrics CAmount totalDDSupply; //!< Total DigiDollar in circulation (cents) CAmount totalCollateral; //!< Total DGB locked as collateral + int totalActivePositions; //!< Active vault count; (re)computed by ScanUTXOSet, + //!< not incrementally maintained between scans int systemHealth; //!< Overall collateral ratio (percentage) bool hasCanonicalHealth; //!< True after health was calculated from the current metric snapshot @@ -70,7 +77,8 @@ struct SystemMetrics { // Historical tracking std::vector healthHistory; //!< Recent health percentages - SystemMetrics() : totalDDSupply(0), totalCollateral(0), systemHealth(0), hasCanonicalHealth(false), + SystemMetrics() : totalDDSupply(0), totalCollateral(0), totalActivePositions(0), + systemHealth(0), hasCanonicalHealth(false), dcaMultiplier(1.0), errActive(false), volatility(0.0), mintingFrozen(false), activeOracles(0), lastOraclePrice(0), lastOracleUpdate(0) {} @@ -170,7 +178,15 @@ class SystemHealthMonitor { * @param blockman BlockManager for accessing full transaction data * @param mempool Optional mempool for checking recent transactions */ - static void ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool = nullptr); + //! Returns false if a DD-era vault's creating transaction could not be read + //! (block data incomplete/damaged) — callers seeding consensus-relevant + //! state must treat that as fatal (fail closed) rather than accept an + //! undercounted supply/collateral baseline. + //! mempool/chain/consensus deliberately have no defaults: the pre-floor + //! coin skip and the fail-closed check are both gated on a non-null chain, + //! so a caller that silently omitted these arguments would revert to the + //! old silent-undercount behavior. Every caller must decide explicitly. + static bool ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool, const CChain* chain, const Consensus::Params* consensus); /** * Reconstruct the cached system-health metrics (total DD supply + total @@ -189,7 +205,10 @@ class SystemHealthMonitor { * full UTXO scan before activation and on non-DD chains). * @param chainman Active chainstate manager (after chainstate load) */ - static void ReconstructFromChain(ChainstateManager& chainman); + //! Returns false when the seed scan found unreadable DD-era block data + //! (see ScanUTXOSet) — the caller must abort startup rather than run + //! consensus with an incomplete health baseline. + static bool ReconstructFromChain(ChainstateManager& chainman); /** * Get cached metrics without triggering updates diff --git a/src/digidollar/validation.cpp b/src/digidollar/validation.cpp index 837a736302..7af40cd80b 100644 --- a/src/digidollar/validation.cpp +++ b/src/digidollar/validation.cpp @@ -69,12 +69,7 @@ static bool IsCanonicalP2TROutput(const CScript& script) static int EarliestDigiDollarActivationHeight(const ValidationContext& ctx) { - const Consensus::Params& cp = ctx.params.GetConsensus(); - const auto& deployment = cp.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; - if (deployment.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) { - return deployment.min_activation_height; - } - return std::min(cp.nDDActivationHeight, deployment.min_activation_height); + return DigiDollar::EarliestActivationFloor(ctx.params.GetConsensus()); } static bool CoinHeightMayCreateDigiDollar(const Coin& coin, const ValidationContext& ctx) @@ -2345,6 +2340,24 @@ bool ValidateCollateralReleaseAmount(const CTransaction& tx, return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-collateral-release-zero-collateral"); } + // A DigiDollar collateral vault can only be created at/after activation, so a coin + // below the activation floor is never real collateral regardless of its byte + // structure. Reject it here on EVERY node so a pruned node (which cannot read a + // pruned pre-floor block to run the structural check below) and a full node reach the + // same verdict — otherwise a deliberately pre-planted DD-lookalike coin used as the + // redemption's input 0 could split pruned from full nodes. Mirrors the pre-floor gate + // in SpendsDigiDollarCollateralVault(). + const int dd_activation_height = EarliestDigiDollarActivationHeight(ctx); + if (dd_activation_height > 0 && + collateralCoin.nHeight < static_cast(dd_activation_height)) { + LogPrintf("DigiDollar: Redemption rejected - input 0 was created below the DigiDollar " + "activation floor (%u < %d) and cannot be collateral\n", + collateralCoin.nHeight, dd_activation_height); + return state.Invalid(TxValidationResult::TX_CONSENSUS, + "bad-collateral-release-not-vault", + "Redemption input 0 must spend the canonical DigiDollar collateral vault output"); + } + CTransactionRef collateralPrevTx; if (LookupPreviousTransaction(tx.vin[0].prevout, collateralCoin.nHeight, ctx, collateralPrevTx) && !IsMintCollateralOutput(collateralPrevTx, tx.vin[0].prevout.n)) { @@ -2567,9 +2580,18 @@ bool ValidateCollateralReleaseAmount(const CTransaction& tx, // Mint validation permits regular non-P2TR DGB change before/after the // collateral, so the collateral is the unique positive P2TR output of a // DD mint rather than a fixed vout index. + // + // Coins created below the activation floor can never be real collateral, so + // skip the structural check for them and treat them as ordinary fee inputs. + // This keeps a pruned node (which cannot read a pruned pre-floor block for the + // lookup below) and a full node in agreement; without it a pre-planted + // DD-lookalike fee input would be rejected on full nodes but accepted on pruned + // nodes. Mirrors the pre-floor gate in SpendsDigiDollarCollateralVault(). bool isCollateral = false; CTransactionRef prev_tx; - if (LookupPreviousTransaction(tx.vin[i].prevout, coin.nHeight, ctx, prev_tx)) { + if ((dd_activation_height <= 0 || + coin.nHeight >= static_cast(dd_activation_height)) && + LookupPreviousTransaction(tx.vin[i].prevout, coin.nHeight, ctx, prev_tx)) { isCollateral = IsMintCollateralOutput(prev_tx, tx.vin[i].prevout.n); } diff --git a/src/init.cpp b/src/init.cpp index c187d5d451..dc86d29142 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -787,10 +787,21 @@ void InitParameterInteraction(ArgsManager& args) // 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) { + // errors in AppInitParameterInteraction. Any nonzero -prune counts (including invalid + // negative values, so they reach their own error instead of the txindex conflict); + // -prune=0 explicitly disables pruning and keeps the txindex default. + if (args.GetIntArg("-prune", 0) != 0) { if (args.SoftSetBoolArg("-txindex", false)) LogPrintf("%s: parameter interaction: -prune set -> setting -txindex=0\n", __func__); + + // DigiByte: the DigiDollar stats index (default on) syncs from genesis, so on a + // pruned node its initial sync would demand blocks below the prune point and the + // generic "index goes beyond pruned data" check would refuse to start. Leave it + // off on pruned nodes unless the user explicitly asked for it. getdigidollarstats + // still works via its live UTXO-set fallback; only historical per-height stats + // queries need the index. + if (args.SoftSetBoolArg("-digidollarstatsindex", false)) + LogPrintf("%s: parameter interaction: -prune set -> setting -digidollarstatsindex=0\n", __func__); } if (args.IsArgSet("-connect") || args.GetIntArg("-maxconnections", DEFAULT_MAX_PEER_CONNECTIONS) <= 0) { @@ -905,6 +916,14 @@ bool IsDigiDollarTxIndexRequired(const CChainParams& chainparams, const ArgsMana { if (!HasDigiDollarDeployment(chainparams)) return false; + // Pruned nodes do not maintain a transaction index (prune is incompatible with + // txindex). DigiDollar validation on a pruned node resolves the amount/lock of a + // spent DD output by reading the creating transaction from the retained block at the + // coin's height (node::GetTransaction's block-db path) instead of the txindex, and + // the DigiDollar-era block window is kept by the "digidollar" prune lock. So a pruned + // node does not require txindex. + if (args.GetIntArg("-prune", 0) > 0) return false; + switch (chainparams.GetChainType()) { case ChainType::MAIN: case ChainType::TESTNET: @@ -2200,13 +2219,26 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) g_signing_orchestrator->SetConnman(node.connman.get()); // Initialize oracle P2P connection for broadcasting OracleBundleManager::GetInstance().SetConnman(node.connman.get()); - // Load oracle prices from blockchain (must be after chainstate is loaded) - OracleBundleManager::LoadPricesFromChain(chainman); + // Load oracle prices from blockchain (must be after chainstate is loaded). + // Fail CLOSED on unreadable post-activation blocks: the reconstructed price + // history feeds the consensus volatility freeze, and the reconstructed health + // metrics feed consensus DCA/ERR. A truncated/partially-restored block file + // passes the index-flag startup guard but must never let the node run + // DigiDollar validation on partial data. + if (!OracleBundleManager::LoadPricesFromChain(chainman)) { + return InitError(_("DigiDollar-era block data is incomplete or unreadable. " + "Restart with -reindex to rebuild it (a pruned node will " + "redownload and re-prune).")); + } // DD-FINAL-003 / AR-CONSENSUS-1: reconstruct cached system-health metrics // (total DD supply + collateral) from the on-chain UTXO set so consensus // DCA/ERR health does not depend on process restart history. No-op until // DigiDollar is active at the tip. - DigiDollar::SystemHealthMonitor::ReconstructFromChain(chainman); + if (!DigiDollar::SystemHealthMonitor::ReconstructFromChain(chainman)) { + return InitError(_("DigiDollar-era block data is incomplete or unreadable. " + "Restart with -reindex to rebuild it (a pruned node will " + "redownload and re-prune).")); + } // DD-FINAL-005 / AR-0: OP_CHECKPRICE is deterministically DISABLED (it now // consumes its witness operand and always pushes vchFalse). The interpreter no diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index e6d1b4a50b..ab74812761 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -154,6 +155,39 @@ static ChainstateLoadResult CompleteChainstateInitialization( }; } + // DigiByte: DigiDollar-compatible pruning. + // + // A DigiDollar output can only be created at or after DigiDollar activation, and + // activation cannot happen below the deployment's minimum activation height. So every + // block a DigiDollar spend ever needs to read lives in [dd_floor, tip]. On a pruned + // node we (1) keep that whole window via a prune lock, so validation can always read a + // DD input's creating transaction from the retained block at the coin's height, and + // (2) refuse to start if a DD-era block is already missing, rather than validate + // DigiDollar with incomplete data. Registered here (cs_main held, tip loaded) before + // the first prune/flush can run. + { + const int dd_floor = DigiDollar::EarliestActivationFloor(chainman.GetConsensus()); + + if (options.prune && dd_floor > 0) { + PruneLockInfo dd_lock; + dd_lock.height_first = dd_floor; + chainman.m_blockman.UpdatePruneLock("digidollar", dd_lock); + LogPrintf("DigiDollar: pruning enabled; retaining all blocks at/above height %d (DigiDollar activation floor)\n", dd_floor); + + if (chainman.m_blockman.m_have_pruned) { + const CBlockIndex* tip = chainman.ActiveChain().Tip(); + if (tip && tip->nHeight >= dd_floor) { + const CBlockIndex* floor_block = chainman.ActiveChain()[dd_floor]; + if (!floor_block || !chainman.m_blockman.CheckBlockDataAvailability(*tip, *floor_block)) { + return {ChainstateLoadStatus::FAILURE, + _("DigiDollar-era block data is incomplete on this pruned node. " + "Restart with -reindex to rebuild it (the node will redownload and re-prune).")}; + } + } + } + } + } + // Now that chainstates are loaded and we're able to flush to // disk, rebalance the coins caches to desired levels based // on the condition of each chainstate. diff --git a/src/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index 45d2a751ec..b40a041a21 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -1782,7 +1783,7 @@ void OracleBundleManager::Shutdown() } } -void OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) +bool OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) { OracleBundleManager& manager = GetInstance(); const Consensus::Params& consensus = Params().GetConsensus(); @@ -1793,7 +1794,7 @@ void OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) CBlockIndex* pindex = chainman.ActiveChain().Tip(); if (!pindex) { LogPrintf("Oracle: No active chain tip, skipping price loading\n"); - return; + return true; } int tip_height = pindex->nHeight; @@ -1802,9 +1803,16 @@ void OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) if (!DigiDollar::IsDigiDollarEnabled(pindex, chainman)) { LogPrintf("Oracle: DigiDollar not yet active (BIP9) at height %d, skipping price loading\n", tip_height); - return; + return true; } + // DigiDollar activation floor: blocks at/above it are guaranteed retained on a + // pruned node (the "digidollar" prune lock). An unreadable block THERE means the + // data is damaged and we must fail closed. Below it — or when the floor is 0 + // (default regtest ALWAYS_ACTIVE, where no retention is guaranteed and no lock is + // registered) — a missing block is a legitimate prune/assumeutxo state, not damage. + const int dd_floor = DigiDollar::EarliestActivationFloor(consensus); + // Scan recent blocks for the live oracle cache and enough history to // deterministically rebuild volatility state after restart/reindex. static constexpr int ORACLE_VALIDITY_BLOCKS = 20; @@ -1831,7 +1839,23 @@ void OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) CBlock block; if (!chainman.m_blockman.ReadBlockFromDisk(block, *block_index)) { - LogPrintf("Oracle: Failed to read block at height %d\n", height); + if (dd_floor > 0 && height >= dd_floor) { + // A block inside the guaranteed-retained DigiDollar window is + // unreadable (e.g. a truncated/partially-restored blk file that the + // index-flag startup guard cannot see). Fail CLOSED: the volatility + // freeze state reconstructed here is enforced as a consensus rule + // post-activation, so refusing to start beats rebuilding it from + // partial price history and diverging from the network. + LogPrintf("ERROR: Oracle: failed to read block at height %d during startup " + "price reconstruction (>= DigiDollar floor %d). Block data is " + "incomplete; restart with -reindex.\n", height, dd_floor); + return false; + } + // Below the floor (or floor 0, e.g. default regtest / assumeutxo gaps) + // a missing block is a legitimate prune state, not damage. + LogPrint(BCLog::DIGIDOLLAR, + "Oracle: skipping unreadable pre-floor block at height %d during " + "startup price reconstruction\n", height); continue; } @@ -1876,6 +1900,7 @@ void OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) } else { LogPrintf("Oracle: No oracle prices found in recent blocks\n"); } + return true; } bool OracleBundleManager::ShouldLoadStartupOraclePriceForBlock(int height, const CBlockIndex* block_index, const Consensus::Params& params) diff --git a/src/oracle/bundle_manager.h b/src/oracle/bundle_manager.h index 17fdfb9f75..6a45b837b1 100644 --- a/src/oracle/bundle_manager.h +++ b/src/oracle/bundle_manager.h @@ -213,7 +213,10 @@ class OracleBundleManager //! Load oracle prices from blockchain on startup //! Must be called after chainstate is fully loaded - static void LoadPricesFromChain(ChainstateManager& chainman); + //! Returns false when a post-activation block that should be present could + //! not be read (incomplete/damaged block data) — the caller must abort + //! startup rather than reconstruct price/volatility state from partial data. + static bool LoadPricesFromChain(ChainstateManager& chainman); static bool ShouldLoadStartupOraclePriceForBlock(int height, const CBlockIndex* block_index, const Consensus::Params& params); //! Clear all state (for testing) diff --git a/src/qt/res/icons/digibyte_wallet.png b/src/qt/res/icons/digibyte_wallet.png index 1aa6313461..2a00f22761 100644 Binary files a/src/qt/res/icons/digibyte_wallet.png and b/src/qt/res/icons/digibyte_wallet.png differ diff --git a/src/rpc/digidollar.cpp b/src/rpc/digidollar.cpp index 5bc910f023..03125a3442 100644 --- a/src/rpc/digidollar.cpp +++ b/src/rpc/digidollar.cpp @@ -445,8 +445,12 @@ namespace { CCoinsView* coins_view = &active_chainstate.CoinsDB(); node::BlockManager* blockman = &active_chainstate.m_blockman; const CTxMemPool* mempool = node.mempool.get(); - DigiDollar::SystemHealthMonitor::ScanUTXOSet( - coins_view, &active_chainstate.CoinsTip(), blockman, mempool); + if (!DigiDollar::SystemHealthMonitor::ScanUTXOSet( + coins_view, &active_chainstate.CoinsTip(), blockman, mempool, + &active_chainstate.m_chain, &Params().GetConsensus())) { + throw JSONRPCError(RPC_MISC_ERROR, + "DigiDollar-era block data is incomplete or unreadable; restart with -reindex"); + } } DigiDollar::SystemMetrics metrics = DigiDollar::SystemHealthMonitor::GetSystemMetrics(); totals.total_collateral = metrics.totalCollateral; @@ -701,7 +705,10 @@ RPCHelpMan getdigidollarstats() // Pass BlockManager for full transaction access // Pass both CoinsDB (for iteration) and CoinsTip (for validation) LogPrintf("DigiDollar: getdigidollarstats - About to call ScanUTXOSet...\n"); - DigiDollar::SystemHealthMonitor::ScanUTXOSet(coins_view, &active_chainstate.CoinsTip(), blockman, mempool); + if (!DigiDollar::SystemHealthMonitor::ScanUTXOSet(coins_view, &active_chainstate.CoinsTip(), blockman, mempool, &active_chainstate.m_chain, &Params().GetConsensus())) { + throw JSONRPCError(RPC_MISC_ERROR, + "DigiDollar-era block data is incomplete or unreadable; restart with -reindex"); + } LogPrintf("DigiDollar: getdigidollarstats - ScanUTXOSet completed\n"); } @@ -768,7 +775,8 @@ RPCHelpMan getdigidollarstats() // Get active position count from the DigiDollar stats index. // The stats index tracks vault_count (incremented on mint, // decremented on redeem) so this reflects real network state. - // Falls back to 0 if the stats index isn't available yet. + // Without the index (e.g. pruned nodes, where it is off), the + // UTXO scan performed above in this call counted the live vaults. uint64_t activePositions = 0; if (g_digidollar_stats_index) { ChainstateManager& chainman = EnsureAnyChainman(request.context); @@ -780,6 +788,9 @@ RPCHelpMan getdigidollarstats() activePositions = ddstats->vault_count; } } + } else { + activePositions = static_cast(std::max( + 0, DigiDollar::SystemHealthMonitor::GetSystemMetrics().totalActivePositions)); } result.pushKV("active_positions", static_cast(activePositions)); int oraclePriceAge = 0; @@ -4381,7 +4392,10 @@ static RPCHelpMan getprotectionstatus() LOCK(::cs_main); coins_view = &active_chainstate.CoinsDB(); blockman = &active_chainstate.m_blockman; - DigiDollar::SystemHealthMonitor::ScanUTXOSet(coins_view, &active_chainstate.CoinsTip(), blockman, mempool); + if (!DigiDollar::SystemHealthMonitor::ScanUTXOSet(coins_view, &active_chainstate.CoinsTip(), blockman, mempool, &active_chainstate.m_chain, &Params().GetConsensus())) { + throw JSONRPCError(RPC_MISC_ERROR, + "DigiDollar-era block data is incomplete or unreadable; restart with -reindex"); + } } DigiDollar::SystemMetrics metrics = DigiDollar::SystemHealthMonitor::GetSystemMetrics(); totalCollateral = metrics.totalCollateral; diff --git a/src/test/digidollar_redteam_tests.cpp b/src/test/digidollar_redteam_tests.cpp index bab5e4c880..a044b084cf 100644 --- a/src/test/digidollar_redteam_tests.cpp +++ b/src/test/digidollar_redteam_tests.cpp @@ -5256,6 +5256,84 @@ BOOST_AUTO_TEST_CASE(redteam_t2_06b_fee_input_collateral_masquerade) } } +// v9.26.4 pruning-parity: a coin created BELOW the DigiDollar activation floor can +// never be real collateral, so ValidateCollateralReleaseAmount must classify it +// identically on pruned and full nodes. A full node (txindex) can read a pre-floor +// block and would otherwise flag a DD-mint-structured pre-floor coin as collateral, +// while a pruned node (pre-floor block deleted) cannot — a consensus split. The +// activation-floor gate resolves it: pre-floor input 0 is rejected as not-vault, and a +// pre-floor DD-structured "fee input" is treated as an ordinary fee input, on EVERY node +// regardless of whether the creating block is readable. +BOOST_AUTO_TEST_CASE(redteam_t2_06d_prefloor_collateral_gate_parity) +{ + // Regtest params with a real activation floor of 650 (default regtest is + // ALWAYS_ACTIVE with min_activation_height 0, which disables the gate). + CChainParams::RegTestOptions opts; + CChainParams::VersionBitsParameters vb{}; + vb.start_time = Consensus::BIP9Deployment::ALWAYS_ACTIVE; + vb.timeout = Consensus::BIP9Deployment::NO_TIMEOUT; + vb.min_activation_height = 650; // EarliestDigiDollarActivationHeight -> 650 + opts.version_bits_parameters[Consensus::DEPLOYMENT_DIGIDOLLAR] = vb; + const auto params = CChainParams::RegTest(opts); + + // A DD-mint-structured transaction whose collateral output sits BELOW the floor. + CKey collKey; collKey.MakeNewKey(true); + XOnlyPubKey xPub(collKey.GetPubKey()); + CScript p2tr = MakeP2TR(xPub); + const CAmount collateral = 200 * COIN; + const CAmount originalDD = 10000; + + CMutableTransaction mintTx; + mintTx.nVersion = 0x01000770; + mintTx.vin.push_back(CTxIn(COutPoint(uint256S("d206b0000000000000000000000000000000000000000000000000000000006d"), 0))); + mintTx.vout.push_back(CTxOut(collateral, p2tr)); + CKey ddKey; ddKey.MakeNewKey(true); + mintTx.vout.push_back(CTxOut(0, MakeP2TR(XOnlyPubKey(ddKey.GetPubKey())))); + mintTx.vout.push_back(CTxOut(0, MakeDDMintOpReturn(originalDD, 1000, 1, xPub))); + CTransactionRef mintRef = MakeTransactionRef(mintTx); + const uint256 mintHash = mintRef->GetHash(); + + auto lookup = [&](const uint256& txid, uint32_t, CTransactionRef& out) -> bool { + if (txid == mintHash) { out = mintRef; return true; } // full node: lookup succeeds + return false; + }; + + // Redeem spending that pre-floor coin as input 0 (the collateral position). + CMutableTransaction rtx; + rtx.nVersion = 0x03000770; + rtx.nLockTime = 1000; + rtx.vin.push_back(CTxIn(COutPoint(mintHash, 0))); + rtx.vout.push_back(CTxOut(195 * COIN, MakeP2TR(xPub))); + CTransaction redeem(rtx); + + // Case A — collateral coin at height 400 (< floor 650): must be rejected on every + // node as not-vault, BEFORE the structural lookup, so pruned==full. + { + CCoinsView base; CCoinsViewCache coins(&base); + coins.AddCoin(COutPoint(mintHash, 0), Coin(CTxOut(collateral, p2tr), 400, false), false); + TxValidationState state; + DigiDollar::ValidationContext ctx(1000, 500000, 700, *params, &coins, false, lookup); + bool ok = DigiDollar::ValidateCollateralReleaseAmount(redeem, ctx, originalDD, state); + BOOST_CHECK_MESSAGE(!ok, "pre-floor collateral input must be rejected"); + BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-collateral-release-not-vault"); + } + // Case B — same coin, but a lookup that FAILS (models a pruned node with the + // pre-floor block deleted): must reach the SAME verdict via the floor gate, proving + // the outcome does not depend on whether the creating block is readable. + { + auto failing_lookup = [](const uint256&, uint32_t, CTransactionRef&) -> bool { return false; }; + CCoinsView base; CCoinsViewCache coins(&base); + coins.AddCoin(COutPoint(mintHash, 0), Coin(CTxOut(collateral, p2tr), 400, false), false); + TxValidationState state; + DigiDollar::ValidationContext ctx(1000, 500000, 700, *params, &coins, false, failing_lookup); + bool ok = DigiDollar::ValidateCollateralReleaseAmount(redeem, ctx, originalDD, state); + BOOST_CHECK_MESSAGE(!ok, "pruned node (failing lookup) must reach the same reject"); + BOOST_CHECK_EQUAL(state.GetRejectReason(), "bad-collateral-release-not-vault"); + } + BOOST_TEST_MESSAGE("DEFENSE [T2-06d]: pre-floor collateral classification is floor-gated " + "and identical whether or not the creating block is readable (pruned==full)."); +} + BOOST_AUTO_TEST_CASE(redteam_t2_06c_collateral_release_fee_tolerance) { // ATTACK [T2-06c]: Exploit the fee tolerance in collateral release validation. @@ -10304,7 +10382,7 @@ BOOST_AUTO_TEST_CASE(redteam_t5_06c_scan_utxo_set_race_condition) DigiDollar::SystemHealthMonitor::Initialize(); // Now call ScanUTXOSet with null view — this resets metrics to 0 - DigiDollar::SystemHealthMonitor::ScanUTXOSet(nullptr, nullptr, nullptr, nullptr); + DigiDollar::SystemHealthMonitor::ScanUTXOSet(nullptr, nullptr, nullptr, nullptr, nullptr, nullptr); // At this point metrics are reset to 0 (scan found nothing with null view) auto metrics = DigiDollar::SystemHealthMonitor::GetCachedMetrics(); diff --git a/src/test/digidollar_txindex_tests.cpp b/src/test/digidollar_txindex_tests.cpp index 9d460ea401..d82432ec74 100644 --- a/src/test/digidollar_txindex_tests.cpp +++ b/src/test/digidollar_txindex_tests.cpp @@ -150,4 +150,23 @@ BOOST_AUTO_TEST_CASE(block_db_fallback_correct_amounts) BOOST_CHECK_EQUAL(amount, 1200); } +BOOST_AUTO_TEST_CASE(dd_chain_prune_does_not_require_txindex) +{ + // v9.26.4: a pruned DigiDollar node does not require txindex. DD amount/lock + // resolution reads the creating transaction from the retained DigiDollar-era block + // window (kept by the "digidollar" prune lock) instead of the transaction index, so + // pruning and DigiDollar can coexist. A full node still requires txindex. + auto check = [](const CChainParams& params) { + ArgsManager args; + SetupServerArgs(args); + // Full node (no prune): txindex required. + BOOST_CHECK(IsDigiDollarTxIndexRequired(params, args)); + // Pruned node: not required. + args.ForceSetArg("-prune", "550"); + BOOST_CHECK(!IsDigiDollarTxIndexRequired(params, args)); + }; + check(*CChainParams::Main()); + check(*CChainParams::TestNet()); +} + BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/fuzz/digidollar_prune_blockdb.cpp b/src/test/fuzz/digidollar_prune_blockdb.cpp new file mode 100644 index 0000000000..e632c8c84e --- /dev/null +++ b/src/test/fuzz/digidollar_prune_blockdb.cpp @@ -0,0 +1,490 @@ +// Copyright (c) 2026 The DigiByte Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +// +// Fuzz targets for the v9.26.4 DigiDollar-compatible pruning surface. +// +// Targets: +// dd_extract_amount_blockdb - DigiDollar::ExtractDDAmountFromBlockDb(), the +// txindex-free DD amount resolution path that reads the creating +// transaction out of the retained block via a TxLookupFn callback +// (src/digidollar/validation.cpp; wired up in production by +// MakeCachedBlockTxLookup in src/validation.cpp). +// dd_prune_activation_floor - property fuzz of the shared dd_floor helper +// DigiDollar::EarliestActivationFloor (src/consensus/digidollar.cpp), +// the single source of truth used by the "digidollar" prune lock in +// src/node/chainstate.cpp, the startup guards in +// src/digidollar/health.cpp and src/oracle/bundle_manager.cpp, and +// validation's EarliestDigiDollarActivationHeight — plus the inline +// prune-lock clamp arithmetic used by Chainstate::FlushStateToDisk in +// src/validation.cpp (still replicated below; update if it changes). +// (unit coverage: src/test/digidollar_txindex_tests.cpp and +// test/functional/feature_digidollar_pruning.py). +// dd_prune_coin_gating - SpendsDigiDollarCollateralVault() / +// RequiresDigiDollarValidation() on MAINNET params with coins created +// around the DigiDollar activation floor and a fuzzed block-db lookup. +// This drives the production EarliestDigiDollarActivationHeight +// pre-floor coin skip and the LookupPreviousTransaction txLookup +// fallback, i.e. the exact reason pruning below the floor is safe. +// + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include