From d5bc68c8c791c1a0d93cd1d47eb01db4bd85762d Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:55:33 -0600 Subject: [PATCH 01/17] DigiDollar-compatible pruning: run pruned nodes with DigiDollar Lets mining pools and users run pruned mainnet nodes with DigiDollar, so they no longer have to store the full ~12-year block history. No consensus rule changes: a pruned node validates every block identically to a full node, and every new behavior is opt-in behind -prune. A DigiDollar output can only be created at/after activation, and activation cannot happen below the deployment's minimum activation height, so every block DigiDollar validation ever reads lives in [activation floor, tip]. A pruned node keeps that window and deletes the older history: - init: IsDigiDollarTxIndexRequired returns false under -prune (a pruned node resolves a DD input's amount/lock by reading the creating tx from the retained block at the coin's height instead of the transaction index); soft-disable the default-on DigiDollar stats index under prune so its from-genesis sync does not demand pruned blocks and refuse startup. Full nodes are unchanged and still require txindex; -prune -txindex=1 still errors. - node/chainstate: register a "digidollar" prune lock at the activation floor during chainstate load, before the first prune, so automatic pruning and the pruneblockchain RPC never delete a DigiDollar-era block; plus a reindex guard that refuses to start (asking for -reindex) if a DigiDollar-era block is already missing, rather than validating with incomplete data. - digidollar/health: SystemHealthMonitor::ScanUTXOSet seeds network DD supply/collateral (which feed consensus DCA/ERR health) by reading each vault's creating tx from the retained block at the coin's height rather than the txindex, and skips pre-floor coins. Result-identical on a full node, correct on a pruned one. - oracle: flag an unexpected block-read failure during startup price reconstruction instead of silently reconstructing from partial data. Tests: - unit: dd_chain_prune_does_not_require_txindex. - functional: feature_digidollar_pruning.py runs a full node and a pruned (no-txindex) node side by side and asserts they agree through BIP9 DigiDollar activation, a DigiDollar mint block mined on the pruned node, a full mint/send/redeem lifecycle (including a redeem 339 blocks after the mint, reading the creating block from the retained window), pruning the pre-activation history, and restart reconstruction. Reference: V9.26.4_PRUNING_PLAN.md; operator notes in doc/release-notes/release-notes-9.26.4.md. --- DIGIDOLLAR_ARCHITECTURE.md | 14 +- V9.26.4_PRUNING_PLAN.md | 552 +++++++++++++++ doc/release-notes/release-notes-9.26.4.md | 84 +++ src/digidollar/health.cpp | 42 +- src/digidollar/health.h | 12 +- src/init.cpp | 23 +- src/node/chainstate.cpp | 41 ++ src/oracle/bundle_manager.cpp | 9 +- src/rpc/digidollar.cpp | 13 +- src/test/digidollar_txindex_tests.cpp | 19 + test/functional/feature_digidollar_pruning.py | 628 ++++++++++++++++++ test/functional/test_runner.py | 1 + 12 files changed, 1421 insertions(+), 17 deletions(-) create mode 100644 V9.26.4_PRUNING_PLAN.md create mode 100644 doc/release-notes/release-notes-9.26.4.md create mode 100755 test/functional/feature_digidollar_pruning.py 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_PRUNING_PLAN.md b/V9.26.4_PRUNING_PLAN.md new file mode 100644 index 0000000000..6917c7b8ef --- /dev/null +++ b/V9.26.4_PRUNING_PLAN.md @@ -0,0 +1,552 @@ +# 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. +**Zero consensus changes. Every 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) +| 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) +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/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..c50bf2f081 --- /dev/null +++ b/doc/release-notes/release-notes-9.26.4.md @@ -0,0 +1,84 @@ +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 **pruned** nodes with DigiDollar, so they no longer have to store the +full ~12-year block history. **It contains no consensus rule changes** — v9.26.2's +Groestl algolock and the DigiDollar BIP9 deployment are carried forward unchanged, and +a pruned v9.26.4 node validates every block identically to a full node. Upgrading is +optional; nodes that do not set `-prune` behave exactly like v9.26.3. + +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 `prune=N` to `digibyte.conf` (or `-prune=N` on the command +line), where `N` is your target size in MiB (minimum 550). See "Pruning with +DigiDollar" below. + +Notable changes +=============== + +Pruning with DigiDollar +----------------------- + +Previously a DigiDollar node had to run `-txindex` and could not prune: the two are +mutually exclusive, and DigiDollar validation was wired to resolve a spent DigiDollar +output's amount and lock term through the transaction index. v9.26.4 removes that +restriction. + +A DigiDollar output can only be *created* at or after DigiDollar activates, and +activation cannot happen below the deployment's minimum activation height. So every +block that DigiDollar validation ever needs to read lives in the window from that +activation height to the chain tip. A pruned v9.26.4 node keeps that whole window and +deletes the older history: + +- A **prune lock** is registered at the DigiDollar activation floor, so automatic + pruning and the `pruneblockchain` RPC never delete a block a DigiDollar spend might + need. +- `-txindex` is **no longer required** 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. +- If a pruned data directory is ever missing a DigiDollar-era block (for example, it + was pruned under different rules), 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, and lets you mint/send/redeem DigiDollar +exactly like a full node, on a fraction of the disk. + +Example `digibyte.conf` for a pruned pool or user node: + + prune=2000 + # no txindex needed + +Notes and limitations for pruned nodes +-------------------------------------- + +- **Disk.** A pruned node keeps only the DigiDollar-era window plus the chainstate, + rather than the full chain plus a transaction index. Note that the DigiDollar-era + window grows with the chain: after activation, 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 transaction 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. +- **`getrawtransaction`** for transactions in deleted (pre-DigiDollar-era) blocks is + unavailable without a transaction index, as on any pruned node. +- **`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`. The 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. +- Setting `-prune` together with an explicit `-txindex=1` is still rejected, as before. + +Credits +======= + +Thanks to everyone testing pruned-mode operation ahead of DigiDollar activation. diff --git a/src/digidollar/health.cpp b/src/digidollar/health.cpp index 9270d9b50c..9e3c66bd22 100644 --- a/src/digidollar/health.cpp +++ b/src/digidollar/health.cpp @@ -304,11 +304,28 @@ void SystemHealthMonitor::Shutdown() s_initialized = false; } -void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool) -{ +void 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). + int dd_floor = 0; + if (consensus) { + const auto& dd_dep = consensus->vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; + if (dd_dep.nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE && + dd_dep.nTimeout != Consensus::BIP9Deployment::NEVER_ACTIVE) { + dd_floor = (dd_dep.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) + ? dd_dep.min_activation_height + : std::min(consensus->nDDActivationHeight, dd_dep.min_activation_height); + } + } + // Reset counters s_currentMetrics.totalDDSupply = 0; s_currentMetrics.totalCollateral = 0; + s_currentMetrics.totalActivePositions = 0; s_currentMetrics.systemHealth = 0; s_currentMetrics.hasCanonicalHealth = false; @@ -379,6 +396,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 +425,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)) { @@ -423,6 +454,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); @@ -489,7 +521,7 @@ void SystemHealthMonitor::ReconstructFromChain(ChainstateManager& chainman) active.ForceFlushStateToDisk(); { LOCK(::cs_main); - ScanUTXOSet(&active.CoinsDB(), &active.CoinsTip(), &active.m_blockman, /*mempool=*/nullptr); + 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", diff --git a/src/digidollar/health.h b/src/digidollar/health.h index 2400a85331..c0cabd2f29 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,7 @@ 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); + static void ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool = nullptr, const CChain* chain = nullptr, const Consensus::Params* consensus = nullptr); /** * Reconstruct the cached system-health metrics (total DD supply + total diff --git a/src/init.cpp b/src/init.cpp index c187d5d451..fa24a122ba 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: diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index e6d1b4a50b..76532479d8 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -154,6 +154,47 @@ 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 Consensus::Params& consensus = chainman.GetConsensus(); + const auto& dd_dep = consensus.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; + int dd_floor = 0; + if (dd_dep.nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE && + dd_dep.nTimeout != Consensus::BIP9Deployment::NEVER_ACTIVE) { + dd_floor = (dd_dep.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) + ? dd_dep.min_activation_height + : std::min(consensus.nDDActivationHeight, dd_dep.min_activation_height); + } + + 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..f28ae745e1 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -1831,7 +1831,14 @@ 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); + // This loop only reaches post-activation blocks (pre-activation heights are + // skipped by the header check above), and on a pruned node the reindex guard + // in CompleteChainstateInitialization has already confirmed every block in + // [DigiDollar floor, tip] is present. So a read failure here is not expected — + // flag it loudly rather than silently reconstructing from partial price data. + LogPrintf("ERROR: Oracle: failed to read block at height %d during startup price " + "reconstruction. The DigiDollar block window may be incomplete; if this " + "persists, restart with -reindex.\n", height); continue; } diff --git a/src/rpc/digidollar.cpp b/src/rpc/digidollar.cpp index 5bc910f023..e1051add7f 100644 --- a/src/rpc/digidollar.cpp +++ b/src/rpc/digidollar.cpp @@ -446,7 +446,8 @@ namespace { node::BlockManager* blockman = &active_chainstate.m_blockman; const CTxMemPool* mempool = node.mempool.get(); DigiDollar::SystemHealthMonitor::ScanUTXOSet( - coins_view, &active_chainstate.CoinsTip(), blockman, mempool); + coins_view, &active_chainstate.CoinsTip(), blockman, mempool, + &active_chainstate.m_chain, &Params().GetConsensus()); } DigiDollar::SystemMetrics metrics = DigiDollar::SystemHealthMonitor::GetSystemMetrics(); totals.total_collateral = metrics.totalCollateral; @@ -701,7 +702,7 @@ 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); + DigiDollar::SystemHealthMonitor::ScanUTXOSet(coins_view, &active_chainstate.CoinsTip(), blockman, mempool, &active_chainstate.m_chain, &Params().GetConsensus()); LogPrintf("DigiDollar: getdigidollarstats - ScanUTXOSet completed\n"); } @@ -768,7 +769,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 +782,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 +4386,7 @@ 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); + DigiDollar::SystemHealthMonitor::ScanUTXOSet(coins_view, &active_chainstate.CoinsTip(), blockman, mempool, &active_chainstate.m_chain, &Params().GetConsensus()); } DigiDollar::SystemMetrics metrics = DigiDollar::SystemHealthMonitor::GetSystemMetrics(); totalCollateral = metrics.totalCollateral; 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/test/functional/feature_digidollar_pruning.py b/test/functional/feature_digidollar_pruning.py new file mode 100755 index 0000000000..fe3052a549 --- /dev/null +++ b/test/functional/feature_digidollar_pruning.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +# 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. +"""DigiDollar-compatible pruning (v9.26.4). + +Before v9.26.4 a DigiByte node that wanted DigiDollar had to run with +``-txindex=1`` (``IsDigiDollarTxIndexRequired`` refused to start otherwise), +and ``-txindex`` is incompatible with ``-prune``. That made it impossible to +run a pruned node once DigiDollar was configured/active. + +v9.26.4 makes pruning DigiDollar-compatible: + 1. On a pruned node ``-txindex`` is left off automatically and + ``IsDigiDollarTxIndexRequired`` returns false, so the node boots. DigiDollar + validation resolves a spent DD output's amount/lock by reading the creating + transaction out of the retained block (``node::GetTransaction``'s block-db + path) instead of the txindex. + 2. A prune lock at the DigiDollar activation floor keeps the DD-era block + window from being deleted, so historical DD blocks stay available for + redemption/validation. Pre-activation blocks (which can never contain DD + data) are still prunable. + 3. ``-digidollarstatsindex`` is left off automatically under prune; + ``getdigidollarstats`` still works via its live UTXO-set fallback. + +This test proves a pruned node (node 1) can do everything a full node (node 0) +can with DigiDollar: + + * F1 - the pruned + DigiDollar-configured node boots with no txindex. + * F0 - DigiDollar activates through the real BIP9 state machine on both nodes + at the same height. + * F4 - the PRUNED node mints a DigiDollar position, mines the block itself, + and the full node accepts it (tips + consensus DD state agree). + * a full user lifecycle (mint -> send -> redeem) runs on the pruned node. + * F2 - ``pruneblockchain`` on the pruned node deletes early pre-activation + block files while the activation-floor block and DD-era blocks stay + available, and the node stays consensus-consistent with the full node. + * F7 - the "digidollar" prune lock is the BINDING constraint: with the tip far + enough past the floor that the last-288-blocks window no longer covers + it, ``pruneblockchain(tip)`` is clamped below the activation floor and + the floor/mint blocks survive. + * F6 - after a restart the pruned node reconstructs identical DigiDollar + stats to the full node. + * F8 - a THIRD node, pruned from genesis, cold-syncs the whole DD-era chain + from the full node over P2P (network-driven ConnectBlock resolves DD + amounts from retained blocks, no txindex, no wallet knowledge) and + reaches identical DigiDollar state. + * F11 - reorg across DigiDollar blocks ON the pruned node + (invalidateblock/reconsiderblock): DisconnectBlock undoes DD supply + via block-db reads, reconnect restores parity. + * F10 - the pool migration path: a FULL node with txindex (node 3, following + the whole test) restarts in place with ``-prune`` (automatic prune + mode), prunes pre-activation history clamped by the DD lock, stays in + DD parity, and can even explicitly re-enable the stats index. + * F9 - a pruned datadir that IS missing DD-era blocks (created by pruning + under default-regtest rules, where DigiDollar is always-active with + floor 0 and no lock is registered) refuses to start under the real + activation schedule with the "DigiDollar-era block data is incomplete" + error; ``-prune`` + explicit ``-txindex=1`` is still rejected; and the + error's prescribed recovery — restart with ``-reindex`` — rebuilds the + DD-era window from the network and restores full parity. + +The fixture uses ``-digidollaractivationheight=432`` so DigiDollar goes through +the real BIP9 DEFINED -> STARTED -> LOCKED_IN -> ACTIVE progression (with +min_activation_height=432 and the 144-block regtest period, ACTIVE lands at +height 432). ``setmockoracleprice`` supplies the regtest oracle quote that lets +DD mint/redeem blocks be built. The pruned node mines every block so its wallet +stays funded (regtest coinbase maturity is 100 blocks for coins at height >= 100, +so seeding funds by mining from genesis is the simplest robust approach). +""" + +import os + +from test_framework.blocktools import MIN_BLOCKS_TO_KEEP +from test_framework.test_framework import DigiByteTestFramework +from test_framework.test_node import ErrorMatch +from test_framework.util import ( + assert_equal, + assert_greater_than, + assert_greater_than_or_equal, + assert_raises_rpc_error, +) + +# BIP9: -digidollaractivationheight sets min_activation_height=432. The state +# machine still walks DEFINED -> STARTED -> LOCKED_IN -> ACTIVE in 144-block +# periods, so ACTIVE first appears at height 432. Mining a few extra blocks past +# that lands us safely inside ACTIVE with mature coinbases to spend. +ACTIVATION_HEIGHT = 432 +FIRST_ACTIVE_TIP = 431 + +ORACLE_PRICE_MICRO_USD = 500_000 # $0.50 / DGB +MINT_AMOUNT_CENTS = 100_000 # $1,000.00 +SEND_AMOUNT_CENTS = 5_000 # $50.00 +MINT_TIER = 0 # tier 0 = 1h lock (shortest, quickest to redeem) + +# Prune below the DigiDollar activation floor: pre-activation blocks can never +# contain DD data and are safe to delete, while the floor block and every DD-era +# block above it must stay available. A margin keeps the floor block itself out +# of any fully-pruned block file. +PRUNE_TARGET = ACTIVATION_HEIGHT - 50 + + +class DigiDollarPruningTest(DigiByteTestFramework): + def set_test_params(self): + self.num_nodes = 4 + self.setup_clean_chain = True + common = [ + f"-digidollaractivationheight={ACTIVATION_HEIGHT}", + "-dandelion=0", + "-fallbackfee=0.0001", + ] + # node 0: full node with txindex, never prunes. + # node 1: pruned node. -prune=1 selects manual prune mode; the init + # parameter interaction leaves -txindex and -digidollarstatsindex off. + # -fastprune shrinks block files (64 KiB) and lowers PruneAfterHeight + # to 100 so pruning is exercisable on a short regtest chain. + # node 2: pruned node kept DISCONNECTED at genesis until F8, when it + # cold-syncs the entire DD-era chain from node 0 (IBD-side DigiDollar + # validation without txindex or wallet knowledge). + # node 3: FULL node with txindex that passively follows the whole test, + # then migrates in place to -prune in F10 (the pool upgrade path). + # It runs -fastprune from birth so its block files are small enough + # that the migration prune actually deletes pre-activation files. + self.extra_args = [ + common + ["-txindex=1"], + common + ["-prune=1", "-fastprune=1"], + common + ["-prune=1", "-fastprune=1"], + common + ["-txindex=1", "-fastprune=1"], + ] + + def add_options(self, parser): + self.add_wallet_options(parser) + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def setup_network(self): + self.setup_nodes() + self.connect_nodes(0, 1) + self.connect_nodes(0, 3) + # node 2 stays unconnected until F8. + + # ------------------------------------------------------------------ helpers + def set_price(self, node): + """Publish a fresh regtest MuSig2 oracle quote so a DD block can build.""" + node.setmockoracleprice(ORACLE_PRICE_MICRO_USD) + + def gen(self, node, n): + """Generate n blocks on `node` and sync BLOCKS only to the other node. + + We deliberately avoid sync_all()/sync_mempools(): a DD tx sitting in the + miner's mempool is not relayable into a peer that has no recent local + oracle quote, so the mempools legitimately differ until the block is + mined. Only block agreement matters here. + """ + return self.generate( + node, n, sync_fun=lambda: self.sync_blocks([self.nodes[0], self.nodes[1]]) + ) + + def blk_file(self, node, index): + return os.path.join(node.blocks_path, f"blk{index:05}.dat") + + def has_blk(self, node, index): + return os.path.isfile(self.blk_file(node, index)) + + def assert_dd_parity(self, label, pruned_node=None): + """The pruned node must agree with the full node on tip, deployment + state and the consensus-derived DigiDollar totals. + + The totals come from the stats index on node 0 and from the equivalent + UTXO-set scan on the pruned node, and must match — including + `active_positions` (index `vault_count` on node 0, live vault count + from the scan on the pruned node). + """ + n0 = self.nodes[0] + n1 = pruned_node if pruned_node is not None else self.nodes[1] + assert_equal(n0.getbestblockhash(), n1.getbestblockhash()) + assert_equal(n0.getblockcount(), n1.getblockcount()) + + d0 = n0.getdigidollardeploymentinfo() + d1 = n1.getdigidollardeploymentinfo() + for key in ("status", "enabled", "activation_height"): + assert_equal(d0[key], d1[key]) + + s0 = n0.getdigidollarstats() + s1 = n1.getdigidollarstats() + for key in ("total_dd_supply", "total_collateral_dgb", + "total_collateral_locked", "active_positions"): + assert_equal(s0[key], s1[key]) + + self.log.info( + " parity OK (%s): height=%d supply=%d", + label, n0.getblockcount(), s0["total_dd_supply"], + ) + + # --------------------------------------------------------------- run_test + def run_test(self): + self.test_f1_pruned_node_booted() + self.test_f0_activation() + mint_block_hash = self.test_f4_mint_on_pruned_node() + self.test_user_lifecycle() + self.test_f2_prune_retains_dd_window(mint_block_hash) + self.test_f7_prune_lock_is_binding(mint_block_hash) + self.test_f6_restart_parity() + self.test_f11_reorg_across_dd_blocks_on_pruned_node() + self.test_f8_cold_ibd_pruned_node() + self.test_f10_full_node_migrates_to_pruned() + self.test_f9_incomplete_dd_window_guard() + + self.log.info("DigiDollar-compatible pruning tests PASSED") + + # ================================================================== F1 + def test_f1_pruned_node_booted(self): + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F1: pruned DigiDollar-configured node booted without txindex") + + # The pruned node came up at all (pre-v9.26.4 it would refuse to start + # with "DigiDollar requires -txindex=1"). + info1 = node1.getblockchaininfo() + assert_equal(info1["pruned"], True) + + # Prune auto-disables both txindex and the DD stats index on node 1. + # (getindexinfo does not enumerate the DD stats index, so its on-disk + # database directory is the observable for the parameter interaction.) + assert "txindex" not in node1.getindexinfo() + assert "txindex" in node0.getindexinfo() + statsindex_db = os.path.join("indexes", "digidollarstats", "db") + assert os.path.isdir(os.path.join(node0.chain_path, statsindex_db)) + assert not os.path.exists(os.path.join(node1.chain_path, statsindex_db)) + + # DigiDollar deployment is still fully queryable on the pruned node. + for node in (node0, node1): + dep = node.getdigidollardeploymentinfo() + assert_equal(dep["status"], "defined") # height 0, pre-activation + assert_equal(dep["enabled"], False) + + # ================================================================== F0 + def test_f0_activation(self): + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F0: activating DigiDollar through real BIP9 (pruned node follows)") + + # Build the activation chain on the FULL node: a pruned node advertises + # NODE_NETWORK_LIMITED and does not serve deep history, so it cannot bootstrap a + # fresh peer via initial block download. The pruned node validates and follows. + self.gen(node0, FIRST_ACTIVE_TIP + 5) + + # Fund the pruned node's wallet from the full node so it can mint on its own in + # F4. The full node holds the mature mined coinbases; DigiByte coinbase maturity + # is 100 confirmations past the early-chain threshold, so coinbases freshly mined + # on the pruned node would still be immature. A normal payment is spendable after + # one confirmation, giving the pruned node DGB to lock as collateral. + addr1 = node1.getnewaddress() + node0.sendtoaddress(addr1, node0.getbalance() / 3) + self.gen(node0, 3) + + dep0 = node0.getdigidollardeploymentinfo() + dep1 = node1.getdigidollardeploymentinfo() + for dep in (dep0, dep1): + assert_equal(dep["status"], "active") + assert_equal(dep["enabled"], True) + # Both nodes must agree on where DigiDollar activated. + assert_equal(dep0["activation_height"], dep1["activation_height"]) + assert_equal(node0.getbestblockhash(), node1.getbestblockhash()) + self.log.info(" DigiDollar ACTIVE on both nodes at height %d", + dep0["activation_height"]) + + # ================================================================== F4 + def test_f4_mint_on_pruned_node(self): + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F4: minting a DigiDollar position ON THE PRUNED node") + + self.set_price(node1) + mint = node1.mintdigidollar(MINT_AMOUNT_CENTS, MINT_TIER) + self.mint_position_id = mint["position_id"] + self.mint_txid = mint["txid"] + + self.set_price(node1) + mint_block_hash = self.gen(node1, 1)[0] + + # The full node accepted the pruned node's DD mint block. + assert_equal(node0.getbestblockhash(), node1.getbestblockhash()) + assert mint["txid"] in node0.getblock(mint_block_hash)["tx"] + + assert_greater_than_or_equal( + node1.getdigidollarstats()["total_dd_supply"], MINT_AMOUNT_CENTS) + self.assert_dd_parity("after mint on pruned node") + return mint_block_hash + + # ============================================================= lifecycle + def test_user_lifecycle(self): + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("Full user lifecycle (mint -> send -> redeem) on the pruned node") + + # --- transfer some DD (to self) --- + dd_addr = node1.getdigidollaraddress() + self.set_price(node1) + node1.senddigidollar(dd_addr, SEND_AMOUNT_CENTS) + self.set_price(node1) + self.gen(node1, 1) + self.assert_dd_parity("after DD transfer on pruned node") + + # --- redeem the position after it matures --- + positions = node1.listdigidollarpositions(False) + position = next( + p for p in positions if p["position_id"] == self.mint_position_id) + unlock_height = position["unlock_height"] + to_unlock = unlock_height - node1.getblockcount() + if to_unlock > 0: + self.log.info(" mining %d blocks to reach the tier-%d unlock height", + to_unlock, MINT_TIER) + self.gen(node1, to_unlock) + + self.set_price(node1) + redeem = node1.redeemdigidollar(self.mint_position_id, MINT_AMOUNT_CENTS) + assert "txid" in redeem + self.set_price(node1) + self.gen(node1, 1) + assert_equal(node1.getdigidollarstats()["total_dd_supply"], 0) + self.assert_dd_parity("after redeem on pruned node") + + # --- mint a fresh position and leave it open so the later parity checks + # compare a non-zero DD supply/collateral state --- + self.set_price(node1) + node1.mintdigidollar(MINT_AMOUNT_CENTS, MINT_TIER) + self.set_price(node1) + self.gen(node1, 1) + assert_equal(node1.getdigidollarstats()["total_dd_supply"], MINT_AMOUNT_CENTS) + self.assert_dd_parity("after second mint (left open)") + + # --- transfer DD cross-node: pruned wallet -> full node's wallet --- + # (uses the confirmed second mint; DD transfers are confirmed-only) + recv_before = int(node0.getdigidollarbalance()["total"]) + dd_addr0 = node0.getdigidollaraddress() + self.set_price(node1) + node1.senddigidollar(dd_addr0, SEND_AMOUNT_CENTS) + self.set_price(node1) + self.gen(node1, 1) + assert_equal(int(node0.getdigidollarbalance()["total"]), + recv_before + SEND_AMOUNT_CENTS) + self.assert_dd_parity("after cross-node DD transfer") + + # ================================================================== F2 + def test_f2_prune_retains_dd_window(self, mint_block_hash): + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F2: prune pre-activation blocks; DD-era window is retained") + + # Extend the chain so the prune target sits comfortably below the + # last-MIN_BLOCKS_TO_KEEP window and pruning is not clamped by it. + target_height = PRUNE_TARGET + MIN_BLOCKS_TO_KEEP + 20 + extra = target_height - node1.getblockcount() + if extra > 0: + self.gen(node1, extra) + + # Capture reference hashes before pruning (getblockhash keeps working on + # pruned data; only getblock, which needs block *data*, fails). + early_hash = node1.getblockhash(1) # pre-activation block + early_txid = node1.getblock(early_hash)["tx"][0] # its coinbase + floor_hash = node1.getblockhash(ACTIVATION_HEIGHT) # DD activation floor + + assert self.has_blk(node1, 0), "blk00000.dat should exist before pruning" + + # Manual prune below the activation floor. + node1.pruneblockchain(PRUNE_TARGET) + + # The first (pre-activation) block file is deleted. + self.wait_until(lambda: not self.has_blk(node1, 0), timeout=30) + + # An early pre-floor block's data is gone. + assert_raises_rpc_error(-1, "Block not available (pruned data)", + node1.getblock, early_hash) + + # The activation-floor block and a DD-era block are still available. + node1.getblock(floor_hash) + node1.getblock(mint_block_hash) + + # getrawtransaction on the no-txindex pruned node: a DD-era tx is + # retrievable with an explicit blockhash (the block is retained)... + raw = node1.getrawtransaction(self.mint_txid, True, mint_block_hash) + assert_equal(raw["txid"], self.mint_txid) + # ...without a blockhash it needs the (absent) txindex... + assert_raises_rpc_error(-5, "Use -txindex", + node1.getrawtransaction, self.mint_txid) + # ...and a tx in a pruned pre-activation block errors cleanly. + assert_raises_rpc_error(-1, "Block not available", + node1.getrawtransaction, early_txid, True, early_hash) + + # getblockchaininfo reflects the prune and the node is still fully + # consistent with the full node's DigiDollar view. + assert_greater_than_or_equal(node1.getblockchaininfo()["pruneheight"], 1) + self.assert_dd_parity("after pruning on pruned node") + + # ================================================================== F7 + def test_f7_prune_lock_is_binding(self, mint_block_hash): + """Prove the "digidollar" prune lock — not the generic keep-the-last-288 + window — is what protects the DD-era blocks. + + We push the tip far enough past the activation floor that the last-288 + window no longer covers the floor. Then ``pruneblockchain(tip)`` asks to + prune everything: without the lock it would delete the floor and mint + blocks; with the lock it must clamp below ``floor - PRUNE_LOCK_BUFFER``. + """ + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F7: the DigiDollar prune lock is the binding constraint") + + PRUNE_LOCK_BUFFER = 10 # src/validation.cpp prune-lock clamp + + # Extend so that tip - MIN_BLOCKS_TO_KEEP is comfortably ABOVE the floor + # (past whole fastprune block files), making the lock the only protection. + target_tip = ACTIVATION_HEIGHT + MIN_BLOCKS_TO_KEEP + 60 + extra = target_tip - node1.getblockcount() + if extra > 0: + self.gen(node1, extra) + tip = node1.getblockcount() + assert_greater_than(tip - MIN_BLOCKS_TO_KEEP, + ACTIVATION_HEIGHT - PRUNE_LOCK_BUFFER) + + floor_hash = node1.getblockhash(ACTIVATION_HEIGHT) + pruned_to = node1.pruneblockchain(tip) + + # Clamped by the lock: nothing at or above floor - buffer was pruned. + assert_greater_than(ACTIVATION_HEIGHT - PRUNE_LOCK_BUFFER, pruned_to) + # The activation-floor block and the DD mint block are still readable. + node1.getblock(floor_hash) + node1.getblock(mint_block_hash) + self.assert_dd_parity("after prune-to-tip clamped by the DD lock") + self.log.info(" pruneblockchain(%d) clamped to %d (floor %d)", + tip, pruned_to, ACTIVATION_HEIGHT) + + # ================================================================== F6 + def test_f6_restart_parity(self): + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F6: restart the pruned node; DigiDollar stats survive and match") + + self.restart_node(1, extra_args=self.extra_args[1]) + self.connect_nodes(0, 1) + self.sync_blocks([node0, node1]) + + # After restart the pruned node has no stats index and reconstructs its + # DigiDollar totals from the UTXO set; they must still match the full node. + self.assert_dd_parity("after restart of pruned node") + + # Service bits: the pruned node advertises NODE_NETWORK_LIMITED, not + # NODE_NETWORK; the full node advertises NODE_NETWORK. + assert "NETWORK_LIMITED" in node1.getnetworkinfo()["localservicesnames"] + assert "NETWORK" not in node1.getnetworkinfo()["localservicesnames"] + assert "NETWORK" in node0.getnetworkinfo()["localservicesnames"] + + # The "digidollar" prune lock is in-memory and must be re-registered on + # every startup: a fresh prune-to-tip is still clamped below the floor. + pruned_to = node1.pruneblockchain(node1.getblockcount()) + assert_greater_than(ACTIVATION_HEIGHT - 10, pruned_to) + node1.getblock(node1.getblockhash(ACTIVATION_HEIGHT)) + + # Wallet rescans on the pruned node: within the retained window they + # work (DD positions/balance intact), beyond it they error cleanly. + balance_before = int(node1.getdigidollarbalance()["total"]) + node1.rescanblockchain(ACTIVATION_HEIGHT) + assert_equal(int(node1.getdigidollarbalance()["total"]), balance_before) + assert_raises_rpc_error(-1, "Can't rescan beyond pruned data", + node1.rescanblockchain, 1) + + # ================================================================== F11 + def test_f11_reorg_across_dd_blocks_on_pruned_node(self): + """Reorg DigiDollar blocks on the pruned node. + + invalidateblock forces DisconnectBlock to undo the DD transfer and the + second mint — resolving their input amounts from retained blocks (no + txindex) — and reconsiderblock reconnects them. Supply must track + exactly and end back in full parity with the full node. + """ + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F11: reorg across DD blocks on the pruned node") + + tip_height = node1.getblockcount() + # Tip is the cross-node transfer block; tip-1 is the second mint block. + mint2_hash = node1.getblockhash(tip_height - 1) + + node1.invalidateblock(mint2_hash) # disconnects transfer + second mint + assert_equal(node1.getblockcount(), tip_height - 2) + assert_equal(node1.getdigidollarstats()["total_dd_supply"], 0) + + node1.reconsiderblock(mint2_hash) # reconnects both DD blocks + self.sync_blocks([node0, node1]) + assert_equal(node1.getblockcount(), tip_height) + self.assert_dd_parity("after reorg across DD blocks on pruned node") + + # ================================================================== F8 + def test_f8_cold_ibd_pruned_node(self): + """A fresh pruned node cold-syncs the entire DD-era chain over P2P. + + node 2 has been idle at genesis the whole test. Connecting it to the + full node makes it validate every DigiDollar mint/transfer/redeem block + via network-driven ConnectBlock — resolving DD input amounts from the + retained blocks on its own disk, with no txindex and no wallet + knowledge of the transactions. + """ + node0, node2 = self.nodes[0], self.nodes[2] + self.log.info("F8: fresh pruned node cold-syncs the DD-era chain (IBD)") + + assert_equal(node2.getblockcount(), 0) + self.connect_nodes(0, 2) + self.sync_blocks([node0, node2], timeout=120) + self.assert_dd_parity("cold IBD onto a pruned node", pruned_node=node2) + + # ================================================================== F10 + def test_f10_full_node_migrates_to_pruned(self): + """The pool upgrade path: an existing FULL node (txindex, complete + history) restarts in place with -prune. + + node 3 has followed the entire test as a full node. After the restart, + txindex and the stats index are auto-disabled, `pruneblockchain` + deletes the pre-activation history (clamped by the DD lock — this run + also covers automatic prune mode, -prune=550, unlike node 1's manual + mode), DigiDollar state stays in parity, and an explicit + -digidollarstatsindex=1 opt-in still works on the pruned datadir. + """ + node0, node3 = self.nodes[0], self.nodes[3] + self.log.info("F10: full node migrates in place to pruned (pool path)") + + assert "txindex" in node3.getindexinfo() + self.assert_dd_parity("full node 3 before migration", pruned_node=node3) + + self.stop_node(3) + migrated_args = [a for a in self.extra_args[3] if a != "-txindex=1"] \ + + ["-prune=550", "-fastprune=1"] + self.start_node(3, extra_args=migrated_args) + self.connect_nodes(0, 3) + + # Prune auto-disabled txindex (the stale txindex db on disk is ignored) + # and the node is in prune mode. + assert "txindex" not in node3.getindexinfo() + assert_equal(node3.getblockchaininfo()["pruned"], True) + + # In-place prune of the pre-activation history: something is actually + # deleted, and the deletion is clamped by the DD lock. + pruned_to = node3.pruneblockchain(node3.getblockcount()) + assert_greater_than_or_equal(pruned_to, 1) + assert_greater_than(ACTIVATION_HEIGHT - 10, pruned_to) + assert_raises_rpc_error(-1, "Block not available (pruned data)", + node3.getblock, node3.getblockhash(1)) + node3.getblock(node3.getblockhash(ACTIVATION_HEIGHT)) + self.assert_dd_parity("after full->pruned in-place migration", + pruned_node=node3) + + # Explicit stats-index opt-in on the (already pruned) migrated node: + # the index was synced during the node's full-node life and continues + # incrementally from retained blocks. + self.restart_node(3, extra_args=migrated_args + ["-digidollarstatsindex=1"]) + self.connect_nodes(0, 3) + self.sync_blocks([node0, node3]) + self.assert_dd_parity("migrated pruned node with explicit stats index", + pruned_node=node3) + self.log.info(" migration OK (pruned to %d, floor %d)", + pruned_to, ACTIVATION_HEIGHT) + + # ================================================================== F9 + def test_f9_incomplete_dd_window_guard(self): + """A pruned datadir missing DD-era blocks refuses to start. + + Under DEFAULT regtest rules DigiDollar is always-active with an + activation floor of 0, so no prune lock is registered and DD-era blocks + CAN be pruned away (a regtest-only property; mainnet/testnet floors are + 23,627,520 / 600). We use that to fabricate exactly the damaged state + the startup guard exists for — "this datadir was pruned under different + rules" — then restart under the real activation schedule and require + the refuse-to-start error. Also pins the -prune/-txindex=1 conflict. + """ + node2 = self.nodes[2] + self.log.info("F9: pruned datadir missing DD-era blocks refuses to start") + + floor_hash = node2.getblockhash(ACTIVATION_HEIGHT) + default_rules_args = ["-prune=1", "-fastprune=1", "-dandelion=0"] + + # Restart node 2 WITHOUT the activation-height knob: always-active DD, + # floor 0, no "digidollar" prune lock. Prune away the DD-era window. + # A 64 KiB fastprune block file holds many small regtest blocks, and a + # file is only deleted once EVERY block in it is prunable, so we mine + # further past the floor and re-prune until the floor block's file goes. + self.stop_node(2) + self.start_node(2, extra_args=default_rules_args) + + def floor_block_pruned(): + try: + node2.getblock(floor_hash) + return False + except Exception: + return True + + for _ in range(8): + if floor_block_pruned(): + break + self.generate(node2, 200, sync_fun=self.no_op) + node2.pruneblockchain(node2.getblockcount()) + assert_raises_rpc_error(-1, "Block not available (pruned data)", + node2.getblock, floor_hash) + self.log.info(" DD-era blocks destroyed under default-regtest rules " + "(tip %d)", node2.getblockcount()) + + # Under the real activation schedule this datadir is now unusable for + # DigiDollar validation: the node must refuse to start and ask for + # -reindex instead of running with an incomplete DD window. + self.stop_node(2) + node2.assert_start_raises_init_error( + extra_args=self.extra_args[2], + expected_msg="DigiDollar-era block data is incomplete on this " + "pruned node\\. Restart with -reindex to rebuild it", + match=ErrorMatch.PARTIAL_REGEX, + ) + self.log.info(" startup correctly refused with the -reindex guidance") + + # -prune plus an explicit -txindex=1 is still rejected outright. + node2.assert_start_raises_init_error( + extra_args=default_rules_args + ["-txindex=1"], + expected_msg="Error: Prune mode is incompatible with -txindex.", + ) + + # Recovery: the guard's error prescribes -reindex. The damaged node + # rebuilds from its remaining block files, re-downloads the missing + # DD-era window from the full node, and returns to full parity. + self.log.info(" recovering the damaged node with -reindex") + self.start_node(2, extra_args=self.extra_args[2] + ["-reindex"]) + self.connect_nodes(0, 2) + self.sync_blocks([self.nodes[0], node2], timeout=240) + self.assert_dd_parity("after -reindex recovery of the damaged node", + pruned_node=node2) + + +if __name__ == "__main__": + DigiDollarPruningTest().main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index c9e0359dfc..dafb07c784 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -326,6 +326,7 @@ 'digidollar_wave26_mixed_node_compat.py', 'digidollar_wallet_restore_redeem.py', 'digidollar_watchonly_rescan.py', + 'feature_digidollar_pruning.py', 'feature_oracle_p2p.py', 'wallet_digidollar_active_restore_redeem.py', 'wallet_digidollar_backup.py', From 3376d03e3ec53500447c3750982b80857aa622ee Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:55:23 -0600 Subject: [PATCH 02/17] build: bump version to v9.26.4 --- configure.ac | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From ddb4aaa6c01d992f6dd0b8d05a50e20248b7e542 Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:17:37 -0600 Subject: [PATCH 03/17] qt: update v9.26.4 DigiDollar wallet image --- src/qt/res/icons/digibyte_wallet.png | Bin 18801 -> 18686 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/qt/res/icons/digibyte_wallet.png b/src/qt/res/icons/digibyte_wallet.png index 1aa6313461139773a51d931b46af504c561e1b6e..2a00f22761b6480937b69c59110ca22ca957f166 100644 GIT binary patch literal 18686 zcmb??WkXwC(>3ny?poY}yB94KDDEEI-Mu(1?pBHfcbDR>#ex-gcb;7L`xo9XIr)%t zGJDRNJ!@vKiBVUTM@Jz+fr5fUS5%PEfP#WHhJ62xgaG+O$yLsWf>Ib#l#%@EopYA! z<^NUdxiz1M=LinRS2Ih22_;1)wkDRz3{+rWPG_R8zjWc`+ix?u^D+z}fLN39a(qv=C=Hg=Sv%L^Y`!LP~xi`XG=}|Qm;K@oJ zn^v53O$G-WOfh)>upU-`7Y~or4Ify-St%5G^WWbD7aKpSvXO){Ov$>khc<*XS{Z#I z50&i|;XTv;q#3xA8{#q9$q*qm+b&wD5QM2u9~Mve@>FB2bbW@hE3t!ZbU}j|(ND=*i{@2B^@_?<-{W{b1hJo1Z5+Q_Fb&NflR>SEc8zRh5r8HI zcyPr-8A>kvhYV&`s+V}7O<}Q#If)}@NxoKWx9pnaE#!MJ!$Xk;+6wkN?4;BPCw;nz8nAva0z17Fpbr5Y zDlT-gtI1}IEi4UXOSygf$u^`2DN}TL?Z!= zRu|zIzw{7Dy^i=8bJ$ZX8+O$_>^3iH6LN(&jXTS!MN#=~ZMC2EO|3+o$xQk*;-Z9J zwAMZ!TZuEu`_hE~f&8+Npr*W#F4?(SLUVBb*$s zn0pT-bjF7re?=Hpnhv9^^lV&jvr#jnx97kZszwX~>N5Ibk~En{^6=yO@KIz>qjN7O z*~D-ki7GWkmUIR{vbEJnBnc_X@luDY&WSt)T2bGWFBwsI^1t`>qD0Wh4az~>V;G6$ zv~5gI<9dwIzb~ua{PjUu;1l*tA;+xom>2l~JSEGLb+ZMjY2i$-U42u5J5@VhlX*GP?dLD?s~ zJXD2w824|({zO~esU4OC@oWv6QdPO?;BshW2#yx@K|uGtRuvLWLWnB<7<4kCOWSX? z_iig4)XyH|=p~I2f#x1weD;ql5xPOOcASt`EV_gQ0Wkt?(?!VAm&B$UYo<5lM1b(7 zfD2I$?--4^sS8I$=0~Fm)ql1W5q7uW-~D0Uhs`Y}8U71((mjW{kYKIn6`@(v*iXP3 zLdrbhyAi6zjE52zqx!hn&KvJ-jqR-Qb@%I!f!`0e>L&1m^pYQOchYT#zYy6AIPLS` zD>az#2yiyu0h>=avnZRflj^rcIGvjd%e}E)rsJVD^0BZ+*4O>21XZ6QkXG31=P!SB z+=~5W1@1!l+3n%K>LtCMe&Mc(_NDFI^N&?fye;=WG9$zhhcfpOIrVg?eVSh;JGoQO)Ry{Wz3nO8_FqK#QzGI&d&JM;jN!Y-kdxCl|;4Z4;1lAZGq?g0kg?@D?X6`cp6l zZEu1@px2OGaN#pSaw1Vbly$jbDgvNO5P@NyAzi0*Tw1VY{vs$op^ zBCqzHNfv`fSjr^mHHiQv?Zi-W>{z4$QVAN@fvEhgi zw`JgFbGFgJy491=h5}>NE0k(Jo~f4Q6BBNFr^DkvkvXInu%r=@r#SM80bUWL4 z`S0)@AdrK+7qLf1v^Ama#o`;iK4Cm%{1}gkt{86*Av5n&s1D}p1)3lW#>q#nyM4a$ zP>9TKPMvy6W}ni4ON6o-75{x0Xwn8=d|arEmb-kO!|e+bI0@WuF6v$D{W-#GYku8- zuFNJBmXksX0m7|fU;m)N&Ej4dwq>wDqyF@eUz!Pna!*!I&RK^$dbjl2l7XwE_RM|5 z=KG)eCV0ySl9eS|sTWXZ%=(Tz)5P=QwMRs(c2H%+=yy`$@Y**8)lrX$5&t!RbQ=Ef zo~y;Ljz$(kJ4HJ+cgPFpJwuJT78vt#Pdb!X%CYPwb4lz1Lw;0vd%4L-{P=}G@IKtv9>rZ$zld-ui5@oj}egC4$-hpB&MjAoF z!?3)ElH~L(b6W;0#H;dXIW~O^M7?V}*bh-1ieEcnAd%10aJXzw{~o%f z=MxHGwjeuqvfZi~P76l@JGo8C9$s$5yKFRmb?{3VKQ_(^VP|}2B5`K^x29jeYR5$x z*nZcJ#(BTr<_3b0US!O4d3Efh%>bTz>r&2w2XIDWQubHWwSP_RW{GWbaPOn<_%7p` zh|yAUc1Okjl@!2mla##zT1Q=uj#Y93+5-vPq|)!vgajWYuR8MIEdH)aSuNWg3c;=3 zqqSJmsII;BUa%Rb68DQSkCjLkngk{>4&o|2IlWfavcgi7a0;6ZwEyc&>PpVp(ZN~= zULGSVoJp4Dzc+wc=1b!1Q}oTdy9pxj)Xl7lo;uq)0We^AH>G_!cbG0K_B6IQGsSKL zzboR-+ZJJS25QWZ{q-M5O^#lpP&1ogZ{Mg`v@lv<+ z$w=B|8VTh84yI{V)^#P&crl&P55_ULgfUPv{Rvv~Iho_#A(@hjjtYq`R({Rkt0#LLtG_avyqyQYW=lJA z0IfQ{>q4BrwB^fu3EL)nM0JidXIwl*VK77P!rO2YE~@Z!g{>P44LyNzpG`yJa064d zE+=+Vjx1lg#ndRjLv#zXD&^FOE;=KZPV_G;YbCUX@B9&bQ@w{NXy&{QQTesdosfWajXvV6H9A zZN?+B(o2!;K`>ctQkbMW%xaPS>Ehw4KYa=xl5TkIheY4Z%;{InSO#iElywPUG3N}J>{E3@B^ zQ^qA}lhu?!yJU@V>3h+DHxp{3N8lK}V4mP^fj=NU-H_zz7o$Vjf}p3wO(zyK=D16eSBkYXM<`S#`AD2W=usf9ys(pSK^@9|o09 znZ->u3X-EJtTi&42HPXxsI0Ff!X30O<*pS`8p0?a_kVW6%qf5QRl+$A2Jy8BV63Qe zZnHhb66@}Zjo=0;4<$R3MuTCbmDgSxNJfLOCXk58QuIaeI8$&a=z=WdmW*F()R?<5 zN9QGd10_tCen1F+){~7+^zVqcF%J@b=m!AUH2Rp)P{M>y1g8M&A`cxZ?QymnmR!Mvm<-sV!8lx4dSbXdb@>?@e2< zHOY1^<#@$tV}?CaiOj)cffH>#%GQRto6vmO6U_I+choDc^<^n8OfH+;zERC-GM(ci z^MvCio)1>;)hGHS6EOW-+{D~9ZdYQ{X6{%RrC~xE&Ju|Mk#lLUw1Sg`vHK1Y+Bmnf zR``ey9QKG9sOU|zPe-O#3SQJdpr>gQvr+4_jx5|sRf@!AHHHTkEB);8DNoKRLgnI- zNA}5AlZZl-Ma2!lL@ZBJhSCh$L#G2A=5H?)%}ade&*z~#4iP8qPSJEHuEl8;7!%C< zkHT~tH~JCMeX+D-yebD)SXR&oH#$t_J0^-)Ue12@&k0Na2qf&d*S?+%{V|K#T20hW z#PUV*P(k+?UgFfCGQ1U!c`|%|0!vQ0;kU`f#u-<@Pu8{!W+MW0bVR8W1Ov1kn9vu5kL=J> z%zy(K9?@s)xo?lEhMU;cV&D=lHqJcjC`#w*2gOvr$OIDvcm)1;W?6pPAtf1o_iv08 zQErW(OVKn=0_uS{m!CME85tM-TeE&O#U7PZ)+ocA7#+D@`7ffde>Nu*HoojL3)aH^ zevop_xR&J3`-WnyqRRc0^;O+#X8q3kxZcqQ-3prIpS$pFE?6tu9LCC+#*_!(r~F^x zF9tuSyiCN7W6l%BiCb`5G*107eF~{;)q@-^Yj!kBIu{;NUMO+*Dyd#EtxD3$-uS*p zfR-*fZec5_=3Oay46JblrdGMHl5r7FbcTbT{?|=RX8Ye^B|n6 z*#A&&swg#+3ux$`O1hQQ0@Ft;QNr^`ogTFXP|sa{t5MeVWuGfSC`59eDHF7Y+6Yx+ z7VF>coFDezLS{l{^@j&tPf2{(!KyNtmSO+pGiTfIPsfHXLOROnMGIpR@=8^IRmooQ zchbuX{e^k%i$JPilX2=+$~oWi$;Vq{u5ZHoZ$UN#&Cy9>>p2SNzF zT9Upye01;M7Tz_x#FF{lmzQkz5$j;z6^FfaaxubOo(1wG=T6%2t@gO-?|}sahy|`n zk8i+xB6@{nE7c3743}r_c~ge;9Fkj1eQTbG%y&uLEUV@SkmPkFcrm9adjd9bZ`fVb zxha1GkQR(n9^y6J+HyuU(Qh8ainN-mN9hli(0jLjQov9>}3P_*_wr}Z2`Q} z(wu`u0=@YVPPWR7SQp=hP>bclrKABsD7#yQ_D?`0MuMAf;x=M6f74nA_UqZfVzJAkW8mt{-htDZxV=V5SE--zzkt;vSwxIOJ;Eo4~8B~n^66OUJJda$zR2h zZ3z88KN02;>+X8_xQA9-RB~E;ILRfJ9{No2rqO zCCpp6rd>Yird~Elmm&E15&+LI>)bM>1m_brT2$WhDeG=F5 zE384;340}fnh#k|k;F5Lm;9)SsIC|q44RSHjLHN&qVlLd zOsf4?uRG@}_Bec{BRvS`^SDDX*`G12Vdt{Bki2AYLwK=gwX8^o$_>3WKqk`SvJEQ{ z53R_^R180rr`onZw_`Z+RmYUwO!gJWn%I0qy+YsD-#$>XNe z5TH!DDB2tIH36Q=*1VBkuf<(WOm|cOi%Do(NxG z0H*C*;-^pdlslWl)k?9(`h;@mBPKFtzB7EJ_M8uzh8N{7B&KoskrmnLuNnQUAK7w) z+im@cI$@$dUQX6W1K}ypZkPVutL`BAvh+pMw(#u+O3L z`5DFuJUI|&cyFW&C5Z!%e9qN$1G7i)0FCP zq#1etg63!Af*^fhMe{<3WyijWK)W)MgBoIYF(A3ygmyZ%lz*gR8jy(vTWdF@s=5P$P|Z<=n~ z-QH@Gm6H8D+FyFo>RaW*Wx#W<^)1c6tIPoN12B$q7{`|ddXm(KwX!sDjf)EPC+D%x z+Jl#FsSICQrc&v5iwHr`BlIF3*+L#nrX(`TtG9sD5FRhNP>PJo+Q%0=mYCooQlt7j zk#d-^CR*xQI;!8r?R|$>0e=SalZG3wZ_bNcooSqgfSMIKt8l{F!GQQxCmkH-prT+7 zU9i~dpLM{uQPGOA4t(neP=9SA+qQ3r`9-rUmng8c8SYGq2#Txs*Mh5X?T#mj!yl=<=Z|dLXooN1M$}B`uqNsx`RB9}Q7{4+_ zQsK`On48#)&8>d}b?$D`w-n9jD7gj-xaC!6bxlzBr*y_34SdPrr7oU8(#H7Sm7%eC zm8{mKc!o<-Kcb5GgCQR2c@a-mQC!ZOJG8Fv?Nr}3%_WqJIpB>C#V{XI}X@J0n;(Ci6-mHoU1Vn(IchO32EFc z3pnW~t4g0aVbP?|a4Yap0hh1)E8da0-2Jxx_H`g4P0MCD8RsAncY3D14*X*w2TpNh ziVcc~&*}!SMQclTAbj?)t>Hpk_H|-8j@Rs#y<{59U7*MzcPE3yritB^C^h_QYqq^k zc1v=h7V8Xd9Q^dzZ{#4aek5CWJ|4Hghtl?(pYAK3Yj+GsFv{LnxlU|)F{W6DtbPhzf0L`RJ>FQt~Dri9!JoCrnXingNAS7uW0sGu~U^q=DOryCoL@CvRq(kOcDNX&#r^{f$L zvZ9#>K)bX88Tu|7RVwOign`hEo(|Iy*78PU_M_9Jy3JOp5iB%CVLfI3&xV&p@TR!Q zm!&F$H%I*}X+TZs;&E}RJQKMx7nFXRem4j1##-4?CyB$D3~BMk%{VN|C&OHWIm$hx z)b2l0GM8&x^pUlNG4${MN-cS;MQW-mA(B8ThLxAbF2ItpT}@FYQMa zT}ufZb0roHJhd|{jB;zarY(pqcMS!cWej$c%=yg$?k^m99%!l&9{{w9a0<26A9 z>S3zVgGl?-0%U(ynCF3xcsP#W_~*gP9utCpZqYvlqH+~?X{Lj{TbuKi#ozn-@%xA~ zXZ>ut9)^l$Vxhyscwk!i-qa%=kFOdI|_K{XoUk~7qrXpCwm41841eKli z1xKX<8Y7XyL9*}VUtEWt1`gM$*>>J9g4&{E-=eDYv7G-A@uX|JZPq?ULP-SB@hcN& zUgjEHlT!Jv#X%ruBC!S3U@v38h^DK*GzdNl^P+v^n$tp%sbAqFFHTE0)z)NB%3U!q zPk_0iVfLV1{}CfilT#1)51a!{H^&RZ)d*4(>iYvAx@MoH3p>*#46H(}6byLk3ofyd z#X4%*6+J*B+N9U<-F}2+7}rTIXp%HSayh}F&El&&$_S)uqgy+e#hWW8<38|R{ZWqt z&Amt4bi9o7rUj3rRhMr_H;BioEKFj%&G9rfSY0Xh6z(e6mL>Z&aofX`$8Y+Rg0dg@ z;@I#67pfu4dz)7Gdu^_~=|0^+xTIuV#Bb3W)U~OTNl5EQ5)LC3BRmZgffd_wz6>hT zD)bVI@=MZL{@jA!r;@i{3;t^7i$A^$Aj+->jo*vJ}B^-q2jo5clJCrM7x<84B#)i?>(l**BAhMhtI=?h;LK?os*E>S- z7iuR+K`NA~Qm;65OaaLVY~GJ=Hp-z%zx@cyvK3(dA|!Lt>)_N_SbQ z)J@oA_`VUzc;l1!dtYrhu{Kq%0z*f4r{nCtPp*01fH_#N%&z3)OuVevm0w-xJf$x$ zIQ_I}|3=}x)W!q|obYqIecNMaY8!I+PTSi#BjrvwC!OGAu|9oOqJ=K8B19uAl0r_m z2=aI{*c(b@&j4P^A%%A}-qPD}5M|4?^lbxTmjQ38yACZir{!8Wp} z&<1C!FIPBwa2jXgI_gvlA2Saqy5(7M%)6k`rehea3wT#vEsHpyUXCtP^v&h@VLV-S*5zIjAWQ6)A_t0;w0Ip;X*gCnX8ilbh#vPOU*Q~x3wV!sdPX;IGoB|Z%canLL= zHmXrDh_I^m>t$J!>UPWv?KsQqNTjis#-o)w?icqJS^*-0`hVeE%@z>W0~@-=UfQ_} zX`;ue$|3EdIZ z*5CcHR1DlEz~NGZA#CSVdKn~*#ZSE8zBfN^e}%`RC{kvS$RMIITQg7;_9d_Pj6V`* z1V4i^cIncn3fL7dL*qZ>!r))dN-P-$$Y0;VrwWo;{b_E_sCti8e)fmiE*SUjke-XA z`dyC7TbJfB!WX-&t2`n_^A(Ybco4ya!v=uWh2_fYi;-ZWZ-Y(^GPly^MbsU}!$|p! zp@F|oI6v#3b+G`JJ21Lml?UtpED;f84$=8=;m7dX3o+36eC{{b4tEM&CrjmD+PH+= zPG21LS3D~4S%hfNB%DPs9o^ZN{5&#nrUQ}4{;#CAThT#V01Q^10Nm`}N5Q(1e^FC0Qnx0#~Qy=N1YdMqlE;T>eSN1EE!y8Gn_* z8u2!;?H2y*8Zwd^{@8%1VnF2D_vfdYSn@EWwbx(eENYVAL(+o0ohwG4?UynbN(vIl zUPv=(lMXev#K}dxXpM9sO!#j1y@_oOK)ne7>CR01U$bYKEZFLcSeDmM8^08EFZ^|{#) zX@+4fGxw1a>7eu15hxzM_)t?Zt8gdvRtOGH zbM_;rgmA(8tt}@7N0|2P=7&;>L3srSA)fl~nBRLEN*3#Do^TK0o09KvJH#m5VA1`} zIHE0_ZZn6K3$4#DM6AOZ&9>DX{eB%lKQ!C7xBkA*S-ed#v7W zlzR44D2`I{dQzgC75WCuEA}I;X8OI1n^o9hTv{%6J*`v;m1PP=krI8h>kPomTrBIG zvS}H-;x|2}i|<~pC!LxxYUI!wLB?A$bhb!Ga9YB>yfZ| z9T`L~wu{|O0yf)KGreo;Q~nr*}gIsBIywq>2KNYh1t=x51DD$Z#*wTG$7lg z3@hy`aVd^*lgZ;Z{phm`WHlOqP|(yRp@@XDRnDyvTq0);FV)GzzNGiAq8btG=%C5> zw3j^pqY+;|qS=a|#{fF%QMSOfXNUj`g)skFsA?Z?TH0Cln>6rMjKK&KkZrQtNq9KR z2E$)rWifn?;!2q_#R!fxx?-?uJVMZI;eCMECaW3lPy$+IL<;KoOYcj_O3N<9c=HRl z6BKj{8^+#VZi!?R%w7(7K!M1s#95e$o*^CLAFBtZn5k(P4*lq8+>EOt81gA+9b;|B z_vNkI-9Z{gf2HaIrOoB!}udM4|e?hcni zP^X)@JB)$9R)V5?2s)=5Koh0=&Txw%=87-fSk`CIMKHciY6Sr-yCoV?Cwya{PX6jx z8YGu^y}<2|x0ix`Yn$84dI#}r%W1?%SA@Wn=vf zgxB62Q>>Db8=!@rrnS_tw`_+&wiIxmx&yqmIdFLbx3>IjM*t)5oQ8^#iwsLQp{J<$ zy`{>OGM?OcIO57z!U_9f^3_%sCD8Y1YZ{ifEibT5?#?~g`{cgxIX$~8P)*hJ1V!2L zHN*RSWCg0Sv@AQfiaBpgXm&6rZ_RkjjvXT`UEII2Z|3XkT6VnzTr+G^A8u^k1Oiv} zl^nvhqcmjA1}bO1KEhBLLtlCKYLU!7;ceXOTdQ~^t)EN{Tg9;lNwHQ48Vo~R^bZ7o zA)m`ZW0gacn;zXyNxgOA_wwvN`u#ERjoLAzX;fao-I08Fbr@mC`OQ%Lc@%@Y(;Cfvo>Ck%6nehEM{n#jszjDlu==IQ!7r7;O zBcy!zsN*lhZSDak5o&7~6&Rbjb5RY?#~yfIr=Wx1N2Kfh%M4q`g-7e6?X#79?0fM?aEDTzC$KE9;Z7}$0!v~-SRMAYQDUsSS8c**LCbk(JG##% zL928=7$+}cjj_g_q}Es>)S`K%m>ABW8#PMc!RWMSd(iTnJYsu9_k;bWL6EP$QF|#P z)Q?5gc9B{bwK*TkO`9t3GY`4iD5KOD-9z!ZHRqt?3E$}%UD;lM5-Wr!R0l~0F$1%{+P2=k40T>#;GO>x(G z*Hb5N!YqHJk)Dz{6393%WP(GuAb){R9^shxn8enUdqR*vG$JNx0akX2@ z3|!thg}fBv?9eK8Yc1HOySX7}ZSS~u=yjOo30Zlw^wb(eyKnGowdFsZldZ1dy?@U;l~T*nrM(aN=oP@XCGkML`VW5% z*b)-22%dLU8!rqli?+6%+=Yb9$j|q$Lr;EfZ+FHUYbQ0fF0UM*{57)e1v+pVg~h*n zl4h-iZ8On_Iq(0Jw|rbpDqK z#2LWQl*eC+YA&v+;zDF-C*|`qf=*$~1-C_1(nni!=lOyMbgQE!yPr;#l1PTXR60sn zq$I)^+8)jvMEIw1*(oou{Jmck9s6F!&-H5jO<^m?LRri=Zyc(B?Vj)O|dlmIqkNexv+8e|z4PM*q&TGU9dO zKmH=>YO7Q4dl>q@Wy)y&-n4lMHI>cou+!?CR8FyX3jczJiP06Y2D!t(v1ksYl1(QJ zQaz2>V6d5?6Lf373@{RxXrUE`%~4Rj#d-I`09;<7sP(Q4CZEN(CvEWj-I#)eH+O$S zHx>U<*dTrPc0k{^M={}7D9zY%+EG;A6lxd6nFBJWH`Xf0c$w@jRs#&O-=@ze#+PSm zEKh~rlgTVcxGX|eGC%v2O=DXk@Bi$$_P`c0f)07j!k- z7|&>W1xImQt(>%IEnJN-{t*U+7>ewNoc*|SG)>O~TCbMoaph&VtbM(N`97}9$oHk? z#}c1H;OjrE8_L6Btn~X%pgo^XXKe_ZiZX-f+K}6YPwx6OWq!x}*UL~4xlT*i3u?`R zOo#p@Y-jtg_30a!f~`Lkj>5O*`R67L%khWm)pZq|Y=5V;%1hhwr*#_>eapo>KZjn# z|LXkR7vMH|oV-KOO_)0uIaRZv>JYZqG?bU^h->ukDMYONCIq$aZ&SH#?FKR(0?=G$ znYz|o@Dx+cO<6g5cd3%5dX|uwqx%N^<6$9(R>*pyc6eZPmeEh=k*C!UCsS0USqyny z@HZ_~HLimJXKnn;-p9*b)JVXh+l;4!wth=Ip2^RcQB%k!)A9%9a@xt06y+qL0z>Zp zitOG0uv7)?qAEX^>Orli3A#$3mL5;u3kdsbq&s}z^-ya5o1JEp^^^4+i$0oRZ=iTt zgP&i|feGw%AgSKHm{WB_v*hm6$=jsmnZ_yIuwPdY0z;I(eDm%qtD!pk`nMrmH!tqi zj)OmVBb$B$M-GbLc_`Lijk-7lG2r z`MIX{-R8N>uJ%8Ple&TvkVX7**&8eE_#Az0aaC2?ok-%B<1gP02py};?l9C~Se;S_ zPU6O&OuOW59d5l3*M$<|)NFUVDQ5PlzA5z0{8Qn`PNO1FgKm@XR&>zpkXSCP6}`pl zvj+foe}ctHFYkC7o^J-S`!*8GMFevBNN-INb%T%tr@yrV$;ClnlIv$ZUgopy@cC>% z{9!s|jK25*DHfpr$;iFP^ASeNgE&%ZS)AvOIkV)*a4??MZut6kVZAR*_WYbSwxOrtSHQr>M`-+JGyxG zouvBBk2N*4UgTCg8gF7$c<|jl~%mk*he#H0)oLx*wS4WDPpvx+{3hG#HBP zHmtU9Ahw)V?w)9T3`jxoZkOMMW?r&QJpWiv5u{>7aIlsEe^IYP{tde-(NA-*dIOOq z^^`2IdEc#f(uOtX-+AuwZRp_v5YalxWPtSzl}|Bm^bBV}Q?Rv<_3jIg+HYtzkvI|fV5oNmmH%6_ zTfe_v6vl9i9gpDbu#Q#hNGCW&Z=3~{Xr1ZHab(zZ2dDO!9LV)EbHTNvyUzSj7ZE5%Y1g@I@p^{S{?1orJ?EMYn;|TJDC5` zeWV$*`DK@&yTFepxaP;kDgDf*zTQzF8`kF9`IsG}aSdRpQYV7nB@_&{U>GYN(lp1V8ZSpWVtQ2dX1(NPw`K?0}!XU$3eKI&G} zjDrTB@{-W7nmTg8Htxbsmp=2V!9Rj-uf+31Pf9d`Tj*Nw(p z?26p2y+ctV$JSF;i1>E=onGAO7P}0$Mv&h`UnN!K>a}Asdu#oZ(0!C+=44l_`A^)n zKW;`riJUBtn?b1Jb4n7Yf-76iM@A58McH9quoWVA3fSgZRwKoHT1g-kZ1PJv;Ds5d33QvzL&nygco?xvDqJ;creK-IfB8e$l*$*kSALUW@nF^zEI79gOdj z)TiCg;*j`!hWm9040VfE(Z#=^8Wzh|YwxjQNa}Mp@Nos^2;}xx`TQys0D9Y)haUc2 zQ}M62@UZAo4^HuD=3{5y2r?}I2dbTiq{a!uf0a-4Lw~&==h)8=f?mwZ8D*Bo_iAmO z*uXV+cX1A%RU_nle% z5q$vRX8y_u@yZ>Is2lSh5T(TB9YC;Z&12<&bMi%QVZpDEsdtjTfX(<47G4?_rF$m) zf``^VL*rVw&~F%gmb!`rn|lD2>~yLjq6nq|+MCC+=8*5}Ff)kb8e%-J{EMQ;Gq zt@UQ_7RHJ4%Q}f0_tbUq9+&YKl{tp6G%u?!Qw2EM{nJ7}f0#>zLUf1^XYhwc;TC6T zIW)&gk6bUu_7g#CBZxH)A^GS?qd^0D5{Y2wJvqVRFL4PK^NDuNeNq)F?n?>Z{T9CKmi(D!B>yE$|cgV)wmNLTcw|$=X9W*D} zz{-1MjhT&Ze-_JSfC7TZD^I~%pVMu969BTk=*tr^wiD0LCkK6hcol=tP$Cqg8EFvd zRr8Z7(cjHn%woyZ&K-|mM**~}^`Hc&EN_klK1khB$C@76eJOo zruubAcN*vPq+_P*kW3L<6P`mMUuKX4RZ2 zpCW3WV3frjc-n>@V5g%NLCGb&M6Tp%6CsfVKmA{AsLWa250}>~1=zoJS4Z**kI(SV zl*pt$S9oewr#j%@z3LeakDc-H@{SVEa4ni<`1M9xci+duy{r)m>t5UQK^g?Z!R#@O zDlY0;Cl&`G2n0ZZ!L=cLN!-N+VS4kAt~>}fOd)%HP7;N@UVRuWpK9Rdmw|S}CjXtL z=35Klk4IlZC`3&ANWf$Vc=b57?j%@8ECx0z7Xl0BbWF#AUx5|wN^#{LDY>!72-*(I z2R8&sE}o`B#s^C-oh=Iu!htO^K_a#GIFziFdyuw)!qySU6W*Va4A56>MEy7Z?}1Rs zpQpW%$CZ;V8}L})y4Fi($L9wtWW1QiXS2j+Bi%E!y)j1Y`vL|;Bt~lxzpv$*3-%qn zfI7nXW~|WLo=X_kjMMjPAY>E|0S>H@WSNyF!ae;O9y!_@8T|~?c#e|EFKu?|ECo+> z+Fl`vY!tD>B>B#lrk#vz+=ceXo9dd^g!$~YexG?eikO0bWp}&L-Xq8eGMjphy{DP< zre&j2Ox?1Fku)vKCl`QT^~mw{-EQ;Vn}!-6A+d$ZSLIwfnm}XRs89eQSqj#HC7Pdk zB8$+j<*@SWK0z0fnbcQvg$ly0_0C`@*@Fa*)w__8bg7)6(G*Ty%Z z>;S;enkZLnf1E`kDT(}s?Z%TTECG3reuMzIzN9PdPE6t2zr##@mFkVkRTa~L|IL`; zbR!Upyr?iOkcVqg9#M}DJy3yOv5M!3YIfXP&pi2p)iVLxF#mP*Q8WP6-F~JAsYun=93Ef zSfQ$r`|JeJNV~(%lm>psx-29G7EuT!12c!CaH&CMR4>w%2w$1->iYDyQ8|6SZ~*T) zg1djl`j#5LOgUqa6e8I?ugt*1uhD_{fWBi5;gUSQLIeH;lMi;s+ zL~if5dP&hhb%fpV z%FOLZ%tp`?xTSJRhCj|34eH>>_=nj;{0!>ZE$00seAN_?s|UT)Y!vcJTz7dli75>& zH6Y*vk_rCjN>i`!T@^TMvRR)KOzAtLuCy$m>tw*HsbJM=q{|^E5ZWa+7y^TH?V;YpO?;zcM%HN+9?`x2Ef|I4z4iU7GRET2ctV5=dg#_S~i|zM$lyT)l?4zU# zks|iB&d&AnB{tr{v`@523sv^EF$$Zqfc5c-?X@gFW|WA2J-t8OzSl)YD`y{au8NRJ z>4;j2*3|wgj%+__6x0!xF49MyOBQE}C*nWw{br(ak8Y}nWvzK0)Z*sWGbgdNNT6V= zs+qU19G0JF|0!JYZ+Tbav{CMX6&1$$EB#l^Mp<%^)-li|K<*lFuRmVDJ4qjAL#?AJ z!y!Ps^vZ3?ZsqS;^YYq?BR!HkW6Yo{AnYgSyEqb^1~#GGJ=#xF4C%Ij z^ChPE+vPiY2E0X4cJf^CO$Z3GsM!)Hc@u1PBs4@lXk>|?t&2mLm5u<6TD%d-Y1+9A zD8bwQV4u=1Lv#69C?P1_Uh`hN>(3lF_CNBk{0eYq5?;TbVNc?%5*1d<&(hfF_7j?9 z%`lDl$MsK8Be@Vi6JCESMcWJDMcMe@WN1P3D2pg|EBFqG&$)Wn0R47xD!%ul44&8& zv;AjfA?jsA2pEdR5CoPnvK3!87?^F+imIH>?ak~p+1kWfwuiMUMtxFfrF{FtiQ|nY zfK~$Ql%gXv2=WO0<9N+;Oq;`!#?`N%SoGJH*a}fPyd*d=Byp5k2s68td~`^{-wniS zM;9kKL05Oj!e%r+$zmAEzJLKkJ~I95&i9OEOQZlJ;Kj+S>pE#|qQL2-M`~Gt1JMYO z^M3(*2ZZ=1KSPpNdJ`~|{0vVn;=I!T0MlL1`U7i$Ke^`G4xApL{qsI_;r=BsCW8KD z0;m$bdCQ}z@u>nww+!HJc4d!+Xnm#CMPVzGxrej35o)dwGfU)S> z^d8^~;4$(uA`4Z|D~$)P0d7U#`Lsg+e7^G=*Ymsl6L`k;|M|dT@-s|Jt{*G3Jk8gL z8QEV`kaZQ#x-P)&2o^T?k|JOWh$pbi3}$2vV@BqT7B6^p8ii#laDn@3UTHgYTPwiF z=mNNrO0UPOy&ew=I#Yf|m5<=n>FD)%7kQVLe#WOjV{)U5OtgH+E4=`C7dQo7q3De+d>@PHdh$w70!o0%=yv2dperx| zSmLsyM?wd5`}6tew&{4FmuuWbuD_S2TnhXMT!}8MkE@bbM~CojWY)-X3bS6IAZrA| zeln8B@%?hQGA)_IB5^ZNOq+C?2=OGM9$81NfD^(kwsGb5Tww?jXP{(Fr@|D5Ky zUhM!}WzegSYtB4Z``<@2_fbjw{V^h>I&e1D;z0pH%-*ElFq$NFYIL}7f1_Bu%`=JYD zONykS3z}^p)`GScD8sM=cC``0A6#ufaR5rN7pKcyI$J^ zTnBvQ_1ql&FzjWoIUXlJ<6YO|6t9;1(YLOvf!okc2ubL!Y7ZHVn+5z4-7GT2@Ac?O z_j+6zq+NGBKK4z$dVa<_;oEdQC*b+cuWZtGIlAx#T_X)o`0Cy&526eA6VZ1T-q`*6 z==?9Nd5$5D4dL6ASwpv=3jq()<~i7&+#8#tAIP3a*&0MEkf}(KiVgjLq#fiT5C=gV zM970^=>JQ0U}r5=@#~l~_!zJ3>BA@J_PF-whbkTLH96?yXRHTCxU|z`^aGaxcm(E8 zA81*3)L^H%t{wu+D=O!e{u9V`J$r)u zj0c;vScNXI1aOkq^%OjwFo6y-t=jqcqX#@RuIF>|Gpd8MF7tXkxXEkQ)IKy?PhROZ zbYYihFz*nr#~Z?&Z7?3eyB6JcyBS>r{v^z?jvJ02;j4(2m(Ydp3BU-~wb?!`@=6Eb zISlKAzO`>O9X!>>E6wnl;Sl*5+sG^Z4*e!@H+iK$ zqYLCbJO^0c1(@Rw^g}Ig2BLc%0Q`kOu5kWzC9kwUx=s2@JO_i-=&o>q$}0_ci!|VKnQ>&!{D@^uNFjz;Nx> z0HX+i626Mi$j?|tUg>glE$2jZ4~W;Bw7MC6Yu}VN&`-j>nTY-c9q;l5J_TRFIKlPf z&8NU6z*JxXy7$COP1+OzuNlr^6d9IR>K$aUI}E||wCl&4y}tXu{FuYNlvz zW_vyUl>CgQewj7sCV_fjC%RU3HTfCWlb_MF>c%Us_V1IQQGq8s*qr=~b?70qFT3Wc zNB@%l9XOZ#j7Ga@(>biB0bRiT5B;XokNk|ULh6~H@jm(iS1Gzpb{)EBK#{9OQ(Le# z>&eg9h3@U~9C{9jdh~j_d0rrre(b1%};wVO$9TbY@Ag}ZebRXp+U^lwfdKI37ukV{@VTvP#621yW^Kc4* zeT^&8eP$KMFv{kuP&5x8p&uZYp?eF|p&wAqL{Bc{=Y*_KC=?2XLZMJ76bgkxp-?Ck z3WY+UP$(1%g+ifFC=?2XLZMJ76bgkxp->!i{C|PoW&w{+D|Y|@002ovPDHLkV1l_i BxR3w< literal 18801 zcmb??WmlVB(>Ctz?ovu|cei3K6n81^4#C~MxNEs^ifeEv?oNURE$;T_etyIIB{|o* zR(AHxo;hZY*)uWU)D$q$$k1S5U@(;wWi?=6U`?RU|Dhm3Khg443SnTB1eIi^e)!~_ z=X?AA(0Xn!bp&ZctxE%u3m}PdTItm6!S5%_8%@_^AfL+p0Yj_YW`J+*qv?n}Z0|j4#A7KeQ62%P;Oc8~$QOT;%{-*v9N{>QAAI7# zSrNF?$jKsH-KSKU$^$K;fB!@5LDIH$WpZL!A4!# znLI1}cVD4)!bjoTUkTvq#=aHXbr&dFl3EIMp^985bI}g_LzKry!`CnxLXULT_$;A7 zF;p2$-#VUwK_MMGuA&_DhlLnwWHmcuXrP+VE0ilZg-H{gf#R)(K7`NrQcL1*A5jls z7=zWU?5KF_k{aZS4)EiYMOr!ylv3(~;~4sjn^W%e|0Qmgp+yYXCB|R&`1<0ZdUAwy z3!S)Y{fn5N`gSjp?ijf~IF~R*d4%wK$eq9uqeg+GmO z)}C`j5u&V4-4V1myP4^1tzOdOw)|7HHSTi#M3hTE1XWCX%)_W48^sT(4Z+$+T$GjA zCtn8ubN1fFPEEH~TpoiN2l+(KxMue}1YJL&RBtW~vF=x@H`s7Oghco+}jn9I`ln zC5-KxEuBgfP@_O&&bLS@Y-mqskTMdnc*CIB~w-_c+DU(yeSIuAVt_%gKxFgi9Br6kG--r)5~u z_%M}Zw-NXMmn8W)b1~R~E!ae2S<@&ofn@l;B<4 zSFW>vql~oxAdoAitbVa!{JYB3>-GS23$dK2MmN7ynDP3%S|m}oT|R9gREuM9U^gaW zs&>nHnHjKVmVf&+K+n6AtL1_idOA?x2(iL)^XSO}iN zg5qltofqG%SWWgW)yFQ?dj@PzMKjoEolhu^X z$jy2xw)g0KYjutO3S5ZpB5o~P4-|+7gvcL~KCvYZRr^$Z6j$ix(E0^06oweU%)o`I zC4bOK-1IZ)XVamCLQj+Uwx2~C{Dz1e^#_Tl5AIhvt+&|x?eSXny<&#U*^sYt>-rb2lBV zh<0My%V?&T&qcm{*pQFT9aj#bKNG64oKM|RJZGg@bIuhuAT}n|*mW1H28)7+k|6{G zsAmdxKT$S>uX((}MI#3y=znXgZJ^T(vTk)*O@F8LiNNu{`a5`gw7%_}JJD3Id*dqy zW9Wa6BR=&|*kyIox^+w9+jVg2L7U%jO)uyuou&2bz#`t{x?m^iMv?yP1HspI?2W;+ zS|V8G+&B7c01;>aG}yNePrlVdGmjIOLc-~O_9tRC!%90FP*XfdheI`n$vs2x3F>Q` zic^Sw2wrS}IFE^?8nu&-lM9-E3{O;MM2Ky>#y*8?l$A_K6%a2UZDgL52PVQzlk(FP zftPs4o0f77XGTrDoq$(pI+P*t4q87O4sTol%zMd61|4|~S=}}0W9VNgC@;dGCNy3{9Ki+(rSnE|@0dKT8 zjsJvLAvj3v^*FN`7)W~6hd6ExG=Kcf9cTAXe~kyRndmgww6y7EO}2-?qHN4q5+?lA ze+C@d-_WrV5#xH-Czbkr)K;7`{K;}lbiRY_4j! z%Pi-Z3?aLsROu>cYB#va?}KJ?#uy8yRP4mvbGL*_aXMzCDL7C?pG-H*7Oe?gy@uoR zl4R^kaT?LD7$Rm1UV?f`3{*J2n(xXPgCU2f*$Y{XnfX%E-5-6BYj{F`-j&+hc%Hdb zlNT_6_Z;Oj%ru!Uaa{!)lgsGt*nCK&vUb1Tkwm!79Gnl03qEZ~K*+|}OLoS4wpLs$ zO4p{vd&TLlSe2oO?O{Cp(L%nOcMlcQtSeeKQfqYc3@dabDucSh7M* zZ78T_$}|gyTKSNL8Id44h4UCYL^V-a$9eZ`;r34!zHrM=;CKjjtaZ>;+)sP-9HZ*u z8ycG}vBiQbK}T$upeBD+E0mXTlr$v7uqUYk9Cf;bpsU(}CJdkG*U1LV(~KLpL-{5& z69t#J{%UI4mZ&Vh^?LDol@$K3u}*Q%&pCt|k}26ShZ7ao>z6^qlk7S8%c!?GLf@Lc zgfjZNzm-?soGf_Dsb0#*If^_qKDRm6829~R>~pf5eo$W3p~|qO@k!MmoUL(D`5hJQ z>IE4tW+E$!*z9RwTuP*rG!NEB4vsX1CHE^&2Qt5oG}B91I0K3BiJC`n)3BQ1UYzn; z46*~)53Otm5wf5;qM9QNzj=zAi2jyU1@^{4TYz@|FbWqlFuKnyGt<||6)l8mopv7) zJ6tM0cRyO^SvKeKZSTC$9cIVZ4j>;+54CYIc`j_ zoY?vO)cEYal!M%SCDHL{y%Cc*j|&H;Qgg3zfX|=T8<%>@Xfos6s%w<<_ooO)ITz_M zt7oFcbtcL1>=WtY^><`AbmL(1v6;(dVP}IJX_YNKlq(b*uk;vf9{F@QcQ$g4brs5P z(p3r%fEmxm&q?sXWDC+tTu9*U-hIh4U9_*uLcms zvP~|r=>h+BM_;1IXT*A3udaj?nHL^6o1lW1Q$g{k*5FFx_u%1)6$6pEL%G?XD666F zJ6`loJBnx~EF-zP)XJYW9?NHzvco1q)Uz7E)@O~wk|GNod3+;W=V`I*(su80u!_ZV zi~*lDWiPp|(lYMl7vw`JLX+X8JYe)o?azK4t@{h5@}cMjH+;x$Cxg#^q@gj_4b*AYB;@>fqC(+5hr6`P!VUAN>0eLo^7lt=8QyRWX2 zHb3^O&sfAvw~BV6s4CM68;3e09%-S`D%?>EEPu^%%>z&UXhqzEHLvph6SKtNNj43wTzT0Qtm zBM$zQDw3UXr4jJLhd?e!L=w}0$>bdU;rO~Ax-jd29on4(#!uG@@lSq@Czc}lAuy|6 zB!k}#V{mA|sDl?%ehe_nLXB5J%;rORLHH99>@)07 zK^l*{)lOHPtYcomw=onST%YvLmvI*Q;xgo1GeacsDf*6N4!b`N_!$a`Tn@CHFOzwK zjw3lamj1v3X*KjmKf4(RXdqlw%!L2fU{AWJ*1tkNh~G6iYIr$qr9YzV2RKKq!R>}| zej)5$i^v~if5lM!0_V$C{4-Jo=1O!;?!jVnj_4V?;Xgx;Cnwy8O)p!Z%^!<|)+)BA z=axR#K|*VN9@AA&RC@@^P0cerKNIu}_Eni`d!Z*HD*Fw|%nimu)i?+3kA}HH;Qqr> z40>0p{vCaE&`w%7vxC||@vj%d9!F!*RC2g`0)I1-&*F_XBq7&YKz`w$k2&6CW1rhe zc-+&I9{`9RW2ViVJ0C7C8gqi2cM98*{`v2eYV0H!=J8K?Qs2>Er!Ifhgt7+z0+!=W zUlBm9`sTfOtBocEO;aR>gHf4HnQ*PnQ~BLb;gKaQN!#3lqWgZPYpta%&zZO;-XG`~ zF*~jsgC+_1n#zGt0?}t2I#9G_;;lp$S~p!F(s)M8fNnmNlZUyxAIiuvO~&;_;mlr0 zPQ&XGP1!l}TbCeyDiiin6$t;nBuMb&@^*}mm7nRlv80K9AtS>IUq);v7>Yo+$9(7T z&87y-#8~;=(w8+t`E$3JzGI!zg`FwJ47SL(zU@ktWfIX)+kMi_M`ps!P=b0q+c~p* zU5e@N+XD~ZHsw%A_2d7fHw+H>6T>BKiDYE3!R4_~3<{okS@m5qKO1>qi+CwyRqg|^ zes7}3(EddpCn;g^=M(DRx7LuKl5$@u9G~bcTFOSWNuNKMeGu1iOmxk9iw>V!J>%*y zg~@rOP~()m$Dt&5nI@sh0_#Glgg=zv3v*tlx?d#LW|P-Ogb`w zaZ>y*p7?!-A3#by&#GT-mv2xME5_3z+3ZO!_dD{g?ezjR|7}=?G!G%Ye2z0TDAA1O z-1>ulLJ)E8Z(x(I1)Bs8t+nl?s1WS>wZqc+Q0$T?uVOIrbUo~PR5$y;g#OV?*EnvN z^Q1`)2}oChA>mcYQRHh6vPX?;B5bR4D>r8*P9j!@6LdSv_VU*r?@M6SS(+196>l)VR&k{4payDCEp3i-?DNDovIxUngp_Ajyu^b z<7N6TKE-rb_aySaV7!k3olB&Phlg5zuqB)oS&9+Q^8A!HsTmDg^0cc8PjrbpALiGTl_(b|#c5{j3;N`6A2*rf!|Kx?TtfW8$51KsSllcPy;dLlnqGrW#j2 z_6O@zcR6yxGOEE}Btj{aR93|3y8{V@D?o}Gp)&Ut6MhXnm%hP>lVpdL3>6ff!;DhX zsBMu}YUE{1&%{P*^X^S+f+;SQ>Yp6U{eAmXqC_1br!>QE6tC^@l%UvJWW8;?X)h_@NX4A?>6z#drx9)>%T#u1plHfd)gL&O~CD> zx+Z<$CcN02j=v6`lHqKj?9;``{)gWofuxrR+ezqYhHI!H;G zJ9&MJ>1KO$QN^N7YNgxF9JmtZX@+1#TFcX5-K8gIjeQfvyceW~H2(&L$srd zg1@Wy9@(O#eL!<#&mKw35v+DOYeem6^gL1)OkZpghE&LFMu<*NyH09)al>OSeuu2l z7kk0YGF_%$Ux*5n{DoW(m1s&Ksp%chOqvw{9%!N{FJ27uxo4zeqAN;YW6EwjNjRr( znaX+jyltf0YSpqKMY6eSH16qOv1^r*9j9Dk>}&Zny)bt(gx~nsuuAXbW1i^*+cI7u z{mQ$Ra8DG=jvjUlt0VPAY#+2AtzY-eT{^QHeqi5U_Gzvq50? zt1!0bnlZ(!N)qxj_qQGyp43fUjqX}9M~OQ~EUEy@Xo zk8tTuA&GC38@_bIRi-JIVifLT(n#-I$nLZJW>WStD@@ek^)ZszGn52An2%0Mntp~6 zYsVe?jDv&RJtyHxk}Jg?ES$N&nsCOh1BzLGPAdI`c@u;+If?Fe%R{FWbP=LB_CP7~(Q#e_22+HVfkv;#i>A%X(ZG}DF0U{AZO#zN4mM79) zfrN%Z_XxcqYre*5|3MwBOp}D{ng6EhVK6E1F#1vpcMO(oUQ?&Xf`#H)W89zZ)(aNb z;5M}H?w&xsxlzO`8-Slw2#N#@?JJAZ=*dQ_=qj6649*A#1HbWW2bp6Uy4;+Pa_W-< zl4+mI&Olbj|Iw90NvI5R4ud3j(U~NoTs6P0w(ss~;hny-kubvh6r#+5*&a-FGRp&I z4jNqAwTKkAt&&8Ul{V4#D-J{Tk#uj8=@)!;|IZOp&y@m<}-q4^= z{?&0dwTrw^Rp5BH-SuTih-fwHBIJ3g%4Q;^kIFvL(s(B^O$7EJc8#%j6T&^EPk4_{ zVEmiaS-FKr=gegjB$f||vP?Li&14pR8}-~_MKzkJ5_R)!3)`v73u(sC4f@uAfTKPM0YMI#*Us~=4x0gRR`V+U);-w~=A!hm+dxB4VzyY$+QYyW? zonKS_P;&FtHZDN)$d+8S_1qlm>G(ZpY%M~km{?GSE8CGZQP<+7*ta3X)u*ll2hJ?H z-8#0pvGgbgNa<=GmwoK6B@u+0#~^==x`6T7-I6d5u~qV)|1h+CPwnEcC9;PNyMYpK zRAabW7GuBl={2Y!DrKF-ScqP5%3^e$2ZqOF20ZZ_9qvF3X^7x@fvqY{o5NqG`9jEk z$0c2PHyk)@ja_VzX^Lk^YT;RK#`=|tO|NAx^q0@K4vrr?xxnypX6~@dH`MZW46{^O z1-o7{$T_P;9TKuykQe^8D_$^7U z<9@Dx!_VCwi|A+G2<$N8+c{MuVZyAlvYNiw{*f`Chqd9<*Uw-Sj9TCQkuS$OcRb6_ zxXTs$g}B$uW={G(6eYa=Ns1gwXZ0fd<~^k7W{Ip-N0X&X7=xHVCVVJD+q}K&pG3W( zK3L?*pTA)R$W}u9kcjiZ zGEou5n!{w_7-ktYYaLA9ovA?9>9^$Lk?ocVlsAsf4}H9fi_507&e3M5iod|t2VZ)V zTLeeY*357u`sFHtMI0^u`?8K@0F24saJyRD$-LITK9$d?U9;SYML7l6a}@?rx7$dK zg){ow65jiFUZOE=EGCa#V@ES*XE^1Ba;9Dy=3=lre0O6)5yN%Aj9LcZ?&23#8R;FzB@N2X zLW-DE2~07bNPi_LxHUL`M|TOoS6b4o@KsQ`Uqx?N8~C`>ue~c+tffES26M;=Q{cUeofsZ6G4gees!ev+%!Oh zLvjo~c|KsUMQIknmEDRJhqLT?~*Zr zg0^&O|8V3Mu}~_7co(V zgau+VJKA`fp_s(Is?+cPcM~<=X!LdUP2$xL_vZ_?UKJ0L#zx-7~Wb;Sh3&!u19;1&0s7|S8J00Aw%Jo$tLc@2c7s9RzH!7NFwMc36q3( zuRPM|j7DNu*z2u`@`A<%vWPP#@;BNJo80L%C5bppWQk1G>Kz zs&E)n2*tGGv>XcpM^pr~2pW$rC}n!wOcW?=?3OY; zR;rDYidp`Hh|#m3d3~<_o;kC%RIfXP##l_p*~Cw6c-{2Xf4MThl0&!fwqjTDcOCMU zx&+|>wCL8N2woau0tD>^xC3k+**`(=jN<8mqO+kM#Pc$V&gQrEjb%S>Wmebe<-Vj+ zGA!14N1SZ8@{76-e?Y%r3N<&5UsPq1{6 zTG>oNkz8Z-jvobqP!S8}Wo}46&-|xNMTDe1pK>26imSS=*Y*hXst37_!QPc=fFG^j zw9Fw4hsBQ}-wWoEa!A;eaXQCD#c2APCy=Sjg+{~B$VC5K<@SqftqV2XO7TUk7u_eR zCZc`2GQ-dYHhRzz;9j1y@kqr1cDFg<(>q*iE~G1(=*pGLp->`!nWflwjIYQxm5QGE z1`y+^x7JiAQTWQttiU_6m81rV&@<6(E|F&_8$kK<13n@7HO`h4Th>}?lw=Ljmj=$U zxa~V(+TqHGe@(KM3!5}T>*yf#5+&31!ve0$E0q#$;rVfq49r?e%b z1-p=#LqGcER48Ev+ID(|lIWA9k1r$H>g9{tH3-#)E*1kof;=eAU&uLfsN%JAE@`?^ zp6*Tc66!rSj$)R#Gc{%RpZP%B4L^%CH~K`c4EX@Gq%=t-M?6N;{x+gWzdpe8^J#Am zP*-Vmnt_{e1zz|=SFewYq9AB2wEKwP>&G?-&VqV*8rlb@RR`A4M^*+YqeUPOZI=tm zz&l~c`3&;hkmu;D>L*2tRdcM8h~H8oIchhQt8F5KND&2r?IH8mGSD(`$6ZdEpDc;%V4is9TPeszT$`du90k+XWEE^Ut@lyOX&LSad% zDX92cP*E;gLPcT0wm0nqCmXPqTli|DmwG)EF1Y=;A|`mYgY2fn((%D^ir4TmOcZ(f zYhIuE=rEqj>I3fi_(l>39`|m%!y$NK&qd%dY+DQ{mqBUC$LLqE!3?{TD1(#mPM!QR zHj#&)P)=F9dJ^y`ErOO5=MYE9&W5+warHHgpo&SCR890IyNhJ8O+nUTzvqoFf;FuA zm>Aa#ftyMv=OVGhf##Kgw)ad5pBUMwq46nA`SEHZh+cyJ2X>Z4L8PO!O{IFwVFAM& zxT1QRNr>g!zht0pwk)Jrq~79on4XTz;(P>;>Jb1I-Oa$A_CDM{T3HR@8acezKw}g|#kq-8YT8Kbe}ll@t-*iJT6p=;YoY#0>wmGI zfbifQ$aaOz67FUSQ}`GE2NEV`iRCP42T4`7Rxf6x7RpBcJ|^mvb#KQXF(GnXb1{j2 z^;JtE4r>Z(feTLC-#bLlQR$2K(P$vW8$sO^S;&WKZw|DC#bw_Aavu8Y)qPz7Hvtzf zQ7~E=t!3rvYOu-z8>>Lxjc0bLxksRdMqJ8H6Iw)elx57!p)Upfp>G#>#JVQNnMuhM zf603JQX<)NNoX(gUX!{mZzeR-tY+JvrL2}@6R%?xb+K{Uu;F{$SQC9R#Mj_3#Z=dG zLpt#vBeGl^=hrV0?lR(J*<6@R%_S3ZD?EVj}{{w2M*k*UD5pH??{0gO=)o> zV*T%73XcytZstb;;ez7f^~`>Cn^UUYv4;`H+&0E<5)lzc0}sGR)w25<*idgI$0bsS z`%Apm@Ub!!>~*U^PL6#BVW?F=T?B%~zqY=b5@sUHvE_y0f%YnCovuU`~|8*&mU0xFZ2_Tle5_i`3j%u39 zVE@gPLe(5VZi$y)7A`nq=qK#G?SXp??9FOxkHy?$vsxTJVrMMu zXw91eUCLu~Kxa?H;gzp!yS7V)pr>58Re9&X8+z6W9(qZ<{6%dx624agc2c2mdk!+S z38Ecj7n1+gbVjysIIy&k>Y6FF^dB%v(;8a;`z>93+0W*GiuHwtzk>`#++J-$%?~)d z^Y&erOoc=&mPFyv7yq8RW*;)Zmj_KjS=iESKox{@!ub3 zCTBW6s&tZSbi!~cpCh*wxWmr|9%@XW?N`4c?#@w5H3PmV@{znlBh;n4ccy<(`KJNd zPDTSRX?D_HXYBMZ#Z~_IMwf)nCGyqm$*%UA_0YyRUinpdpXz|n*|yw#I`4k&-PDw0 zg?w{LCfyPBi@g}WS>Zoy2<=Rhg;EY(1ydbB zT7UVxMF$feK62-;tx_ZA4ukKD4#L@>_e)jPdfmu*vrRj2^#L;iPR;vWQJ7(0@ z8f1DI`nUdCdn=JydzX2ozmO>i3c%8e8vKn#p}Kw_Nn($ULpWRNNa1Ke!u(6vu==}( z@kB4$UMz#y#t81|EL#BK5}Cn-^h@q>D&8O|b4!uT1WD?yL%Oyg%eHS;;W9PDYH7s2 zkBAI_VXi|9qZaS%tonCpS$76wpN<>2;oqM^9E(x|^0tYOk)EZ+MPGFs3*uWlh&a`M zb!iUkr@0KD`Mwv{TU3+i53Q{MekVHqk>@M8W0t zb|dnmwvi18S$PELcl5*#w!EKO6|D1x2u9V#W#}w72DZKQ_yjsL9<e$MTYIw#p4A{WETawxH)hmIHZ9B4|n&^DWq3>&oY~W7HOF-N2}bCElCff-#@(HR;@*R z_7_r%4ts9XwM9LYm?Q6ar)L_?)cHzp_?tYcKgPzDk(zh6Z`R$_3N2U2MD?^ZhJmBn zrAqB@XI`Qr`k<+QP_^BJv39SwDtmDPG&rVDYFEB*Huh!MS2($#n&bxCN0qtF{JzJy zSc5Aqy!IYOCcVp}N_cHk7D;iJLY5@n&8Ifpq4)198)~?3d|y3920ZTJ6=eMxyzKFE z#-EsvdY=E>AD|0*+0lA!-4F}|qgFM33HV)TpC9yQBcdMAxNv72M}2yM_gql#>o#ax z>wCBS4<=nl`1lr#wy6Z^@#!kwZK=QXTn7Q`#MC~;q@82EjlLV~Q+?&a64|RKC%h7z zD}thtCg?fm)%%cNwM@UE`MCJy;q#ZD`{Uykp~T>tZMMf7Im(WkbCA)B6Y2H{<84{E z4sq5?!071eSl6+v6Dg4(y-~fFtG~ZB$Ft}wt5!mD*Rm3p)#iLcHBvbO#oo4eTW2%m zsbFy2pmTum_eKTB?-!x!hGZQ zR2Cr7X6Q4Vr3X(hteW6f{^?=uaQS*IW|K%zc+WeJ553Tc4z( zmf{wf`_hxl@hUrG30I9(e`<^9$1}zI!vV+QvYbSouz9CKM!2F`JOtZeu)waDbs0Ti z)sgckTsAT<>&P!alGr&dsZ#Q#^${lb0g+#5Xms`QtS*h@*CCO4Vg2Te`YHBzZM{)o zUCd^nmxzvBDZYjUslPcLI)A&jIZKRJZ%4gAnELDrBjaoPJB@Oi_7%VuGp3Q>uQlP| zTgyn>-NjFvB!*{iullVUCf6x2fid+;Seak|;QjAw&*s(gMcuh4B%LI1?0An`L6C2} zZ5UY(>TXcqWEkBz-V#s51pKGhsG5nNnZF*3#kR#1rE^FQN*_sZ3A#6_7YDEGUtX5y zlgv%DHFZerup-2h@4P$p>IVd$AN=FCz@NTUqsdqkOBu=Aq%Gi4bDI$woB_aOBnu3A zk~uqcr>>u(;O1Pmj1HiZHgGlNb);xBwZ;+-8~8X+`)}v|3BJosXcL%9O~)TlE-OV* z{92N4A6eF3Ai-sg%tPGH$3Sa3%2U~iFn;amgbzIK(Vwjh>zY1fy-8@{S1;(PtIh;d zKCD~SKf&lTuaV{+FO%(AY?9^RT;2~Au=jV}NV2VTcZZnpBk^PUZJTB|)mlEaeh#0F zSWll2rbk$`O?}zk7(u_;+_`SV>L2ya+sEtfJFh!D$rNb#kD-D4Pzbc#MAob44y>G7 zV`g^O06ZjGjLeJ-oX4l>U(Pc6T^+Y{VJ-BiQn+IBd%=@)13LmzkLQ+s&dkKpj#kFI z#O+oVb%k5w%^>K*ysi3#4pt7u!r$ybz5fN+6^G9qj03B6!1!Gm8r1gHI z(%SleEn79cDH@svqXR!rAD^Mu1@G6y%T5^6R4Zpku2!1$;H-V@>{_MhIlf-_sQhl7 zqkL;|zB#E=hKCv3og(qd5C8nLSAEa(#sqJ};jldt5TMjov^Q2l4&u`S^IMl_H3q6fX^?M_&4hnAX#tX8!DFu^adsArz`& zF9P+()g$Z^4V4OQ=ET{$F|5!uMfsZBh%=ulP{cg7tTP=i^FuYz_)p2K;snVp=C|O^ zr{rGX3OeCM-Lf}~`)X?<6(o4JeeW*z`LP{1>OE1r7G*^=*}apl2D-PPuxIMw_j&xP zv$nZH?fS+!D(l0P8fnRXW};QwZE1phb$-lpzDM=TL;gzt-D`}Hh$#QPH#KVUN&ON%@gO%w0jkVkO%&VRWiU{Uz4F>SZo zS+<r!#kSictiGd0H8p=oH)&=DxbLzN&2Ly(jQ%%*oYlu|S`Ep| z6O{G325=Uw<-6hfpG|7FeJpWYhQ)bWJvKJP8ie~MC`z`#a_tq*HOdPN68yV{hz z`Y~|4pB*g(V}iwrrTbOhyoeaYI=#RP7ihM*>#2*{N({`5$yqoG4)O3&TWTut`TQ&) z6NMj8675X%rP5M|)JUXtCq*I=bv5uA07B^LwNjtqp<$1|zJyv-1OjmXPdq(G;gcOR zc7WmTd;xG{zrn+vaRRPA3|7>~Okrhjuq6jHn0v~j-_bBhN9?Tqii^KK zHxV~ZK<9Ba8PJxr_te-qTlagp{b@;6kngzDme){V;09pS-Wa+hvWeOts*2^z#(q$F z=TzR(zgKKALBcLp{_C{UBrVL8)>kG&pw~eoR96(<626$RMfD#-eiZN?tcg&+){tQE zaoY`AnHf8pP`^O%k|?;H{juyY8zyG;Dt0MmvD4)F+8Jm+duqST&=P^KW`CkZkv1fR zAb^Qk*Kp-Xzka<65iuNSbFTD>1Uw!-i+r*x@=Ohz5DlE9<^ZYtmS1J8EljtYskbN# z8gg+Cw_qaqJVl8~)o$Gk^>G2PpNO{wFdg^iRZe-wdD9+NCHKhnIeCekxV-ue@B#BB zf!3}cF9t@v-RG%Wg&}ih1{N3TI-=deWT0rPNs#i@^q?SL%odYP=2;OSS9ip+*{cKF zX-#KPV7NCb0>)-Q;S`Z)C%5oQWKHgmWFq$Jbo4eva*gTttvx(yOwH*G`s4F!i8(Zv zaKA}JSq&Tk?5ud$Yp+twetgB58=v;-c3&zY$gEXQa~Y27>!8a^-8<|NWaAvG||5xXvh9023ojw)M5ubolr&z zm2XFI{sE-wbm^ zw+O%!T=#2hmv&ZMSO1^i6m`MIzw58eCiVQwW&b2VZvi?#?Ds#)?Asg-1iQnhKQhvh z^XfyONp+bftC40po>P8DcKttBh3j(9ct~g<+tZ28#og^W!G+sT^q;+r*;VY0dS87G ztBo8SJS2%BI2fnLy|9zU8i$0-04II!piZD;$#EXuUM#1*ip7+`G;O=<@0Tv$zTfd- zcNQWJ1FXM?-P<%8ItCd1FG!cR55!469l`tLL*z-eVW*bLNss$N9xH7{=u<_2<0$~n zO`l;shM||tH|4;sBSw``Bkwv;YYvm|b@`fVE_n0v`a=Z!v1(7OMFzgXsGG4#8|TE! zoHx3}fQ;mskS?U@*x1XCm7}Y)4;+GY#=pb2!aTlb^CCNb-nS=?mFn!_(BZQ`fvrp1*h^2Ow|&m%=oSq9VbL zZZ>KArpxVAk!BO}ysb~q@#ki4?W=q8i>rWQm$@-4wOAeY@Xr3P@E^@aEw6p<}si<%!?E1|Di~@cY;6(UN5|((Bd?$_H&wgX7u983Y@o~>> zyZcyIH5&)o`eZYZVLJu+8>!#uB`Zc0Nioj4k7+vjAT%aUYNiInC zuX?B*D62Q~s1+2p^9yk!sDZo8?NsC;J&JGUdl)^IS`QILv0f-aKYe&$@G>y7@1J-6 z^r#TRn=s=g@QKpUV8*ix>&8D|NC!f20S?*Ck9v}+xGLWId#+WpX?!5eYX_d(W+R!a zMnueIRg7=pYF5CTN&I&bOH*Al_c(Z)U|eUyw%dI}y1%uU0O?(mgF+WY-BPssDipg+ zgMOtYofs`RksgZkuA3ZqWMkb!<7Qa-&pH%gT91tjBdVv*WHS$k;Z&~=U0V*TnyRY# z^8WD&SZo%n`uc;L&trV*{Scu-=B@Q1?$1+iPrh~)X)|`Czu+|u?Ymt^n7qh++(rGX zUMh}`A*Vd8QSw`lP@`dU8NUB)cT%>ZTH9PQeW&*fh7{F?fWtW?jh`zmiT%-buZv!I zO_L&|R%CWgza0QFHNu0Zu;3MB&_Rwx1H$sL+E^MtKvO)org|H+Y$Id~Ismk-KPLX^r1M&^^Ss2sRn(;;T@4cK%0UyUOSZ#-! zz->mx$j#GkcSmJZnUC*he*bLYe7cGjCsZ9yk^nsena-5kk$r*9`EPeAo~*4}euykb zPA1#GnyOD3-l(1kXV1X`zN`A=Gb=#H!36xR+f#y>zL9p2`f?3hWGM0N>?x9{WAC1P zv**F!?*)c>0EM03c694m_0ER;HaeeY>cPh6@w0!+n;=8O6AsUP@m+¥UgCV#<^a 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 Sfy-L}0000 Date: Wed, 1 Jul 2026 19:26:30 -0600 Subject: [PATCH 04/17] test: add missing executable bit on 21 functional test scripts They are registered in test_runner.py (which invokes them via the Python interpreter, so CI was unaffected), but running one directly as ./test/functional/.py failed with 'Permission denied'. Mode-only change (100644 -> 100755); no file contents are touched. --- test/functional/digidollar_health_restart_consensus.py | 0 test/functional/digidollar_listoracle_schema.py | 0 test/functional/digidollar_listunspent.py | 0 test/functional/digidollar_oracle_price.py | 0 test/functional/digidollar_oracle_signers.py | 0 test/functional/digidollar_pending_position_status.py | 0 test/functional/digidollar_protection_status.py | 0 test/functional/digidollar_rpc_amount_filters.py | 0 test/functional/digidollar_send.py | 0 test/functional/digidollar_stats_reordered_mint.py | 0 test/functional/digidollar_stats_reorg.py | 0 test/functional/digidollar_testnet26_oracle_roster_rpc.py | 0 test/functional/digidollar_transaction_fees.py | 0 test/functional/digidollar_verifychain_cache_side_effect.py | 0 test/functional/rpc_getblockreward.py | 0 test/functional/wallet_digidollar_encrypted_received_redeem.py | 0 test/functional/wallet_digidollar_mixed_output_accounting.py | 0 test/functional/wallet_digidollar_rc33_regressions.py | 0 test/functional/wallet_digidollar_reorg.py | 0 test/functional/wallet_digidollar_transfer_ancestor_reorg.py | 0 test/functional/wallet_digidollar_transfer_reorg.py | 0 21 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 test/functional/digidollar_health_restart_consensus.py mode change 100644 => 100755 test/functional/digidollar_listoracle_schema.py mode change 100644 => 100755 test/functional/digidollar_listunspent.py mode change 100644 => 100755 test/functional/digidollar_oracle_price.py mode change 100644 => 100755 test/functional/digidollar_oracle_signers.py mode change 100644 => 100755 test/functional/digidollar_pending_position_status.py mode change 100644 => 100755 test/functional/digidollar_protection_status.py mode change 100644 => 100755 test/functional/digidollar_rpc_amount_filters.py mode change 100644 => 100755 test/functional/digidollar_send.py mode change 100644 => 100755 test/functional/digidollar_stats_reordered_mint.py mode change 100644 => 100755 test/functional/digidollar_stats_reorg.py mode change 100644 => 100755 test/functional/digidollar_testnet26_oracle_roster_rpc.py mode change 100644 => 100755 test/functional/digidollar_transaction_fees.py mode change 100644 => 100755 test/functional/digidollar_verifychain_cache_side_effect.py mode change 100644 => 100755 test/functional/rpc_getblockreward.py mode change 100644 => 100755 test/functional/wallet_digidollar_encrypted_received_redeem.py mode change 100644 => 100755 test/functional/wallet_digidollar_mixed_output_accounting.py mode change 100644 => 100755 test/functional/wallet_digidollar_rc33_regressions.py mode change 100644 => 100755 test/functional/wallet_digidollar_reorg.py mode change 100644 => 100755 test/functional/wallet_digidollar_transfer_ancestor_reorg.py mode change 100644 => 100755 test/functional/wallet_digidollar_transfer_reorg.py diff --git a/test/functional/digidollar_health_restart_consensus.py b/test/functional/digidollar_health_restart_consensus.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_listoracle_schema.py b/test/functional/digidollar_listoracle_schema.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_listunspent.py b/test/functional/digidollar_listunspent.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_oracle_price.py b/test/functional/digidollar_oracle_price.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_oracle_signers.py b/test/functional/digidollar_oracle_signers.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_pending_position_status.py b/test/functional/digidollar_pending_position_status.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_protection_status.py b/test/functional/digidollar_protection_status.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_rpc_amount_filters.py b/test/functional/digidollar_rpc_amount_filters.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_send.py b/test/functional/digidollar_send.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_stats_reordered_mint.py b/test/functional/digidollar_stats_reordered_mint.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_stats_reorg.py b/test/functional/digidollar_stats_reorg.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_testnet26_oracle_roster_rpc.py b/test/functional/digidollar_testnet26_oracle_roster_rpc.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_transaction_fees.py b/test/functional/digidollar_transaction_fees.py old mode 100644 new mode 100755 diff --git a/test/functional/digidollar_verifychain_cache_side_effect.py b/test/functional/digidollar_verifychain_cache_side_effect.py old mode 100644 new mode 100755 diff --git a/test/functional/rpc_getblockreward.py b/test/functional/rpc_getblockreward.py old mode 100644 new mode 100755 diff --git a/test/functional/wallet_digidollar_encrypted_received_redeem.py b/test/functional/wallet_digidollar_encrypted_received_redeem.py old mode 100644 new mode 100755 diff --git a/test/functional/wallet_digidollar_mixed_output_accounting.py b/test/functional/wallet_digidollar_mixed_output_accounting.py old mode 100644 new mode 100755 diff --git a/test/functional/wallet_digidollar_rc33_regressions.py b/test/functional/wallet_digidollar_rc33_regressions.py old mode 100644 new mode 100755 diff --git a/test/functional/wallet_digidollar_reorg.py b/test/functional/wallet_digidollar_reorg.py old mode 100644 new mode 100755 diff --git a/test/functional/wallet_digidollar_transfer_ancestor_reorg.py b/test/functional/wallet_digidollar_transfer_ancestor_reorg.py old mode 100644 new mode 100755 diff --git a/test/functional/wallet_digidollar_transfer_reorg.py b/test/functional/wallet_digidollar_transfer_reorg.py old mode 100644 new mode 100755 From a9e46d328dcb5bfe3189aa4226ce3ada437185cf Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:03:37 -0600 Subject: [PATCH 05/17] doc: add v9.26.4 pruning explainer and mainnet validation record V9.26.4_PRUNING_EXPLAINER.md: shareable summary of how DigiDollar-compatible pruning works and how it was tested. V9.26.4_MAINNET_VALIDATION.md: record of the live mainnet prune validation (42 GB -> 0.21 GB, prune lock clamped at the activation floor, restart guard). --- V9.26.4_MAINNET_VALIDATION.md | 151 ++++++++++++++++++++++++++++++++++ V9.26.4_PRUNING_EXPLAINER.md | 75 +++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 V9.26.4_MAINNET_VALIDATION.md create mode 100644 V9.26.4_PRUNING_EXPLAINER.md diff --git a/V9.26.4_MAINNET_VALIDATION.md b/V9.26.4_MAINNET_VALIDATION.md new file mode 100644 index 0000000000..c1d4856f42 --- /dev/null +++ b/V9.26.4_MAINNET_VALIDATION.md @@ -0,0 +1,151 @@ +# 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. + +## Verdict + +Every mainnet observation matched the regtest functional suite +(`feature_digidollar_pruning.py`, 11 phases) 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..cb983b155d --- /dev/null +++ b/V9.26.4_PRUNING_EXPLAINER.md @@ -0,0 +1,75 @@ +# 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** (zero consensus changes, all 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 11-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:** + +``` +prune=2000 +# no txindex needed +``` + +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.* From f1a5fa02c734da2304b7e1a914bc83133b5272b7 Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:06:50 -0600 Subject: [PATCH 06/17] doc: add digibyte.conf pruning setup steps to the v9.26.4 explainer --- V9.26.4_PRUNING_EXPLAINER.md | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/V9.26.4_PRUNING_EXPLAINER.md b/V9.26.4_PRUNING_EXPLAINER.md index cb983b155d..1cf4a3255b 100644 --- a/V9.26.4_PRUNING_EXPLAINER.md +++ b/V9.26.4_PRUNING_EXPLAINER.md @@ -59,12 +59,34 @@ release v9.26.4 Qt on **live mainnet** with `prune=2000` and no txindex: Full test record: `V9.26.4_MAINNET_VALIDATION.md`. Design + test plan: `V9.26.4_PRUNING_PLAN.md`. -**For pools — upgrade once:** +**For pools — upgrade once. How to set up `digibyte.conf`:** -``` -prune=2000 -# no txindex needed -``` +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. From 51920fded0da40b683860cbe030875ab06b075a0 Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:12:16 -0600 Subject: [PATCH 07/17] doc: add v9.26.4 PR explainer (summary, testing guide, testnet checklist) --- V9.26.4_PR_EXPLAINER.md | 210 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 V9.26.4_PR_EXPLAINER.md diff --git a/V9.26.4_PR_EXPLAINER.md b/V9.26.4_PR_EXPLAINER.md new file mode 100644 index 0000000000..2e14ab0fe5 --- /dev/null +++ b/V9.26.4_PR_EXPLAINER.md @@ -0,0 +1,210 @@ +# 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:** nothing. Zero consensus changes. A pruned node +accepts and rejects exactly the same blocks as a full node. 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 + +- **No consensus changes.** Mint/transfer/redeem rules, collateral rules, oracle + bundle rules, lock tiers — all byte-identical. +- **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 11-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; + - a deliberately damaged datadir triggers the startup guard, and the guard's + prescribed `-reindex` recovery restores full parity. +- **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. + +## 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 (11 phases, ~70s) +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 +``` + +## Still to do before release: TESTNET validation + +We have **not yet run this on testnet26** — that is the remaining validation +step, and it matters for a specific reason: DigiDollar is expected to already be +**ACTIVE** on testnet (activation floor 600 — confirm with +`getdigidollardeploymentinfo`). That makes testnet the only live, multi-node +network where a pruned node can perform **real** DigiDollar mints, transfers and +redeems against the real 35-oracle roster and MuSig2 price quotes — i.e. the +exact situation mainnet will be in *after* activation, which regtest can only +simulate. + +Testnet checklist (config note: testnet options go under the `[test]` section of +`digibyte.conf`, or use `-testnet` on the command line): + +1. Run a pruned testnet node (`prune=550`, no txindex); confirm the log line + `retaining all blocks at/above height 600`. (Because the floor is 600, a + testnet node saves little disk — the point here is functional validation, + not space.) +2. Sync to tip alongside a full testnet node. +3. Mint, send, and redeem DigiDollar **on the pruned node**; confirm the full + node accepts every block, and `getdigidollarstats` matches between the two. +4. Restart the pruned node; confirm the guard passes and stats still match. +5. `pruneblockchain ` on the pruned node; confirm the clamp and that the + floor block stays readable. + +## 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.* From 0c5453db075a5a1d30702f6f2cd38fdd7948dcb9 Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:33:40 -0600 Subject: [PATCH 08/17] test: add offline-miner reorg (F12) and un-prune guard (F13) pruning phases F12: a pruned mining node goes offline, mines a stale chain carrying a DigiDollar mint, while the network advances past the 288-block window; on reconnect it abandons the stale chain and returns to exact DigiDollar parity. F13: dropping -prune on a pruned datadir refuses to start without -reindex. Also record the first-pass live testnet26 validation results in the PR explainer (pruned node active-DD parity with a full node on the live network) and document the [test]-section txindex=1 conf gotcha. --- V9.26.4_PR_EXPLAINER.md | 64 ++++++++++++------- test/functional/feature_digidollar_pruning.py | 56 ++++++++++++++++ 2 files changed, 98 insertions(+), 22 deletions(-) diff --git a/V9.26.4_PR_EXPLAINER.md b/V9.26.4_PR_EXPLAINER.md index 2e14ab0fe5..e4407e7093 100644 --- a/V9.26.4_PR_EXPLAINER.md +++ b/V9.26.4_PR_EXPLAINER.md @@ -170,30 +170,50 @@ test/functional/test_runner.py --jobs=8 # 4. your real datadir is untouched; delete the copy when done ``` -## Still to do before release: TESTNET validation - -We have **not yet run this on testnet26** — that is the remaining validation -step, and it matters for a specific reason: DigiDollar is expected to already be -**ACTIVE** on testnet (activation floor 600 — confirm with -`getdigidollardeploymentinfo`). That makes testnet the only live, multi-node -network where a pruned node can perform **real** DigiDollar mints, transfers and -redeems against the real 35-oracle roster and MuSig2 price quotes — i.e. the -exact situation mainnet will be in *after* activation, which regtest can only +## 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. -Testnet checklist (config note: testnet options go under the `[test]` section of -`digibyte.conf`, or use `-testnet` on the command line): - -1. Run a pruned testnet node (`prune=550`, no txindex); confirm the log line - `retaining all blocks at/above height 600`. (Because the floor is 600, a - testnet node saves little disk — the point here is functional validation, - not space.) -2. Sync to tip alongside a full testnet node. -3. Mint, send, and redeem DigiDollar **on the pruned node**; confirm the full - node accepts every block, and `getdigidollarstats` matches between the two. -4. Restart the pruned node; confirm the guard passes and stats still match. -5. `pruneblockchain ` on the pruned node; confirm the clamp and that the - floor block stays readable. +**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 diff --git a/test/functional/feature_digidollar_pruning.py b/test/functional/feature_digidollar_pruning.py index fe3052a549..69c063e20f 100755 --- a/test/functional/feature_digidollar_pruning.py +++ b/test/functional/feature_digidollar_pruning.py @@ -202,9 +202,11 @@ def run_test(self): self.test_f7_prune_lock_is_binding(mint_block_hash) self.test_f6_restart_parity() self.test_f11_reorg_across_dd_blocks_on_pruned_node() + self.test_f12_offline_miner_catches_up() self.test_f8_cold_ibd_pruned_node() self.test_f10_full_node_migrates_to_pruned() self.test_f9_incomplete_dd_window_guard() + self.test_f13_unprune_requires_reindex() self.log.info("DigiDollar-compatible pruning tests PASSED") @@ -483,6 +485,39 @@ def test_f11_reorg_across_dd_blocks_on_pruned_node(self): assert_equal(node1.getblockcount(), tip_height) self.assert_dd_parity("after reorg across DD blocks on pruned node") + # ================================================================== F12 + def test_f12_offline_miner_catches_up(self): + """The offline-miner scenario: a pruned mining node drops offline, mines + a short stale chain of its own (including a DigiDollar mint!), while the + network advances by MORE blocks than the generic 288-block window. On + reconnect it must abandon its stale chain, adopt the network chain, and + end in exact DigiDollar parity — never stuck, never on the wrong chain. + """ + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F12: offline pruned miner reorgs onto the network chain") + + parity_supply = node1.getdigidollarstats()["total_dd_supply"] + self.disconnect_nodes(0, 1) + + # Offline: node1 mines its own short chain WITH a DD mint on it. + self.set_price(node1) + node1.mintdigidollar(MINT_AMOUNT_CENTS, MINT_TIER) + self.set_price(node1) + self.generate(node1, 2, sync_fun=self.no_op) + assert_equal(node1.getdigidollarstats()["total_dd_supply"], + parity_supply + MINT_AMOUNT_CENTS) + + # Meanwhile the network advances well past MIN_BLOCKS_TO_KEEP. + self.generate(node0, MIN_BLOCKS_TO_KEEP + 32, sync_fun=self.no_op) + + # Back online: the pruned node must reorg off its stale chain (the + # stale mint disappears from consensus totals) and catch up. + self.connect_nodes(0, 1) + self.sync_blocks([node0, node1], timeout=120) + assert_equal(node0.getbestblockhash(), node1.getbestblockhash()) + assert_equal(node1.getdigidollarstats()["total_dd_supply"], parity_supply) + self.assert_dd_parity("after offline miner rejoined the network") + # ================================================================== F8 def test_f8_cold_ibd_pruned_node(self): """A fresh pruned node cold-syncs the entire DD-era chain over P2P. @@ -623,6 +658,27 @@ def floor_block_pruned(): self.assert_dd_parity("after -reindex recovery of the damaged node", pruned_node=node2) + # ================================================================== F13 + def test_f13_unprune_requires_reindex(self): + """A pruned datadir cannot silently become a full node again: starting + without -prune must refuse with the standard go-back-to-unpruned error + (a pruned DigiDollar node can't sneak around its guards by dropping the + prune flag).""" + node1 = self.nodes[1] + self.log.info("F13: dropping -prune on a pruned datadir requires -reindex") + + no_prune_args = [a for a in self.extra_args[1] + if a not in ("-prune=1", "-fastprune=1")] + self.stop_node(1) + node1.assert_start_raises_init_error( + extra_args=no_prune_args, + expected_msg="You need to rebuild the database using -reindex to go " + "back to unpruned mode", + match=ErrorMatch.PARTIAL_REGEX, + ) + # Restart normally (still pruned) so teardown is clean. + self.start_node(1, extra_args=self.extra_args[1]) + if __name__ == "__main__": DigiDollarPruningTest().main() From 30a1658812527f6c4fe6ae178f4e99d95c8ee503 Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:54:58 -0600 Subject: [PATCH 09/17] pruning: fail closed on unreadable DigiDollar-era block data at startup Audit finding: a truncated or partially-restored DD-era block file passes the startup data-availability guard (which checks block-index flags; whole-file deletion is already caught when the index verifies every flagged file opens) and previously let two startup reconstruction paths continue silently with partial data: - SystemHealthMonitor::ScanUTXOSet skipped unreadable vaults, undercounting the supply/collateral baseline that feeds consensus DCA/ERR health; - OracleBundleManager::LoadPricesFromChain rebuilt the price cache and consensus volatility-freeze history with gaps. Either could make the damaged node disagree with the network on DigiDollar mint validity. Both now return false on an unreadable post-floor block and init aborts with the incomplete-data -reindex error; the getdigidollarstats / protection-status RPC fallbacks throw instead of reporting undercounted totals. Healthy nodes are unaffected (the condition can only fire when a block that must be readable is not). New test phase F14 (written RED-first) truncates DD-era block files on the pruned node, asserts startup is refused, and recovers via -reindex to full parity. Release notes and PR explainer document the audit results and pool operational guidance (restart after long outages; -reindex needs network). --- V9.26.4_MAINNET_VALIDATION.md | 2 +- V9.26.4_PRUNING_EXPLAINER.md | 2 +- V9.26.4_PR_EXPLAINER.md | 59 ++++++++++++++++++- doc/release-notes/release-notes-9.26.4.md | 10 ++++ src/digidollar/health.cpp | 41 +++++++++---- src/digidollar/health.h | 11 +++- src/init.cpp | 19 +++++- src/oracle/bundle_manager.cpp | 16 +++-- src/oracle/bundle_manager.h | 5 +- src/rpc/digidollar.cpp | 19 ++++-- test/functional/feature_digidollar_pruning.py | 41 +++++++++++++ 11 files changed, 192 insertions(+), 33 deletions(-) diff --git a/V9.26.4_MAINNET_VALIDATION.md b/V9.26.4_MAINNET_VALIDATION.md index c1d4856f42..1699961a31 100644 --- a/V9.26.4_MAINNET_VALIDATION.md +++ b/V9.26.4_MAINNET_VALIDATION.md @@ -145,7 +145,7 @@ activation. ## Verdict Every mainnet observation matched the regtest functional suite -(`feature_digidollar_pruning.py`, 11 phases) and the code review. The pool +(`feature_digidollar_pruning.py`, 14 phases) 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 index 1cf4a3255b..b48bd8568e 100644 --- a/V9.26.4_PRUNING_EXPLAINER.md +++ b/V9.26.4_PRUNING_EXPLAINER.md @@ -29,7 +29,7 @@ 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 11-phase `feature_digidollar_pruning.py`: +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; diff --git a/V9.26.4_PR_EXPLAINER.md b/V9.26.4_PR_EXPLAINER.md index e4407e7093..685bbcb85c 100644 --- a/V9.26.4_PR_EXPLAINER.md +++ b/V9.26.4_PR_EXPLAINER.md @@ -117,7 +117,7 @@ remove `prune=`, set `txindex=1`, restart with `-reindex`. 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 11-phase functional test** (`test/functional/feature_digidollar_pruning.py`), +- **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**; @@ -129,8 +129,16 @@ remove `prune=`, set `txindex=1`, restart with `-reindex`. - 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. + 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: @@ -143,6 +151,51 @@ remove `prune=`, set `txindex=1`, restart with `-reindex`. - 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. +- **One real gap found and FIXED in this branch**: 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 same "block data is incomplete… restart with + -reindex" guidance, pinned by test phase F14 (written RED-first against the + old binary, GREEN after the fix). +- **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 @@ -153,7 +206,7 @@ remove `prune=`, set `txindex=1`, restart with `-reindex`. ./src/test/test_digibyte ./src/test/test_digibyte --run_test=digidollar_txindex_tests -# The headline functional test (11 phases, ~70s) +# The headline functional test (14 phases, ~2 min) test/functional/feature_digidollar_pruning.py # Full functional suite diff --git a/doc/release-notes/release-notes-9.26.4.md b/doc/release-notes/release-notes-9.26.4.md index c50bf2f081..97eb34de5a 100644 --- a/doc/release-notes/release-notes-9.26.4.md +++ b/doc/release-notes/release-notes-9.26.4.md @@ -77,6 +77,16 @@ Notes and limitations for pruned nodes to rescan (standard pruned-node behavior). Wallets created on or after DigiDollar activation are entirely within the retained window and rescan normally. - Setting `-prune` together with an explicit `-txindex=1` is still rejected, as before. +- **`-reindex` on a pruned node redownloads from the network.** The node deletes its + local block files and rebuilds by re-syncing, so it needs connectivity; pools should + keep one archival node (or a datadir snapshot) available. +- **Damaged block data fails closed.** If DigiDollar-era block data turns out to be + 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. Credits ======= diff --git a/src/digidollar/health.cpp b/src/digidollar/health.cpp index 9e3c66bd22..047f166167 100644 --- a/src/digidollar/health.cpp +++ b/src/digidollar/health.cpp @@ -304,7 +304,7 @@ void SystemHealthMonitor::Shutdown() s_initialized = false; } -void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool, const CChain* chain, const Consensus::Params* consensus) +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. @@ -339,24 +339,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; @@ -444,8 +445,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; @@ -481,9 +497,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 @@ -497,18 +514,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 @@ -519,13 +536,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, &active.m_chain, &chainman.GetConsensus()); + 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 c0cabd2f29..e5a6ea500e 100644 --- a/src/digidollar/health.h +++ b/src/digidollar/health.h @@ -178,7 +178,11 @@ 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, const CChain* chain = nullptr, const Consensus::Params* consensus = 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. + static bool ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool = nullptr, const CChain* chain = nullptr, const Consensus::Params* consensus = nullptr); /** * Reconstruct the cached system-health metrics (total DD supply + total @@ -197,7 +201,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/init.cpp b/src/init.cpp index fa24a122ba..dc86d29142 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2219,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/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index f28ae745e1..5d58f1d5e2 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -1782,7 +1782,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 +1793,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,7 +1802,7 @@ 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; } // Scan recent blocks for the live oracle cache and enough history to @@ -1837,9 +1837,12 @@ void OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) // [DigiDollar floor, tip] is present. So a read failure here is not expected — // flag it loudly rather than silently reconstructing from partial price data. LogPrintf("ERROR: Oracle: failed to read block at height %d during startup price " - "reconstruction. The DigiDollar block window may be incomplete; if this " - "persists, restart with -reindex.\n", height); - continue; + "reconstruction. The DigiDollar block window may be incomplete; " + "restart with -reindex.\n", height); + // Fail CLOSED: volatility freeze state reconstructed here is enforced as a + // consensus rule post-activation. Refusing to start beats rebuilding it + // from partial price history and diverging from the network. + return false; } // Extract oracle bundle from coinbase @@ -1883,6 +1886,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/rpc/digidollar.cpp b/src/rpc/digidollar.cpp index e1051add7f..03125a3442 100644 --- a/src/rpc/digidollar.cpp +++ b/src/rpc/digidollar.cpp @@ -445,9 +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, - &active_chainstate.m_chain, &Params().GetConsensus()); + 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; @@ -702,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, &active_chainstate.m_chain, &Params().GetConsensus()); + 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"); } @@ -4386,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, &active_chainstate.m_chain, &Params().GetConsensus()); + 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/test/functional/feature_digidollar_pruning.py b/test/functional/feature_digidollar_pruning.py index 69c063e20f..22c924907b 100755 --- a/test/functional/feature_digidollar_pruning.py +++ b/test/functional/feature_digidollar_pruning.py @@ -207,6 +207,7 @@ def run_test(self): self.test_f10_full_node_migrates_to_pruned() self.test_f9_incomplete_dd_window_guard() self.test_f13_unprune_requires_reindex() + self.test_f14_truncated_dd_block_file_fails_closed() self.log.info("DigiDollar-compatible pruning tests PASSED") @@ -679,6 +680,46 @@ def test_f13_unprune_requires_reindex(self): # Restart normally (still pruned) so teardown is clean. self.start_node(1, extra_args=self.extra_args[1]) + # ================================================================== F14 + def test_f14_truncated_dd_block_file_fails_closed(self): + """DD-era block data lost WITHOUT the block index knowing must fail + CLOSED at startup. + + Whole-file deletion is already caught by the generic block-db check at + index load (every flagged blk file is opened). The sneaky case is a + TRUNCATED / partially-restored file: it opens fine, the startup guard's + index-flag walk passes, but reads of DigiDollar-era blocks fail. The + startup reconstruction paths (oracle price cache + health metrics that + feed consensus DCA/ERR) must then refuse to start rather than silently + rebuild consensus-relevant state from partial data. + """ + node0, node1 = self.nodes[0], self.nodes[1] + self.log.info("F14: truncated DD-era block file refuses to start") + + self.stop_node(1) + blk_files = sorted( + f for f in os.listdir(node1.blocks_path) + if f.startswith("blk") and f.endswith(".dat")) + assert len(blk_files) > 3 + # Truncate everything except the two newest files (keeps the tip region + # readable for the shallow startup block verification). + for name in blk_files[:-2]: + with open(os.path.join(node1.blocks_path, name), "r+b") as f: + f.truncate(8) + + node1.assert_start_raises_init_error( + extra_args=self.extra_args[1], + expected_msg="DigiDollar-era block data is incomplete", + match=ErrorMatch.PARTIAL_REGEX, + ) + self.log.info(" startup correctly refused on truncated block data") + + # Recovery: -reindex rebuilds from the network, back to full parity. + self.start_node(1, extra_args=self.extra_args[1] + ["-reindex"]) + self.connect_nodes(0, 1) + self.sync_blocks([node0, node1], timeout=240) + self.assert_dd_parity("after -reindex recovery from truncated files") + if __name__ == "__main__": DigiDollarPruningTest().main() From eec84239791350fa9bfc926d58a9eacd86272eff Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:31:25 -0600 Subject: [PATCH 10/17] pruning: make LoadPricesFromChain floor-aware (fix assumeutxo/pre-floor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the fail-closed startup change: only treat an unreadable block as fatal when it is at/above the DigiDollar activation floor (the guaranteed- retained window). Below the floor — or when the floor is 0 (default regtest ALWAYS_ACTIVE, and the assumeutxo background-sync case) — a missing block is a legitimate prune/snapshot state, not damage, so skip it instead of aborting. Restores feature_assumeutxo.py and feature_remove_pruned_files_on_startup.py, which legitimately start with gaps below the floor. --- src/oracle/bundle_manager.cpp | 46 ++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index 5d58f1d5e2..33753bf33c 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -1805,6 +1805,22 @@ bool OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) 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. + int dd_floor = 0; + { + const auto& dd_dep = consensus.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; + if (dd_dep.nStartTime != Consensus::BIP9Deployment::NEVER_ACTIVE && + dd_dep.nTimeout != Consensus::BIP9Deployment::NEVER_ACTIVE) { + dd_floor = (dd_dep.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) + ? dd_dep.min_activation_height + : std::min(consensus.nDDActivationHeight, dd_dep.min_activation_height); + } + } + // 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,18 +1847,24 @@ bool OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) CBlock block; if (!chainman.m_blockman.ReadBlockFromDisk(block, *block_index)) { - // This loop only reaches post-activation blocks (pre-activation heights are - // skipped by the header check above), and on a pruned node the reindex guard - // in CompleteChainstateInitialization has already confirmed every block in - // [DigiDollar floor, tip] is present. So a read failure here is not expected — - // flag it loudly rather than silently reconstructing from partial price data. - LogPrintf("ERROR: Oracle: failed to read block at height %d during startup price " - "reconstruction. The DigiDollar block window may be incomplete; " - "restart with -reindex.\n", height); - // Fail CLOSED: volatility freeze state reconstructed here is enforced as a - // consensus rule post-activation. Refusing to start beats rebuilding it - // from partial price history and diverging from the network. - return false; + 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; } // Extract oracle bundle from coinbase From f2cefff13c3eb6c5c5263b650a3f8088dbc168d8 Mon Sep 17 00:00:00 2001 From: DigiSwarm <13957390+JaredTate@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:04:48 -0600 Subject: [PATCH 11/17] fuzz: add DigiDollar pruning fuzz targets (block-db extraction, floor, coin gating) Three targets in one file, validated with a full clang-20 --enable-fuzz build (~15M executions, ASan clean): - dd_extract_amount_blockdb: ExtractDDAmountFromBlockDb over arbitrary and DD-shaped transactions with fuzzed outpoints/coin-heights and four TxLookupFn flavors; asserts fail-closed (ok <=> amount > 0) and pruned/archival determinism. - dd_prune_activation_floor: the activation-floor expression and the FlushStateToDisk prune-lock clamp model over extreme deployment params; asserts the floor always equals validation's earliest-DD height and nothing at/above floor-10 is prunable. - dd_prune_coin_gating: mainnet-params coin gating through SpendsDigiDollarCollateralVault/RequiresDigiDollarValidation with a real coins view; asserts pre-floor coins are never classified as DD vault spends. Note for runners: unset DEBUGINFOD_URLS or libFuzzer coverage symbolization makes network fetches and throughput collapses. --- src/Makefile.test.include | 1 + src/test/fuzz/digidollar_prune_blockdb.cpp | 504 +++++++++++++++++++++ 2 files changed, 505 insertions(+) create mode 100644 src/test/fuzz/digidollar_prune_blockdb.cpp 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/test/fuzz/digidollar_prune_blockdb.cpp b/src/test/fuzz/digidollar_prune_blockdb.cpp new file mode 100644 index 0000000000..19271aabd8 --- /dev/null +++ b/src/test/fuzz/digidollar_prune_blockdb.cpp @@ -0,0 +1,504 @@ +// 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 - model-based differential check of the dd_floor +// (prune lock) computation in src/node/chainstate.cpp against +// EarliestDigiDollarActivationHeight in src/digidollar/validation.cpp, +// plus the inline prune-lock clamp arithmetic used by +// Chainstate::FlushStateToDisk in src/validation.cpp. Those expressions +// are static/inline in production, so they are REPLICATED here verbatim; +// if either production expression changes, update the models below +// (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