diff --git a/V9.26.4_PRUNING_EXPLAINER.md b/V9.26.4_PRUNING_EXPLAINER.md index b48bd8568e..c4b847963e 100644 --- a/V9.26.4_PRUNING_EXPLAINER.md +++ b/V9.26.4_PRUNING_EXPLAINER.md @@ -13,7 +13,9 @@ and activation cannot happen below the deployment's minimum activation height: b **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`): +**How v9.26.4 does it** (one narrowly scoped consensus rule — the redeem collateral +floor gate that keeps pruned and full nodes in agreement — everything else opt-in +behind `-prune`): 1. **Prune lock at the floor.** Startup registers a "digidollar" prune lock at 23,627,520. Automatic pruning *and* the `pruneblockchain` RPC are clamped below diff --git a/V9.26.4_PRUNING_PLAN.md b/V9.26.4_PRUNING_PLAN.md index 6917c7b8ef..36ac48dcf3 100644 --- a/V9.26.4_PRUNING_PLAN.md +++ b/V9.26.4_PRUNING_PLAN.md @@ -5,7 +5,8 @@ ~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 +**One narrowly scoped consensus change (the redeem collateral floor gate, added by the +ship audit); every other new behavior is opt-in behind `-prune`.** The whole job is plumbing: make sure a pruned node has every piece of data DigiDollar reads, and that each piece is wired to the right source so the node computes the same answers as a full node. @@ -374,6 +375,14 @@ exercise our plumbing, not upstream's. | `regtest_prune_still_honors_explicit_request` | regtest `-digidollar -prune` → false (relaxed); plain regtest → false | n/a | ### 3b. `digidollar_pruning_tests.cpp` (NEW) + +> **As shipped:** this file was not created. The floor computation is covered by the +> `dd_prune_activation_floor` fuzz target (`src/test/fuzz/digidollar_prune_blockdb.cpp`), +> which exercises the shared `DigiDollar::EarliestActivationFloor` helper directly, and +> the reindex-guard cases (complete window, hole, truncated block file) are covered by +> `test/functional/feature_digidollar_pruning.py` phases F9/F14 rather than unit tests; +> the tip-below-floor no-op case has no direct unit test. + | Test | Asserts | |---|---| | `earliest_dd_activation_height_matches_consensus` | plan floor `== min(nDDActivationHeight, DEPLOYMENT_DIGIDOLLAR.min_activation_height)` for Main (23,627,520) / TestNet / RegTest — pin the exact number so a chainparams edit can't silently move the floor | @@ -384,6 +393,11 @@ exercise our plumbing, not upstream's. | `reindex_guard_hole_signals_reindex` | tip≥floor, a DD-era block lacks `BLOCK_HAVE_DATA` → guard returns the "needs reindex" result (not a crash) | ### 3c. `digidollar_health_pruned_tests.cpp` (NEW — the one wiring gap) + +> **As shipped:** this file was not created. ScanUTXOSet pruned-vs-full parity and the +> pre-floor coin skip are covered by `test/functional/feature_digidollar_pruning.py` +> (phases F6 restart parity and F10 full-node-to-pruned migration) rather than unit tests. + Build a tiny regtest chain with one DD mint, then: | Test | Asserts | |---|---| diff --git a/V9.26.4_PR_EXPLAINER.md b/V9.26.4_PR_EXPLAINER.md index 4f771c0f92..b16c4ee8e1 100644 --- a/V9.26.4_PR_EXPLAINER.md +++ b/V9.26.4_PR_EXPLAINER.md @@ -22,9 +22,12 @@ 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. +**What we changed in consensus:** one narrowly scoped rule. Redemption collateral +classification is now gated on the DigiDollar activation floor (a pre-floor coin can +never be vault collateral), which is what makes a pruned node accept and reject +exactly the same blocks as a full node. The pre-release mainnet scan found no +existing coins the rule affects. The Groestl algolock and the DigiDollar activation +schedule are untouched. --- @@ -77,8 +80,9 @@ scan that reports the same totals *and* position count; pruned nodes advertise ## What did NOT change -- **No consensus changes.** Mint/transfer/redeem rules, collateral rules, oracle - bundle rules, lock tiers — all byte-identical. +- **Consensus, except the redeem collateral floor gate.** Mint/transfer rules, + oracle bundle rules, lock tiers — all byte-identical. The one change is in + redemption collateral classification (the activation-floor gate above). - **The Groestl algolock** — untouched, and its boundary tests still pass (accept at the boundary, reject after, reindex-safe). - **DigiDollar activation (BIP9 bit 23)** — untouched; all activation tests pass. diff --git a/doc/release-notes/release-notes-9.26.4.md b/doc/release-notes/release-notes-9.26.4.md index ad90a31157..c3455cc383 100644 --- a/doc/release-notes/release-notes-9.26.4.md +++ b/doc/release-notes/release-notes-9.26.4.md @@ -8,11 +8,18 @@ only a few gigabytes of blocks instead of the entire ~12-year, ~40 GB block hist **In one line:** add `prune=2000` to your `digibyte.conf`, upgrade, and your node runs DigiByte + everything DigiDollar needs on a fraction of the disk. -**It contains no consensus rule changes for existing nodes.** v9.26.2's Groestl +**This release adds one narrowly scoped consensus rule.** Redemption collateral +classification is now gated on the DigiDollar activation floor: a coin created below +the floor is never treated as vault collateral, whether it appears as the redemption's +input 0 (rejected as `bad-collateral-release-not-vault`) or as a fee input (see +`ValidateCollateralReleaseAmount` in `src/digidollar/validation.cpp`). This rule is +what keeps pruned and full nodes in agreement, and the pre-release mainnet scan found +no existing coins it affects (`V9.26.4_MAINNET_VALIDATION.md`). v9.26.2's Groestl algolock and the DigiDollar BIP9 deployment are carried forward unchanged, and a pruned v9.26.4 node accepts and rejects exactly the same blocks and transactions as a -full node. Upgrading is optional — a node that does not set `-prune` behaves exactly -like v9.26.3. +full node. Upgrading is optional — a node that does not set `-prune` behaves like +v9.26.3 except for this one rule; node operators, pools, and exchanges should treat it +as a consensus rule addition when assessing upgrade urgency. How to Upgrade diff --git a/src/consensus/digidollar.cpp b/src/consensus/digidollar.cpp index b91d10eb8c..63c462895d 100644 --- a/src/consensus/digidollar.cpp +++ b/src/consensus/digidollar.cpp @@ -13,6 +13,19 @@ namespace DigiDollar { +int EarliestActivationFloor(const Consensus::Params& params) +{ + const auto& deployment = params.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; + if (deployment.nStartTime == Consensus::BIP9Deployment::NEVER_ACTIVE || + deployment.nTimeout == Consensus::BIP9Deployment::NEVER_ACTIVE) { + return 0; + } + if (deployment.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) { + return deployment.min_activation_height; + } + return std::min(params.nDDActivationHeight, deployment.min_activation_height); +} + int GetCollateralRatioForLockTime(int64_t lockBlocks, const ConsensusParams& params) { const auto it = params.collateralRatios.find(lockBlocks); diff --git a/src/consensus/digidollar.h b/src/consensus/digidollar.h index 3237747399..168882bc23 100644 --- a/src/consensus/digidollar.h +++ b/src/consensus/digidollar.h @@ -45,6 +45,21 @@ enum DigiDollarTxType : uint8_t { DD_TX_MAX = 4 // For validation }; +/** + * Earliest height at which a DigiDollar output can exist on this chain (the + * "activation floor"). Single source of truth shared by consensus validation + * (EarliestDigiDollarActivationHeight), the "digidollar" prune lock + * (src/node/chainstate.cpp), and the startup fail-closed guards + * (src/digidollar/health.cpp, src/oracle/bundle_manager.cpp) — these must + * never disagree, or a pruned node could delete a block validation still + * needs to read. + * + * Returns 0 when the deployment can never activate (NEVER_ACTIVE start or + * timeout); callers treat 0 as "no floor" (no prune lock, no fail-closed + * window, pre-floor gates disabled). + */ +int EarliestActivationFloor(const Consensus::Params& params); + /** * Core consensus parameters for the DigiDollar stablecoin system. * These parameters define the economic model, collateral requirements, diff --git a/src/digidollar/health.cpp b/src/digidollar/health.cpp index 047f166167..2c221bd2d3 100644 --- a/src/digidollar/health.cpp +++ b/src/digidollar/health.cpp @@ -311,16 +311,7 @@ bool SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v // 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); - } - } + const int dd_floor = consensus ? DigiDollar::EarliestActivationFloor(*consensus) : 0; // Reset counters s_currentMetrics.totalDDSupply = 0; diff --git a/src/digidollar/health.h b/src/digidollar/health.h index e5a6ea500e..fe5d77bc7c 100644 --- a/src/digidollar/health.h +++ b/src/digidollar/health.h @@ -182,7 +182,11 @@ class SystemHealthMonitor { //! (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); + //! mempool/chain/consensus deliberately have no defaults: the pre-floor + //! coin skip and the fail-closed check are both gated on a non-null chain, + //! so a caller that silently omitted these arguments would revert to the + //! old silent-undercount behavior. Every caller must decide explicitly. + static bool ScanUTXOSet(CCoinsView* view, CCoinsView* validation_view, const node::BlockManager* blockman, const CTxMemPool* mempool, const CChain* chain, const Consensus::Params* consensus); /** * Reconstruct the cached system-health metrics (total DD supply + total diff --git a/src/digidollar/validation.cpp b/src/digidollar/validation.cpp index a0fec570a3..7af40cd80b 100644 --- a/src/digidollar/validation.cpp +++ b/src/digidollar/validation.cpp @@ -69,12 +69,7 @@ static bool IsCanonicalP2TROutput(const CScript& script) static int EarliestDigiDollarActivationHeight(const ValidationContext& ctx) { - const Consensus::Params& cp = ctx.params.GetConsensus(); - const auto& deployment = cp.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; - if (deployment.nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE) { - return deployment.min_activation_height; - } - return std::min(cp.nDDActivationHeight, deployment.min_activation_height); + return DigiDollar::EarliestActivationFloor(ctx.params.GetConsensus()); } static bool CoinHeightMayCreateDigiDollar(const Coin& coin, const ValidationContext& ctx) diff --git a/src/node/chainstate.cpp b/src/node/chainstate.cpp index 76532479d8..ab74812761 100644 --- a/src/node/chainstate.cpp +++ b/src/node/chainstate.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -165,15 +166,7 @@ static ChainstateLoadResult CompleteChainstateInitialization( // 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); - } + const int dd_floor = DigiDollar::EarliestActivationFloor(chainman.GetConsensus()); if (options.prune && dd_floor > 0) { PruneLockInfo dd_lock; diff --git a/src/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index 33753bf33c..b40a041a21 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -1810,16 +1811,7 @@ bool OracleBundleManager::LoadPricesFromChain(ChainstateManager& chainman) // 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); - } - } + const int dd_floor = DigiDollar::EarliestActivationFloor(consensus); // Scan recent blocks for the live oracle cache and enough history to // deterministically rebuild volatility state after restart/reindex. diff --git a/src/test/digidollar_redteam_tests.cpp b/src/test/digidollar_redteam_tests.cpp index 0723e4b585..a044b084cf 100644 --- a/src/test/digidollar_redteam_tests.cpp +++ b/src/test/digidollar_redteam_tests.cpp @@ -10382,7 +10382,7 @@ BOOST_AUTO_TEST_CASE(redteam_t5_06c_scan_utxo_set_race_condition) DigiDollar::SystemHealthMonitor::Initialize(); // Now call ScanUTXOSet with null view — this resets metrics to 0 - DigiDollar::SystemHealthMonitor::ScanUTXOSet(nullptr, nullptr, nullptr, nullptr); + DigiDollar::SystemHealthMonitor::ScanUTXOSet(nullptr, nullptr, nullptr, nullptr, nullptr, nullptr); // At this point metrics are reset to 0 (scan found nothing with null view) auto metrics = DigiDollar::SystemHealthMonitor::GetCachedMetrics(); diff --git a/src/test/fuzz/digidollar_prune_blockdb.cpp b/src/test/fuzz/digidollar_prune_blockdb.cpp index 19271aabd8..e632c8c84e 100644 --- a/src/test/fuzz/digidollar_prune_blockdb.cpp +++ b/src/test/fuzz/digidollar_prune_blockdb.cpp @@ -11,13 +11,14 @@ // 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 +// dd_prune_activation_floor - property fuzz of the shared dd_floor helper +// DigiDollar::EarliestActivationFloor (src/consensus/digidollar.cpp), +// the single source of truth used by the "digidollar" prune lock in +// src/node/chainstate.cpp, the startup guards in +// src/digidollar/health.cpp and src/oracle/bundle_manager.cpp, and +// validation's EarliestDigiDollarActivationHeight — plus the inline +// prune-lock clamp arithmetic used by Chainstate::FlushStateToDisk in +// src/validation.cpp (still replicated below; update if it changes). // (unit coverage: src/test/digidollar_txindex_tests.cpp and // test/functional/feature_digidollar_pruning.py). // dd_prune_coin_gating - SpendsDigiDollarCollateralVault() / @@ -204,30 +205,16 @@ DigiDollar::TxLookupFn MakeFuzzedLookup(uint8_t mode, const CTransactionRef& pre } } -// ---------------------------------------------------------------------------- -// Replicas of the static/inline production expressions (see file header). -// ---------------------------------------------------------------------------- - -/** Mirrors the dd_floor computation in CompleteChainstateInitialization - * (src/node/chainstate.cpp, "DigiByte: DigiDollar-compatible pruning"). */ -int ChainstateDdFloorModel(int64_t nStartTime, int64_t nTimeout, int min_activation_height, int nDDActivationHeight) +/** Build a Consensus::Params carrying only the fields EarliestActivationFloor reads. */ +Consensus::Params MakeFloorParams(int64_t nStartTime, int64_t nTimeout, int min_activation_height, int nDDActivationHeight) { - int dd_floor = 0; - if (nStartTime != NEVER && nTimeout != NEVER) { - dd_floor = (nStartTime == ALWAYS) - ? min_activation_height - : std::min(nDDActivationHeight, min_activation_height); - } - return dd_floor; -} - -/** Mirrors static EarliestDigiDollarActivationHeight (src/digidollar/validation.cpp). */ -int ValidationEarliestFloorModel(int64_t nStartTime, int min_activation_height, int nDDActivationHeight) -{ - if (nStartTime == ALWAYS) { - return min_activation_height; - } - return std::min(nDDActivationHeight, min_activation_height); + Consensus::Params cp{}; + auto& dep = cp.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; + dep.nStartTime = nStartTime; + dep.nTimeout = nTimeout; + dep.min_activation_height = min_activation_height; + cp.nDDActivationHeight = nDDActivationHeight; + return cp; } } // namespace @@ -307,8 +294,8 @@ FUZZ_TARGET(dd_extract_amount_blockdb, .init = initialize_dd_prune_blockdb) // ============================================================================ // Target 2: dd_prune_activation_floor // -// Differential/model fuzz of the prune-lock floor computation and the -// prune-lock clamp arithmetic. +// Property fuzz of the shared activation-floor helper +// (DigiDollar::EarliestActivationFloor) and the prune-lock clamp arithmetic. // ============================================================================ FUZZ_TARGET(dd_prune_activation_floor, .init = initialize_dd_prune_blockdb) @@ -332,20 +319,21 @@ FUZZ_TARGET(dd_prune_activation_floor, .init = initialize_dd_prune_blockdb) const int min_act = fdp.ConsumeBool() ? fdp.PickValueInArray(height_values) : fdp.ConsumeIntegral(); const int dd_act = fdp.ConsumeBool() ? fdp.PickValueInArray(height_values) : fdp.ConsumeIntegral(); - const int dd_floor = ChainstateDdFloorModel(start, timeout, min_act, dd_act); + const Consensus::Params cp = MakeFloorParams(start, timeout, min_act, dd_act); + const int dd_floor = DigiDollar::EarliestActivationFloor(cp); // Deterministic. - assert(dd_floor == ChainstateDdFloorModel(start, timeout, min_act, dd_act)); + assert(dd_floor == DigiDollar::EarliestActivationFloor(cp)); if (start == NEVER || timeout == NEVER) { // DD can never activate: no block retention needed, no prune lock. assert(dd_floor == 0); } else { - // Parity: the prune-lock floor must equal validation's earliest - // DD-creating height, so the retained window [dd_floor, tip] covers - // every block a DD spend can ever need to read. dd_floor > - // earliest-activation would let pruning delete a needed block. - assert(dd_floor == ValidationEarliestFloorModel(start, min_act, dd_act)); + // Spec check: the floor is the earliest DD-creating height, so the + // retained window [dd_floor, tip] covers every block a DD spend can + // ever need to read. A higher floor would let pruning delete a + // needed block. + assert(dd_floor == ((start == ALWAYS) ? min_act : std::min(dd_act, min_act))); // The floor never exceeds the BIP9 minimum activation height. assert(dd_floor <= min_act); @@ -357,15 +345,14 @@ FUZZ_TARGET(dd_prune_activation_floor, .init = initialize_dd_prune_blockdb) if (min_act > 0 && dd_act > 0) assert(dd_floor > 0); } - // Live-params parity for the selected chain (regtest here): the two - // production expressions must agree on real chainparams as well. + // Live-params sanity for the selected chain (regtest here): the shared + // helper is what production consumes, so it must be deterministic and + // non-negative on real chainparams too. { - const Consensus::Params& cp = Params().GetConsensus(); - const auto& dep = cp.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; - if (dep.nStartTime != NEVER && dep.nTimeout != NEVER) { - assert(ChainstateDdFloorModel(dep.nStartTime, dep.nTimeout, dep.min_activation_height, cp.nDDActivationHeight) == - ValidationEarliestFloorModel(dep.nStartTime, dep.min_activation_height, cp.nDDActivationHeight)); - } + const Consensus::Params& cp_live = Params().GetConsensus(); + const int live_floor = DigiDollar::EarliestActivationFloor(cp_live); + assert(live_floor == DigiDollar::EarliestActivationFloor(cp_live)); + assert(live_floor >= 0); } // Prune-lock registration predicate from chainstate.cpp: lock registered @@ -414,11 +401,10 @@ FUZZ_TARGET(dd_prune_coin_gating, .init = initialize_dd_prune_gating_main) FuzzedDataProvider fdp(buffer.data(), buffer.size()); const CChainParams& chainparams = Params(); const Consensus::Params& cp = chainparams.GetConsensus(); - const auto& dep = cp.vDeployments[Consensus::DEPLOYMENT_DIGIDOLLAR]; // The production pre-floor skip boundary. On mainnet v9.26.4 this is the // DigiDollar activation floor used for the prune lock. - const int dd_floor = ValidationEarliestFloorModel(dep.nStartTime, dep.min_activation_height, cp.nDDActivationHeight); + const int dd_floor = DigiDollar::EarliestActivationFloor(cp); assert(dd_floor == 23'627'520); // "Creating transaction" served by the fuzzed block-db lookup.