diff --git a/MVP_PLAN.md b/MVP_PLAN.md new file mode 100644 index 0000000000..8fe60c7ee1 --- /dev/null +++ b/MVP_PLAN.md @@ -0,0 +1,667 @@ +# MVP_PLAN.md — DigiDollar Mainnet V1 Work Plan + +**Branch:** `feature/digidollar-v1` +**Purpose:** TDD wave-based execution plan for DigiDollar V1 mainnet deployment. +**Rule:** Planning only. All code work done by separate agents using TDD. No pushes — Jared reviews and pushes. + +--- + +## 0. Non-Negotiable V1 Invariants + +1. **No early redemptions.** Collateral cannot unlock before the chosen timelock expires. +2. **DD burn enforcement is mandatory.** A collateral vault spend must require burning the correct DigiDollar amount. +3. **ERR must be finished, not disabled.** If system health drops below 100%, redemption still requires timelock expiry and returns full collateral — but the redeemer must burn extra DD according to the ERR ratio. No haircut on DGB. +4. **Consensus math must be deterministic.** No float/double in consensus-visible DCA/ERR/collateral math. Integer basis points and `__int128` only. +5. **Oracle data must be deterministic.** DCA, ERR, mint validation, and redemption validation must use the same consensus-safe price/health source. +6. **MuSig2 oracle bundles only for V1.** Legacy v0x01/v0x02 formats were development steps — removed from production validation, mining, and fallback paths. +7. **Oracle roster must be expandable.** Launch quorum: minimum 9 signatures. Active oracle set must grow beyond 17 via deterministic consensus-visible roster rules. +8. **Lock tiers are canonical only.** No in-between or custom lock durations for V1. +9. **Watch-only DD is read-only.** Watch-only wallets may display/monitor DD positions but cannot mint, redeem, send, or sign. +10. **Legacy DigiDollar wallets unsupported for V1.** Require descriptor/bech32m-capable wallets. Fail clearly on unsupported types. + +--- + +## 1. Audit Sources + +### Primary DigiDollar / Red Hornet +- `DIGIDOLLAR_BUG_HUNT_REPORT.md`, `Z_RED_HORNET.md`, `Z_RED_HORNET_v2.md` +- `doc/RED_HORNET_V3_REPORT.md`, `reports/red_hornet_final_report.md` +- `reports/red_hornet_ledger.md`, `reports/red_hornet_security_final_report.md` +- `reports/red_hornet_security_ledger.md`, `RELEASE_v9.26.0-rc33.md` + +### Architecture / repo maps +- `ARCHITECTURE.md`, `REPO_MAP.md`, `DIGIDOLLAR_ARCHITECTURE.md`, `REPO_MAP_DIGIDOLLAR.md` + +### Fuzz / regression readiness +- `doc/FUZZ_COMPLETION_REPORT.md`, `doc/FUZZ_MARATHON_COMPLETE.md`, `doc/FUZZ_PHASE4_FINAL_REPORT.md` + +--- + +## 2. Red Hornet Continuation Fixes (Pre-committed by Red Hornet Team) + +The Red Hornet team will have committed all campaign fixes before this plan's Wave 1 begins. The Pre-Wave agent's only job is to verify the baseline is clean and the full test suite passes — no commit-splitting work needed. + +**Fixes expected in baseline (committed by Red Hornet team):** + +| RH ID | Severity | Fix | +|-------|----------|-----| +| DD-RH-001 | Medium | Valid reordered mint now correctly tracked in health accounting | +| DD-RH-003 | Low | DCA fractional multipliers now ceiling-rounded (no undercut) | +| DD-RH-004 | Low | Required collateral uses ceiling division (no one-sat undercount) | +| DD-RH-005 | High | Nonzero-vout mint collateral no longer treated as redemption fee input | +| DD-RH-006 | Low | `getprotectionstatus` no longer reports false emergency on zero DD supply | +| DD-RH-007 | Critical | Transfer OP_RETURN spoof cannot inflate later spends | +| DD-RH-008 | Medium | DD address validators reject corrupted/wrong-shape strings | +| DD-RH-009 | Medium | `importdigidollaraddress` returns explicit failure instead of false success | +| DD-RH-105 | Medium | Transfer rejects unresolved zero-value DD inputs | +| DD-RH-106 | High | Redemption burn accounting prefers authoritative mint tx over poisonable metadata | +| DD-RH-109 | Medium | Transfer builder rejects underfunded DGB fee inputs | +| DD-RH-112 | Medium | Malformed transfer OP_RETURN script numbers reject cleanly | +| DD-RH-113 | High | Mint accounting prefers modern `"DD"` OP_RETURN over legacy spoof marker | +| DD-RH-114 | High | Non-canonical `OP_1` impostor scripts rejected as DD outputs | +| DD-RH-115 | High | Mint price/collateral validation no longer depends on `skipOracleValidation` | +| DD-RH-116 | Low | `getmockoracleprice` is activation-gated | +| DD-RH-118 | Medium | Reorg connect/disconnect now restores DD supply exactly near `MAX_DIGIDOLLAR` | +| DD-RH-120 | Low | Non-final DD transactions now fail cheaply before DD contextual validation | +| DD-RH-121 | Medium | DD transfer descendants no longer survive reorg through stale txindex | + +**Additional fixes from waves 6–19** (DD-RH-010 through DD-RH-049) will also be in the baseline. Pre-Wave agent should verify by reading `reports/red_hornet_ledger.md` to confirm all "fixed" items are present in committed history. + +**Pre-Wave responsibility:** Verify baseline compiles, all committed RH fixes are present, and the full unit + functional + fuzz suite passes clean. No commit work. No push. + +--- + +## 3. P0 Launch Blockers (Detail + TDD Requirements) + +### P0.1 — DD Burn Enforcement on Every Collateral Spend (`DD-RH-069`) + +**Problem:** The normal collateral script path is essentially `CLTV + owner sig`. After timelock, a non-DD tx can spend backing DGB without entering DD redemption validation — leaving DD supply live with no collateral. + +**Files:** +- `src/script/interpreter.cpp`, `src/script/script.h` +- `src/digidollar/scripts.cpp`, `src/digidollar/validation.cpp` + +**Fix direction:** +1. Consensus-level detector: any input spending a known DD collateral vault must enter DD redemption validation, even without a DD marker in the spending tx. +2. Require correct DD burn amount before collateral release. +3. Fix both normal and ERR Taproot leaves so ABI matches `OP_DIGIDOLLAR` / `OP_DDVERIFY` semantics. +4. Timelock mandatory for both normal and ERR leaves. + +**TDD requirements:** +- Failing test: post-timelock non-DD collateral spend is rejected. +- Failing test: normal redemption succeeds only when full original DD amount is burned. +- Failing test: partial burn cannot release collateral. +- Failing test: collateral spend with no DD inputs cannot release collateral. +- Failing test: reordered mint outputs still identify the correct collateral output. + +--- + +### P0.2 — ERR End-to-End (`ARCH-RH-004`, `DD-RH-110`) + +**Design:** ERR is not early redemption. ERR only changes the DD burn requirement after timelock expiry when system health drops below 100%. + +**Current problems:** +- ERR consensus validation incomplete. +- ERR Taproot leaf ABI broken (`OP_DIGIDOLLAR` not followed by positive DD amount). +- RPC/wallet can disagree with consensus on ERR state. + +**Files:** +- `src/consensus/err.cpp/h`, `src/digidollar/validation.cpp` (ERR sections) +- `src/digidollar/txbuilder.cpp`, `src/rpc/digidollar.cpp` +- `src/test/digidollar_err*_tests.cpp`, `src/test/digidollar_redeem_tests.cpp` + +**Required V1 behavior:** +- Health `≥ 100%`: burn original DD, return full DGB. +- Health `< 100%`: burn `ceil(originalDD * 10000 / errRatioBps)`, return full DGB. +- ERR still requires timelock expiry. +- New minting blocked while ERR active. +- Normal redemption blocked while ERR active unless ERR burn requirement met. +- No DGB haircut. Extra DD burned is the penalty. + +**Consensus math:** Replace doubles with integer basis points. `ceil(originalDD * 10000 / errRatioBps)` using `__int128`. Compare against `MAX_MONEY` before casting. + +**TDD requirements:** +- Failing test: ERR no longer returns `err-validation-incomplete` for valid ERR redemption. +- Failing test: health `<100%`, original-only burn fails. +- Failing test: health `<100%`, required extra burn succeeds. +- Failing test: ERR before timelock fails. +- Failing test: normal redemption while ERR active fails unless ERR burn requirement met. +- Functional test: regtest price/health crash activates ERR, wallet builds correct ERR redemption, block validates. +- Fuzz: ERR burn boundary fuzz for health 0, 1, 84, 85, 89, 90, 94, 95, 99, 100, overflow values. + +--- + +### P0.3 — Canonical Deterministic Health for DCA + ERR (`DD-RH-108`) + +**Problem:** DCA/ERR decisions can depend on cached or placeholder health. `TxBuilder::GetCurrentSystemCollateral()` returns a default healthy value; validation can fall back to `150%` when data is missing. + +**Files:** +- `src/digidollar/health.cpp/h`, `src/consensus/dca.cpp` + +**Required V1 behavior:** +- One canonical health calculation for consensus validation. +- Inputs: chain-derived DD supply, locked collateral, and the same oracle price used for the block/tx being validated. +- No healthy fallback after activation when oracle/health data is missing. Fail closed. +- Caches OK for performance but consensus validates against deterministic data. +- DCA multiplier doubles → integer multipliers/basis points. Conservative rounding up. `__int128` + `MAX_MONEY` checks. + +**TDD requirements:** +- Failing test: stale cached health cannot allow a mint at base collateral when current health requires DCA. +- Failing test: missing post-activation price/health rejects DD validation instead of defaulting to 150%. +- Failing test: DCA, ERR, RPC quote, and txbuilder agree on health source for the same block/price. +- Fuzz: health calculation with extreme collateral, DD supply, and price values. + +--- + +### P0.4 — Lock Tier Canonicalization (`DD-RH-107`) + +**Problem:** Non-standard lock duration just above a tier boundary can receive the next tier's lower collateral ratio. E.g., `1-hour tier + 1 block` gets the 30-day ratio. + +**Files:** +- `src/consensus/digidollar.cpp/h`, `src/digidollar/validation.cpp` (lock-tier sections), `src/digidollar/txbuilder.cpp` + +**Required V1 rule:** Accept only canonical lock tiers. Reject all in-between/custom durations. + +**TDD requirements:** +- Failing test: each canonical tier is accepted with correct collateral ratio. +- Failing test: `tier + 1 block`, `tier - 1 block`, and arbitrary custom durations reject. +- Functional test: RPC/Qt cannot create non-canonical lock durations. + +--- + +### P0.5 — Oracle Consensus / Activation Rules (`ARCH-RH-001`, `DD-RH-084/085/086/117`) + +**Locked direction:** Non-DD blocks are valid without oracle data. Any block containing a DD transaction or spending a DD collateral vault must include a valid MuSig2 oracle bundle — missing or invalid oracle data makes the DD-touching block invalid for every node. + +**Files:** +- `src/oracle/*`, `src/primitives/oracle*` +- `src/kernel/chainparams.cpp`, `src/consensus/params.h` +- `src/node/miner.cpp`, `src/validation.cpp` + +**Required V1 rules:** +1. **MuSig2 only.** Remove v0x01/v0x02 acceptance, mining, and fallback paths. +2. **No separate oracle activation maze.** DD V1 starts with MuSig2. Gate DD behavior to the DigiDollar deployment predicate, not stale phase heights. +3. **Avoid chain splits:** Non-DD blocks need no oracle bundle. DD-touching blocks must have one valid MuSig2 oracle bundle. +4. **Expandable roster:** variable-length signer encoding + deterministic height/deployment-indexed active roster. Quorum floor remains 9. Expansion requires a new release with explicit activation rules. +5. **Reserve/inactive operators** listed but cannot count toward quorum. +6. **Domain separation:** signatures commit to network/genesis/deployment, epoch/height, price, timestamp, message type. +7. **Signer liveness:** deterministic reselection/retry rules so nonce withholding cannot stall block construction. +8. **Mempool policy:** may require recent valid MuSig2 oracle quote for DD transactions; block validity is the final consensus rule. + +**TDD requirements:** +- Failing test: v0x01/v0x02 oracle formats rejected; not mined as fallback. +- Failing test: non-DD block without oracle data remains valid. +- Failing test: any DD mint/transfer/redeem/collateral-spend block without oracle data is rejected. +- Failing test: malformed/stale/wrong-domain/insufficient-quorum oracle bundle makes DD-touching block invalid. +- Failing test: roster with >17 active operators verifies valid 9-sig MuSig2 bundle after roster activation. +- Failing test: same >17 roster bundle rejected before roster activation. +- Failing test: out-of-roster signer, duplicate signer, wrong-domain sig all reject. +- Functional: 9-of-N signs passes, 8-of-N fails, N can be increased without invalidating historical blocks. + +--- + +### P0.6 — `MAX_DIGIDOLLAR` Supply Cap Clarification (`DD-RH-119`) — RESOLVED + +**Severity:** High → RESOLVED as Option B (docs only) + +**Decision (locked):** DigiDollar has **no global supply cap**. Total DigiDollars in circulation are theoretically unlimited — the only constraint is available DGB collateral and the per-block minting rate. `MAX_DIGIDOLLAR` is a per-output serialization bound only, not a global cap. No consensus change required. + +**Docs already updated:** +- `REPO_MAP_DIGIDOLLAR.md` — removed "hard cap on total DD supply" language; clarified as per-output bound +- `AlertThresholds::MAX_DD_SUPPLY` in `health.h` — monitoring alert threshold only, not a supply cap; rename to `ALERT_DD_SUPPLY` for clarity + +**Wave 6 docs agent must also verify:** No remaining "global supply cap" or "total supply limit" language survives in `DIGIDOLLAR_ARCHITECTURE.md`, `DIGIDOLLAR_EXPLAINER.md`, or any whitepaper/spec docs under `digidollar/`. + +--- + +### P0.7 — Qt Mint Owner Key Lifecycle (`DD-RH-034`) + +**Severity:** High + +**Problem:** Qt mint generates a random non-HD key (`MakeNewKey(true)`) and stores it in the DD wallet only AFTER `commitTransaction()`. The RPC mint path uses the wallet-derived `GetHDKeyForDigiDollar()` helper. A crash between successful broadcast and `StoreOwnerKey()` leaves the collateral vault locked forever — the owner key that controls normal and ERR redemption is lost. This is a **loss-of-funds** risk on mainnet. + +**Files:** +- `src/qt/walletmodel.cpp` (lines 866–870, 1111–1138) +- `src/rpc/digidollar.cpp` (HD key helper, lines 97–151) +- `src/wallet/` (key derivation path) + +**Required V1 fix:** Move `GetHDKeyForDigiDollar()` to shared wallet code (not private to `rpc/digidollar.cpp`). Both Qt and RPC mint must derive the owner key from the wallet's HD seed. Persist the derived key to wallet database before broadcast. If the wallet is non-HD, fail clearly at mint time with an explicit error. + +**TDD requirements:** +- Failing test: Qt mint path derives owner key from HD seed (same derivation as RPC path). +- Failing test: owner key persisted to wallet database before transaction broadcast. +- Failing test: non-HD wallet returns explicit error at Qt mint time. +- Failing test: wallet restore from seed recovers Qt-minted DD position owner key without extra wallet.dat backup. +- Functional test: mint via Qt, crash-simulate between broadcast and StoreOwnerKey, reload wallet — position is still redeemable. + +--- + +### P0.8 — Volatility Precheck (`DD-RH-111`) + +**Problem:** First mint that crosses a volatility threshold can pass because freeze state is checked before recording/evaluating the candidate price. + +**Files:** `src/digidollar/validation.cpp` (volatility section only) + +**Fix:** +- Non-mutating "would this candidate price freeze minting?" check before any state mutation. +- Reject candidate mint if it crosses freeze threshold. + +**TDD requirements:** +- Failing test: seeded prior price + candidate mint price crossing threshold is rejected. +- Failing test: invalid candidate tx cannot poison volatility state. +- Fuzz: volatility boundary/cooldown thresholds. + +--- + +### P0.9 — Mempool / Block / IBD / Reorg Consensus Parity + +**Problem:** Several audit findings were chain-split shaped: behavior differed between mempool and block validation, IBD and caught-up nodes, stale cache and fresh cache, or pre/post reorg state. + +**Files:** +- `src/validation.cpp`, `src/node/miner.cpp`, `src/net_processing.cpp` +- `test/functional/` (functional test scenarios) + +**Required V1 behavior:** +- `AcceptToMemoryPool`, miner block assembly, `ConnectBlock`, IBD, reindex, and reorg replay apply identical DD consensus rules. +- Mempool may be stricter for liveness/safety but must never allow a DD tx that block validation rejects. +- `skipOracleValidation` must not bypass post-activation DD requirements. +- Reorg rollback must restore DD supply, collateral, health, oracle cache, wallet positions, and pending redemption state deterministically. +- Historical validation uses the oracle bundle and roster rules at that historical height. + +**TDD requirements:** +- Functional: DD block accepted by caught-up node is also accepted by IBD/reindex node. +- Functional: DD mempool acceptance and mined block validity agree on oracle, DCA, ERR, and burn requirements. +- Functional: reorg across DD mint/transfer/redeem restores supply/collateral/health and wallet state exactly. +- Functional: expanded oracle roster validates only after activation height and does not invalidate older blocks. +- Fuzz: validation contexts with `skipOracleValidation`, stale cache, missing cache, and reorg rollback. + +--- + +## 4. P1 Mainnet Readiness Work + +### P1.1 — Wallet / RPC / Qt State Consistency +- `getprotectionstatus`, `getredemptioninfo`, wallet redemption, Qt display, and consensus agree on normal vs ERR state. +- RPC reports required DD burn during ERR. Wallet selects enough DD inputs for ERR extra burn. +- Watch-only wallets: display/monitor only. Cannot mint/send/redeem/sign. +- Restore/rescan recovers DD positions with reordered outputs and modern metadata. +- Legacy wallet path fails with explicit unsupported error. + +**TR-RH-003 — RPC unit/filter drift (reachable, loss-of-funds adjacent):** +- `listdigidollarpositions min_amount` compared DGB units to DD cents → fix filter to use DD cents. +- `senddigidollar change_amount` reported wallet remaining DGB as DD change → fix to report correct unit. +- `getdigidollarbalance minconf` accepted but ignored `minconf` parameter → apply correctly. +- Fix these in RPC layer; add schema validation tests. + +**TR-RH-004 — Qt stale collateral ratios for tiers 5–8:** +- `WalletModel::calculateRequiredCollateral()` had stale hardcoded ratios for higher tiers. +- RPC collateral estimate reported base consensus minimum; `MintTxBuilder` adds 1% safety margin. +- Decide whether RPC should report consensus minimum or builder-padded amount and document clearly. +- Fix Qt to read ratios from consensus parameters, not hardcoded constants. + +**TR-RH-005 — `listdigidollaraddresses` hides zero-balance DD addresses:** +- Currently only lists addresses inferred from current DD UTXOs; hides generated zero-balance addresses and hardcodes `iswatchonly=false`. +- Fix: list all generated DD addresses including zero-balance; set `iswatchonly` from wallet metadata. + +**TR-RH-006 — `CDigiDollarAddress` accepts any chain's address prefix:** +- A mainnet wallet can accept testnet/regtest-prefixed DD address if version+checksum are valid. +- Fix: validate the address version byte against the currently active chain params. Reject cross-chain addresses. + +**ARCH-RH-003 — Watch-only address storage (V1 scope: explicit failure is acceptable):** +- `importdigidollaraddress` now returns `success=false` with an explicit warning (fixed in Wave 9). +- For V1: this explicit failure is sufficient. Full watch-only address import/rescan/listing is a post-V1 feature. +- Ensure the error message is clear and documented in release notes. + +**TDD requirements:** +- Functional: health crash changes all RPC/Qt-visible protection state consistently. +- Functional: wallet builds both normal redemption and ERR redemption. +- Functional: legacy wallet path fails with explicit unsupported error. +- Functional: watch-only wallet can view DD metadata but cannot mint/send/redeem/sign. +- Unit: restore/rescan recovers DD positions with reordered outputs. +- Unit: `listdigidollarpositions min_amount` filters in DD cents, not DGB satoshis. +- Unit: Qt collateral estimate matches consensus tier ratios for all tiers including 5–8. +- Unit: `CDigiDollarAddress` rejects testnet address on mainnet and vice versa. +- Functional: `importdigidollaraddress` returns explicit unsupported error (not false success). + +### P1.2 — Oracle Operator Key Security +- Oracle operator private keys must not remain plaintext if wallet/node is encrypted. +- Key IDs/slots cannot drift from consensus roster. +- Define backup/rotation procedure. + +### P1.3 — Backward Compatibility +- Existing non-DD DigiByte blocks/transactions remain valid. +- No mainnet DD state before activation; rules can be finalized without preserving old mainnet DD positions. +- Testnet reset allowed if final consensus changes require it. Recommendation: reset testnet after final V1 changes. +- Legacy DigiDollar wallets unsupported; document in release notes and RPC errors. + +### P1.4 — Documentation Cleanup +Update after code fixes: `ARCHITECTURE.md`, `DIGIDOLLAR_ARCHITECTURE.md`, `DIGIDOLLAR_ORACLE_ARCHITECTURE.md`, `DIGIDOLLAR_EXPLAINER.md`, `REPO_MAP_DIGIDOLLAR.md`, release notes for next RC. + +--- + +## 5. Decisions Locked + +| # | Decision | +|---|---------| +| 1 | **DD burn enforcement:** locked. Collateral unlock requires burning DD. | +| 2 | **ERR:** locked. Finish ERR; do not disable it. No DGB haircut. | +| 3 | **No early redemptions:** locked. ERR does not bypass timelock. | +| 4 | **ERR ratio table:** locked. Keep current 95/90/85/80% ratio tiers. | +| 5 | **Oracle strictness:** locked. Non-DD blocks may omit oracle data; any DD-touching block requires valid MuSig2 data and fails deterministically without it. | +| 6 | **Oracle bundle format:** locked. MuSig2 only. Remove v0x01/v0x02 production support and fallback paths. | +| 7 | **Oracle roster:** locked. Min quorum remains 9. Active oracle set expandable beyond 17 via deterministic roster rules. | +| 8 | **Expandable roster mechanism:** locked. Variable-length signer encoding + deterministic height/deployment-indexed roster. New oracles added by release + explicit activation. | +| 9 | **Lock tiers:** locked. Canonical tiers only; no in-between/custom lock durations. | +| 10 | **Legacy DigiDollar wallets:** locked. Unsupported for V1. | +| 11 | **Watch-only DD:** locked. Read-only display/monitoring allowed; spend/sign requires private keys. | +| 12 | **Qt mint owner key:** locked. Must derive from HD seed via shared wallet helper. Non-HD wallet fails clearly at mint time. Key persisted before broadcast. | +| 13 | **Cross-chain DD address:** locked. `CDigiDollarAddress` must validate version byte against active chain params. Testnet addresses rejected on mainnet. | +| 14 | **No global DD supply cap:** locked. DigiDollar total supply is theoretically unlimited. `MAX_DIGIDOLLAR` is a per-output serialization bound only. No consensus-enforced aggregate cap. The only rate limit is per-block minting. | + +--- + +## 6. TDD Wave Execution Model + +**Rules for all agents:** +1. Write the failing test first. Confirm it fails for the expected reason. +2. Implement the minimum safe fix. +3. Run targeted tests to confirm green. +4. Commit immediately after targeted tests pass — one commit per logical fix (not one per file, not one giant dump). +5. **Commit message standard:** Subject line (≤72 chars) states what was broken and what was fixed in plain English. No ticket jargon, no vague "fix bug" messages. Body optional — only add it if the why isn't obvious from the subject. Examples: + - `digidollar: collateral spend could bypass DD burn check — add consensus-level vault detector` + - `digidollar/err: ERR validation returned incomplete for valid redemption — wire integer burn calc` + - `digidollar/health: stale cached health could allow mint under DCA — fail closed when data missing` +6. At end of each wave: run **all unit tests** (`make check`), **all functional tests** (`test/functional/test_runner.py`), **all fuzz tests**. These are the regression gate — no exceptions. +7. Wave does not close until: full test gate is green AND working tree is clean (all changes committed, no pending diffs). +8. Never touch a file owned by a sibling agent in the same wave. +9. Local commits only. No pushing. + +--- + +### Pre-Wave — Verify Clean Baseline (1 agent, sequential) + +**Goal:** Confirm the Red Hornet team's commits are all present and the full test suite passes before Wave 1 begins. No new features, no new files, no commit work. + +**Agent 1 tasks:** +- Read `reports/red_hornet_ledger.md` — confirm every "fixed" RH ID has a corresponding commit in git log +- Run full unit + functional + fuzz suite: `make check && test/functional/test_runner.py --jobs=4` +- Confirm clean working tree (no uncommitted diffs) +- Report any missing RH fixes or test failures before Wave 1 begins + +**File scope:** Read-only git history verification + test runner only. + +**Gate:** Full unit + functional + fuzz suite passes cleanly. All Section 2 RH IDs confirmed committed. + +--- + +### Wave 1 — TDD Red Phase: Write All Failing Tests (3 agents, parallel) + +**Goal:** Write all failing tests for the P0 blockers before touching any production code. Zero production code changes in this wave. All agents write to different test files/directories. + +**Agent A — Burn + Tiers + Address** (test files only, no conflicts with B/C) +- Write failing unit tests for P0.1 (DD burn enforcement, partial burn, no-DD-input collateral spend) +- Write failing unit tests for P0.4 (lock tier boundary above/below, canonical tier acceptance) +- Write failing unit tests for TR-RH-006 (mainnet rejects testnet DD address, regtest rejects mainnet address) +- Target files: `src/test/digidollar_burn_enforcement_tests.cpp` (new), `src/test/digidollar_locktier_tests.cpp` (new/modify), `src/test/digidollar_address_tests.cpp` (new/modify) + +**Agent B — Oracle** (test files only, no conflicts with A/C) +- Write failing unit tests for P0.5 (v0x01/v0x02 rejection, non-DD block without oracle, DD block requires oracle, malformed/stale/wrong-domain rejection) +- Write failing unit tests for expandable roster (>17 operators, pre/post activation) +- Target files: `src/test/digidollar_oracle_musig2_tests.cpp` (new), `src/test/digidollar_oracle_roster_tests.cpp` (new or modify existing) + +**Agent C — ERR + Health + Volatility + Wallet + RPC** (test files only, no conflicts with A/B) +- Write failing unit tests for P0.2 (ERR: extra burn success/fail, ERR before timelock, normal while ERR active) +- Write failing unit tests for P0.3 (stale health rejection, missing-price fail-closed, DCA/ERR/RPC health agreement) +- Write failing unit tests for P0.7 (Qt mint derives HD key, persists before broadcast, non-HD wallet fails clearly) +- Write failing unit tests for P0.8 (volatility threshold candidate rejection, no state poison) +- Write failing unit tests for TR-RH-003/004 (listdigidollarpositions DD-cent filter, Qt stale tier ratios) +- Target files: `src/test/digidollar_err_tests.cpp` (new/modify), `src/test/digidollar_health_dca_tests.cpp` (new), `src/test/digidollar_volatility_tests.cpp` (new/modify), `src/test/digidollar_wallet_hd_tests.cpp` (new), `src/test/digidollar_rpc_unit_tests.cpp` (new/modify) + +**Gate after Wave 1:** +- `make check` passes — zero pre-existing test regressions +- New tests compile and fail for the correct expected reason (not compile error, not wrong-failure message) +- All new test files committed; working tree clean +- Commit messages explain what each test is covering and why it must fail at this stage + +--- + +### Wave 2 — Script / Oracle / Lock Layer (3 agents, parallel) + +**Goal:** Fix the foundational script, oracle, and lock-tier layers. Each agent owns separate file domains. + +**Agent A — Script/Burn** (`src/script/`, `src/digidollar/scripts.cpp` only) +- Implement DD burn enforcement (P0.1) +- Add consensus-level detector for DD collateral vault spends +- Fix normal + ERR Taproot leaf ABI to match `OP_DIGIDOLLAR` / `OP_DDVERIFY` semantics +- Files: `src/script/interpreter.cpp`, `src/script/script.h`, `src/digidollar/scripts.cpp` + +**Agent B — Oracle Core** (`src/oracle/`, `src/primitives/oracle*`, `src/kernel/chainparams.cpp`, `src/consensus/params.h`) +- Remove v0x01/v0x02 oracle bundle acceptance, mining, and fallback paths +- Gate DD oracle behavior to DigiDollar deployment predicate (not stale phase heights) +- Implement expandable roster: variable-length signer encoding, deterministic activation rules +- Implement MuSig2 domain separation (network/genesis/deployment, epoch/height, price, timestamp, message type) +- Implement signer liveness / deterministic reselection rules +- Files: `src/oracle/*`, `src/primitives/oracle*`, `src/kernel/chainparams.cpp`, `src/consensus/params.h` + +**Agent C — Lock Tiers + Volatility + Address Routing** (`src/consensus/digidollar.cpp/h`, specific validation.cpp sections, `src/digidollar/address.cpp`) +- Canonicalize lock tiers: reject all non-canonical durations (P0.4) +- Fix volatility candidate precheck: non-mutating freeze check before state mutation (P0.8) +- Fix `CDigiDollarAddress` network routing: validate version byte against active chain params, reject cross-chain addresses (TR-RH-006) +- Rename `AlertThresholds::MAX_DD_SUPPLY` → `AlertThresholds::ALERT_DD_SUPPLY` in `health.h` and all callers — clarify it is a monitoring alert threshold, not a supply cap (P0.6 docs-only resolution) +- Files: `src/consensus/digidollar.cpp`, `src/consensus/digidollar.h`, `src/digidollar/txbuilder.cpp` (lock-tier input validation only), `src/digidollar/address.cpp` or equivalent, `src/digidollar/health.h`, `src/digidollar/health.cpp` (rename only) +- validation.cpp scope: lock-tier validation section, volatility section only — do not touch mint/redemption/ERR sections; no supply cap check needed (no global cap) + +**Gate after Wave 2:** +- All Wave 1 failing tests now pass (burn, lock tiers, address routing, oracle, alert rename) +- `make check` — zero unit test regressions vs Wave 1 baseline +- `test/functional/test_runner.py` — zero functional test regressions +- Fuzz suite — zero new crashes or errors +- All changes committed in clean, logical commits; working tree clean +- Each commit subject line explains what was broken and what was fixed + +--- + +### Wave 3 — Health / ERR Core (3 agents, parallel) + +**Goal:** Replace doubles with integer math, implement canonical health, finish ERR consensus. Each agent owns distinct files. + +**Agent A — Canonical Health + DCA Math** (`src/digidollar/health.cpp/h`, `src/consensus/dca.cpp`) +- Implement one canonical health calculation for consensus (P0.3) +- Replace DCA consensus doubles with integer basis points +- Conservative rounding up for collateral requirements +- `__int128` + `MAX_MONEY` checks before casting +- No fallback to 150% after activation when data is missing — fail closed +- Files: `src/digidollar/health.cpp`, `src/digidollar/health.h`, `src/consensus/dca.cpp` + +**Agent B — ERR Consensus** (`src/consensus/err.cpp/h`) +- Finish ERR validation logic (P0.2 part 1) +- Replace ERR consensus doubles with integer basis points +- Implement `ceil(originalDD * 10000 / errRatioBps)` using `__int128` +- Gate: health `≥ 100%` = normal burn, health `< 100%` = ERR burn +- Block new minting while ERR active +- Files: `src/consensus/err.cpp`, `src/consensus/err.h` + +**Agent C — Oracle Roster + Miner Gating** (`src/oracle/musig2*`, `src/node/miner.cpp`) +- Wire expandable roster into MuSig2 verification path +- Enforce that miner only includes/mines valid MuSig2 bundles for DD-touching blocks +- Non-DD blocks: no oracle bundle required +- Files: `src/oracle/musig2*.cpp`, `src/oracle/musig2*.h`, `src/node/miner.cpp` (oracle bundle gating only) + +**Gate after Wave 3:** +- Wave 1 Agent C failing tests now pass (ERR, health, DCA, volatility) +- Oracle roster unit tests pass (expandable roster, quorum, domain separation) +- `make check` — zero unit test regressions vs Wave 2 baseline +- `test/functional/test_runner.py` — zero functional test regressions +- Fuzz suite — zero new crashes or errors +- All changes committed in clean, logical commits; working tree clean +- Each commit subject line explains what was broken and what was fixed + +--- + +### Wave 4 — Integration Wiring (3 agents, parallel) + +**Goal:** Wire the fixed subsystems (health, ERR, oracle) into validation, txbuilder, and RPC. Each agent owns separate call sites in shared files. + +**Agent A — Mint path wiring** (validation.cpp mint sections, txbuilder.cpp mint/collateral) +- Wire canonical health + DCA into DD mint validation (P0.3 wiring) +- Wire canonical health into txbuilder collateral requirement calculations +- Remove all `skipOracleValidation` bypasses in mint and DCA paths +- Ensure mint rejects without oracle data post-activation (fail closed) +- Files: `src/digidollar/validation.cpp` (mint + DCA sections), `src/digidollar/txbuilder.cpp` (mint/collateral sections) + +**Agent B — Redemption/ERR wiring** (validation.cpp redemption sections, txbuilder.cpp redemption, rpc/digidollar.cpp ERR status) +- Wire ERR into redemption validation path (P0.2 wiring) +- Wire burn enforcement into redemption path (must call P0.1 collateral detector) +- Update txbuilder redemption to select correct DD burn amount based on ERR state +- RPC: report ERR state and required DD burn amount correctly +- Files: `src/digidollar/validation.cpp` (redemption + ERR sections), `src/digidollar/txbuilder.cpp` (redemption sections), `src/rpc/digidollar.cpp` (ERR status RPCs) + +**Agent C — Wallet + Qt + RPC unit fixes + mempool oracle policy** +- Move HD key helper to shared wallet code; both Qt and RPC mint derive owner key from HD seed (P0.7 / DD-RH-034) +- Persist derived owner key to wallet DB before transaction broadcast +- Non-HD wallet returns explicit error at mint time +- Wire wallet normal redemption and ERR redemption with correct DD input selection (P1.1) +- Enforce watch-only wallet cannot mint/send/redeem/sign +- Legacy wallet path fails with explicit unsupported error +- Fix Qt collateral ratios for tiers 5–8 to read from consensus params (TR-RH-004) +- Fix `listdigidollarpositions min_amount` to filter in DD cents (TR-RH-003) +- Fix `senddigidollar change_amount` unit (TR-RH-003) +- Fix `getdigidollarbalance minconf` application (TR-RH-003) +- Fix `listdigidollaraddresses` to list all generated DD addresses including zero-balance (TR-RH-005) +- Confirm `importdigidollaraddress` explicit failure message is clear and release-note worthy (ARCH-RH-003) +- Mempool oracle policy: require recent valid MuSig2 oracle quote before accepting DD txs +- Files: `src/qt/walletmodel.cpp`, `src/wallet/` (shared HD helper), `src/rpc/digidollar.cpp` (RPC unit fixes, oracle policy), wallet DD paths, `src/validation.cpp` (mempool DD oracle gating), `src/net_processing.cpp` (mempool policy only) + +**Gate after Wave 4:** +- All Wave 1 failing tests now pass across all three agent domains +- RPC, wallet, and Qt state visibly consistent with consensus for normal redemption, ERR, watch-only, legacy wallet failure +- `make check` — zero unit test regressions vs Wave 3 baseline +- `test/functional/test_runner.py` — zero functional test regressions +- Fuzz suite — zero new crashes or errors +- All changes committed in clean, logical commits; working tree clean +- Each commit subject line explains what was broken and what was fixed + +--- + +### Wave 5 — Mempool / IBD / Reorg Consensus Parity (3 agents, parallel) + +**Goal:** Close all chain-split-shaped audit findings. Prove identical behavior across all validation contexts. + +**Agent A — Mempool/ConnectBlock parity** (validation.cpp connect path, miner.cpp) +- Ensure `AcceptToMemoryPool` + `ConnectBlock` + miner block assembly apply identical DD consensus rules (P0.7) +- Fix any remaining `skipOracleValidation` bypasses in post-activation paths +- Historical validation must use oracle bundle/roster rules committed at that historical height +- Files: `src/validation.cpp` (ConnectBlock + AcceptToMemoryPool sections), `src/node/miner.cpp` (final oracle validation check) + +**Agent B — IBD / reindex / reorg rollback** (validation.cpp disconnect path, net_processing.cpp) +- Ensure reindex, IBD, and reorg rollback restore DD supply, collateral, health, oracle cache deterministically (P0.7) +- Expanded oracle roster validates only after activation height; does not invalidate older blocks +- Files: `src/validation.cpp` (DisconnectBlock + IBD path), `src/net_processing.cpp` (reorg chain state) + +**Agent C — Functional test coverage** (test/functional/ only) +- Write/run functional test scenarios: + - Caught-up node accepts same DD block as IBD/reindex node + - Mempool + mined block validity agree on oracle, DCA, ERR, burn requirements + - Reorg across DD mint/transfer/redeem restores supply/collateral/health and wallet state exactly + - Expanded roster validates only after activation height +- Files: `test/functional/digidollar_ibdreorg_tests.py` (new), `test/functional/digidollar_consensus_parity_tests.py` (new), existing functional tests + +**Gate after Wave 5:** +- All P0.9 consensus parity scenarios covered and passing (mempool/ConnectBlock/IBD/reindex/reorg) +- New functional tests pass: caught-up node and IBD node agree; reorg restores all DD state exactly; roster activation height enforced +- `make check` — zero unit test regressions vs Wave 4 baseline +- `test/functional/test_runner.py` — zero functional test regressions (full suite, not just new tests) +- Fuzz suite — zero new crashes or errors +- All changes committed in clean, logical commits; working tree clean +- Each commit subject line explains what was broken and what was fixed + +--- + +### Wave 6 — Final Regression Gates + Release Prep (3 agents, parallel) + +**Goal:** Green on everything. Then docs + RC cut. + +**Agent A — Full unit test suite** +- Run `make check` (all unit tests) +- Fix any remaining unit test failures +- Target: zero failures +- Files: targeted fixes only, no new features + +**Agent B — Full functional test suite** +- Run full `test/functional/` suite +- Fix any remaining functional test failures +- Target: zero failures +- Files: targeted fixes only, no new features + +**Agent C — Full fuzz suite** +- Run all 208 registered fuzz targets per `doc/FUZZ_MARATHON_COMPLETE.md` +- Fix any remaining fuzz failures or OOM issues (use per-target RSS config, not global) +- Target: zero crashes/errors + +**After all three agents green:** +- Update `ARCHITECTURE.md`, `DIGIDOLLAR_ARCHITECTURE.md`, `DIGIDOLLAR_ORACLE_ARCHITECTURE.md` +- Update `DIGIDOLLAR_EXPLAINER.md`, `REPO_MAP_DIGIDOLLAR.md` +- Remove stale claims that ERR is incomplete or disabled +- Write release notes for next RC +- Cut RC tag locally. Jared reviews and pushes. + +**Gate after Wave 6:** +- `make check` — zero failures across all unit tests +- `test/functional/test_runner.py` — zero failures across all functional tests +- All 208 fuzz targets — zero crashes or errors +- All fixes committed in clean, logical commits with clear commit messages; working tree clean except doc/release artifacts +- Doc artifacts (updated architecture docs, release notes) committed separately from code fixes +- RC tag created locally; Jared reviews and pushes + +--- + +## 7. Wave Summary + +| Phase | Agents | Focus | Files Touched | +|-------|--------|-------|---------------| +| Pre-Wave | 1 | Verify Red Hornet team commits + full test suite baseline | Read-only — git log verification + test runner only | +| Wave 1 | 3 (parallel) | Write all failing tests (red phase, no prod code) | `src/test/`, `test/functional/` only | +| Wave 2 | 3 (parallel) | Script/oracle/lock layer + address routing + alert rename (no supply cap) | `src/script/*`, `src/oracle/*`, `src/primitives/oracle*`, `src/kernel/chainparams.cpp`, `src/consensus/digidollar.*`, `src/digidollar/scripts.cpp`, `src/digidollar/address.cpp`, `src/digidollar/txbuilder.cpp` (lock input only), `src/digidollar/validation.cpp` (lock/volatility sections), `src/digidollar/health.h/cpp` (alert rename only) | +| Wave 3 | 3 (parallel) | Health/ERR core + oracle roster wiring | `src/digidollar/health.*`, `src/consensus/dca.cpp`, `src/consensus/err.*`, `src/oracle/musig2*`, `src/node/miner.cpp` (oracle gating) | +| Wave 4 | 3 (parallel) | Integration wiring — mint path, redemption/ERR path, wallet/Qt/RPC | `src/digidollar/validation.cpp` (by section), `src/digidollar/txbuilder.cpp` (by section), `src/rpc/digidollar.cpp`, `src/qt/walletmodel.cpp`, `src/wallet/` (HD helper), `src/validation.cpp` (mempool policy), `src/net_processing.cpp` (mempool) | +| Wave 5 | 3 (parallel) | Mempool/IBD/reorg consensus parity + functional tests | `src/validation.cpp` (connect/disconnect/IBD), `src/node/miner.cpp`, `src/net_processing.cpp`, `test/functional/` | +| Wave 6 | 3 (parallel) | Full regression gates (unit + functional + fuzz) + docs + RC | Targeted fixes only; docs; release notes | + +**Total:** 7 phases (1 pre-wave + 6 waves) +**Sub-agent executions:** 19 (1 + 3×6) +**After every wave — mandatory before the next wave starts:** +1. `make check` — zero unit test failures (no regressions) +2. `test/functional/test_runner.py` — zero functional test failures (no regressions) +3. Fuzz suite — zero new crashes or errors +4. All changes committed; working tree clean +5. Every commit subject line explains what was broken and what was fixed — plain English, ≤72 chars + +--- + +## 8. Pre-Coding Checklist + +Before Wave 2 coding begins, the following must be confirmed: + +- [x] **DD-RH-119 supply cap decision:** LOCKED — Option B. No global DD supply cap. `MAX_DIGIDOLLAR` is per-output bound only. Docs updated. No consensus change. +- [ ] **Pre-Wave complete:** Red Hornet team commits verified in git log, full test suite passes clean. +- [ ] **Wave 1 complete:** all failing tests written, existing suite still green. + +--- + +## 9. MVP Summary + +**7 phases, 19 sub-agent executions, mandatory full-suite test gate after every wave.** + +DigiDollar V1 ships when: +- All Red Hornet fixes committed by the Red Hornet team, verified by Pre-Wave baseline check +- Collateral spends cannot bypass DD burn (P0.1) +- ERR is fully implemented with integer math and no DGB haircut (P0.2 + P0.3) +- DCA/ERR health uses one canonical oracle-backed source (P0.3) +- Lock tiers are canonical only (P0.4) +- Oracle handling is MuSig2-only with expandable roster, DD-touching blocks require oracle data (P0.5) +- No global DD supply cap enforced in consensus; `MAX_DIGIDOLLAR` is per-output bound only; all supply-cap language removed from docs (P0.6) +- Qt mint owner keys derived from HD seed, persisted before broadcast (P0.7) +- Volatility precheck is non-mutating (P0.8) +- All validation contexts (mempool/IBD/reorg) are provably identical (P0.9) +- Wallet/RPC/Qt state is consistent with consensus including ERR, watch-only, legacy, and unit/filter correctness (P1.1) +- Cross-chain DD addresses rejected at validation (TR-RH-006) +- Full unit + functional + fuzz gate green, clean working tree, docs updated, RC cut for Jared review diff --git a/REPO_MAP_DIGIDOLLAR.md b/REPO_MAP_DIGIDOLLAR.md index 2e6c82762c..d4f66ef253 100644 --- a/REPO_MAP_DIGIDOLLAR.md +++ b/REPO_MAP_DIGIDOLLAR.md @@ -24,7 +24,7 @@ This is the granular file index for all DigiDollar and Oracle source code. Read ## DigiDollar Core ### src/digidollar/digidollar.h -- `MAX_DIGIDOLLAR` → static constant: 21 billion DGB equivalent in cents (21B × 100), hard cap on total DD supply +- `MAX_DIGIDOLLAR` → static constant: per-output serialization bound (21B DGB equivalent in cents); DigiDollar has no global supply cap — total DD in circulation is theoretically unlimited, constrained only by available DGB collateral and the per-block minting rate - `CDigiDollarOutput` (class) → represents a DigiDollar UTXO with Taproot-based redemption paths - `CDigiDollarOutput()` → default constructor, zeroes amount/locktime, nulls collateral ID - `CDigiDollarOutput(nDDAmountIn, collateralIdIn, nLockTimeIn)` → parameterized constructor linking DD to specific collateral @@ -49,7 +49,7 @@ This is the granular file index for all DigiDollar and Oracle source code. Read ### src/digidollar/health.h - `DigiDollar::SystemMetrics` (struct) → aggregates all system-wide DD health data: supply, collateral, per-tier breakdown, DCA/ERR/volatility status, oracle status - `TierMetrics` (nested struct) → per-tier stats: lockDays, ddMinted, dgbLocked, positions count, healthRatio -- `DigiDollar::AlertThresholds` (struct) → static constexpr thresholds for alerts: MAX_DD_SUPPLY (100M), MIN_HEALTH_RATIO (120%), CRITICAL (110%), MIN_ORACLES (5), MAX_VOLATILITY (30%), STALE_ORACLE_BLOCKS (100) +- `DigiDollar::AlertThresholds` (struct) → static constexpr monitoring alert thresholds: ALERT_DD_SUPPLY (monitoring trigger at 100M, not a supply cap), MIN_HEALTH_RATIO (120%), CRITICAL (110%), MIN_ORACLES (5), MAX_VOLATILITY (30%), STALE_ORACLE_BLOCKS (100) - `DigiDollar::SystemHealthMonitor` (class) → real-time system health tracking and alerting - `GetSystemMetrics()` → returns current SystemMetrics after updating tiers/protection/oracle status - `GetTierBreakdown()` → returns per-tier metrics vector with health ratios per lock period diff --git a/configure.ac b/configure.ac index db4ecd15c6..4fe3619924 100644 --- a/configure.ac +++ b/configure.ac @@ -2,7 +2,7 @@ AC_PREREQ([2.69]) define(_CLIENT_VERSION_MAJOR, 9) define(_CLIENT_VERSION_MINOR, 26) define(_CLIENT_VERSION_BUILD, 0) -define(_CLIENT_VERSION_RC, 33) +define(_CLIENT_VERSION_RC, 34) define(_CLIENT_VERSION_IS_RELEASE, false) define(_COPYRIGHT_YEAR, 2026) define(_COPYRIGHT_HOLDERS,[The %s developers]) diff --git a/reports/red_hornet_ledger.md b/reports/red_hornet_ledger.md index 78a8c25ea3..9a30c4ee55 100644 --- a/reports/red_hornet_ledger.md +++ b/reports/red_hornet_ledger.md @@ -2900,3 +2900,774 @@ PRINT_ALL_FUZZ_TARGETS_AND_ABORT=1 src/test/fuzz/fuzz 2>&1 | rg 'digidollar|orac **Agent C result summary:** returned final-report structure and open-risk summary; content was integrated into the final package. **Wave 20 status:** complete. `DD-RH-050` fixed, `DD-RH-051` test coverage repaired, final configured and scoped DD/oracle suites passed, no new architecture item added, scope stayed within DigiDollar/oracle. + +--- + +# Red Hornet Continuation Campaign — 2026-04-29 + +**Scope:** DigiDollar and DigiDollar-oracle only, on branch `feature/digidollar-v1`. + +**Required context loaded by main agent in order:** +`CLAUDE.md`, `ARCHITECTURE.md`, `REPO_MAP.md`, `REPO_MAP_GUIDE.md`, `DIGIDOLLAR_ARCHITECTURE.md`, `DIGIDOLLAR_ORACLE_ARCHITECTURE.md`, `REPO_MAP_DIGIDOLLAR.md`, `DIGIDOLLAR_EXPLAINER.md`, `DIGIDOLLAR_ORACLE_EXPLAINER.md`, `DIGIDOLLAR_ACTIVATION_EXPLAINER.md`, `DIGIDOLLAR_WALLET_INTEGRATION.md`, `DIGIDOLLAR_EXCHANGE_INTEGRATION.md`, `DIGIDOLLAR_OPRETURN_PQC_MINT_PLAN.md`, `ORACLE_DISCOVERY_ARCHITECTURE.md`, `DIGIDOLLAR_ORACLE_SETUP.md`, `docs/ORACLE_OPERATOR_GUIDE.md`, `digidollar/DIGIDOLLAR_FLOWCHART.md`, `digidollar/DIGIDOLLAR_ORACLE_PHASE_ONE_SPEC.md`, `digidollar/ORACLE_PHASE_2_SPEC_PRD.md`, `digidollar/4_tier_collateral.md`, `RELEASE_v9.26.0-rc33.md`. + +**Initial worktree check:** + +```bash +git status --short --branch +``` + +Result: clean worktree, `## feature/digidollar-v1...origin/feature/digidollar-v1`. + +**Attack-surface enumeration command required for every wave:** + +```bash +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort +``` + +Initial result: 909 matching files. + +**Report paths for this continuation:** +- Running ledger: `reports/red_hornet_ledger.md` +- Final review package: `reports/red_hornet_final_report.md` + +**Commit policy:** no local commits unless explicitly authorized during this continuation. Fixes, if any, remain uncommitted with proposed commit split recorded by vulnerability ID. + +## Continuation Wave 1 — Threat Model, Baseline, Attack-Test Inventory + +**Assignments:** +- Agent A / exploit-path attacker: production invariant map and high-risk exploit assignments. +- Agent B / invariant/test breaker: redteam, regression, fuzz, wallet, Qt, and functional coverage inventory. +- Agent C / boundary adversary: baseline test artifact discovery and scoped security-relevant test run. + +**Wave attack-surface enumeration:** required `find ... | grep ... | sort` command run; 909 matching files. Scope stayed within DigiDollar/oracle and direct boundary files. + +**Confirmed vulnerabilities:** none newly assigned in this continuation wave. + +**ARCHITECTURAL_REVIEW_REQUIRED:** +- `DD-RH-069` carryover remains high priority and appears reachable: after timelock, collateral can be spent through the normal Taproot script path as a non-DigiDollar DGB transaction, so the DD validation path requiring a DD burn is not entered. Key references: `src/digidollar/scripts.cpp:61`, `src/validation.cpp:779`, `src/validation.cpp:2947`, `src/digidollar/validation.cpp:2107`. Exploit sketch: owner mints, waits until lock height, spends the collateral vault script path without the DD marker/metadata, and leaves the DD token UTXO live. This needs a consensus/script/protocol decision before implementation; no production change made without Jared approval. +- Existing carryovers reaffirmed: strict post-activation `OP_ORACLE` handling, Phase 3 acceptance of legacy v0x02 bundles, `skipOracleValidation` during IBD/catch-up, MuSig2 domain separation, ERR behavior, watch-only storage, and mint owner-key storage timing. + +**Rejected false positives / narrowed claims:** +- Baseline checkout does not have local build artifacts (`./src/test/test_digibyte`, `src/Makefile`, `test/config.ini` absent). This is an environment/build-state blocker, not a product vulnerability. +- The attack-surface command intentionally over-includes generic Qt/wallet files because the required grep contains `qt|wallet`; this was recorded but not treated as scope expansion. + +**Theoretical / not yet reachable risks:** +- Need deeper proof for oracle fail-open/fail-closed behavior across activation, malformed `OP_ORACLE`, IBD, reindex, and reorg. +- Need adversarial multi-node functional proof for MuSig2 signer reselection, nonce/partial churn, and mined bundle recovery across restarts/reorgs. +- OP_RETURN/PQC/P2MR mint hardening remains design-plan work, not an implemented v2 surface. + +**Test/coverage evidence:** +- Agent B found coverage: 67 `src/test/digidollar_*_tests.cpp`, 16 `src/test/oracle_*_tests.cpp`, 19 `src/test/musig2_*_tests.cpp`, 19 `src/test/rh*_tests.cpp`, 16 DigiDollar/oracle fuzz files with 35 targets, and 65 focused functional scripts. +- Agent C used Guix RC33 x86_64 artifacts because the working tree is unconfigured. Scoped C++ baseline passed: 135 selected tests / 628 assertions across DigiDollar, oracle, MuSig2, and RH suites. +- Agent C functional smoke passed with process-substituted config: `digidollar_activation_boundary.py`, `digidollar_oracle_rpc_staleness.py`, `rpc_getoracles_pending.py`. + +**Commands/results recorded:** +```bash +git status --short --branch +# clean: ## feature/digidollar-v1...origin/feature/digidollar-v1 + +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort +# 909 matching files +``` + +**Fixes landed:** none. + +**Commit status:** no commits; ledger edit only. + +**Wave 1 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 2 — Inflation and Supply Integrity + +**Assignments:** +- Agent A / exploit-path attacker: mint/transfer production paths for unauthorized DD creation and collateral-release accounting. +- Agent B / invariant/test breaker: conservation/supply assertions and missing negative cases. +- Agent C / boundary adversary: RPC/wallet/functional supply-accounting drift and restart/cache candidates. + +**Wave attack-surface enumeration:** required `find ... | grep ... | sort` command run; 909 matching files. Scope stayed within DigiDollar/oracle and direct RPC/wallet boundary surfaces. + +**Confirmed vulnerabilities fixed in working tree:** + +### DD-RH-105 — Medium — transfer conservation ignores an unresolved zero-value input when another DD input resolves + +- **Affected invariant:** transfer validation cannot create, hide, or route DD value unless every DD-like input amount is known. +- **Exploit path:** `ValidateTransferTransaction()` summed only inputs whose DD amount could be resolved, then accepted if at least one DD input was found. A transaction with one resolved DD input and one unresolved zero-value P2TR input could pass conservation without proving the second input's DD amount. +- **Exact code reference:** `src/digidollar/validation.cpp:1373` pre-fix only counted resolved inputs and deferred rejection until `ddInputCount == 0`; fixed zero-value unresolved rejection starts at `src/digidollar/validation.cpp:1377`. +- **Attack test:** `src/test/digidollar_validation_tests.cpp:3133`. +- **Fail-before evidence:** `./src/test/test_digibyte --run_test=digidollar_validation_tests/transfer_rejects_unresolved_zero_value_input_when_other_input_resolves --log_level=all --report_level=short` failed with 1 selected test failed, 2/2 assertions failed; observed acceptance and empty reject reason. +- **Fix summary:** if coins view shows an input is zero-value and txindex/block-db/metadata cannot resolve a positive DD amount, reject immediately with `dd-input-amounts-unknown`, even if earlier inputs resolved. +- **Pass-after evidence:** same selected test passed: 1 test, 2 assertions. +- **Additional regression coverage:** `digidollar_validation_tests` passed 103 tests / 265 assertions; `digidollar_transfer_tests` passed 43 tests / 168 assertions; `digidollar_redteam_tests` passed 356 tests / 1756 assertions; `digidollar_rh42_formal_invariant_tests` passed 15 tests / 7897668 assertions. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `digidollar validation: fix DD-RH-105 unresolved transfer input`. + +### DD-RH-106 — High — rejected mint can poison collateral script metadata and lower redemption burn requirement + +- **Affected invariant:** collateral cannot be released unless the DD burned is at least the amount minted by the original collateral-creating mint transaction. +- **Exploit path:** `CreateCollateralP2TR()` registers process-local metadata keyed by collateral script, but normal collateral script creation does not bind `ddAmount`; same owner and lock height produce the same script. `ValidateMintTransaction()` reconstructs expected collateral script before later mint failures, so a rejected undercollateralized mint with the same script and a smaller DD amount overwrote metadata. `ValidateCollateralReleaseAmount()` trusted metadata before looking up the creating mint transaction, so a redemption could burn the smaller poisoned amount and release all collateral. +- **Exact code references:** metadata registration in `src/digidollar/scripts.cpp:165` and keying in `src/digidollar/scripts.cpp:227`; mint-side side effect in `src/digidollar/validation.cpp:1118`; pre-fix metadata-first redemption amount extraction at `src/digidollar/validation.cpp:1840`; fixed authoritative tx lookup before metadata starts at `src/digidollar/validation.cpp:1848`. +- **Attack test:** `src/test/digidollar_validation_tests.cpp:3330`. +- **Fail-before evidence:** `./src/test/test_digibyte --run_test=digidollar_validation_tests/redemption_uses_authoritative_mint_amount_before_script_metadata --log_level=all --report_level=short` failed with 1 selected test failed, 2/8 assertions failed; rejected poison mint had overwritten metadata to the smaller DD amount and redemption was accepted instead of `bad-collateral-release-partial-burn`. +- **Fix summary:** `ValidateCollateralReleaseAmount()` now prefers txindex and validation-context block-db lookup of the collateral-creating mint transaction before falling back to process-local script metadata. +- **Pass-after evidence:** same selected test passed: 1 test, 8 assertions. +- **Additional regression coverage:** `digidollar_validation_tests` passed 103 tests / 265 assertions; `digidollar_no_partial_redeem_tests` passed 10 tests / 52 assertions; `digidollar_rh07_redemption_attacks` passed 18 tests / 27 assertions; `digidollar_rh06_mint_attacks` passed 36 tests / 69 assertions; `digidollar_redteam_tests` passed 356 tests / 1756 assertions. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `digidollar redemption: fix DD-RH-106 metadata-poisoned burn accounting`. + +**ARCHITECTURAL_REVIEW_REQUIRED:** +- `ARCH-RH-002` carryover reaffirmed by Agent A: `fSkipOracle`/IBD catch-up can still bypass oracle/collateral-dependent mint checks in production validation (`src/validation.cpp:2990`, `src/digidollar/validation.cpp:1147`). This is a deployment/consensus policy decision; no implementation change made without Jared approval. +- `DD-RH-069` carryover still open from Wave 1: normal collateral path after timelock can bypass DD redemption validation if spent as non-DD. No implementation change made without Jared approval. + +**Rejected false positives / downgraded items:** +- Agent B's stats/health mint accounting helper concern remains a test-gap candidate only; consensus-valid chains already route through mint validation before stats indexing. +- Agent C's address-filtered `getdigidollarbalance(..., minconf=0)` pending-balance discrepancy and `sendmanydigidollar` `"multiple"` history row are user-surface/accounting risks, not yet proven loss-of-funds or consensus supply bugs. + +**Theoretical / not yet reachable risks:** +- Restart/state divergence between persisted DigiDollar stats index and in-memory health/ERR cache needs a focused Wave 7/9 proof. +- RPC/wallet display of pending address-filtered DD and multi-recipient history may mislead users; carry forward to Waves 9-11. + +**Commands/results recorded:** + +```bash +./autogen.sh +# passed + +./configure --disable-bench --with-gui=no +# passed; local build configured without GUI, wallet/tests enabled + +make -C src -j2 test/test_digibyte +# passed; linked src/test/test_digibyte + +./src/test/test_digibyte --run_test=digidollar_validation_tests/transfer_rejects_unresolved_zero_value_input_when_other_input_resolves --log_level=all --report_level=short +# pre-fix failed: 1 test failed, 2/2 assertions failed + +./src/test/test_digibyte --run_test=digidollar_validation_tests/redemption_uses_authoritative_mint_amount_before_script_metadata --log_level=all --report_level=short +# pre-fix failed: 1 test failed, 2/8 assertions failed + +make -C src -j2 test/test_digibyte +# post-fix incremental rebuild passed + +./src/test/test_digibyte --run_test=digidollar_validation_tests/transfer_rejects_unresolved_zero_value_input_when_other_input_resolves --report_level=short +# passed: 1 test, 2 assertions + +./src/test/test_digibyte --run_test=digidollar_validation_tests/redemption_uses_authoritative_mint_amount_before_script_metadata --report_level=short +# passed: 1 test, 8 assertions + +./src/test/test_digibyte --run_test=digidollar_validation_tests --report_level=short +# passed: 103 tests, 265 assertions, 2 Boost warning-status cases + +./src/test/test_digibyte --run_test=digidollar_transfer_tests --report_level=short +# passed: 43 tests, 168 assertions + +./src/test/test_digibyte --run_test=digidollar_no_partial_redeem_tests --report_level=short +# passed: 10 tests, 52 assertions + +./src/test/test_digibyte --run_test=digidollar_rh07_redemption_attacks --report_level=short +# passed: 18 tests, 27 assertions + +./src/test/test_digibyte --run_test=digidollar_rh06_mint_attacks --report_level=short +# passed: 36 tests, 69 assertions + +./src/test/test_digibyte --run_test=digidollar_redteam_tests --report_level=short +# passed: 356 tests, 1756 assertions + +./src/test/test_digibyte --run_test=digidollar_rh17_mempool_attacks_tests --report_level=short +# passed: 10 tests, 34 assertions + +./src/test/test_digibyte --run_test=digidollar_rh42_formal_invariant_tests --report_level=short +# passed: 15 tests, 7897668 assertions +``` + +**Fixes landed:** DD-RH-105 and DD-RH-106 in working tree. + +**Commit status:** no commits; user has not authorized local commits. Current modified files: `reports/red_hornet_ledger.md`, `src/digidollar/validation.cpp`, `src/test/digidollar_validation_tests.cpp`. + +**Wave 2 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 3 — Collateral Accounting, Rounding, Overflow + +**Assignments:** +- Agent A / exploit-path attacker: collateral math, cents/COIN conversions, overflow clamps, DCA health source, and redemption-release math. +- Agent B / invariant/test breaker: ratio, MAX_MONEY, `__int128`, invalid amount, price, rounding, and stale/insecure test assertions. +- Agent C / boundary adversary: wallet/RPC/Qt/txbuilder collateral, change, fee, lock-tier, and quote behavior. + +**Wave attack-surface enumeration:** required `find ... | grep ... | sort` command run. Current post-build tree has 1669 matching paths because configured build/test artifacts and logs are now present. Scope stayed within DigiDollar/oracle and direct wallet/RPC/Qt/validation boundary files. + +**Confirmed vulnerabilities fixed in working tree:** + +### DD-RH-109 — Medium — transfer builder can return success while selected DGB fee inputs underpay the reported fee + +- **Affected invariant:** wallet/txbuilder fee accounting must not report a higher fee than the transaction can actually pay, and must not construct DigiDollar transfers that silently underfund the intended miner fee. +- **Exploit path:** `TransferDigiDollarMany` estimates fee inputs before final transfer size is known, and `TransferTxBuilder::BuildTransferTransaction()` computed `actualFee` after all DD outputs were present but never rejected `totalFeeIn < actualFee`. The builder omitted DGB change and still returned `result.totalFees = actualFee`, so callers could believe a 0.1 DGB or selected-rate fee was paid even when the fee inputs were much smaller. +- **Exact code reference:** pre-fix fee calculation accepted underfunded inputs at `src/digidollar/txbuilder.cpp:714`; fixed rejection starts at `src/digidollar/txbuilder.cpp:722`. +- **Attack test:** `src/test/digidollar_txbuilder_tests.cpp:238`. +- **Fail-before evidence:** `./src/test/test_digibyte --run_test=digidollar_txbuilder_tests/transfer_rejects_underfunded_fee_inputs --log_level=all --report_level=short` failed with 1 selected test failed, 2/2 assertions failed; observed `result.success == true` with a 1-sat fee input and a 0.1 DGB reported fee. +- **Fix summary:** `BuildTransferTransaction()` now rejects when no DGB fee inputs are selected or when selected fee input value is below the calculated/minimum required fee. +- **Tests upgraded:** `src/test/digidollar_transfer_tests.cpp` now funds direct builder tests with realistic 1 DGB fee UTXOs and updates the change-output expectation where DGB fee change is now present. +- **Pass-after evidence:** selected attack test passed; `digidollar_txbuilder_tests` passed 18 tests / 63 assertions; `digidollar_transfer_tests` passed 43 tests / 168 assertions; `digidollar_validation_tests` passed 103 tests / 265 assertions with the existing 2 Boost warning-status cases. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `digidollar txbuilder: fix DD-RH-109 underfunded transfer fees`. + +**ARCHITECTURAL_REVIEW_REQUIRED:** + +### DD-RH-107 — High — non-canonical lock periods receive the next longer tier's lower collateral ratio + +- **Affected invariant:** a DD mint cannot be under-collateralized by manipulating lock duration around tier boundaries. +- **Exploit path:** `GetCollateralRatioForLockTime()` uses `lower_bound(lockBlocks)` and returns the first tier with lock time greater than or equal to the requested period. A raw mint with `lockTier=0` and `lockHeight=currentHeight+241` passes the tier-0 consistency check because 241 blocks is at least `240 - 10`, but collateral math passes `lockPeriod=241` into the ratio helper and receives the 30-day 500% ratio instead of the 1-hour 1000% ratio. Similar boundary gaming exists at every tier: `30d+1` gets the 90-day ratio, etc. +- **Exact code references:** `src/consensus/digidollar.cpp:26`, `src/consensus/digidollar.cpp:34`, `src/digidollar/validation.cpp:930`, `src/digidollar/validation.cpp:943`, `src/digidollar/validation.cpp:1157`. +- **Proof status:** reachable from current validation logic; existing tests currently assert this behavior as design in `src/test/digidollar_rh20_time_ordering_tests.cpp` and `src/test/digidollar_rh32_collateral_dca_tests.cpp`. +- **Why no fix landed:** changing the helper to choose the previous/shorter tier or requiring exact canonical lock periods changes consensus economics and existing policy tests. Needs Jared approval before implementation. +- **Recommended decision:** define whether only canonical tier durations are valid, or whether intermediate durations should receive the shorter tier's more conservative ratio. Then add a consensus regression such as `fresh_mint_tier0_plus_one_block_must_not_get_30day_ratio`. + +### DD-RH-108 — High — DCA health source can be stale/unit-inflated relative to the oracle price used for mint collateral + +- **Affected invariant:** the DCA multiplier used for mint collateral must be a deterministic function of current chain DD supply, current locked collateral, and the same oracle price used for the mint. +- **Exploit path:** block and mempool validation pass `DigiDollar::GetSystemCollateralRatio()` into `ValidationContext`, while mint collateral math uses `ctx.oraclePriceMicroUSD` directly. Cached health can remain at a healthy value after incremental mint/redeem mutations, and volatility price history records `ctx.oraclePriceMicroUSD` while `SystemHealthMonitor::GetLastOraclePrice()` treats the last price as the health-calculation price unit. A stressed system can therefore validate at the base ratio when actual health requires a DCA multiplier. +- **Exact code references:** `src/validation.cpp:818`, `src/validation.cpp:2998`, `src/digidollar/validation.cpp:463`, `src/digidollar/validation.cpp:2213`, `src/digidollar/validation.cpp:2336`, `src/digidollar/health.cpp:449`, `src/digidollar/health.cpp:773`. +- **Proof status:** independently reported by Agents A and B. It needs a deterministic consensus-safe health-source design before production changes. +- **Why no fix landed:** forcing recomputation or changing price units in consensus validation affects activation/IBD/reindex/reorg behavior and overlaps existing `skipOracleValidation` architecture decisions. +- **Recommended decision:** define one canonical chain-derived health calculation for validation, including exact price units and IBD/catch-up behavior; then add failing tests for stale health after incremental mints and for micro-USD/cents unit confusion. + +**Rejected false positives / downgraded items:** +- `ValidateCollateralReleaseAmount()` returning true when `ctx.coins == nullptr` was not counted as reachable: live mempool and block validation pass coin views through `src/validation.cpp:818` and `src/validation.cpp:3000`. +- `estimatecollateral` floors a fractional DCA multiplier in `src/rpc/digidollar.cpp:2833`; this is a reachable quote drift, but not a consensus acceptance bug because mint validation uses consensus DCA calculation. Carry forward to RPC/UX waves. +- `Transfer OP_RETURN` "output_count" wording is a stale comment; builder and validator both use `DD type amount...`. +- DD transfer dust/change through RPC is guarded by wallet coin selection; direct builder sub-min DD change remains rejected by validation. + +**Theoretical / not yet reachable / carry-forward risks:** +- Legacy `OP_RETURN OP_DIGIDOLLAR` amount parsing uses a signed left shift on attacker-controlled high-bit bytes in `src/digidollar/validation.cpp:135`. This is C++ UB and consensus-risk shaped, but no deterministic non-sanitizer failing test was produced in Wave 3. Carry forward to fuzz/sanitizer work before counting as a confirmed vulnerability. +- `CalculateRequiredCollateral()` caps overflowed requirements to `MAX_MONEY` at `src/digidollar/validation.cpp:501`, while RPC quote paths throw and the txbuilder returns 0. Current parameters make this impractical, but it should be turned into an explicit policy/design decision instead of silent cap semantics. +- `mintdigidollar` and Qt mint flows persist wallet DD position data after `CommitTransaction()`, while wallet commit only logs mempool rejection. Exact references: `src/wallet/wallet.cpp:2500`, `src/rpc/digidollar.cpp:1143`, `src/rpc/digidollar.cpp:1162`, `src/rpc/digidollar.cpp:1174`, and the Qt path in `src/qt/walletmodel.cpp`. This is a credible wallet-corruption boundary risk, but Wave 3 did not produce a deterministic functional reproducer; carry forward to Waves 8, 11, and 12. +- `calculatecollateralrequirement` optional price help text says cents while implementation treats it as micro-USD. This is a low RPC quote/unit trap; carry forward to Wave 11. + +**Commands/results recorded:** + +```bash +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort | wc -l +# 1669 matching paths in the post-build tree + +make -C src -j2 test/test_digibyte +# passed after adding the DD-RH-109 attack test to the binary + +./src/test/test_digibyte --run_test=digidollar_txbuilder_tests/transfer_rejects_underfunded_fee_inputs --log_level=all --report_level=short +# pre-fix failed: 1 selected test failed, 2/2 assertions failed + +make -C src -j2 test/test_digibyte && \ +./src/test/test_digibyte --run_test=digidollar_txbuilder_tests/transfer_rejects_underfunded_fee_inputs --report_level=short +# post-fix passed: 1 selected test, 2 assertions + +./src/test/test_digibyte --run_test=digidollar_txbuilder_tests --report_level=short +# passed: 18 tests, 63 assertions + +./src/test/test_digibyte --run_test=digidollar_transfer_tests --report_level=short +# initially failed after the new guard because stale tests used 0.001 DGB fee UTXOs; after test fixture update passed: 43 tests, 168 assertions + +./src/test/test_digibyte --run_test=digidollar_validation_tests --report_level=short +# passed: 103 tests, 265 assertions, 2 Boost warning-status cases + +git diff --check +# passed +``` + +**Fixes landed:** DD-RH-109 in working tree. DD-RH-107 and DD-RH-108 recorded as `ARCHITECTURAL_REVIEW_REQUIRED`. + +**Commit status:** no commits; user has not authorized local commits. Proposed commit split includes DD-RH-105, DD-RH-106, and DD-RH-109 as separate commits. Wave 3 touched `src/digidollar/txbuilder.cpp`, `src/test/digidollar_txbuilder_tests.cpp`, and `src/test/digidollar_transfer_tests.cpp`; unrelated pre-existing/current modifications outside this wave were not reverted. + +**Wave 3 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 4 — Timelock, ERR, DCA, Volatility Bypass + +**Assignments:** +- Agent A / exploit-path attacker: redemption paths, ERR/DCA/volatility state transitions, and production validation bypasses. +- Agent B / invariant/test breaker: height/time edges, stale redemption tests, ERR burn requirements, DCA thresholds, volatility threshold assertions. +- Agent C / boundary adversary: RPC/Qt/user-facing redemption state, restart/cache protection boundaries, and misleading redeemability flows. + +**Wave attack-surface enumeration:** required `find ... | grep ... | sort` command run. Current post-build tree has 1669 matching paths because generated `.deps`, `.o`, `.Po`, logs, and `.dirstamp` files are present. Review stayed within DigiDollar/oracle and directly gated wallet/RPC/Qt/validation surfaces. + +**Confirmed vulnerabilities fixed in working tree:** none in Wave 4. The strongest findings require consensus/protocol or wallet-state design approval before implementation. + +**ARCHITECTURAL_REVIEW_REQUIRED:** + +### DD-RH-108 — High — stale/default system health can bypass DCA and ERR redemption protection + +- **Affected invariant:** DCA and ERR decisions must use the same current, deterministic system health as mint/redemption validation. +- **Exploit path:** mempool/block validation passes `DigiDollar::GetSystemCollateralRatio()` into the validation context (`src/validation.cpp:818`, `src/validation.cpp:2998`). The helper returns cached health when present and falls back to a healthy `150%` value when oracle/collateral data is missing (`src/digidollar/validation.cpp:2336`, `src/digidollar/validation.cpp:2360`). Incremental health updates mutate supply/collateral without recomputing health from the current oracle price (`src/digidollar/health.cpp:299`, `src/digidollar/health.cpp:449`). A restart/cache gap or stale monitor can therefore validate mints at too-low DCA collateral and route redemptions down the normal path when live protection status is emergency. +- **Exact related code:** mint DCA uses `ctx.systemCollateral` at `src/digidollar/validation.cpp:515`; mint freeze/status depends on global volatility at `src/digidollar/validation.cpp:761`; redemption chooses ERR versus normal at `src/digidollar/validation.cpp:1692`; normal redemption rejects ERR only when `ctx.systemCollateral < 100` at `src/digidollar/validation.cpp:1739`; ERR path is currently incomplete at `src/digidollar/validation.cpp:1809`. +- **Proof status:** independently reported in Waves 3 and 4. Wave 4 expanded impact from undercollateralized mints to normal redemption acceptance/failure under stale health. No deterministic regression was added yet because the required fix depends on the approved authoritative health source and cache/IBD semantics. +- **Recommended decision:** define one consensus-safe health calculation for validation, including price units, cache invalidation, restart/reindex behavior, and skip-oracle historical handling; then add attack tests for stale health after incremental mints, restart before health refresh, and normal redemption attempted while live protection status is emergency. + +### DD-RH-110 — High — ERR redemption state is split across incompatible RPC, wallet, and consensus paths + +- **Affected invariant:** when system health is under 100%, all wallet/RPC/consensus paths must agree whether a position is redeemable, which redemption path is valid, and how much DD must be burned. +- **Exploit path:** `getprotectionstatus` computes emergency from current stats/current price and reports `err.active` from that live health (`src/rpc/digidollar.cpp:3501`, `src/rpc/digidollar.cpp:3511`). `getredemptioninfo` instead calls `EmergencyRedemptionRatio::GetCurrentState()` (`src/rpc/digidollar.cpp:2975`), whose inactive-state path only records health and does not activate ERR (`src/consensus/err.cpp:141`). `redeemdigidollar` hardcodes `RedemptionPath::NORMAL` (`src/rpc/digidollar.cpp:1657`) and consensus currently rejects all ERR redemptions as incomplete (`src/digidollar/validation.cpp:1809`). The user-visible result can be `getprotectionstatus.err.active == true` while redemption info/wallet flows still indicate normal redeemability or produce a normal-path transaction. +- **Reproducer plan:** on regtest, enable/set mock oracle, mint and confirm a tier-0 vault, mine to unlock height, crash price so `getprotectionstatus.err.active` is true, call `getredemptioninfo `, then call `redeemdigidollar`. Expected behavior is a single authoritative "ERR not implemented/not redeemable" result or an implemented ERR transaction; current code has split status and hardcoded normal construction. +- **Why no fix landed:** choosing whether RED phase should block all redemptions under ERR, permit normal redemptions until ERR is implemented, or implement ERR burn semantics is a protocol/wallet UX decision. No consensus or RPC schema behavior was changed without Jared approval. + +### DD-RH-111 — Medium — first high-volatility mint can pass before volatility freeze state is updated + +- **Affected invariant:** a mint that introduces a price movement at or above the freeze threshold should not be accepted merely because the monitor only records accepted transactions after validation. +- **Exploit path:** mint validation checks `VolatilityMonitor::ShouldFreezeMinting()` before recording the mint's oracle price (`src/digidollar/validation.cpp:761`). The price history is only mutated after a transaction is accepted (`src/digidollar/validation.cpp:2206`), and `VolatilityMonitor::RecordPrice()` may also skip too-frequent samples (`src/consensus/volatility.cpp:39`). A candidate mint with a large price move can be the transaction that causes freeze, but the current pre-check still sees the old non-frozen state. +- **Proof status:** static reachable path identified by Agent A. A small fix would need a pure "would this candidate price freeze?" API or another consensus-safe prevalidation mechanism; mutating global volatility state before validation would let invalid transactions poison the monitor. +- **Recommended decision:** add a non-mutating volatility check that evaluates the candidate price and timestamp against the current history, then add a failing mint-validation attack test seeded with a prior price and a candidate price crossing the freeze threshold. + +**Rejected false positives / downgraded items:** +- Partial burn for full collateral was rejected as already blocked by `ValidateCollateralReleaseAmount()` after DD-RH-106's authoritative mint lookup fix. +- Timelock key-path bypass was rejected for current standard-script validation: the mint collateral output uses NUMS P2TR construction plus CLTV script leaves, and block validation still runs standard script checks. +- RPC amount NaN/Inf/trailing-garbage was rejected: `ParseDigiDollarRpcAmount` checks full string consumption, finite values, and range. +- Unconfirmed DD replay/chaining was rejected for consensus and wallet selection: transfer and redemption reject mempool-created DD inputs, and wallet DD selection skips unconfirmed coins. + +**Theoretical / not yet reachable / carry-forward risks:** +- Existing redemption tests still assert RED-phase failures for expired normal redemption and stale ERR behavior in `src/test/digidollar_redeem_tests.cpp`. This is a test-suite quality issue until Jared chooses the intended RED/ERR redemption behavior. +- Volatility cooldown/threshold tests do not pin exact `ShouldFreezeMinting()`/`ShouldFreezeAll()` behavior at threshold and cooldown boundaries. Carry forward to Wave 19 fuzz/adversarial expansion unless a Wave 4 design decision is approved earlier. +- DCA boundary comments/docs still conflict with runtime behavior (`src/consensus/dca.cpp:119`, `src/consensus/dca.h:42`, `DIGIDOLLAR_ARCHITECTURE.md:386`). This is not counted as a live exploit until a canonical policy source is selected. +- `getredemptioninfo` and Qt redeem enablement do not check actual spendable DD burn inputs before reporting redeemability (`src/rpc/digidollar.cpp:2993`, `src/qt/digidollarpositionswidget.cpp:497`). This is a reachable user-surface deception risk; carry forward to wallet/RPC/Qt waves for a deterministic functional/Qt test. +- Qt's details context menu omits tier 0 and labels it as the default `1 year` (`src/qt/digidollarpositionswidget.cpp:365`, `src/qt/digidollarpositionswidget.cpp:375`), while the table mapping knows tier 0 is 240 blocks (`src/qt/digidollarpositionswidget.cpp:1075`). Carry forward to Wave 12 for a focused UI regression/fix. +- Stale oracle cache rollback needs a test that restores an old source-time cache entry and asserts `GetLatestPrice()` returns stale/zero rather than a rolled-back old price. + +**Commands/results recorded:** + +```bash +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort | wc -l +# 1669 matching paths in the post-build tree + +git status --short --branch +# dirty working tree; Red Hornet fixes remain uncommitted and unrelated pre-existing/user edits were not reverted +``` + +**Fixes landed:** none in Wave 4. + +**Commit status:** no Wave 4 commits. Existing proposed commit split from Waves 2-3 remains DD-RH-105, DD-RH-106, and DD-RH-109. + +**Wave 4 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 5 — Script, OP_RETURN, Metadata, Address Abuse + +**Assignments:** +- Agent A / exploit-path attacker: malformed script/template/OP_RETURN exploit paths. +- Agent B / invariant/test breaker: parser/classifier fuzz and regression coverage gaps. +- Agent C / boundary adversary: wallet/index/scan confusion and address display/routing attacks. + +**Wave attack-surface enumeration:** required local `find ... | grep ... | sort` command run successfully. Current post-build tree has 1669 matching paths because generated build artifacts are present. Agent B and C also reported that their prompted command had a trailing `sort .` typo and failed with `sort: read failed: .: Is a directory`; they reran the corrected pipeline. Review stayed within DigiDollar/oracle and directly gated wallet/RPC/Qt/validation surfaces. + +### DD-RH-112 — Medium — malformed transfer OP_RETURN script numbers escape validation + +- **Affected invariant:** malformed DD/oracle inputs must deterministically reject, not throw through consensus/mempool validation. +- **Exploit path:** `ValidateTransferTransaction()` parsed the transfer OP_RETURN type and output amounts with `CScriptNum(data, true)` / `CScriptNum(data, true, 8)` without catching `scriptnum_error`. A DD transfer carrying `OP_RETURN "DD" <5-byte non-minimal type> ...` or a 9-byte amount push threw out of validation instead of returning invalid state. +- **Exact code references:** fixed parser at `src/digidollar/validation.cpp:1258` and `src/digidollar/validation.cpp:1275`; attack test at `src/test/digidollar_transfer_tests.cpp:1179`. +- **Reproducer:** `transfer_rejects_malformed_opreturn_without_throwing` first failed with two unexpected exceptions and empty reject reason. +- **Expected secure behavior:** return false with `transfer-malformed-op-return`. +- **Fix summary:** catch `scriptnum_error` for transfer OP_RETURN tx type and amount fields and return `TX_CONSENSUS` invalid state. +- **Test evidence:** + - Pre-fix: `make -C src -j2 test/test_digibyte && ./src/test/test_digibyte --run_test=digidollar_transfer_tests/transfer_rejects_malformed_opreturn_without_throwing --log_level=all --report_level=short` failed: 1 selected test failed, 6/6 assertions failed. + - Post-fix: same target with `--report_level=short` passed: 1 selected test, 6 assertions. + +### DD-RH-113 — High — legacy OP_DIGIDOLLAR OP_RETURN can undercount mint accounting + +- **Affected invariant:** DD supply and collateral accounting must match the modern mint metadata validated by consensus. +- **Exploit path:** a valid DD mint could include a valid legacy `OP_RETURN OP_DIGIDOLLAR ` before the modern `OP_RETURN "DD" ...`. Mint validation counted only the modern marker, but `ExtractMintAccountingAmounts()` called `FindDDOpReturn()`, which returned the first legacy marker. Health scanning then adds the spoofed DD amount at `src/digidollar/health.cpp:397` and `src/digidollar/health.cpp:415`, undercounting supply and overstating system health. +- **Exact code references:** `FindDDOpReturn()` now prefers modern metadata and falls back to legacy-only transactions at `src/digidollar/validation.cpp:178`; accounting caller at `src/digidollar/validation.cpp:403`; attack test at `src/test/digidollar_validation_tests.cpp:3375`. +- **Reproducer:** `mint_accounting_ignores_legacy_opreturn_spoof` built a valid 20,000-cent mint with a preceding valid legacy 10,000-cent marker. Pre-fix, `ExtractMintAccountingAmounts()` returned `10000` instead of `20000`. +- **Expected secure behavior:** block-connect accounting must recover the same DD amount that mint validation accepted. +- **Fix summary:** record the first legacy marker as fallback only; immediately return the first modern `"DD"` marker when present. +- **Test evidence:** + - Pre-fix: `make -C src -j2 test/test_digibyte && ./src/test/test_digibyte --run_test=digidollar_validation_tests/mint_accounting_ignores_legacy_opreturn_spoof --report_level=short` failed with `extractedDD == ddAmount [10000 != 20000]`. + - Post-fix: same target passed: 1 selected test, 7 assertions. + +### DD-RH-114 — High — non-canonical OP_1 scripts were accepted as DD transfer outputs + +- **Affected invariant:** DD outputs must be canonical P2TR witness programs, not arbitrary scripts that start with `OP_1`. +- **Exploit path:** transfer output validation treated any zero-value script with `size == 34 && script[0] == OP_1` as a DD output. A malformed script such as `OP_1 OP_DROP OP_TRUE OP_NOP...` padded to 34 bytes is not P2TR and can be spent as a true legacy script, yet DD amount mapping would assign it transfer value. +- **Exact code references:** canonical witness-program helper at `src/digidollar/validation.cpp:60`; transfer reject at `src/digidollar/validation.cpp:1302`; previous-tx amount mapping at `src/digidollar/validation.cpp:344`; mint/redeem/collateral accounting canonicalization at `src/digidollar/validation.cpp:428`, `src/digidollar/validation.cpp:805`, `src/digidollar/validation.cpp:1669`, and `src/digidollar/validation.cpp:2053`; attack test at `src/test/digidollar_validation_tests.cpp:3145`. +- **Reproducer:** `transfer_rejects_noncanonical_taproot_like_output` spent a valid DD input to a zero-value 34-byte `OP_1 OP_DROP OP_TRUE ...` output with a matching DD amount. Pre-fix, validation returned success and no reject reason. +- **Expected secure behavior:** reject with `bad-dd-script`. +- **Fix summary:** added `IsCanonicalP2TROutput()` using `CScript::IsWitnessProgram()` and `WITNESS_V1_TAPROOT_SIZE`; replaced loose P2TR classifications in DD consensus/accounting paths; transfer validation now explicitly rejects 34-byte OP_1 impostors. +- **Test evidence:** + - Pre-fix: `make -C src -j2 test/test_digibyte && ./src/test/test_digibyte --run_test=digidollar_validation_tests/transfer_rejects_noncanonical_taproot_like_output --report_level=short` failed because the transfer was accepted. + - Post-fix: same target passed: 1 selected test, 4 assertions. + +**Additional tests/results:** + +```bash +./src/test/test_digibyte --run_test=digidollar_transfer_tests --report_level=short +# passed: 44 tests, 174 assertions + +./src/test/test_digibyte --run_test=digidollar_validation_tests --report_level=short +# passed: 103 tests + 2 warning-status tests, 276 assertions, exit 0 + +./src/test/test_digibyte --run_test=digidollar_rh12_script_attacks_tests --report_level=short +# passed: 12 tests, 25 assertions + +./src/test/test_digibyte --run_test=digidollar_rh49_find_opreturn_tests --report_level=short +# exit 200: no matching test cases or all disabled; source exists but this suite is not registered/enabled in the current test binary + +./src/test/test_digibyte --run_test=digidollar_script_attacks_tests --report_level=short +# failed independently of DD-RH-112/113/114: 4 failed tests, 1 abort, error-code expectations at lines 68/90, stack-size expectations at 180/181, and crash in attack_boundary_amounts at line 417 + +git diff --check -- src/digidollar/validation.cpp src/test/digidollar_validation_tests.cpp src/test/digidollar_transfer_tests.cpp +# passed +``` + +**Rejected false positives / downgraded items:** +- Multiple modern DD OP_RETURNs remain blocked for mint, transfer, and redemption. +- Transfer OP_RETURN amount-count spoofing is blocked: validation now enforces matched canonical DD output count and OP_RETURN amount count. +- Coinbase DD minting remains blocked by coinbase DD marker rejection and DD source extraction rejecting coinbase sources. +- Phase-one unsigned compact oracle price spoof remains not reachable post-DD activation under current mainnet/testnet/signet/regtest deployment parameters. +- Unknown/truncated `OP_ORACLE` fail-open is accepted transition behavior; normal tip DD mints still fail without a block oracle price. + +**Theoretical / not yet reachable / carry-forward risks:** +- Legacy DD amount parsing still uses signed left shift on attacker-controlled 8-byte legacy payloads at `src/digidollar/validation.cpp:137`, and oracle v0x02 timestamp parsing has the same UB shape in `src/oracle/bundle_manager.cpp:1318` and `src/oracle/bundle_manager.cpp:1384`. Count only after UBSan/fuzz reproducer; carry forward to Waves 17-19. +- Mint OP_RETURN trailing data after owner pubkey is not rejected at `src/digidollar/validation.cpp:983`. Add `mint_rejects_trailing_data_after_owner_pubkey`. +- Planned PQC v2 mint OP_RETURN shape can be positionally misread by current v1 parser; this is `ARCHITECTURAL_REVIEW_REQUIRED` until the v2 format/version gate is approved. +- Wallet restore/rescan assumes fixed mint output indexes despite consensus allowing reordered collateral/DD outputs: `src/wallet/digidollarwallet.cpp:2124`, `src/wallet/digidollarwallet.cpp:2226`, `src/wallet/digidollarwallet.cpp:2330`, `src/wallet/digidollarwallet.cpp:2355`, `src/wallet/digidollarwallet.cpp:3949`, and `src/wallet/digidollarwallet.cpp:4664`. This is reachable wallet-storage risk but needs wallet-storage design approval; carry to Wave 9. +- Wallet incoming DD scan can throw on malformed OP_RETURN and can credit the first of duplicate DD OP_RETURNs: `src/wallet/digidollarwallet.cpp:6994`, `src/wallet/digidollarwallet.cpp:7002`, `src/wallet/digidollarwallet.cpp:7007`. Carry to Waves 9-11. +- Restored send history and generic raw tx RPC display DD recipients/outputs as base-chain DGB addresses, not DD addresses: `src/wallet/digidollarwallet.cpp:2475`, `src/core_write.cpp:166`, `src/core_write.cpp:186`. Carry to Waves 11-12. +- Qt `WalletModel::validateDigiDollarAddress()` checks prefix/length/base58 but not checksum/network at `src/qt/walletmodel.cpp:1318`. Carry to Wave 12. +- Oracle bundle script extraction concatenates split pushes at `src/oracle/bundle_manager.cpp:1202`; add noncanonical split-push rejection tests in oracle waves. + +**Fixes landed:** DD-RH-112, DD-RH-113, and DD-RH-114 in working tree. + +**Commit status:** no commits; user has not authorized local commits. Proposed commit split: one commit for DD-RH-112 transfer OP_RETURN exception handling, one for DD-RH-113 modern OP_RETURN accounting preference, and one for DD-RH-114 canonical P2TR classification. Existing Wave 2-3 proposed splits remain separate. + +**Wave 5 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 6 — Activation and Consensus-Split Risks + +**Assignments:** +- Agent A / exploit-path attacker: validation, chainparams, deployment gates, and shared hooks. +- Agent B / invariant/test breaker: enabled/disabled boundary tests and mempool/block validation mismatch coverage. +- Agent C / boundary adversary: multi-node activation, reorg, RPC, Qt, wallet, and oracle P2P boundary behavior. + +**Wave attack-surface enumeration:** required local `find ... | grep ... | sort` command run successfully. Current post-build tree has 1669 matching paths because generated build artifacts are present. Review stayed within DigiDollar/oracle and directly gated RPC, P2P, validation, Qt, wallet, chainparams, and miner surfaces. + +### DD-RH-115 — High — post-activation mint validity depended on local IBD/catch-up state + +- **Affected invariant:** DD mint acceptance must be deterministic and cannot depend on a node-local sync flag. +- **Exploit path:** `ConnectBlock()` passed `fSkipOracle` into `DigiDollar::ValidationContext` based on `IsInitialBlockDownload()` / header catch-up state. `ValidateMintTransaction()` then skipped both zero-price rejection and collateral-ratio validation when `ctx.skipOracleValidation == true`. A post-activation block containing an undercollateralized mint could therefore be rejected by caught-up nodes and accepted by IBD/catch-up nodes. +- **Exact code references:** block context wiring at `src/validation.cpp:2993`; pre-fix mint price gate at `src/digidollar/validation.cpp:768`; pre-fix collateral skip at `src/digidollar/validation.cpp:1162`; fixed mandatory price gate at `src/digidollar/validation.cpp:768`; fixed mandatory collateral validation at `src/digidollar/validation.cpp:1164`. +- **Reproducer:** `src/test/digidollar_skip_oracle_tests.cpp:92` builds a validly structured mint for 10,000 cents of DD with only 1,000 satoshis of collateral. Pre-fix, the `skipOracleValidation=true` context returned success and no reject reason; the strict context rejected with `insufficient-collateral`. +- **Expected secure behavior:** the same mint must reject with `insufficient-collateral` regardless of local IBD/catch-up state. A zero oracle price must reject with `bad-oracle-price` in every sync state. +- **Fix summary:** mint oracle price and collateral calculations are now mandatory. `skipOracleValidation` no longer skips mint price/collateral consensus checks; it remains limited to the existing local protection checks that still need a broader deterministic design. +- **Tests added/upgraded:** `src/test/digidollar_skip_oracle_tests.cpp` now asserts that undercollateralized and zero-price mints reject even when `skipOracleValidation=true`. +- **Fail-before evidence:** `make -C src -j2 test/test_digibyte && ./src/test/test_digibyte --run_test=digidollar_skip_oracle_tests/skip_oracle_allows_insufficient_collateral_exploit --log_level=all --report_level=short` failed with exit 201, 1 selected test failed, 2/4 assertions failed; observed `skipOracle=true` returned `result: 1` and empty reject reason. +- **Pass-after evidence:** same selected test passed after the fix; full `digidollar_skip_oracle_tests` passed 6 tests / 18 assertions. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `digidollar validation: fix DD-RH-115 skip-oracle mint bypass`. + +### DD-RH-116 — Low — `getmockoracleprice` was callable before DigiDollar activation + +- **Affected invariant:** user/operator RPC surfaces that influence or expose DigiDollar/oracle state should follow the same pre-activation gate unless explicitly deployment-monitoring-only. +- **Exploit path:** `setmockoracleprice` and `enablemockoracle` checked DigiDollar activation before running, but `getmockoracleprice` only checked regtest. This let scripts observe mock oracle state while every other DigiDollar/oracle RPC in the gating test was blocked. +- **Exact code references:** pre-fix ungated handler at `src/rpc/digidollar.cpp:4627`; fixed activation check at `src/rpc/digidollar.cpp:4629`; functional test list updated at `test/functional/digidollar_rpc_gating.py:66`. +- **Reproducer:** adding `getmockoracleprice` to `digidollar_rpc_gating.py` made the pre-activation phase fail because the RPC returned successfully instead of raising "DigiDollar is not yet active". +- **Expected secure behavior:** `getmockoracleprice` rejects pre-activation like the paired mock-oracle mutation RPCs. +- **Fix summary:** added the same activation check used by `setmockoracleprice` and `enablemockoracle`. +- **Tests added/upgraded:** `test/functional/digidollar_rpc_gating.py` now verifies all 28 gated DD/oracle RPCs, including `getmockoracleprice`. +- **Fail-before evidence:** `test/functional/test_runner.py --jobs=1 digidollar_rpc_gating.py` failed at `getmockoracleprice should have been rejected pre-activation`. +- **Pass-after evidence:** `make -C src -j2 digibyted && test/functional/test_runner.py --jobs=1 digidollar_rpc_gating.py` passed; the activation trio also passed. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `rpc digidollar: fix DD-RH-116 mock oracle activation gate`. + +**ARCHITECTURAL_REVIEW_REQUIRED:** + +### DD-RH-117 — Medium — oracle P2P activation uses static height instead of DigiDollar BIP9 state + +- **Affected invariant:** oracle P2P discovery and price-message handling should not become reachable before the DigiDollar deployment predicate that consumes oracle data. +- **Exploit path:** `Consensus::IsOracleActive()` checks only `nOracleActivationHeight`, while DD validation/RPC/mining use the BIP9 `DEPLOYMENT_DIGIDOLLAR` predicate. Mainnet has `nOracleActivationHeight = 3000000` and `nDDActivationHeight = 22014720`; regtest overrides can also make DD active while oracle P2P remains inactive. P2P call sites include `VERACK` auto-`GETORACLES`, `ORACLEPRICE`, `ORACLEBUNDLE`, and `GETORACLES` handling. +- **Exact code references:** `src/consensus/params.h:244`, `src/kernel/chainparams.cpp:307`, `src/kernel/chainparams.cpp:309`, `src/net_processing.cpp:4031`, `src/net_processing.cpp:5440`, `src/net_processing.cpp:5607`, and `src/net_processing.cpp:6192`. +- **Proof status:** reachable P2P surface mismatch reported by all Wave 6 agents. Not counted as a direct consensus split because block oracle extraction and DD transaction validation remain BIP9-gated. +- **Why no fix landed:** changing P2P activation semantics is a protocol/operator behavior decision. Needs Jared approval on whether oracle P2P should be gated by DD BIP9 active, `DD active && oracle height`, or a documented separate oracle deployment. +- **Recommended decision:** align oracle P2P activation with DD BIP9 unless there is a deliberate reason to pre-warm oracle P2P. Then add a unit/functional boundary test for "oracle P2P not reachable before DD deployment active." + +**Rejected false positives / downgraded items:** +- Pre-activation DD txs remain rejected by both mempool and block validation (`digidollar-not-active`), and reorg-below-activation mempool filtering is already covered. +- Miner template inclusion of stale invalid DD transactions is guarded by DD pre-validation and `TestBlockValidity` retry logic. +- Oracle cache pre-activation poisoning was not reachable through block connection; extraction/cache updates are DD activation gated. +- `CheckPhase3OracleBundleVersion()` and `ValidateBlockOracleData()` null-`pindex_prev` static-height fallbacks were not shown reachable in production `ConnectBlock`, which passes `pindex->pprev`. + +**Theoretical / not yet reachable / carry-forward risks:** +- Qt DigiDollar activation status uses a height heuristic for visible text while actual enablement uses BIP9 state (`src/qt/digidollartab.cpp:401`, `src/qt/digidollartab.cpp:425`). Carry to Wave 12 for a Qt regression/fix because it is a user-deception surface, not a consensus split. +- `skipOracleValidation` still gates volatility/ERR local protection checks. DD-RH-115 removed the direct price/collateral mint split, but DD-RH-108 remains the architectural item for deterministic system-health/volatility sources. +- Malformed `OP_ORACLE` extraction can fail open as transition behavior when no DD transaction depends on the price (`src/oracle/bundle_manager.cpp:2525`). Carry to oracle waves. +- Phase-2 oracle bundle format remains accepted after Phase 3 height under current chainparams; no production exploit shown because `nDigiDollarPhase3Height` is currently `0`. +- `OracleNode::Start()` is testnet-only despite mainnet oracle parameters; operator-readiness risk only, no direct exploit in Wave 6. + +**Commands/results recorded:** + +```bash +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort | wc -l +# 1669 matching paths in the post-build tree + +make -C src -j2 test/test_digibyte && \ +./src/test/test_digibyte --run_test=digidollar_skip_oracle_tests/skip_oracle_allows_insufficient_collateral_exploit --log_level=all --report_level=short +# pre-fix DD-RH-115 failed: exit 201, 1 selected test failed, 2/4 assertions failed + +make -C src -j2 test/test_digibyte && \ +./src/test/test_digibyte --run_test=digidollar_skip_oracle_tests/skip_oracle_allows_insufficient_collateral_exploit --report_level=short +# post-fix passed: 1 selected test, 4 assertions + +./src/test/test_digibyte --run_test=digidollar_skip_oracle_tests --report_level=short +# passed: 6 tests, 18 assertions + +./src/test/test_digibyte --run_test=digidollar_validation_tests --report_level=short +# passed: 103 tests + 2 warning-status tests, 276 assertions + +./src/test/test_digibyte --run_test=digidollar_rh18_cross_feature_tests --report_level=short +# passed: 9 tests, 36 assertions + +./src/test/test_digibyte --run_test=digidollar_rh31_consensus_fork_tests --report_level=short +# passed: 24 tests, 33 assertions + +./src/test/test_digibyte --run_test=digidollar_rh47_consensus_fork_deep_tests --report_level=short +# passed: 30 tests, 93 assertions + +./src/test/test_digibyte --run_test=digidollar_consensus_tests --report_level=short +# passed: 13 tests, 125 assertions + +./src/test/test_digibyte --run_test=digidollar_activation_tests --report_level=short +# passed: 5 tests, 17 assertions + +./src/test/test_digibyte --run_test=musig2_activation_tests --report_level=short +# passed: 20 tests, 103 assertions + +./src/test/test_digibyte --run_test=digidollar_rh06_mint_attacks --report_level=short +# passed: 36 tests, 69 assertions + +test/functional/test_runner.py --jobs=1 digidollar_rpc_gating.py +# pre-fix DD-RH-116 failed at getmockoracleprice pre-activation gate + +make -C src -j2 digibyted && test/functional/test_runner.py --jobs=1 digidollar_rpc_gating.py +# post-fix passed + +test/functional/test_runner.py --jobs=1 digidollar_activation.py digidollar_activation_boundary.py digidollar_rpc_gating.py +# passed: all 3 functional tests + +git diff --check -- src/digidollar/validation.cpp src/test/digidollar_skip_oracle_tests.cpp src/rpc/digidollar.cpp test/functional/digidollar_rpc_gating.py reports/red_hornet_ledger.md +# passed +``` + +**Fixes landed:** DD-RH-115 and DD-RH-116 in working tree. + +**Commit status:** no commits; user has not authorized local commits. Proposed commit split: one commit for DD-RH-115 mint validation determinism, one commit for DD-RH-116 mock oracle RPC gating. Existing Wave 2-5 proposed splits remain separate. + +**Wave 6 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 7 — Reorg, Replay, Rollback, Cache Corruption + +**Assignments:** +- Agent A / exploit-path attacker: production connect/disconnect state mutation, rollback order, cached DD metrics, oracle price caches, block/chain reorg state, and `ConnectBlock`/`DisconnectBlock`. +- Agent B / invariant/test breaker: duplicate/reversed/missing connect/disconnect state updates, DD supply/collateral metrics, oracle cache rollback, reorg/reindex/restart/rescan, and mempool re-add tests. +- Agent C / boundary adversary: restart/reindex/rescan/reorg functional boundaries, wallet restore/rescan, RPC stats after restart, oracle cache persistence/rollback, multi-node DD reorgs, and mempool resurrection. + +**Wave attack-surface enumeration:** required local `find ... | grep ... | sort` command run successfully. Current post-build tree has 1669 matching paths because generated build artifacts are present. All three sub-agents confirmed they read the required context files in order and ran the live enumeration. Review stayed within DigiDollar/oracle and directly gated validation, wallet, index, RPC, and oracle-cache surfaces. + +### DD-RH-118 — Medium — cached DD supply can drift downward after valid reorg near `MAX_DIGIDOLLAR` + +- **Affected invariant:** reorg connect/disconnect cycles must exactly restore DD supply/collateral accounting for valid chain states. +- **Exploit path:** `SystemHealthMonitor::OnMintConnected()` capped `totalDDSupply` at `MAX_DIGIDOLLAR`, but `OnMintDisconnected()` subtracted the full mint amount. If valid aggregate supply was near `MAX_DIGIDOLLAR`, connecting a normal valid-size mint that crossed the cap recorded only the capped delta; disconnecting that block then subtracted the full mint and permanently undercounted cached supply. The same lossy cap existed on `OnRedeemDisconnected()`. +- **Exact code references:** pre-fix lossy cap was at `src/digidollar/health.cpp:452`; fixed helper and call sites are at `src/digidollar/health.cpp:449`, `src/digidollar/health.cpp:463`, and `src/digidollar/health.cpp:493`; attack regression is at `src/test/digidollar_rh16_reorg_attacks_tests.cpp:206`; overflow guard expectation was updated at `src/test/digidollar_rh11_consensus_tests.cpp:47`. +- **Reproducer:** `rh16_overflow_after_reorg_reconnect` sets cached supply to `MAX_DIGIDOLLAR - 50`, connects a normal `10000000`-cent mint, disconnects it, and expects supply to return to `MAX_DIGIDOLLAR - 50`. +- **Fail-before evidence:** after rebuilding, `./src/test/test_digibyte --run_test=digidollar_rh16_reorg_attacks_tests/rh16_overflow_after_reorg_reconnect --log_level=all --report_level=short` failed with `got 2099990000000 expected 2099999999950`. +- **Expected secure behavior:** cached supply must return to the exact pre-connect value. Arithmetic overflow protection must guard `CAmount` overflow, not silently apply a lossy business cap to reversible state transitions. +- **Fix summary:** replaced the `MAX_DIGIDOLLAR` supply cap in connect/disconnect add paths with a true `CAmount` overflow clamp. Valid aggregate accounting remains exact and reversible until `std::numeric_limits::max()` would overflow. +- **Tests added/upgraded:** upgraded `rh16_overflow_after_reorg_reconnect` to use a valid per-mint amount and assert exact reorg restoration; upgraded `rh11_supply_overflow_no_protection` to verify exact accounting until true `CAmount` overflow, then clamp. +- **Pass-after evidence:** selected DD-RH-118 regression passed; selected RH-11 overflow test passed; full `digidollar_rh16_reorg_attacks_tests`, `digidollar_rh11_consensus_tests`, `digidollar_rh34_multiblock_state_tests`, `digidollar_rh42_formal_invariant_tests`, and `digidollar_health_tests` passed. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `digidollar health: fix DD-RH-118 reorg supply cap drift`. + +**ARCHITECTURAL_REVIEW_REQUIRED:** + +### DD-RH-119 — High — `MAX_DIGIDOLLAR` is documented inconsistently as output bound vs global supply cap + +- **Affected invariant:** DD global supply policy must be deterministic and consistently enforced if it exists. +- **Observed conflict:** `src/digidollar/digidollar.h:18` defines `MAX_DIGIDOLLAR` as a maximum amount, production validation uses it as a per-output/per-structure bound at `src/digidollar/validation.cpp:467`, and mint consensus uses `maxMintAmount` as the per-mint cap at `src/consensus/digidollar.h:65`. `REPO_MAP_DIGIDOLLAR.md:27` calls the same constant a hard cap on total DD supply, but no consensus rule rejects a valid mint because aggregate supply would exceed it. +- **Why no consensus fix landed:** adding or removing a global DD supply cap is an economic/consensus decision. Wave 7 fixed the local cache corruption without choosing new issuance policy. +- **Recommended decision:** Jared should decide whether DigiDollar has a consensus-enforced aggregate supply cap. If yes, add a deterministic chain-state/index-derived consensus check and activation tests. If no, update docs and keep `MAX_DIGIDOLLAR` as an amount/output serialization bound only. + +**Rejected false positives / downgraded items:** +- Invalid-block oracle cache poisoning through `ConnectBlock` was rejected: block oracle cache mutation is deferred until after full block validation at `src/validation.cpp:3143`, and existing `rh67`/`rh61` coverage passed. +- Missed rollback for DD health updates on ordinary `ConnectBlock` failures was rejected: the `DigiDollarHealthUpdateGuard` reverses uncommitted mint/redeem updates before `Commit()` at `src/validation.cpp:2822`; `rh68` coverage passed. +- Redeem input-0 mismatch was rejected: current redemption validation treats input 0 as collateral and requires DD inputs after it, so disconnect's vault-coin assumption is not attacker-reachable without another confirmed validation bug. +- Transfer missing supply updates was rejected: DD transfers are supply-neutral and should not mutate supply/collateral metrics. +- Mempool resurrection of disconnected DD transactions was rejected as intentional reorg behavior: re-addition routes through normal mempool admission and DD validation rechecks deployment, UTXO, oracle, and unconfirmed-parent constraints. +- Wallet rescan/restart DD loss was rejected for the inspected paths: existing functional tests cover mint/redeem/transfer reorg, reindex, rescan, and persistence restart. + +**Theoretical / not yet reachable / carry-forward risks:** +- DD-RH-069 remains the critical architectural item: collateral can be unlocked by the normal Taproot timelock+owner leaf in a non-DD transaction after lock expiry, bypassing DD burn validation. This is a script/consensus redesign, so no fix was landed without Jared approval. +- DD-RH-108 remains active: DCA/ERR mint and redemption behavior still depends on process-local cached health/volatility in several paths. Wave 7 found the restart variant again, where a node with an empty cache can compute "max healthy" from zero cached supply at `src/digidollar/validation.cpp:2382`. Needs deterministic chain/index-derived health design. +- Overburned DD in redemption is a reachable accounting lead but not yet counted as a confirmed vulnerability in Wave 7. Validation permits `ddBurned > originalDDMinted` at `src/digidollar/validation.cpp:2001`, while in-memory and stats-index accounting subtract only the original vault amount at `src/validation.cpp:3031` and `src/index/digidollarstatsindex.cpp:276`. This appears conservative/liveness-skewing rather than inflationary, but needs a targeted regression that distinguishes actual DD UTXO supply from reported supply. +- Oracle startup reload only scans the last 20 blocks at `src/oracle/bundle_manager.cpp:2103` while price freshness is also wall-clock based at `src/oracle/bundle_manager.cpp:1523`. A restarted node may forget a still-time-fresh bundle that is more than 20 blocks behind tip. This appears fail-closed for minting; carry to oracle staleness waves. +- No combined multi-node functional test covers mint, transfer, redeem, branch split, longer competing chain, reconnect, wallet rescan, stats comparison, and mempool checks in one scenario. +- No direct stats-index restart test asserts `getdigidollarstats` immediately after node restart with `-digidollarstatsindex=1`. +- No deep oracle cache reorg test exceeds the current cache retention window. + +**Commands/results recorded:** + +```bash +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort | wc -l +# 1669 matching paths in the post-build tree + +make -C src -j2 test/test_digibyte && \ +./src/test/test_digibyte --run_test=digidollar_rh16_reorg_attacks_tests/rh16_overflow_after_reorg_reconnect --log_level=all --report_level=short +# pre-fix DD-RH-118 failed after rebuild: got 2099990000000 expected 2099999999950 + +make -C src -j2 test/test_digibyte && \ +./src/test/test_digibyte --run_test=digidollar_rh16_reorg_attacks_tests/rh16_overflow_after_reorg_reconnect --report_level=short && \ +./src/test/test_digibyte --run_test=digidollar_rh11_consensus_tests/rh11_supply_overflow_no_protection --report_level=short +# passed: both selected tests + +./src/test/test_digibyte --run_test=digidollar_rh16_reorg_attacks_tests --report_level=short +# passed: 11 tests, 43 assertions + +./src/test/test_digibyte --run_test=digidollar_rh11_consensus_tests --report_level=short +# passed: 15 tests, 538 assertions + +./src/test/test_digibyte --run_test=digidollar_rh34_multiblock_state_tests --report_level=short +# passed: 21 tests, 1358 assertions + +./src/test/test_digibyte --run_test=digidollar_rh42_formal_invariant_tests --report_level=short +# passed: 15 tests, 7897668 assertions + +./src/test/test_digibyte --run_test=digidollar_health_tests --report_level=short +# passed: 21 tests, 567 assertions + +./src/test/test_digibyte --run_test=digidollar_rh25_serialization_cache_tests --report_level=short +# passed: 6 tests, 26 assertions + +./src/test/test_digibyte --run_test=rh68_test_block_validity_health_metrics_side_effect_tests --report_level=short +# passed: 1 test, 13 assertions + +./src/test/test_digibyte --run_test=rh67_invalid_block_oracle_cache_side_effect_tests --report_level=short +# passed: 1 test, 6 assertions + +./src/test/test_digibyte --run_test=rh66_startup_oracle_price_loading_tests --report_level=short +# passed: 1 test, 14 assertions + +./src/test/test_digibyte --run_test=rh63_oracle_validator_escape_hatches_tests --report_level=short +# passed: 7 tests, 35 assertions + +./src/test/test_digibyte --run_test=rh61_coinbase_price_cache_poisoning_tests --report_level=short +# passed: 7 tests, 19 assertions + +test/functional/test_runner.py --jobs=1 digidollar_stats_reorg.py digidollar_oracle_reorg_cache.py wallet_digidollar_reorg.py wallet_digidollar_transfer_reorg.py wallet_digidollar_mint_reorg.py wallet_digidollar_pending_redeem_restart.py wallet_digidollar_reindex.py +# passed: all 7 functional tests + +test/functional/test_runner.py --jobs=1 digidollar_stats_reordered_mint.py wallet_digidollar_rescan.py wallet_digidollar_persistence_restart.py digidollar_network_tracking.py digidollar_redeem_stats.py digidollar_persistence.py +# passed: all 7 functional entries; wallet_digidollar_persistence_restart.py ran legacy-wallet and descriptors variants + +git diff --check -- src/digidollar/health.cpp src/test/digidollar_rh16_reorg_attacks_tests.cpp src/test/digidollar_rh11_consensus_tests.cpp reports/red_hornet_ledger.md +# passed + +git status --short --branch +# dirty working tree; DD-RH-118 and earlier Red Hornet fixes remain uncommitted, unrelated pre-existing/user edits were not reverted +``` + +**Fixes landed:** DD-RH-118 in working tree. + +**Commit status:** no commits; user has not authorized local commits. Proposed commit split: one commit for DD-RH-118 health monitor reversible accounting. Existing Wave 2-6 proposed splits remain separate. + +**Wave 7 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. + +## Continuation Wave 8 — Mempool Relay, Conflict, Replacement + +**Assignments:** +- Agent A / exploit-path attacker: DD mempool admission, RBF/full-RBF replacement, conflict removal, package accept, and oracle-mempool boundaries. +- Agent B / invariant/test breaker: mempool attack tests, replacement/package coverage, malformed conflict cases, and missing negative assertions. +- Agent C / boundary adversary: multi-node/reorg/restart mempool behavior, persisted mempool reload, wallet-visible pending DD state, and txindex/reorg replay. + +**Wave attack-surface enumeration:** required local `find ... | grep ... | sort` command run successfully. Current post-build tree has 1670 matching paths after adding the new functional regression. All three sub-agents confirmed they read the required context files in order and ran the live enumeration. Review stayed within DigiDollar/oracle and directly gated mempool, validation, wallet, RPC, and functional-test surfaces. + +### DD-RH-120 — Low — non-final DD transactions forced DD contextual validation before cheap finality rejection + +- **Affected invariant:** malformed or non-final DD/oracle inputs must not create practical validation DoS beyond intended mempool policy cost. +- **Exploit path:** `MemPoolAccept::PreChecks()` ran full DigiDollar contextual validation before `CheckFinalTxAtTip()`. A peer could send structurally DD-marked, non-final transactions and force DD parsing/oracle/block-db work, receiving a DD structural reject instead of the cheap `non-final` policy reject. +- **Exact code references:** finality now runs before DD contextual validation at `src/validation.cpp:817`; cheap DD marker/type gating remains at `src/validation.cpp:824`; full DD contextual validation now runs after input caching at `src/validation.cpp:947`. Regression is at `test/functional/digidollar_activation_boundary.py:176`. +- **Reproducer:** `digidollar_activation_boundary.py` constructs a future-locktime DD transfer with sequence `0xfffffffe` and calls `testmempoolaccept`. +- **Fail-before evidence:** `test/functional/test_runner.py --jobs=1 digidollar_activation_boundary.py` failed with `AssertionError: not(transfer-no-op-return-data == non-final)`. +- **Expected secure behavior:** non-final DD transactions are rejected as `non-final` before expensive DD validation. +- **Fix summary:** moved `CheckFinalTxAtTip()` immediately after `CheckTransaction()` and before DD contextual validation. +- **Tests added/upgraded:** `test/functional/digidollar_activation_boundary.py` now asserts the non-final DD reject reason. +- **Pass-after evidence:** activation boundary functional test passed; DD mempool attack/relay unit tests passed. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `validation: fix DD-RH-120 non-final DD mempool ordering`. + +### DD-RH-121 — Medium — DD transfer descendants can persist after multi-block reorg through stale txindex and missing final-tip DD revalidation + +- **Affected invariant:** reorgs, restarts, rescans, cache invalidation, wallet reloads, and mempool conflicts cannot corrupt DD supply/collateral/accounting state; DD transfer inputs must remain confirmed before their OP_RETURN amount is trusted. +- **Exploit path:** during `invalidateblock`/multi-block disconnect, `MaybeUpdateMempoolForReorg()` re-added the transfer block while the mint ancestor was still temporarily visible as a confirmed UTXO. After the mint block disconnected, the mint was re-added to mempool but the transfer stayed accepted as if it spent a confirmed DD input. Because DD amount extraction can also read stale historical txindex data, the transfer survived with an unconfirmed DD parent and was visible in wallet/mempool state. +- **Exact code references:** reorg re-add loop at `src/validation.cpp:358`; final-tip DD revalidation added at `src/validation.cpp:441`; DD revalidation uses a mempool-aware coin view at `src/validation.cpp:442`; failing transfer rejection now occurs through `ValidateDigiDollarTransaction()` at `src/validation.cpp:471`; ordinary ATMP DD validation also now uses the mempool-aware cached view at `src/validation.cpp:947`. Attack regression is `test/functional/wallet_digidollar_transfer_ancestor_reorg.py:6`. +- **Reproducer:** with `-txindex=1 -persistmempool=1`, mine a DD mint at the low regtest fallback oracle price, mine a DD transfer spending that mint, then invalidate the mint ancestor block. Before the fix, both the mint and transfer returned to mempool even though the transfer's DD input was now from an unconfirmed mint. +- **Fail-before evidence:** `test/functional/test_runner.py --jobs=1 wallet_digidollar_transfer_ancestor_reorg.py` failed with `AssertionError: DD transfer descendant must not return to mempool while its DD input comes from an unconfirmed mint`. +- **Expected secure behavior:** after the final reorg tip is known, any DD mempool entry resurrected from disconnected blocks must still validate against the final chain plus mempool view. A transfer whose DD input is only available from a mempool parent must be removed and must not survive restart through `mempool.dat`. +- **Fix summary:** kept cheap DD type/activation rejects early, moved ordinary DD mempool validation to use the mempool-aware cached coin view after input loading, and added DD-specific final-tip revalidation in `MaybeUpdateMempoolForReorg()` before `removeForReorg()` finishes. +- **Tests added/upgraded:** added `test/functional/wallet_digidollar_transfer_ancestor_reorg.py` and registered it in `test/functional/test_runner.py`. +- **Pass-after evidence:** the new ancestor-reorg regression passed; surrounding transfer/reorg/restart functional tests passed; DD mempool unit suites passed. +- **Status:** fixed in working tree, uncommitted. Proposed commit: `validation: fix DD-RH-121 reorg DD descendant resurrection`. + +**Rejected false positives / downgraded items:** +- DD tx replacement by a higher-fee non-DD transaction is owner-authorized cancellation/confusion, not theft or inflation: the replacement must spend the same inputs and pass generic RBF/full-RBF policy. No fix landed. +- Invalid replacement cannot evict a valid DD tx because conflicts are removed only after replacement prechecks, policy checks, scripts, and finalization succeed. +- Wallet-built DD mint/transfer transactions do not opt into BIP125 by default; redemption uses `0xfffffffe` for CLTV and does not signal BIP125 opt-in. The redemption sequence comment is misleading, not itself a funds-loss bug. +- Package accept did not show a DD bypass in this wave: package-created coins are tagged `MEMPOOL_HEIGHT`, and DD transfer/redeem validation rejects unconfirmed zero-value DD inputs when the mempool view is visible. +- Oracle P2P pending objects do not interact directly with tx mempool admission; they remain in scope for oracle/P2P waves. + +**Theoretical / not yet reachable / carry-forward risks:** +- No end-to-end DD RBF/replacement functional test proves DD-to-non-DD and non-DD-to-DD replacement behavior under both opt-in and full-RBF. Coverage gap, not a confirmed vulnerability. +- No DD-specific package submission negative test proves an unconfirmed DD parent/child package remains rejected through `submitpackage`/package evaluation. +- DD redemption with an unconfirmed collateral parent should receive a targeted reorg regression in Wave 9 or Wave 4 follow-up. DD-RH-121 fixed final-tip DD revalidation broadly, but the current regression proves the transfer path only. +- Existing full `digidollar_redteam_tests` still has an unrelated stale failure in `redteam_nums_key_legitimate_collateral_accepted` (`insufficient-collateral`). Not counted as a Wave 8 regression failure because targeted DD mempool suites passed. + +**Commands/results recorded:** + +```bash +find src/digidollar src/oracle src/wallet src/rpc src/qt src/test src/test/fuzz test/functional -type f \ + | grep -Ei 'digidollar|oracle|musig2|rh|red|attack|security|wallet|qt' \ + | sort | wc -l +# 1670 matching paths after adding wallet_digidollar_transfer_ancestor_reorg.py + +./src/test/test_digibyte --run_test=digidollar_rh17_mempool_attacks_tests --report_level=short +# passed: 10 tests, 34 assertions + +./src/test/test_digibyte --run_test=digidollar_rh33_mempool_relay_tests --report_level=short +# passed: 12 tests, 318 assertions + +./src/test/test_digibyte --run_test=digidollar_transfer_tests --report_level=short +# passed: 44 tests, 174 assertions + +./src/test/test_digibyte --run_test=digidollar_txbuilder_tests --report_level=short +# passed: 18 tests, 63 assertions + +test/functional/test_runner.py --jobs=1 wallet_digidollar_transfer_ancestor_reorg.py +# pre-fix DD-RH-121 failed: transfer descendant returned to mempool with unconfirmed DD parent + +make -C src -j2 digibyted test/test_digibyte +# passed; pre-existing redundant CheckMinimalPush declaration warning remained + +test/functional/test_runner.py --jobs=1 wallet_digidollar_transfer_ancestor_reorg.py +# post-fix passed + +test/functional/test_runner.py --jobs=1 digidollar_activation_boundary.py +# pre-fix DD-RH-120 failed with transfer-no-op-return-data instead of non-final +# post-fix passed + +test/functional/test_runner.py --jobs=1 wallet_digidollar_transfer_reorg.py wallet_digidollar_pending_redeem_restart.py digidollar_stats_reorg.py +# passed: all 3 functional tests + +test/functional/test_runner.py --jobs=1 -t /tmp/dgb_redhornet_networkrelay_ digidollar_network_relay.py +# passed: 68 seconds + +./src/test/test_digibyte --run_test=digidollar_validation_tests --report_level=short +# passed: 103 tests + 2 warning-status tests, 276 assertions + +test/functional/test_runner.py --jobs=1 digidollar_network_relay.py +# infrastructure-only failure: parallel test_runner invocation reused the same timestamped tmpdir and raised FileExistsError; rerun with -t passed +``` + +**Fixes landed:** DD-RH-120 and DD-RH-121 in working tree. + +**Commit status:** no commits; user has not authorized local commits. Proposed commit split: one commit for DD-RH-120 mempool ordering, one commit for DD-RH-121 reorg descendant resurrection. Existing Wave 2-7 proposed splits remain separate. + +**Wave 8 status:** complete. Ledger updated at `reports/red_hornet_ledger.md`. diff --git a/src/Makefile.test.include b/src/Makefile.test.include index 5523fe486c..0894b72221 100644 --- a/src/Makefile.test.include +++ b/src/Makefile.test.include @@ -75,6 +75,7 @@ DIGIBYTE_TESTS =\ test/base64_tests.cpp \ test/digidollar_activation_tests.cpp \ test/digidollar_address_tests.cpp \ + test/digidollar_burn_enforcement_tests.cpp \ test/digidollar_consensus_tests.cpp \ test/digidollar_dca_tests.cpp \ test/digidollar_err_attack_tests.cpp \ @@ -82,11 +83,14 @@ DIGIBYTE_TESTS =\ test/digidollar_rh33_mempool_relay_tests.cpp \ test/digidollar_rh34_multiblock_state_tests.cpp \ test/digidollar_err_tests.cpp \ + test/digidollar_health_dca_tests.cpp \ test/digidollar_health_tests.cpp \ test/digidollar_t2_05_tests.cpp \ test/digidollar_opcodes_tests.cpp \ test/digidollar_script_attacks_tests.cpp \ test/digidollar_oracle_tests.cpp \ + test/digidollar_oracle_musig2_tests.cpp \ + test/digidollar_oracle_roster_tests.cpp \ test/digidollar_hot_path_logging_tests.cpp \ test/rh50_oracle_keyset_alignment_tests.cpp \ test/rh51_checkphase3_v1_split_tests.cpp \ @@ -191,6 +195,7 @@ DIGIBYTE_TESTS =\ test/digidollar_restore_tests.cpp \ test/digidollar_timelock_tests.cpp \ test/digidollar_lock_height_tests.cpp \ + test/digidollar_locktier_tests.cpp \ test/bech32_tests.cpp \ test/bip32_tests.cpp \ test/blockchain_tests.cpp \ @@ -306,7 +311,9 @@ DIGIBYTE_TESTS += \ wallet/test/group_outputs_tests.cpp \ wallet/test/digidollar_persistence_wallet_tests.cpp \ wallet/test/digidollar_wallet_security_tests.cpp \ - wallet/test/rh59_coincontrol_dd_lock_bypass_tests.cpp + wallet/test/rh59_coincontrol_dd_lock_bypass_tests.cpp \ + test/digidollar_rpc_unit_tests.cpp \ + test/digidollar_wallet_hd_tests.cpp FUZZ_SUITE_LD_COMMON +=\ $(SQLITE_LIBS) \ diff --git a/src/digidollar/health.cpp b/src/digidollar/health.cpp index 917ee31e2f..359bfee281 100644 --- a/src/digidollar/health.cpp +++ b/src/digidollar/health.cpp @@ -446,24 +446,22 @@ void SystemHealthMonitor::ScanUTXOSet(CCoinsView* view, CCoinsView* validation_v // Called from ConnectBlock/DisconnectBlock under cs_main. // ============================================================================ +static void AddClampedAmount(CAmount& total, CAmount amount, const char* label) +{ + if (amount <= 0) return; + if (total <= std::numeric_limits::max() - amount) { + total += amount; + return; + } + LogPrintf("Health: WARNING - %s would overflow, capping\n", label); + total = std::numeric_limits::max(); +} + void SystemHealthMonitor::OnMintConnected(CAmount ddAmount, CAmount dgbCollateral) { std::lock_guard lock(s_metricsMutex); // RH-44: thread safety - // SECURITY [RH-11]: Prevent supply overflow — cap at MAX_DIGIDOLLAR - if (ddAmount > 0 && s_currentMetrics.totalDDSupply <= MAX_DIGIDOLLAR - ddAmount) { - s_currentMetrics.totalDDSupply += ddAmount; - } else if (ddAmount > 0) { - LogPrintf("Health: WARNING - totalDDSupply would exceed MAX_DIGIDOLLAR, capping at %s\n", - FormatMoney(MAX_DIGIDOLLAR)); - s_currentMetrics.totalDDSupply = MAX_DIGIDOLLAR; - } - // Cap collateral at MAX_MONEY to prevent int64_t overflow - if (dgbCollateral > 0 && s_currentMetrics.totalCollateral <= std::numeric_limits::max() - dgbCollateral) { - s_currentMetrics.totalCollateral += dgbCollateral; - } else if (dgbCollateral > 0) { - LogPrintf("Health: WARNING - totalCollateral would overflow, capping\n"); - s_currentMetrics.totalCollateral = std::numeric_limits::max(); - } + AddClampedAmount(s_currentMetrics.totalDDSupply, ddAmount, "totalDDSupply"); + AddClampedAmount(s_currentMetrics.totalCollateral, dgbCollateral, "totalCollateral"); LogPrint(BCLog::DIGIDOLLAR, "Health: Mint connected - DD +%s, Collateral +%s (totals: DD=%s, Collateral=%s)\n", FormatMoney(ddAmount), FormatMoney(dgbCollateral), FormatMoney(s_currentMetrics.totalDDSupply), FormatMoney(s_currentMetrics.totalCollateral)); @@ -492,17 +490,8 @@ void SystemHealthMonitor::OnMintDisconnected(CAmount ddAmount, CAmount dgbCollat void SystemHealthMonitor::OnRedeemDisconnected(CAmount ddAmount, CAmount dgbCollateral) { std::lock_guard lock(s_metricsMutex); // RH-44: thread safety - // SECURITY [RH-11]: Same overflow protection as OnMintConnected - if (ddAmount > 0 && s_currentMetrics.totalDDSupply <= MAX_DIGIDOLLAR - ddAmount) { - s_currentMetrics.totalDDSupply += ddAmount; - } else if (ddAmount > 0) { - s_currentMetrics.totalDDSupply = MAX_DIGIDOLLAR; - } - if (dgbCollateral > 0 && s_currentMetrics.totalCollateral <= std::numeric_limits::max() - dgbCollateral) { - s_currentMetrics.totalCollateral += dgbCollateral; - } else if (dgbCollateral > 0) { - s_currentMetrics.totalCollateral = std::numeric_limits::max(); - } + AddClampedAmount(s_currentMetrics.totalDDSupply, ddAmount, "totalDDSupply"); + AddClampedAmount(s_currentMetrics.totalCollateral, dgbCollateral, "totalCollateral"); LogPrint(BCLog::DIGIDOLLAR, "Health: Redeem disconnected - DD +%s, Collateral +%s (totals: DD=%s, Collateral=%s)\n", FormatMoney(ddAmount), FormatMoney(dgbCollateral), FormatMoney(s_currentMetrics.totalDDSupply), FormatMoney(s_currentMetrics.totalCollateral)); diff --git a/src/digidollar/txbuilder.cpp b/src/digidollar/txbuilder.cpp index 9dc283bcda..4ba8105808 100644 --- a/src/digidollar/txbuilder.cpp +++ b/src/digidollar/txbuilder.cpp @@ -457,11 +457,16 @@ CAmount TransferTxBuilder::GetDDFromUTXO(const COutPoint& outpoint) const { return 5000; // Default: $50.00 in cents for testing } -bool TransferTxBuilder::ValidateTransferParams(const TxBuilderTransferParams& params) const { +bool TransferTxBuilder::ValidateTransferParams(const TxBuilderTransferParams& params, std::string* reason) const { + auto fail = [&](const std::string& msg) { + LogPrintf("DigiDollar: ValidateTransferParams FAILED - %s\n", msg); + if (reason) *reason = msg; + return false; + }; + // Must have recipients if (params.recipients.empty()) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - No recipients\n"); - return false; + return fail("No recipients specified"); } // Validate all recipient addresses and amounts @@ -471,25 +476,26 @@ bool TransferTxBuilder::ValidateTransferParams(const TxBuilderTransferParams& pa for (const auto& [address, amount] : params.recipients) { // Validate address format if (!ValidateDDAddress(address)) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - Invalid address: %s\n", address); - return false; + return fail(strprintf("Invalid DD address: %s", address)); } // Validate amount ranges if (amount <= 0) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - Non-positive amount: %d\n", amount); - return false; // No zero or negative amounts + return fail(strprintf("Recipient amount must be positive (got %lld cents)", + static_cast(amount))); } if (amount < minOutput) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - Below dust threshold: %d < %d\n", amount, minOutput); - return false; // Below dust threshold + return fail(strprintf("Recipient amount %lld cents is below DD minimum output (%lld cents / $%.2f)", + static_cast(amount), + static_cast(minOutput), + static_cast(minOutput) / 100.0)); } // Check maximum single transfer limit ($100,000) if (amount > 10000000) { // $100,000.00 in cents - LogPrintf("DigiDollar: ValidateTransferParams FAILED - Exceeds max transfer: %d > 10000000\n", amount); - return false; + return fail(strprintf("Recipient amount %lld cents exceeds maximum single-transfer limit ($100,000 / 10000000 cents)", + static_cast(amount))); } totalOutput += amount; @@ -497,20 +503,18 @@ bool TransferTxBuilder::ValidateTransferParams(const TxBuilderTransferParams& pa // Must have DD inputs if (params.ddUtxos.empty()) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - No DD UTXOs\n"); - return false; + return fail("No DD UTXOs provided to fund the transfer"); } // Validate key if (!params.spenderKey.IsValid()) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - Invalid spender key\n"); - return false; + return fail("Spender key is missing or invalid"); } // Validate fee rate if (!ValidateFeeRate(params.feeRate)) { - LogPrintf("DigiDollar: ValidateTransferParams FAILED - Invalid fee rate: %d\n", params.feeRate); - return false; + return fail(strprintf("Invalid DGB fee rate: %lld sat/kB", + static_cast(params.feeRate))); } LogPrintf("DigiDollar: ValidateTransferParams PASSED\n"); @@ -588,10 +592,13 @@ bool TransferTxBuilder::SelectDDInputs(const std::vector& available, CAm TxBuilderResult TransferTxBuilder::BuildTransferTransaction(const TxBuilderTransferParams& params) { TxBuilderResult result; - // Validate parameters - if (!ValidateTransferParams(params)) { - result.error = "Invalid transfer parameters"; - return result; + // Validate parameters; capture specific reason instead of generic catch-all. + { + std::string reason; + if (!ValidateTransferParams(params, &reason)) { + result.error = reason.empty() ? "Invalid transfer parameters" : reason; + return result; + } } // Calculate totals and check DD conservation @@ -719,6 +726,17 @@ TxBuilderResult TransferTxBuilder::BuildTransferTransaction(const TxBuilderTrans LogPrintf("DigiDollar: Fee calculation - calculated: %d sats, minimum: %d sats, actual: %d sats\n", calculatedFee, MIN_DD_FEE, actualFee); + if (totalFeeIn <= 0) { + result.error = "Insufficient DGB fee input: no fee inputs selected"; + return result; + } + + if (totalFeeIn < actualFee) { + result.error = strprintf("Insufficient DGB fee input: selected=%d sats, required=%d sats", + totalFeeIn, actualFee); + return result; + } + // Add DGB change output if needed (after we know actual fee) if (totalFeeIn > 0) { CAmount dgbChange = totalFeeIn - actualFee; diff --git a/src/digidollar/txbuilder.h b/src/digidollar/txbuilder.h index 07b599197e..ece74a65da 100644 --- a/src/digidollar/txbuilder.h +++ b/src/digidollar/txbuilder.h @@ -190,7 +190,8 @@ class TransferTxBuilder : public TxBuilder { public: using TxBuilder::TxBuilder; TxBuilderResult BuildTransferTransaction(const TxBuilderTransferParams& params); - bool ValidateTransferParams(const TxBuilderTransferParams& params) const; + // If reason is non-null and validation fails, populates with a specific error message. + bool ValidateTransferParams(const TxBuilderTransferParams& params, std::string* reason = nullptr) const; CAmount CalculateTotalDDInput(const std::vector& inputs, const std::vector& amounts) const; CScript CreateDDTransferScript(const CPubKey& recipient, CAmount amount) const; diff --git a/src/digidollar/validation.cpp b/src/digidollar/validation.cpp index 3398008fe6..fe2298e1f8 100644 --- a/src/digidollar/validation.cpp +++ b/src/digidollar/validation.cpp @@ -57,13 +57,22 @@ struct ValidationCache { // Global validation cache instance static ValidationCache g_validationCache; +static bool IsCanonicalP2TROutput(const CScript& script) +{ + int witnessVersion = -1; + std::vector witnessProgram; + return script.IsWitnessProgram(witnessVersion, witnessProgram) && + witnessVersion == 1 && + witnessProgram.size() == WITNESS_V1_TAPROOT_SIZE; +} + // ============================================================================ // Script Analysis Functions // ============================================================================ ScriptType IdentifyScriptType(const CScript& script) { // Quick rejection for obviously non-P2TR scripts - if (script.size() != 34 || script[0] != OP_1) { + if (!IsCanonicalP2TROutput(script)) { return ScriptType::NOT_DIGIDOLLAR; } @@ -167,6 +176,8 @@ bool ExtractDDAmount(const CScript& script, CAmount& amount) { } int FindDDOpReturn(const CTransaction& tx) { + int legacyIdx = -1; + for (size_t i = 0; i < tx.vout.size(); i++) { const CScript& script = tx.vout[i].scriptPubKey; if (script.size() < 2) continue; @@ -174,7 +185,10 @@ int FindDDOpReturn(const CTransaction& tx) { // Format 1: OP_RETURN OP_DIGIDOLLAR ... if (script.size() >= 2 && script[1] == OP_DIGIDOLLAR) { - return static_cast(i); + if (legacyIdx < 0) { + legacyIdx = static_cast(i); + } + continue; } // Format 2: OP_RETURN ... @@ -190,7 +204,8 @@ int FindDDOpReturn(const CTransaction& tx) { return static_cast(i); } } - return -1; + + return legacyIdx; } /** @@ -326,8 +341,8 @@ static bool ExtractDDAmountFromTxRef(const CTransactionRef& prev_tx, const COutP if (txout.scriptPubKey.size() > 0 && txout.scriptPubKey[0] == OP_RETURN) continue; if (txout.nValue != 0) continue; - // Check if it's a P2TR output (OP_1 + 32 bytes = DD output) - if (txout.scriptPubKey.size() == 34 && txout.scriptPubKey[0] == OP_1) { + // Check if it's a canonical P2TR output (OP_1 OP_PUSHBYTES_32 ) + if (IsCanonicalP2TROutput(txout.scriptPubKey)) { if (n == prevout.n) { // Found the matching output if (dd_output_idx < dd_amounts.size()) { @@ -410,7 +425,7 @@ bool ExtractMintAccountingAmounts(const CTransaction& tx, continue; } - if (out.scriptPubKey.size() == 34 && out.scriptPubKey[0] == OP_1) { + if (IsCanonicalP2TROutput(out.scriptPubKey)) { collateralOutputs++; collateralAmount = out.nValue; } @@ -750,8 +765,10 @@ bool ValidateMintTransaction(const CTransaction& tx, return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-mint-outputs"); } - // 2. Oracle price validation (skip for historical blocks) - if (!ctx.skipOracleValidation && ctx.oraclePriceMicroUSD <= 0) { + // 2. Oracle price validation. This is consensus-critical for every mint: + // local sync state must not allow IBD/catch-up nodes to accept mints that + // caught-up nodes reject for missing deterministic oracle data. + if (ctx.oraclePriceMicroUSD <= 0) { LogPrintf("DigiDollar: Invalid oracle price: %d\n", ctx.oraclePriceMicroUSD); return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-oracle-price"); } @@ -787,7 +804,7 @@ bool ValidateMintTransaction(const CTransaction& tx, // Phase 1 workaround: Identify outputs by structure since metadata doesn't cross nodes // P2TR outputs: collateral has value > 0, DD token has value = 0 - bool isP2TR = (output.scriptPubKey.size() == 34 && output.scriptPubKey[0] == OP_1); + bool isP2TR = IsCanonicalP2TROutput(output.scriptPubKey); bool isOpReturn = (output.scriptPubKey.size() > 0 && output.scriptPubKey[0] == OP_RETURN); // Check script type using metadata @@ -1144,42 +1161,42 @@ bool ValidateMintTransaction(const CTransaction& tx, return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-dd-mint-amount"); } - // 7. Calculate and verify collateral (skip for historical blocks - oracle price dependent) + // 7. Calculate and verify collateral. This remains mandatory even when + // skipOracleValidation is set during IBD/catch-up; otherwise block validity + // depends on a node-local sync flag. CAmount requiredCollateral = 0; - if (!ctx.skipOracleValidation) { - // SECURITY [T2-01]: Convert absolute lock HEIGHT to relative lock PERIOD for - // collateral ratio calculation. The OP_RETURN stores an absolute lockHeight - // (currentHeight + lockPeriod), but GetCollateralRatioForLockTime expects a - // relative lock period in blocks. Without this conversion, on mainnet (height ~22M) - // the absolute height exceeds ALL tier thresholds (max is 10yr = 21M blocks), - // causing every lock tier to use the 200% (10-year) ratio instead of its correct - // higher ratio. A 1-hour lock would require only 200% instead of 1000% collateral. - int64_t lockPeriod = lockTime - ctx.nHeight; - if (lockPeriod <= 0) { - LogPrintf("DigiDollar: Invalid lock period: lockTime=%lld, height=%d, period=%lld\n", - (long long)lockTime, ctx.nHeight, (long long)lockPeriod); - return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-lock-period"); - } - - requiredCollateral = CalculateRequiredCollateral(totalDD, lockPeriod, ctx); - if (requiredCollateral <= 0) { - LogPrintf("DigiDollar: Failed to calculate required collateral\n"); - return state.Invalid(TxValidationResult::TX_CONSENSUS, "collateral-calculation-failed"); - } + // SECURITY [T2-01]: Convert absolute lock HEIGHT to relative lock PERIOD for + // collateral ratio calculation. The OP_RETURN stores an absolute lockHeight + // (currentHeight + lockPeriod), but GetCollateralRatioForLockTime expects a + // relative lock period in blocks. Without this conversion, on mainnet (height ~22M) + // the absolute height exceeds ALL tier thresholds (max is 10yr = 21M blocks), + // causing every lock tier to use the 200% (10-year) ratio instead of its correct + // higher ratio. A 1-hour lock would require only 200% instead of 1000% collateral. + int64_t lockPeriod = lockTime - ctx.nHeight; + if (lockPeriod <= 0) { + LogPrintf("DigiDollar: Invalid lock period: lockTime=%lld, height=%d, period=%lld\n", + (long long)lockTime, ctx.nHeight, (long long)lockPeriod); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-lock-period"); + } + + requiredCollateral = CalculateRequiredCollateral(totalDD, lockPeriod, ctx); + if (requiredCollateral <= 0) { + LogPrintf("DigiDollar: Failed to calculate required collateral\n"); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "collateral-calculation-failed"); + } - // Verify sufficient collateral - if (totalCollateral < requiredCollateral) { - LogPrintf("DigiDollar: Insufficient collateral: provided %lld, required %lld (totalDD=%lld, lockPeriod=%lld)\n", - (long long)totalCollateral, (long long)requiredCollateral, - (long long)totalDD, (long long)lockPeriod); - return state.Invalid(TxValidationResult::TX_CONSENSUS, "insufficient-collateral"); - } + // Verify sufficient collateral + if (totalCollateral < requiredCollateral) { + LogPrintf("DigiDollar: Insufficient collateral: provided %lld, required %lld (totalDD=%lld, lockPeriod=%lld)\n", + (long long)totalCollateral, (long long)requiredCollateral, + (long long)totalDD, (long long)lockPeriod); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "insufficient-collateral"); + } - // 8. Additional validation checks - if (!ValidateCollateralRatio(totalCollateral, totalDD, lockPeriod, ctx)) { - LogPrintf("DigiDollar: Collateral ratio validation failed\n"); - return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-collateral-ratio"); - } + // 8. Additional validation checks + if (!ValidateCollateralRatio(totalCollateral, totalDD, lockPeriod, ctx)) { + LogPrintf("DigiDollar: Collateral ratio validation failed\n"); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-collateral-ratio"); } // 9. Log successful validation @@ -1244,8 +1261,15 @@ bool ValidateTransferTransaction(const CTransaction& tx, if (!output.scriptPubKey.GetOp(pc, opcode, data)) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "transfer-malformed-op-return"); } - CScriptNum txType(data, true); - if (txType.getint() != 2) { + int64_t txType = 0; + try { + CScriptNum txTypeNum(data, true); + txType = txTypeNum.GetInt64(); + } catch (const scriptnum_error&) { + return state.Invalid(TxValidationResult::TX_CONSENSUS, "transfer-malformed-op-return", + "Malformed transfer OP_RETURN transaction type"); + } + if (txType != static_cast(DD_TX_TRANSFER)) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "transfer-opreturn-type-mismatch", "Transfer OP_RETURN type must match transaction version"); } @@ -1253,9 +1277,14 @@ bool ValidateTransferTransaction(const CTransaction& tx, // Extract DD amounts while (output.scriptPubKey.GetOp(pc, opcode, data)) { if (data.size() > 0) { - // Allow up to 8 bytes for DD amounts (int64_t range) - CScriptNum amount(data, true, 8); - dd_amounts.push_back(amount.GetInt64()); + try { + // Allow up to 8 bytes for DD amounts (int64_t range) + CScriptNum amount(data, true, 8); + dd_amounts.push_back(amount.GetInt64()); + } catch (const scriptnum_error&) { + return state.Invalid(TxValidationResult::TX_CONSENSUS, "transfer-malformed-op-return", + "Malformed transfer OP_RETURN DD amount"); + } } } } @@ -1272,8 +1301,15 @@ bool ValidateTransferTransaction(const CTransaction& tx, if (output.scriptPubKey.size() > 0 && output.scriptPubKey[0] == OP_RETURN) continue; if (output.nValue != 0) continue; - // Check if it's a P2TR output (OP_1 + 32 bytes) - if (output.scriptPubKey.size() == 34 && output.scriptPubKey[0] == OP_1) { + // Reject 34-byte OP_1 impostors instead of treating them as DD outputs. + if (output.scriptPubKey.size() == 34 && output.scriptPubKey[0] == OP_1 && + !IsCanonicalP2TROutput(output.scriptPubKey)) { + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-dd-script", + "DD output must be canonical P2TR"); + } + + // Check if it's a canonical P2TR output (OP_1 OP_PUSHBYTES_32 ) + if (IsCanonicalP2TROutput(output.scriptPubKey)) { if (dd_amount_index >= dd_amounts.size()) { return state.Invalid(TxValidationResult::TX_CONSENSUS, "transfer-dd-output-amount-mismatch"); } @@ -1373,6 +1409,14 @@ bool ValidateTransferTransaction(const CTransaction& tx, if (found) { inputDD += ddAmt; ddInputCount++; + } else if (ctx.coins) { + Coin coin; + if (ctx.coins->GetCoin(txin.prevout, coin) && coin.out.nValue == 0) { + LogPrint(BCLog::DIGIDOLLAR, "DigiDollar: REJECT - Could not determine DD amount for zero-value input %s:%u\n", + txin.prevout.hash.ToString(), txin.prevout.n); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "dd-input-amounts-unknown", + "Cannot verify DD conservation: input DD amount undetermined"); + } } } @@ -1622,9 +1666,9 @@ bool ValidateRedemptionTransaction(const CTransaction& tx, if (output.scriptPubKey.size() > 0 && output.scriptPubKey[0] == OP_RETURN) { continue; } - // Check if it's a P2TR DD output (nValue=0, starts with OP_1) + // Check if it's a canonical P2TR DD output. // For DD outputs, use the amount from OP_RETURN metadata if available - if (output.scriptPubKey.size() > 1 && output.scriptPubKey[0] == OP_1) { + if (IsCanonicalP2TROutput(output.scriptPubKey)) { if (foundOpReturn && ddAmountFromOpReturn > 0) { // Use the authoritative amount from OP_RETURN totalDDOutputs += ddAmountFromOpReturn; @@ -1837,106 +1881,112 @@ bool ValidateCollateralReleaseAmount(const CTransaction& tx, return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-collateral-release-zero-collateral"); } - // Extract original DD amount — first try metadata registry, then creating tx lookup. + // Extract original DD amount from the creating mint transaction before trusting metadata. // // SECURITY [T1-08]: The collateral UTXO is a P2TR script (OP_1 + 32 bytes) which does // NOT contain the DD amount. During cross-node block validation, the ephemeral metadata // registry is empty. Without this fix, the function silently allowed ANY release amount, // enabling an attacker to burn 1 cent of DD and steal all locked collateral. // - // Strategy: 1) metadata registry, 2) txindex, 3) block-db lookup, 4) REJECT + // SECURITY [DD-RH-106]: Metadata is mutable process-local state keyed only by script. + // A rejected mint with the same owner/lock script but a smaller DD amount can overwrite + // that metadata. Prefer the authoritative creating mint transaction whenever available. + // + // Strategy: 1) txindex, 2) block-db lookup, 3) metadata registry fallback, 4) REJECT. CAmount originalDDMinted = 0; - if (!ExtractDDAmount(collateralCoin.out.scriptPubKey, originalDDMinted) || originalDDMinted <= 0) { - LogPrint(BCLog::DIGIDOLLAR, "DigiDollar: Could not extract original DD amount from collateral script, " - "trying creating transaction lookup...\n"); - - // Helper: extract DD minted amount from a mint transaction's OP_RETURN - auto extractDDFromMintTx = [](const CTransactionRef& prev_tx, CAmount& ddOut) -> bool { - // SECURITY [T5-04]: Verify source tx is actually a DD transaction. - // Without this check, a regular (non-DD) tx with a crafted DD OP_RETURN - // could be treated as a legitimate mint, allowing an attacker to set - // originalDDMinted to an arbitrary (e.g. trivially small) value. - // This is consistent with ExtractDDAmountFromTxRef() which also checks - // HasDigiDollarMarker, and isCollateralOutput (T2-06b) which checks nVersion. - if (!DigiDollar::HasDigiDollarMarker(*prev_tx)) { - return false; - } - // Also verify it's a MINT transaction (type byte = 1 in upper nVersion bits) - if (DigiDollar::GetDigiDollarTxType(*prev_tx) != DD_TX_MINT) { - return false; - } - for (const auto& vout : prev_tx->vout) { - if (vout.scriptPubKey.size() == 0 || vout.scriptPubKey[0] != OP_RETURN) continue; + bool found = false; + + // Helper: extract DD minted amount from a mint transaction's OP_RETURN. + auto extractDDFromMintTx = [](const CTransactionRef& prev_tx, CAmount& ddOut) -> bool { + // SECURITY [T5-04]: Verify source tx is actually a DD transaction. + // Without this check, a regular (non-DD) tx with a crafted DD OP_RETURN + // could be treated as a legitimate mint, allowing an attacker to set + // originalDDMinted to an arbitrary (e.g. trivially small) value. + // This is consistent with ExtractDDAmountFromTxRef() which also checks + // HasDigiDollarMarker, and isCollateralOutput (T2-06b) which checks nVersion. + if (!DigiDollar::HasDigiDollarMarker(*prev_tx)) { + return false; + } + // Also verify it's a MINT transaction (type byte = 1 in upper nVersion bits) + if (DigiDollar::GetDigiDollarTxType(*prev_tx) != DD_TX_MINT) { + return false; + } + for (const auto& vout : prev_tx->vout) { + if (vout.scriptPubKey.empty() || vout.scriptPubKey[0] != OP_RETURN) continue; - CScript::const_iterator pc = vout.scriptPubKey.begin(); - opcodetype opcode; - std::vector data; + CScript::const_iterator pc = vout.scriptPubKey.begin(); + opcodetype opcode; + std::vector data; - if (!vout.scriptPubKey.GetOp(pc, opcode)) continue; // Skip OP_RETURN - if (!vout.scriptPubKey.GetOp(pc, opcode, data)) continue; - if (data.size() != 2 || data[0] != 'D' || data[1] != 'D') continue; + if (!vout.scriptPubKey.GetOp(pc, opcode)) continue; // Skip OP_RETURN + if (!vout.scriptPubKey.GetOp(pc, opcode, data)) continue; + if (data.size() != 2 || data[0] != 'D' || data[1] != 'D') continue; - // Read tx type - if (!vout.scriptPubKey.GetOp(pc, opcode, data)) continue; - int64_t txType = 0; - if (data.size() > 0) { - try { - CScriptNum txTypeNum(data, true); - txType = txTypeNum.GetInt64(); - } catch (const scriptnum_error&) { continue; } - } + // Read tx type + if (!vout.scriptPubKey.GetOp(pc, opcode, data)) continue; + int64_t txType = 0; + if (!data.empty()) { + try { + CScriptNum txTypeNum(data, true); + txType = txTypeNum.GetInt64(); + } catch (const scriptnum_error&) { continue; } + } - // Only process MINT (type 1) — that's the transaction that created collateral - if (txType != 1) continue; + // Only process MINT (type 1) — that's the transaction that created collateral + if (txType != 1) continue; - // Read DD amount (first push after type for mint) - if (vout.scriptPubKey.GetOp(pc, opcode, data) && data.size() > 0) { - try { - CScriptNum scriptNum(data, true, 8); - ddOut = scriptNum.GetInt64(); - return ddOut > 0; - } catch (const scriptnum_error&) {} - } + // Read DD amount (first push after type for mint) + if (vout.scriptPubKey.GetOp(pc, opcode, data) && !data.empty()) { + try { + CScriptNum scriptNum(data, true, 8); + ddOut = scriptNum.GetInt64(); + return ddOut > 0; + } catch (const scriptnum_error&) {} } - return false; - }; - - bool found = false; - - // Try txindex (authoritative — reads creating tx from indexed database) - if (!found && g_txindex) { - uint256 block_hash; - CTransactionRef prev_tx; - if (g_txindex->FindTx(tx.vin[0].prevout.hash, block_hash, prev_tx)) { - found = extractDDFromMintTx(prev_tx, originalDDMinted); - if (found) { - LogPrint(BCLog::DIGIDOLLAR, "DigiDollar: Extracted original DD minted (%lld) from txindex\n", - (long long)originalDDMinted); - } + } + return false; + }; + + // Try txindex (authoritative — reads creating tx from indexed database) + if (!found && g_txindex) { + uint256 block_hash; + CTransactionRef prev_tx; + if (g_txindex->FindTx(tx.vin[0].prevout.hash, block_hash, prev_tx)) { + found = extractDDFromMintTx(prev_tx, originalDDMinted); + if (found) { + LogPrint(BCLog::DIGIDOLLAR, "DigiDollar: Extracted original DD minted (%lld) from txindex\n", + (long long)originalDDMinted); } } + } - // Try block-db lookup (universal fallback — every full node has every block) - if (!found && ctx.txLookup) { - CTransactionRef prev_tx; - if (ctx.txLookup(tx.vin[0].prevout.hash, collateralCoin.nHeight, prev_tx)) { - found = extractDDFromMintTx(prev_tx, originalDDMinted); - if (found) { - LogPrint(BCLog::DIGIDOLLAR, "DigiDollar: Extracted original DD minted (%lld) from block db\n", - (long long)originalDDMinted); - } + // Try block-db lookup (universal fallback — every full node has every block) + if (!found && ctx.txLookup) { + CTransactionRef prev_tx; + if (ctx.txLookup(tx.vin[0].prevout.hash, collateralCoin.nHeight, prev_tx)) { + found = extractDDFromMintTx(prev_tx, originalDDMinted); + if (found) { + LogPrint(BCLog::DIGIDOLLAR, "DigiDollar: Extracted original DD minted (%lld) from block db\n", + (long long)originalDDMinted); } } + } - if (!found || originalDDMinted <= 0) { - // SECURITY: REJECT if we cannot determine original DD amount. - // A consensus rule must never be silently bypassed. - LogPrintf("DigiDollar: SECURITY [T1-08] - Cannot determine original DD minted amount " - "for collateral at %s:%d. Rejecting to prevent collateral theft.\n", - tx.vin[0].prevout.hash.ToString(), tx.vin[0].prevout.n); - return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-collateral-release-unknown-dd-amount", - "Cannot verify proportional collateral release: original DD amount undetermined"); - } + // Last resort for legacy/unit-test contexts that lack tx lookup. This is intentionally + // after authoritative sources because script metadata can be overwritten by failed mints + // that reuse the same collateral script. + if (!found && ExtractDDAmount(collateralCoin.out.scriptPubKey, originalDDMinted) && originalDDMinted > 0) { + found = true; + } + + if (!found || originalDDMinted <= 0) { + // SECURITY: REJECT if we cannot determine original DD amount. + // A consensus rule must never be silently bypassed. + LogPrintf("DigiDollar: SECURITY [T1-08] - Cannot determine original DD minted amount " + "for collateral at %s:%d. Rejecting to prevent collateral theft.\n", + tx.vin[0].prevout.hash.ToString(), tx.vin[0].prevout.n); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-collateral-release-unknown-dd-amount", + "Cannot verify proportional collateral release: original DD amount undetermined"); } // SECURITY [T2-03]: Require full DD burn for collateral release. @@ -2003,9 +2053,7 @@ bool ValidateCollateralReleaseAmount(const CTransaction& tx, if (outputIndex >= prev_tx->vout.size()) return false; const CTxOut& candidate = prev_tx->vout[outputIndex]; - if (candidate.nValue <= 0 || - candidate.scriptPubKey.size() != 34 || - candidate.scriptPubKey[0] != OP_1) { + if (candidate.nValue <= 0 || !IsCanonicalP2TROutput(candidate.scriptPubKey)) { return false; } @@ -2014,9 +2062,7 @@ bool ValidateCollateralReleaseAmount(const CTransaction& tx, int collateralCount = 0; for (uint32_t candidateIndex = 0; candidateIndex < prev_tx->vout.size(); ++candidateIndex) { const CTxOut& vout = prev_tx->vout[candidateIndex]; - if (vout.nValue > 0 && - vout.scriptPubKey.size() == 34 && - vout.scriptPubKey[0] == OP_1) { + if (vout.nValue > 0 && IsCanonicalP2TROutput(vout.scriptPubKey)) { collateralIndex = candidateIndex; collateralCount++; } diff --git a/src/node/miner.cpp b/src/node/miner.cpp index b0693ac4c8..2232364868 100644 --- a/src/node/miner.cpp +++ b/src/node/miner.cpp @@ -372,6 +372,10 @@ std::unique_ptr BlockAssembler::CreateNewBlock(const CScript& sc LogPrintf("CreateNewBlock(): Warning - Failed to add oracle bundle to block %d\n", nHeight); // Continue with block creation even if oracle bundle fails (graceful degradation) } + // AddOracleBundleToBlock() mutates the coinbase after GenerateCoinbaseCommitment(). + // Keep the template header internally consistent for GBT/miner consumers that use + // the returned block directly instead of first calling IncrementExtraNonce(). + pblock->hashMerkleRoot = BlockMerkleRoot(*pblock); } LogPrintf("CreateNewBlock(): block weight: %u txs: %u fees: %ld sigops %d\n", GetBlockWeight(*pblock), nBlockTx, nFees, nBlockSigOpsCost); diff --git a/src/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index a72c008214..2c5833f327 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -1173,6 +1173,20 @@ bool OracleBundleManager::ExtractOracleBundle(const CTransaction& coinbase_tx, C } }(); + int oracle_output_count = 0; + for (const auto& output : coinbase_tx.vout) { + if (output.scriptPubKey.size() >= 2 && + output.scriptPubKey[0] == OP_RETURN && + output.scriptPubKey[1] == OP_ORACLE) { + ++oracle_output_count; + } + } + if (oracle_output_count > 1) { + LogPrintf("Oracle: Rejecting transaction with %d oracle outputs (expected at most 1)\n", + oracle_output_count); + return false; + } + // Look for OP_RETURN output with OP_ORACLE marker for (const auto& output : coinbase_tx.vout) { if (output.scriptPubKey.size() > 2 && output.scriptPubKey[0] == OP_RETURN) { @@ -1193,32 +1207,32 @@ bool OracleBundleManager::ExtractOracleBundle(const CTransaction& coinbase_tx, C data.insert(data.end(), script_it, script_it + chunk_size); script_it += chunk_size; } else { - break; + return false; } } else if (*script_it == 0x4c) { // OP_PUSHDATA1: next byte is length ++script_it; - if (script_it >= output.scriptPubKey.end()) break; + if (script_it >= output.scriptPubKey.end()) return false; unsigned int chunk_size = *script_it; ++script_it; if (script_it + chunk_size <= output.scriptPubKey.end()) { data.insert(data.end(), script_it, script_it + chunk_size); script_it += chunk_size; } else { - break; + return false; } } else if (*script_it == 0x4d) { // OP_PUSHDATA2: next 2 bytes are length (LE) ++script_it; - if (script_it + 2 > output.scriptPubKey.end()) break; + if (script_it + 2 > output.scriptPubKey.end()) return false; unsigned int chunk_size = *script_it | (*(script_it + 1) << 8); script_it += 2; if (script_it + chunk_size <= output.scriptPubKey.end()) { data.insert(data.end(), script_it, script_it + chunk_size); script_it += chunk_size; } else { - break; + return false; } } else { - break; + return false; } } @@ -1280,9 +1294,9 @@ bool OracleBundleManager::ExtractOracleBundle(const CTransaction& coinbase_tx, C return true; } else if (data[0] == 0x01) { - // Phase One compact format: oracle_id (1) + price (8) + timestamp (8) = 17 bytes - if (data.size() < 18) { // 1 (version) + 17 (data) - LogPrintf("Oracle: Invalid Phase One bundle size: %d\n", data.size()); + // Phase One compact format: version (1) + oracle_id (1) + price (8) + timestamp (8) + if (data.size() != 18) { + LogPrintf("Oracle: Invalid Phase One bundle size: %zu (expected 18)\n", data.size()); return false; } @@ -1349,10 +1363,11 @@ bool OracleBundleManager::ExtractOracleBundle(const CTransaction& coinbase_tx, C return false; } - // Validate we have enough data for all messages + // Validate exact data size for all messages. Trailing bytes would + // create alternate serializations for the same oracle bundle. size_t expected_size = 1 + 1 + 8 + 8 + num_messages * 65; // version + header + per-msg - if (data.size() < expected_size) { - LogPrintf("Oracle: Phase Two data too short: %zu < %zu (for %d messages)\n", + if (data.size() != expected_size) { + LogPrintf("Oracle: Invalid Phase Two data size: %zu != %zu (for %d messages)\n", data.size(), expected_size, num_messages); return false; } @@ -1468,7 +1483,7 @@ bool OracleBundleManager::ValidateV03BundleFormat(const CScript& script, uint8_t return false; } } else { - break; + return false; } } @@ -1485,14 +1500,15 @@ bool OracleBundleManager::ValidateV03BundleFormat(const CScript& script, uint8_t size_t expected = 1 + 1 + bitmap_len + 4 + 8 + 8 + 64; return data.size() == expected; } else if (version == 0x02) { - // v0x02: version(1) + num_msgs(1) + price(8) + timestamp(8) = 18 minimum + // v0x02: version(1) + num_msgs(1) + price(8) + timestamp(8) + N*(oracle_id(1)+sig(64)) if (data.size() < 18) return false; uint8_t num_msgs = data[1]; + if (num_msgs == 0 || num_msgs > ORACLE_ACTIVE_COUNT) return false; size_t expected = 1 + 1 + 8 + 8 + num_msgs * 65; - return data.size() >= expected; + return data.size() == expected; } else if (version == 0x01) { // v0x01: version(1) + oracle_id(1) + price(8) + timestamp(8) = 18 - return data.size() >= 18; + return data.size() == 18; } return false; diff --git a/src/qt/res/icons/digibyte_wallet.png b/src/qt/res/icons/digibyte_wallet.png index 3acd8b0de6..ffa13428f0 100644 Binary files a/src/qt/res/icons/digibyte_wallet.png and b/src/qt/res/icons/digibyte_wallet.png differ diff --git a/src/rpc/digidollar.cpp b/src/rpc/digidollar.cpp index ef0f8cc54d..1f553da05a 100644 --- a/src/rpc/digidollar.cpp +++ b/src/rpc/digidollar.cpp @@ -534,7 +534,7 @@ static RPCHelpMan calculatecollateralrequirement() "the exact amount of DGB needed for a given DD mint amount and lock period.\n", { {"dd_amount_cents", RPCArg::Type::NUM, RPCArg::Optional::NO, "DigiDollar amount to mint in cents (e.g., 10000 = $100)"}, - {"lock_days", RPCArg::Type::NUM, RPCArg::Optional::NO, "Lock period in days (30, 90, 180, 365, 1095, 1825, 2555, or 3650)"}, + {"lock_days", RPCArg::Type::NUM, RPCArg::Optional::NO, "Lock period in days. Must be one of the consensus tiers: 30, 90, 180, 365, 730, 1095, 1825, 2555, or 3650. (For the sub-day testing tier, use estimatecollateral with lock_tier 0.)"}, {"oracle_price", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "DGB price in cents per DGB (uses current price if omitted)"} }, RPCResult{ @@ -620,11 +620,34 @@ static RPCHelpMan calculatecollateralrequirement() // Convert lock days to blocks int64_t lockBlocks = DigiDollar::LockDaysToBlocks(lockDays); + // Strict validation: lockDays must map to an exact tier. The underlying + // GetCollateralRatioForLockTime is intentionally lenient (silently buckets + // intermediate durations to the next tier), but the advisory RPC must match + // the strict tier set actually accepted by mintdigidollar. Otherwise callers + // get a quote for a position they cannot construct. + if (ddParams.collateralRatios.find(lockBlocks) == ddParams.collateralRatios.end()) { + std::string validDays; + bool first = true; + for (const auto& [blocks, ratio] : ddParams.collateralRatios) { + // Skip sub-day tiers (e.g. 240-block testing tier) — they cannot + // be selected via lockDays since the RPC requires lockDays > 0. + int days = static_cast(blocks / DigiDollar::BLOCKS_PER_DAY); + if (days < 1) continue; + if (!first) validDays += ", "; + validDays += strprintf("%d", days); + first = false; + } + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("Invalid lock period: %d days. Valid periods (in days): %s. " + "For sub-day tiers (e.g. 1-hour testing), use estimatecollateral with lock_tier 0.", + lockDays, validDays)); + } + // Get base collateral ratio int baseRatio = DigiDollar::GetCollateralRatioForLockTime(lockBlocks, ddParams); if (baseRatio <= 0) { throw JSONRPCError(RPC_INVALID_PARAMETER, - strprintf("Invalid lock period: %d days. Valid periods: 30, 90, 180, 365, 1095, 1825, 2555, 3650", lockDays)); + strprintf("Invalid lock period: %d days", lockDays)); } // Get current system health @@ -774,7 +797,7 @@ RPCHelpMan mintdigidollar() "Creates a new DigiDollar position by locking DGB as collateral.\n" "The amount of collateral required depends on the lock period and current system health.\n", { - {"dd_amount", RPCArg::Type::NUM, RPCArg::Optional::NO, "Amount of DigiDollar to mint in cents (min 10000/$100, max 10000000/$100K)", RPCArgOptions{.skip_type_check = true}}, + {"dd_amount", RPCArg::Type::NUM, RPCArg::Optional::NO, "Amount of DigiDollar to mint in cents (limits per network: mainnet 10000-10000000 / $100-$100K; testnet 10000-1000000 / $100-$10K; regtest 1-100000 / $0.01-$1K). Use getdigidollarstats or estimatecollateral to check current chain limits.", RPCArgOptions{.skip_type_check = true}}, {"lock_tier", RPCArg::Type::NUM, RPCArg::Optional::NO, "Lock tier 0-9 (0=1h testing, 1=30d, 2=90d, 3=180d, 4=1y, 5=2y, 6=3y, 7=5y, 8=7y, 9=10y)", RPCArgOptions{.skip_type_check = true}}, {"fee_rate", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "Fee rate in sat/kB (default: 100000)", RPCArgOptions{.skip_type_check = true}} }, @@ -1317,8 +1340,11 @@ RPCHelpMan senddigidollar() std::string txid; std::string error; CAmount dd_change = 0; + CAmount dgb_fee = 0; + int inputs_used = 0; LogPrintf("DigiDollar RPC: Calling TransferDigiDollar()...\n"); - bool success = dd_wallet->TransferDigiDollar(dd_address, amount, txid, error, &dd_change); + bool success = dd_wallet->TransferDigiDollar(dd_address, amount, txid, error, + &dd_change, &dgb_fee, &inputs_used); LogPrintf("DigiDollar RPC: TransferDigiDollar() returned success=%d\n", success); if (!success) { @@ -1331,31 +1357,15 @@ RPCHelpMan senddigidollar() strprintf("Transfer failed: %s", error)); } - // Build result + // Build result. Fee/inputs come straight from the freshly-built tx via + // out-parameters so we don't race against mapWallet indexing. UniValue result(UniValue::VOBJ); result.pushKV("txid", txid); result.pushKV("to_address", addressStr); result.pushKV("amount", amount); // Bug #11/25 fix: raw integer cents, not ValueFromAmount result.pushKV("status", "success"); - - // Bug #11/25 fix: Compute actual fee, inputs, and change from the wallet transaction - { - uint256 hash; - hash.SetHex(txid); - LOCK(pwallet->cs_wallet); - auto it = pwallet->mapWallet.find(hash); - if (it != pwallet->mapWallet.end()) { - const wallet::CWalletTx& wtx = it->second; - CAmount debit = wallet::CachedTxGetDebit(*pwallet, wtx, wallet::ISMINE_ALL); - CAmount credit = wallet::CachedTxGetCredit(*pwallet, wtx, wallet::ISMINE_ALL); - CAmount fee = debit - credit; - result.pushKV("fee_paid", ValueFromAmount(fee > 0 ? fee : 0)); - result.pushKV("inputs_used", static_cast(wtx.tx->vin.size())); - } else { - result.pushKV("fee_paid", ValueFromAmount(0)); - result.pushKV("inputs_used", 0); - } - } + result.pushKV("fee_paid", ValueFromAmount(dgb_fee > 0 ? dgb_fee : 0)); + result.pushKV("inputs_used", inputs_used); result.pushKV("change_amount", dd_change); // Optional: Add comment to wallet transaction if provided @@ -1476,7 +1486,11 @@ RPCHelpMan sendmanydigidollar() std::string txid; std::string error; - bool success = dd_wallet->TransferDigiDollarMany(recipients, txid, error); + CAmount dd_change = 0; + CAmount dgb_fee = 0; + int inputs_used = 0; + bool success = dd_wallet->TransferDigiDollarMany(recipients, txid, error, + &dd_change, &dgb_fee, &inputs_used); if (!success) { if (error.find("dd-input-amounts-unknown") != std::string::npos) { throw JSONRPCError(RPC_WALLET_ERROR, @@ -1491,6 +1505,9 @@ RPCHelpMan sendmanydigidollar() result.pushKV("amounts", result_amounts); result.pushKV("total_amount", total_amount); result.pushKV("status", "success"); + result.pushKV("fee_paid", ValueFromAmount(dgb_fee > 0 ? dgb_fee : 0)); + result.pushKV("inputs_used", inputs_used); + result.pushKV("change_amount", dd_change); if (OptionalParamIsSet(request, 2) && !request.params[2].get_str().empty()) { result.pushKV("comment", request.params[2].get_str()); } @@ -1727,8 +1744,19 @@ RPCHelpMan redeemdigidollar() std::vector positions = ddWallet->GetDDTimeLocks(false); bool found = false; + bool foundButClosed = false; for (const auto& pos : positions) { if (pos.dd_timelock_id == positionId) { + if (!pos.is_active) { + // Position has already been redeemed (or marked inactive). + // Refuse to build a duplicate redeem tx — the original may + // still be in mempool or already confirmed, and a duplicate + // would over-release collateral and be rejected by consensus + // (bad-collateral-release-excessive). If a prior redeem was + // dropped/reorged the wallet will reactivate the position. + foundButClosed = true; + break; + } // Found the position in wallet's cache! redeemParams.collateralAmount = pos.dgb_collateral; redeemParams.ddMinted = pos.dd_minted; @@ -1743,6 +1771,11 @@ RPCHelpMan redeemdigidollar() break; } } + if (foundButClosed) { + throw JSONRPCError(RPC_INVALID_PARAMETER, + strprintf("Position %s has already been redeemed. If a prior redeem transaction was dropped or reorged out, the wallet will reactivate the position automatically.", + positionIdStr)); + } if (!found) { LogPrintf("DigiDollar: WARNING - Position not found in wallet cache, using fallback\n"); @@ -2670,7 +2703,7 @@ static RPCHelpMan estimatecollateral() "\nEstimate DGB collateral requirement for minting DigiDollar.\n" "Calculates the required DGB amount based on DD amount, lock tier, and current system conditions.\n", { - {"dd_amount", RPCArg::Type::NUM, RPCArg::Optional::NO, "DigiDollar amount to mint in cents (min 10000/$100, max 10000000/$100K)"}, + {"dd_amount", RPCArg::Type::NUM, RPCArg::Optional::NO, "DigiDollar amount to mint in cents (limits per network: mainnet 10000-10000000 / $100-$100K; testnet 10000-1000000 / $100-$10K; regtest 1-100000 / $0.01-$1K)."}, {"lock_tier", RPCArg::Type::NUM, RPCArg::Optional::NO, "Lock tier 0-9 (0=1h testing, 1=30d, 2=90d, 3=180d, 4=1y, 5=2y, 6=3y, 7=5y, 8=7y, 9=10y)"}, {"oracle_price_micro_usd", RPCArg::Type::NUM, RPCArg::Optional::OMITTED, "Custom DGB price in micro-USD (1,000,000 = $1.00). Uses current oracle if omitted."} }, @@ -3175,7 +3208,7 @@ static RPCHelpMan getoracleprice() RPCResult::Type::OBJ, "", "", { {RPCResult::Type::NUM, "price_micro_usd", "Current DGB price in micro-USD (1,000,000 = $1.00)"}, - {RPCResult::Type::NUM, "price_cents", "Current DGB price in cents per DGB"}, + {RPCResult::Type::NUM, "price_cents", "Current DGB price in cents per DGB (round-half-up; for sub-cent precision use price_micro_usd)"}, {RPCResult::Type::NUM, "price_usd", "Current DGB price in USD (full precision)"}, {RPCResult::Type::NUM, "last_update_height", "Block height of last price update"}, {RPCResult::Type::NUM, "last_update_time", "Timestamp of last update"}, @@ -3229,8 +3262,10 @@ static RPCHelpMan getoracleprice() if (mockPrice > 0) { usingMockOracle = true; priceMicroUSD = mockPrice; - // Convert micro-USD to cents with full precision - priceCents = priceMicroUSD / 10000; // integer division: micro-USD to cents + // Convert micro-USD to cents using round-half-up so sub-cent prices + // don't silently floor to 0. (e.g. 5000 µUSD = $0.005 → 1 cent, not 0) + // For full precision, callers should use price_micro_usd. + priceCents = (priceMicroUSD + 5000) / 10000; priceUSD = static_cast(priceMicroUSD) / 1000000.0; // Mock oracle is always "current" - use current time lastBundleTime = GetTime(); @@ -3246,8 +3281,10 @@ static RPCHelpMan getoracleprice() if (!usingMockOracle) { // Get the raw micro-USD price from the oracle (full precision) priceMicroUSD = oracle_manager.GetLatestPrice(); - // Derive cents from the same micro-USD source (integer division) - priceCents = priceMicroUSD / 10000; + // Derive cents using round-half-up so sub-cent prices don't silently floor + // to 0. (e.g. 5000 µUSD = $0.005 → 1 cent, not 0). For full precision + // callers should use price_micro_usd. + priceCents = (priceMicroUSD + 5000) / 10000; // Calculate true USD price from micro-USD (full precision) priceUSD = static_cast(priceMicroUSD) / 1000000.0; @@ -3703,6 +3740,31 @@ static OracleScanResult ScanOracleDataFromChain( } } + // 4. Consensus fallback. If no fresh on-chain bundle was found in the scan + // window, derive consensus from the assembled oracle_data so the response + // doesn't report consensus_price=0 while listing N oracles all agreeing. + // Use the median of reporting oracles; this matches the IQR-median consensus + // logic used elsewhere and is internally consistent with the oracle list + // we are about to return. + if (res.consensus_price == 0) { + std::vector prices; + prices.reserve(res.oracle_data.size()); + for (const auto& [id, od] : res.oracle_data) { + if (od.has_data && od.price_micro_usd > 0) { + prices.push_back(od.price_micro_usd); + } + } + if (!prices.empty()) { + std::sort(prices.begin(), prices.end()); + // Median (lower for even-count to remain deterministic). + res.consensus_price = prices[prices.size() / 2]; + } else { + // Last resort: same source getoracleprice uses, so the two RPCs agree. + CAmount latest = OracleIntegration::GetCurrentOraclePriceMicroUSD(); + if (latest > 0) res.consensus_price = static_cast(latest); + } + } + return res; } @@ -4623,9 +4685,18 @@ static RPCHelpMan getmockoracleprice() RPCExamples{ HelpExampleCli("getmockoracleprice", "") + HelpExampleRpc("getmockoracleprice", "") - }, + }, [&](const RPCHelpMan& self, const JSONRPCRequest& request) -> UniValue { + // Check DigiDollar activation + { + const node::NodeContext& node = EnsureAnyNodeContext(request.context); + ChainstateManager& chainman = EnsureChainman(node); + const CBlockIndex* tip = WITH_LOCK(cs_main, return chainman.ActiveChain().Tip()); + if (!DigiDollar::IsDigiDollarEnabled(tip, chainman)) { + throw JSONRPCError(RPC_MISC_ERROR, "DigiDollar is not yet active on this blockchain"); + } + } // Only allow in RegTest mode if (Params().GetChainType() != ChainType::REGTEST) { throw JSONRPCError(RPC_METHOD_NOT_FOUND, diff --git a/src/script/interpreter.cpp b/src/script/interpreter.cpp index 8424b5fb24..71d66eca50 100644 --- a/src/script/interpreter.cpp +++ b/src/script/interpreter.cpp @@ -436,6 +436,22 @@ static bool EvalChecksig(const valtype& sig, const valtype& pubkey, CScript::con // OP_CHECKPRICE to fail closed — no hardcoded fallback of any kind. GetOracleConsensusPriceFn g_get_oracle_consensus_price = nullptr; +static bool IsDigiDollarOpcode(opcodetype opcode) +{ + return opcode >= OP_DIGIDOLLAR && opcode <= OP_ORACLE; +} + +static bool IsOpSuccessForFlags(opcodetype opcode, unsigned int flags) +{ + // DigiDollar opcodes are BIP342 OP_SUCCESSx before activation, exactly as + // old Taproot nodes see them. Once SCRIPT_VERIFY_DIGIDOLLAR is active they + // are removed from OP_SUCCESSx and evaluated by the stricter new rules. + if (IsDigiDollarOpcode(opcode) && (flags & SCRIPT_VERIFY_DIGIDOLLAR)) { + return false; + } + return IsOpSuccess(opcode); +} + bool EvalScript(std::vector >& stack, const CScript& script, unsigned int flags, const BaseSignatureChecker& checker, SigVersion sigversion, ScriptExecutionData& execdata, ScriptError* serror) { static const CScriptNum bnZero(0); @@ -635,10 +651,14 @@ bool EvalScript(std::vector >& stack, const CScript& // DigiDollar specific opcodes case OP_DIGIDOLLAR: { - // CRITICAL FIX: Check flag BEFORE stack validation to avoid consensus split + if (sigversion != SigVersion::TAPSCRIPT) { + return set_error(serror, SCRIPT_ERR_BAD_OPCODE); + } + // Before activation, these bytes are still BIP342 OP_SUCCESSx. + // ExecuteWitnessScript normally short-circuits before EvalScript; + // keep direct EvalScript callers compatible too. if (!(flags & SCRIPT_VERIFY_DIGIDOLLAR)) { - // Behave as NOP when flag is not set (soft fork compatibility) - break; + return set_success(serror); } // CRITICAL FIX: Read DD amount from SCRIPT (next element), not from stack @@ -667,9 +687,11 @@ bool EvalScript(std::vector >& stack, const CScript& case OP_DDVERIFY: { + if (sigversion != SigVersion::TAPSCRIPT) { + return set_error(serror, SCRIPT_ERR_BAD_OPCODE); + } if (!(flags & SCRIPT_VERIFY_DIGIDOLLAR)) { - // Behave as NOP when flag is not set (soft fork compatibility) - break; + return set_success(serror); } // Verify DigiDollar conditions @@ -685,10 +707,11 @@ bool EvalScript(std::vector >& stack, const CScript& case OP_CHECKPRICE: { - // CRITICAL FIX: Check flag BEFORE stack validation to avoid consensus split + if (sigversion != SigVersion::TAPSCRIPT) { + return set_error(serror, SCRIPT_ERR_BAD_OPCODE); + } if (!(flags & SCRIPT_VERIFY_DIGIDOLLAR)) { - // Behave as NOP when flag is not set (soft fork compatibility) - break; + return set_success(serror); } // Stack: @@ -725,12 +748,11 @@ bool EvalScript(std::vector >& stack, const CScript& case OP_CHECKCOLLATERAL: { - // CRITICAL FIX: Check flag BEFORE stack validation AND do NOT pop stack in NOP mode - // Popping stack when flag not set causes CONSENSUS SPLIT! + if (sigversion != SigVersion::TAPSCRIPT) { + return set_error(serror, SCRIPT_ERR_BAD_OPCODE); + } if (!(flags & SCRIPT_VERIFY_DIGIDOLLAR)) { - // Behave as TRUE NOP when flag is not set (soft fork compatibility) - // DO NOT touch stack - that would cause consensus split! - break; + return set_success(serror); } // Stack: @@ -1963,7 +1985,7 @@ static bool ExecuteWitnessScript(const Span& stack_span, const CS return set_error(serror, SCRIPT_ERR_BAD_OPCODE); } // New opcodes will be listed here. May use a different sigversion to modify existing opcodes. - if (IsOpSuccess(opcode)) { + if (IsOpSuccessForFlags(opcode, flags)) { if (flags & SCRIPT_VERIFY_DISCOURAGE_OP_SUCCESS) { return set_error(serror, SCRIPT_ERR_DISCOURAGE_OP_SUCCESS); } diff --git a/src/script/script.cpp b/src/script/script.cpp index dca4fd9f10..b3b1cd93bc 100644 --- a/src/script/script.cpp +++ b/src/script/script.cpp @@ -149,6 +149,7 @@ std::string GetOpName(opcodetype opcode) case OP_DDVERIFY : return "OP_DDVERIFY"; case OP_CHECKPRICE : return "OP_CHECKPRICE"; case OP_CHECKCOLLATERAL : return "OP_CHECKCOLLATERAL"; + case OP_ORACLE : return "OP_ORACLE"; case OP_INVALIDOPCODE : return "OP_INVALIDOPCODE"; @@ -341,16 +342,10 @@ bool GetScriptOp(CScriptBase::const_iterator& pc, CScriptBase::const_iterator en bool IsOpSuccess(const opcodetype& opcode) { - // CRITICAL: Exclude DigiDollar opcodes (0xbb-0xbf) from OP_SUCCESSx range - // These opcodes are used for DigiDollar redemption scripts and must be executed. - // OP_ORACLE (0xbf) is a marker opcode used in coinbase OP_RETURN outputs; if it - // were OP_SUCCESS in Tapscript, any leaf containing it would be unconditionally - // spendable. See rh54_op_oracle_opsuccess_tests for PoC. - if (opcode >= OP_DIGIDOLLAR && opcode <= OP_ORACLE) { - return false; // 0xbb OP_DIGIDOLLAR, 0xbc OP_DDVERIFY, 0xbd OP_CHECKPRICE, - // 0xbe OP_CHECKCOLLATERAL, 0xbf OP_ORACLE - } - + // BIP342 raw OP_SUCCESSx set. DigiDollar uses 0xbb..0xbf, which are in + // this range, as future-upgrade Tapscript opcodes. They remain OP_SUCCESSx + // until SCRIPT_VERIFY_DIGIDOLLAR is active; the interpreter applies that + // activation flag when deciding whether to short-circuit or execute them. return opcode == 80 || opcode == 98 || (opcode >= 126 && opcode <= 129) || (opcode >= 131 && opcode <= 134) || (opcode >= 137 && opcode <= 138) || (opcode >= 141 && opcode <= 142) || (opcode >= 149 && opcode <= 153) || diff --git a/src/script/script.h b/src/script/script.h index 48e92a9386..e45dea83c7 100644 --- a/src/script/script.h +++ b/src/script/script.h @@ -206,12 +206,12 @@ enum opcodetype // Opcode added by BIP 342 (Tapscript) OP_CHECKSIGADD = 0xba, - // DigiDollar specific opcodes (using OP_NOP slots for soft fork) - OP_DIGIDOLLAR = 0xbb, // OP_NOP11 - Marks DD outputs - OP_DDVERIFY = 0xbc, // OP_NOP12 - Verify DD conditions - OP_CHECKPRICE = 0xbd, // OP_NOP13 - Check oracle price - OP_CHECKCOLLATERAL = 0xbe, // OP_NOP14 - Verify collateral ratio - OP_ORACLE = 0xbf, // OP_NOP15 - Oracle price data marker + // DigiDollar specific opcodes (using Tapscript OP_SUCCESSx slots for soft fork) + OP_DIGIDOLLAR = 0xbb, // Marks DD outputs / payloads + OP_DDVERIFY = 0xbc, // Verify DD conditions + OP_CHECKPRICE = 0xbd, // Check oracle price + OP_CHECKCOLLATERAL = 0xbe, // Verify collateral ratio + OP_ORACLE = 0xbf, // Oracle price data marker OP_INVALIDOPCODE = 0xff, }; diff --git a/src/test/data/script_tests.json b/src/test/data/script_tests.json index 1ae9a170b0..409e4bd445 100644 --- a/src/test/data/script_tests.json +++ b/src/test/data/script_tests.json @@ -890,10 +890,10 @@ ["0x50","1", "P2SH,STRICTENC", "BAD_OPCODE", "opcode 0x50 is reserved"], ["1", "IF 0xba ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE", "opcodes above MAX_OPCODE invalid if executed"], -["1", "IF 0xbb ELSE 1 ENDIF", "P2SH,STRICTENC", "EVAL_FALSE", "OP_DIGIDOLLAR (0xbb) is valid DigiDollar opcode"], -["1", "IF 0xbc ELSE 1 ENDIF", "P2SH,STRICTENC", "EVAL_FALSE", "OP_DDVERIFY (0xbc) is valid DigiDollar opcode"], -["1", "IF 0xbd ELSE 1 ENDIF", "P2SH,STRICTENC", "EVAL_FALSE", "OP_CHECKPRICE (0xbd) is valid DigiDollar opcode"], -["1", "IF 0xbe ELSE 1 ENDIF", "P2SH,STRICTENC", "EVAL_FALSE", "OP_CHECKCOLLATERAL (0xbe) is valid DigiDollar opcode"], +["1", "IF 0xbb ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE", "OP_DIGIDOLLAR (0xbb) is Tapscript-only"], +["1", "IF 0xbc ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE", "OP_DDVERIFY (0xbc) is Tapscript-only"], +["1", "IF 0xbd ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE", "OP_CHECKPRICE (0xbd) is Tapscript-only"], +["1", "IF 0xbe ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE", "OP_CHECKCOLLATERAL (0xbe) is Tapscript-only"], ["1", "IF 0xbf ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE"], ["1", "IF 0xc0 ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE"], ["1", "IF 0xc1 ELSE 1 ENDIF", "P2SH,STRICTENC", "BAD_OPCODE"], diff --git a/src/test/digidollar_address_tests.cpp b/src/test/digidollar_address_tests.cpp index 1d7fa1e46d..f98f661625 100644 --- a/src/test/digidollar_address_tests.cpp +++ b/src/test/digidollar_address_tests.cpp @@ -285,4 +285,41 @@ BOOST_AUTO_TEST_CASE(address_format_consistency_test) } } -BOOST_AUTO_TEST_SUITE_END() \ No newline at end of file +BOOST_AUTO_TEST_CASE(network_specific_digidollar_address_validation_test) +{ + struct ParamsRestorer { + ChainType original; + ~ParamsRestorer() { SelectParams(original); } + } restore{Params().GetChainType()}; + + uint256 hash; + hash.SetHex("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"); + XOnlyPubKey xonly_pubkey(hash); + WitnessV1Taproot taproot_dest(xonly_pubkey); + CTxDestination dest = taproot_dest; + + CDigiDollarAddress main_addr; + CDigiDollarAddress test_addr; + CDigiDollarAddress regtest_addr; + BOOST_REQUIRE(main_addr.SetDigiDollar(dest, CChainParams::DIGIDOLLAR_ADDRESS)); + BOOST_REQUIRE(test_addr.SetDigiDollar(dest, CChainParams::DIGIDOLLAR_ADDRESS_TESTNET)); + BOOST_REQUIRE(regtest_addr.SetDigiDollar(dest, CChainParams::DIGIDOLLAR_ADDRESS_REGTEST)); + + const std::string mainnet_dd = main_addr.ToString(); + const std::string testnet_td = test_addr.ToString(); + const std::string regtest_rd = regtest_addr.ToString(); + + SelectParams(ChainType::MAIN); + BOOST_CHECK(CDigiDollarAddress::IsValidDigiDollarAddressForCurrentNetwork(mainnet_dd)); + BOOST_CHECK(!CDigiDollarAddress::IsValidDigiDollarAddressForCurrentNetwork(testnet_td)); + + SelectParams(ChainType::TESTNET); + BOOST_CHECK(CDigiDollarAddress::IsValidDigiDollarAddressForCurrentNetwork(testnet_td)); + BOOST_CHECK(!CDigiDollarAddress::IsValidDigiDollarAddressForCurrentNetwork(mainnet_dd)); + + SelectParams(ChainType::REGTEST); + BOOST_CHECK(CDigiDollarAddress::IsValidDigiDollarAddressForCurrentNetwork(regtest_rd)); + BOOST_CHECK(!CDigiDollarAddress::IsValidDigiDollarAddressForCurrentNetwork(mainnet_dd)); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/src/test/digidollar_burn_enforcement_tests.cpp b/src/test/digidollar_burn_enforcement_tests.cpp new file mode 100644 index 0000000000..555f4ad99c --- /dev/null +++ b/src/test/digidollar_burn_enforcement_tests.cpp @@ -0,0 +1,280 @@ +// 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. + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include