From 9712b60ff5afce6db374953d5b8e4633b8570ace Mon Sep 17 00:00:00 2001 From: GTO90 Date: Thu, 12 Feb 2026 21:10:26 -0600 Subject: [PATCH 1/8] security: [T1-05] fix oracle signature verification bypass (DGB-SEC-002) Bind authorized pubkey from chainparams before Schnorr signature verification in all code paths. Previously, Phase One validation called VerifyPhase2() using the message's embedded pubkey, allowing an attacker to forge oracle price messages with their own keypair. Fix applied to 4 verification sites: - IsValidOracleMessage() Phase One path (critical P2P gate) - IsValidOracleMessage() Phase Two path (already had binding, kept) - OracleDataValidator::ValidateOracleMessage() (defense-in-depth) - ValidatePhaseOneBundle() (defense-in-depth) - ValidatePhaseTwoBundle() (defense-in-depth) The pattern is: copy the message, overwrite oracle_pubkey with the chainparams-authorized key, then verify. This ensures signatures are always checked against trusted keys, never attacker-supplied ones. --- src/oracle/bundle_manager.cpp | 60 ++++++++++++++++++++++++++++------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/src/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index e45e394602..5737870a2a 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -1033,17 +1033,30 @@ void OracleBundleManager::UpdateEpochBundle(int32_t epoch) bool OracleBundleManager::IsValidOracleMessage(const COraclePriceMessage& message) const { - // Phase One: Skip chainparams check when min_oracle_count == 1 (testing mode) + // Common price bounds validation (applies to both phases) + if (message.price_micro_usd < ORACLE_MIN_PRICE_MICRO_USD) return false; + if (message.price_micro_usd > ORACLE_MAX_PRICE_MICRO_USD) return false; + + // Phase One: min_oracle_count == 1 (regtest/testing mode) if (min_oracle_count == 1) { + // Field validation (timestamp freshness, price range) via IsValid() if (!message.IsValid()) return false; - return message.VerifyPhase2(); + + // SECURITY FIX (DGB-SEC-002): Bind authorized pubkey from chainparams + // before signature verification, same as Phase 2. Previously this path + // called message.VerifyPhase2() using the message's embedded pubkey, + // allowing an attacker to forge messages with their own keypair. + const CChainParams& params = Params(); + const OracleNodeInfo* oracle_config = params.GetOracleNode(message.oracle_id); + if (!oracle_config) { + return false; + } + COraclePriceMessage bound_msg = message; + bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); + return bound_msg.VerifyPhase2(); } // Phase Two (min_oracle_count > 1): Use Phase 2 signature hash - // Basic field validation without calling IsValid() which uses Phase 1 Verify() - if (message.price_micro_usd < ORACLE_MIN_PRICE_MICRO_USD) return false; - if (message.price_micro_usd > ORACLE_MAX_PRICE_MICRO_USD) return false; - // Verify oracle ID is in valid range and matches chainparams const CChainParams& params = Params(); const OracleNodeInfo* oracle_config = params.GetOracleNode(message.oracle_id); @@ -1354,8 +1367,11 @@ bool OracleDataValidator::ValidateOracleMessage(const COraclePriceMessage& messa return false; } - // Verify signature - return message.VerifyPhase2(); + // SECURITY (DGB-SEC-002): Bind pubkey from chainparams before verification. + // Never verify against the message's embedded pubkey — it may be attacker-supplied. + COraclePriceMessage bound_msg = message; + bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); + return bound_msg.VerifyPhase2(); } bool OracleDataValidator::ValidateOracleBundle(const COracleBundle& bundle, int32_t epoch, const Consensus::Params& params) @@ -1448,9 +1464,20 @@ bool OracleBundleManager::ValidatePhaseOneBundle(const COracleBundle& bundle, co } // Phase One: Schnorr signature verification (if signature is present) - if (!msg.schnorr_sig.empty() && !msg.VerifyPhase2()) { - LogPrintf("Oracle: Phase One signature verification failed\n"); - return false; + if (!msg.schnorr_sig.empty()) { + // SECURITY (DGB-SEC-002): Bind pubkey from chainparams before verification + const CChainParams& chainparams = Params(); + const OracleNodeInfo* oracle_config = chainparams.GetOracleNode(msg.oracle_id); + if (!oracle_config) { + LogPrintf("Oracle: Phase One oracle %d not found in chainparams\n", msg.oracle_id); + return false; + } + COraclePriceMessage bound_msg = msg; + bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); + if (!bound_msg.VerifyPhase2()) { + LogPrintf("Oracle: Phase One signature verification failed\n"); + return false; + } } return true; @@ -1499,7 +1526,16 @@ bool OracleBundleManager::ValidatePhaseTwoBundle(const COracleBundle& bundle, co // Verify Schnorr signature using Phase 2 hash (oracle_id + price + timestamp only) if (!msg.schnorr_sig.empty()) { - if (!msg.VerifyPhase2()) { + // SECURITY (DGB-SEC-002): Bind pubkey from chainparams before verification + const CChainParams& chainparams_p2 = Params(); + const OracleNodeInfo* oracle_config_p2 = chainparams_p2.GetOracleNode(msg.oracle_id); + if (!oracle_config_p2) { + LogPrintf("Oracle: Phase Two oracle %d not found in chainparams\n", msg.oracle_id); + continue; + } + COraclePriceMessage bound_msg = msg; + bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config_p2->pubkey); + if (!bound_msg.VerifyPhase2()) { LogPrintf("Oracle: Phase Two signature verification failed for oracle %d\n", msg.oracle_id); continue; // Skip message with invalid signature } From f1fbaa986a03d3c73a90be0471140e5ae79d903f Mon Sep 17 00:00:00 2001 From: GTO90 Date: Thu, 12 Feb 2026 21:42:21 -0600 Subject: [PATCH 2/8] build: fix CI depends failures for macOS ARM64 and Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenSSL 1.1.1w Configure misparses multi-word flags like "-arch arm64" when passed as positional args — the shell splits them and Configure treats the second word as a target name, causing "target already defined" on ARM64 macOS. Pass CFLAGS/CPPFLAGS as VAR=value assignments per OpenSSL's official INSTALL docs. libcurl 8.5.0 fopen.c uses fileno/fdopen (POSIX) which are hidden by the depends system's strict -std=c11. Add -D_GNU_SOURCE for Linux. --- depends/packages/libcurl.mk | 3 +++ depends/packages/openssl.mk | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/depends/packages/libcurl.mk b/depends/packages/libcurl.mk index 6376e37653..2a3e5ddce6 100644 --- a/depends/packages/libcurl.mk +++ b/depends/packages/libcurl.mk @@ -15,6 +15,9 @@ define $(package)_set_vars $(package)_config_opts += --disable-tftp --without-brotli --without-zstd --without-libidn2 $(package)_config_opts += --without-libpsl --without-nghttp2 --disable-dependency-tracking $(package)_config_opts_linux=--with-pic + # -D_GNU_SOURCE exposes POSIX functions (fileno, fdopen) that libcurl's + # fopen.c needs but are hidden by the depends system's strict -std=c11. + $(package)_cppflags_linux=-D_GNU_SOURCE $(package)_config_env_linux=LIBS="-ldl -lpthread" $(package)_config_opts_mingw32=--with-pic $(package)_config_env_mingw32=LIBS="-lws2_32 -lcrypt32" diff --git a/depends/packages/openssl.mk b/depends/packages/openssl.mk index 1bbcb671cf..83f676062d 100644 --- a/depends/packages/openssl.mk +++ b/depends/packages/openssl.mk @@ -11,7 +11,13 @@ define $(package)_set_vars $(package)_config_opts+=no-md2 no-rc5 no-rdrand no-rfc3779 no-sctp no-shared $(package)_config_opts+=no-ssl-trace no-ssl2 no-ssl3 no-tests no-unit-test no-weak-ssl-ciphers $(package)_config_opts+=no-zlib no-zlib-dynamic no-static-engine no-comp no-afalgeng - $(package)_config_opts+=no-engine no-hw no-asm $($(package)_cflags) $($(package)_cppflags) + $(package)_config_opts+=no-engine no-hw no-asm + # Pass compiler flags as VAR=value assignments (per OpenSSL INSTALL docs) + # rather than positional args, because multi-word flags like "-arch arm64" + # get split by the shell and OpenSSL's Configure misparses the second word + # as a target name, causing "target already defined" errors on ARM64 macOS. + $(package)_config_opts+=CFLAGS="$($(package)_cflags)" + $(package)_config_opts+=CPPFLAGS="$($(package)_cppflags)" $(package)_config_opts_linux=-fPIC -D_GNU_SOURCE $(package)_config_opts_freebsd=-fPIC $(package)_config_opts_x86_64_linux=linux-x86_64 From b09bb1b82ea2f38b288621c1ba8969055abba13a Mon Sep 17 00:00:00 2001 From: GTO90 Date: Thu, 12 Feb 2026 21:48:09 -0600 Subject: [PATCH 3/8] refactor: extract VerifyOracleMessageWithChainparamsKey() helper Address Copilot review comments on SEC-002 PR: - Extract duplicated chainparams pubkey binding into a single static helper function, reducing 5 inline copies to 1 definition + 5 calls - Standardize variable naming (remove _p2 suffix) - Unify comment style across all verification sites --- src/oracle/bundle_manager.cpp | 90 ++++++++++++++--------------------- 1 file changed, 35 insertions(+), 55 deletions(-) diff --git a/src/oracle/bundle_manager.cpp b/src/oracle/bundle_manager.cpp index 5737870a2a..4354d84dfc 100644 --- a/src/oracle/bundle_manager.cpp +++ b/src/oracle/bundle_manager.cpp @@ -32,6 +32,29 @@ int32_t GetBestHeight() { return 0; } +/** + * Verify an oracle price message against the chain-configured oracle pubkey. + * + * SECURITY (DGB-SEC-002): Oracle messages arrive via P2P with an embedded pubkey + * that is attacker-controlled. We must never verify the signature against that + * embedded key — instead, look up the trusted pubkey from chainparams by oracle_id, + * rebind it onto the message, and then verify the Phase 2 Schnorr signature. + * + * @param message The oracle price message to verify + * @return true if the message's signature is valid against the chainparams pubkey + */ +static bool VerifyOracleMessageWithChainparamsKey(const COraclePriceMessage& message) +{ + const CChainParams& params = Params(); + const OracleNodeInfo* oracle_config = params.GetOracleNode(message.oracle_id); + if (!oracle_config) { + return false; + } + COraclePriceMessage bound_msg = message; + bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); + return bound_msg.VerifyPhase2(); +} + //! Global oracle bundle manager instance std::unique_ptr g_oracle_bundle_manager; @@ -1042,36 +1065,12 @@ bool OracleBundleManager::IsValidOracleMessage(const COraclePriceMessage& messag // Field validation (timestamp freshness, price range) via IsValid() if (!message.IsValid()) return false; - // SECURITY FIX (DGB-SEC-002): Bind authorized pubkey from chainparams - // before signature verification, same as Phase 2. Previously this path - // called message.VerifyPhase2() using the message's embedded pubkey, - // allowing an attacker to forge messages with their own keypair. - const CChainParams& params = Params(); - const OracleNodeInfo* oracle_config = params.GetOracleNode(message.oracle_id); - if (!oracle_config) { - return false; - } - COraclePriceMessage bound_msg = message; - bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); - return bound_msg.VerifyPhase2(); + // SECURITY FIX (DGB-SEC-002): Verify against chainparams pubkey + return VerifyOracleMessageWithChainparamsKey(message); } - // Phase Two (min_oracle_count > 1): Use Phase 2 signature hash - // Verify oracle ID is in valid range and matches chainparams - const CChainParams& params = Params(); - const OracleNodeInfo* oracle_config = params.GetOracleNode(message.oracle_id); - if (!oracle_config) { - return false; - } - - // SECURITY: Bind pubkey from chainparams before verification. - // The message may contain an attacker-supplied pubkey — we must verify - // against the authorized key, not whatever was deserialized from P2P. - COraclePriceMessage bound_msg = message; - bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); - - // Verify Phase 2 Schnorr signature against chainparams pubkey - return bound_msg.VerifyPhase2(); + // Phase Two (min_oracle_count > 1): Verify against chainparams pubkey + return VerifyOracleMessageWithChainparamsKey(message); } std::vector OracleBundleManager::GetActiveOraclesForEpoch(int32_t epoch) const @@ -1360,18 +1359,15 @@ bool OracleDataValidator::ValidateOracleMessage(const COraclePriceMessage& messa return false; } - // Verify oracle is authorized + // Verify oracle is authorized and active const CChainParams& chainparams = Params(); const OracleNodeInfo* oracle_config = chainparams.GetOracleNode(message.oracle_id); if (!oracle_config || !oracle_config->is_active) { return false; } - // SECURITY (DGB-SEC-002): Bind pubkey from chainparams before verification. - // Never verify against the message's embedded pubkey — it may be attacker-supplied. - COraclePriceMessage bound_msg = message; - bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); - return bound_msg.VerifyPhase2(); + // SECURITY (DGB-SEC-002): Verify against chainparams pubkey + return VerifyOracleMessageWithChainparamsKey(message); } bool OracleDataValidator::ValidateOracleBundle(const COracleBundle& bundle, int32_t epoch, const Consensus::Params& params) @@ -1465,17 +1461,9 @@ bool OracleBundleManager::ValidatePhaseOneBundle(const COracleBundle& bundle, co // Phase One: Schnorr signature verification (if signature is present) if (!msg.schnorr_sig.empty()) { - // SECURITY (DGB-SEC-002): Bind pubkey from chainparams before verification - const CChainParams& chainparams = Params(); - const OracleNodeInfo* oracle_config = chainparams.GetOracleNode(msg.oracle_id); - if (!oracle_config) { - LogPrintf("Oracle: Phase One oracle %d not found in chainparams\n", msg.oracle_id); - return false; - } - COraclePriceMessage bound_msg = msg; - bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config->pubkey); - if (!bound_msg.VerifyPhase2()) { - LogPrintf("Oracle: Phase One signature verification failed\n"); + // SECURITY (DGB-SEC-002): Verify against chainparams pubkey + if (!VerifyOracleMessageWithChainparamsKey(msg)) { + LogPrintf("Oracle: Phase One signature verification failed for oracle %d\n", msg.oracle_id); return false; } } @@ -1526,16 +1514,8 @@ bool OracleBundleManager::ValidatePhaseTwoBundle(const COracleBundle& bundle, co // Verify Schnorr signature using Phase 2 hash (oracle_id + price + timestamp only) if (!msg.schnorr_sig.empty()) { - // SECURITY (DGB-SEC-002): Bind pubkey from chainparams before verification - const CChainParams& chainparams_p2 = Params(); - const OracleNodeInfo* oracle_config_p2 = chainparams_p2.GetOracleNode(msg.oracle_id); - if (!oracle_config_p2) { - LogPrintf("Oracle: Phase Two oracle %d not found in chainparams\n", msg.oracle_id); - continue; - } - COraclePriceMessage bound_msg = msg; - bound_msg.oracle_pubkey = XOnlyPubKey(oracle_config_p2->pubkey); - if (!bound_msg.VerifyPhase2()) { + // SECURITY (DGB-SEC-002): Verify against chainparams pubkey + if (!VerifyOracleMessageWithChainparamsKey(msg)) { LogPrintf("Oracle: Phase Two signature verification failed for oracle %d\n", msg.oracle_id); continue; // Skip message with invalid signature } From 9d6b6447bcc7698b86fd2304c5a12afba11341e4 Mon Sep 17 00:00:00 2001 From: GTO90 Date: Fri, 13 Feb 2026 11:23:23 -0600 Subject: [PATCH 4/8] build: fix deferred evaluation for OpenSSL CFLAGS in depends Use $$ deferred evaluation for CFLAGS/CPPFLAGS assignments so OS-specific flags (cflags_linux, cppflags_linux) appended by funcs.mk after set_vars are included. Route -fPIC and -D_GNU_SOURCE through proper cflags/cppflags variables instead of bare config_opts to avoid OpenSSL's "Mixing make variables" rejection. --- depends/packages/openssl.mk | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/depends/packages/openssl.mk b/depends/packages/openssl.mk index 83f676062d..45b730600c 100644 --- a/depends/packages/openssl.mk +++ b/depends/packages/openssl.mk @@ -16,10 +16,13 @@ define $(package)_set_vars # rather than positional args, because multi-word flags like "-arch arm64" # get split by the shell and OpenSSL's Configure misparses the second word # as a target name, causing "target already defined" errors on ARM64 macOS. - $(package)_config_opts+=CFLAGS="$($(package)_cflags)" - $(package)_config_opts+=CPPFLAGS="$($(package)_cppflags)" - $(package)_config_opts_linux=-fPIC -D_GNU_SOURCE - $(package)_config_opts_freebsd=-fPIC + # Use $$ for deferred evaluation so OS-specific flags (cflags_linux, etc.) + # appended by funcs.mk after set_vars runs are included in the expansion. + $(package)_config_opts+=CFLAGS="$$($(package)_cflags)" + $(package)_config_opts+=CPPFLAGS="$$($(package)_cppflags)" + $(package)_cflags_linux=-fPIC + $(package)_cppflags_linux=-D_GNU_SOURCE + $(package)_cflags_freebsd=-fPIC $(package)_config_opts_x86_64_linux=linux-x86_64 $(package)_config_opts_i686_linux=linux-generic32 $(package)_config_opts_arm_linux=linux-generic32 From b171e9efd2f1edf5e7a2089f98097a7398a8b0f1 Mon Sep 17 00:00:00 2001 From: GTO90 Date: Fri, 13 Feb 2026 11:53:32 -0600 Subject: [PATCH 5/8] build: remove empty ARFLAGS from OpenSSL config_env The depends system never defines $(package)_arflags, so ARFLAGS=$($(package)_arflags) in config_env exported an empty ARFLAGS="" to the environment. When CFLAGS/CPPFLAGS are passed as VAR=value assignments (not positional flags), OpenSSL 1.1.1w's Configure sets $anyuseradd=false and falls back to reading env vars. The empty ARFLAGS overrides the target default "r" from Configurations/00-base-templates.conf, producing a Makefile with ARFLAGS= (empty). This causes "ar: two different operation options specified" on Linux and "ar: illegal option -- /" on macOS during build_libs, as ar interprets the archive path as flags. Fix: remove ARFLAGS from config_env entirely, letting Configure use its target default ARFLAGS="r". Verified locally: - Linux x86_64: ar r apps/libapps.a ... (ARFLAGS=r in Makefile) - macOS ARM64: ar r apps/libapps.a ... (ARFLAGS=r in Makefile) - Full build_libs completes successfully on darwin64-arm64-cc --- depends/packages/openssl.mk | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/depends/packages/openssl.mk b/depends/packages/openssl.mk index 45b730600c..5780089a92 100644 --- a/depends/packages/openssl.mk +++ b/depends/packages/openssl.mk @@ -5,7 +5,14 @@ $(package)_file_name=$(package)-$($(package)_version).tar.gz $(package)_sha256_hash=cf3098950cb4d853ad95c0841f1f9c6d3dc102dccfcacd521d93925208b76ac8 define $(package)_set_vars - $(package)_config_env=AR="$($(package)_ar)" ARFLAGS=$($(package)_arflags) RANLIB="$($(package)_ranlib)" CC="$($(package)_cc)" + # Do NOT export ARFLAGS in config_env. The depends system never defines + # $(package)_arflags, so it expands to empty string. When CFLAGS/CPPFLAGS + # are passed as VAR=value (no positional flags), OpenSSL's Configure sets + # $anyuseradd=false and falls back to reading env vars. An empty ARFLAGS + # in the environment overrides the target default ("r"), producing a + # Makefile with ARFLAGS= (empty), which causes "ar: two different + # operation options specified" at build time. + $(package)_config_env=AR="$($(package)_ar)" RANLIB="$($(package)_ranlib)" CC="$($(package)_cc)" $(package)_config_env_android=ANDROID_NDK_ROOT=$(host_prefix)/native $(package)_config_opts=no-capieng no-dso no-dtls1 no-ec_nistp_64_gcc_128 no-gost $(package)_config_opts+=no-md2 no-rc5 no-rdrand no-rfc3779 no-sctp no-shared From 058823288498e37e4f2d3970337bc19eac775007 Mon Sep 17 00:00:00 2001 From: GTO90 Date: Fri, 13 Feb 2026 13:38:17 -0600 Subject: [PATCH 6/8] test: add diagnostic messages to CI-failing mint validation tests Replace BOOST_CHECK with BOOST_CHECK_MESSAGE in 5 mint validation tests that fail on CI but pass locally. Diagnostic output captures the exact reject reason, script sizes, collateral amounts, and script type identification to help debug the CI-specific failure. --- src/test/digidollar_validation_tests.cpp | 47 +++++++++++++++++++----- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/src/test/digidollar_validation_tests.cpp b/src/test/digidollar_validation_tests.cpp index 0ded290409..06f4171a41 100644 --- a/src/test/digidollar_validation_tests.cpp +++ b/src/test/digidollar_validation_tests.cpp @@ -342,8 +342,14 @@ BOOST_FIXTURE_TEST_CASE(transaction_validation_mint_tx, DigiDollarValidationTest CTransaction tx(mtx); TxValidationState state; - BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); - BOOST_CHECK(state.IsValid()); + bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); + BOOST_CHECK_MESSAGE(result, "[transaction_validation_mint_tx] Expected valid, got reject: " + state.GetRejectReason() + + " | collateralScript.size()=" + std::to_string(collateralScript.size()) + + " ddScript.size()=" + std::to_string(ddScript.size()) + + " requiredCollateral=" + std::to_string(requiredCollateral) + + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) + + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); + BOOST_CHECK_MESSAGE(state.IsValid(), "[transaction_validation_mint_tx] state invalid: " + state.GetRejectReason()); } BOOST_FIXTURE_TEST_CASE(transaction_validation_invalid_mint_amount, DigiDollarValidationTestSetup) @@ -466,8 +472,14 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_valid_basic_mint, DigiDollarValidationTe TxValidationState state; // This should pass when implementation is complete - BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); - BOOST_CHECK(state.IsValid()); + bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); + BOOST_CHECK_MESSAGE(result, "[mint_validation_valid_basic_mint] Expected valid, got reject: " + state.GetRejectReason() + + " | collateralScript.size()=" + std::to_string(collateralScript.size()) + + " ddScript.size()=" + std::to_string(ddScript.size()) + + " requiredCollateral=" + std::to_string(requiredCollateral) + + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) + + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); + BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_valid_basic_mint] state invalid: " + state.GetRejectReason()); } BOOST_FIXTURE_TEST_CASE(mint_validation_insufficient_collateral, DigiDollarValidationTestSetup) @@ -724,8 +736,13 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_dca_multiplier_adjustment, DigiDollarVal TxValidationState state; // Should pass with adjusted collateral - BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); - BOOST_CHECK(state.IsValid()); + bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); + BOOST_CHECK_MESSAGE(result, "[mint_validation_dca_multiplier] Expected valid, got reject: " + state.GetRejectReason() + + " | systemCollateral=" + std::to_string(validationContext.systemCollateral) + + " adjustedCollateral=" + std::to_string(adjustedCollateral) + + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) + + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); + BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_dca_multiplier] state invalid: " + state.GetRejectReason()); // Test with original 500% collateral - should fail CAmount originalCollateral = (static_cast(ddAmount) * COIN * 500 * 100) / mockOraclePrice; @@ -881,8 +898,13 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_edge_case_exact_minimum, DigiDollarValid CTransaction tx(mtx); TxValidationState state; - BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); - BOOST_CHECK(state.IsValid()); + bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); + BOOST_CHECK_MESSAGE(result, "[mint_validation_edge_case_exact_minimum] Expected valid, got reject: " + state.GetRejectReason() + + " | ddAmount=" + std::to_string(ddAmount) + + " exactCollateral=" + std::to_string(exactCollateral) + + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) + + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); + BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_edge_case_exact_minimum] state invalid: " + state.GetRejectReason()); } BOOST_FIXTURE_TEST_CASE(mint_validation_edge_case_exact_maximum, DigiDollarValidationTestSetup) @@ -917,8 +939,13 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_edge_case_exact_maximum, DigiDollarValid CTransaction tx(mtx); TxValidationState state; - BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); - BOOST_CHECK(state.IsValid()); + bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); + BOOST_CHECK_MESSAGE(result, "[mint_validation_edge_case_exact_maximum] Expected valid, got reject: " + state.GetRejectReason() + + " | ddAmount=" + std::to_string(ddAmount) + + " requiredCollateral=" + std::to_string(requiredCollateral) + + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) + + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); + BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_edge_case_exact_maximum] state invalid: " + state.GetRejectReason()); } // ============================================================================= From 5bc797e73c21d2752ced115a8e2425665ce44a1c Mon Sep 17 00:00:00 2001 From: GTO90 Date: Fri, 13 Feb 2026 13:46:07 -0600 Subject: [PATCH 7/8] test: fix cross-suite state pollution causing CI-only mint test failures Replace ClearFreeze() with ClearHistory() in the validation test fixture. ClearFreeze() only clears freeze flags, but UpdateState() (called during ValidateDigiDollarTransaction) recalculates volatility from stale price history left by earlier test suites (e.g., digidollar_health_tests) and re-sets the freeze. This caused all "valid mint" tests to fail with "minting-frozen-volatility" on CI where the test suite ordering (linker-dependent) places health_tests before validation_tests. ClearHistory() resets price history, volatility state, and all freeze flags, ensuring each test starts from a clean state. --- src/test/digidollar_validation_tests.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/digidollar_validation_tests.cpp b/src/test/digidollar_validation_tests.cpp index 06f4171a41..f83a28f6a2 100644 --- a/src/test/digidollar_validation_tests.cpp +++ b/src/test/digidollar_validation_tests.cpp @@ -37,8 +37,10 @@ struct DigiDollarValidationTestSetup : public TestingSetup { testPubKey = testKey.GetPubKey(); testXOnlyKey = XOnlyPubKey(testPubKey); - // Clear volatility freeze state from previous tests - DigiDollar::Volatility::VolatilityMonitor::ClearFreeze(); + // Clear ALL volatility state from previous test suites. + // ClearFreeze() alone is insufficient — UpdateState() recalculates + // from stale price history and can re-set freeze flags. + DigiDollar::Volatility::VolatilityMonitor::ClearHistory(); // Validation context is initialized in member initializer list } From 464e84d145a91eddc2673b17ee059462bdefcfad Mon Sep 17 00:00:00 2001 From: GTO90 Date: Fri, 13 Feb 2026 14:51:18 -0600 Subject: [PATCH 8/8] fix: resolve bad auto-merge that dropped DD amount consistency check The merge with feature/digidollar-v1 silently dropped critical code: 1. DD amount double-counting prevention in ValidateMintTransaction() The OP_RETURN and DD token output both encode ddAmount. Without the consistency check, totalDD was counted twice (e.g. 10000 + 10000 = 20000), requiring 2x the collateral and failing all valid mint tests. 2. Missing #include 3. Improved log format for insufficient-collateral diagnostic Also adopts the base branch's test file which includes: - DD OP_RETURN outputs required for T1-04b NUMS verification - Proper NUMS key usage via GetCollateralNUMSKey() - ClearHistory() fix for cross-suite volatility state pollution --- src/digidollar/validation.cpp | 19 ++++- src/test/digidollar_validation_tests.cpp | 93 +++++++++++++----------- 2 files changed, 65 insertions(+), 47 deletions(-) diff --git a/src/digidollar/validation.cpp b/src/digidollar/validation.cpp index 8ea1ad5962..9ef339c0b8 100644 --- a/src/digidollar/validation.cpp +++ b/src/digidollar/validation.cpp @@ -5,6 +5,7 @@ #include #include #include +#include // Phase 1 metadata tracking support using DigiDollar::ScriptMetadata; @@ -870,7 +871,18 @@ bool ValidateMintTransaction(const CTransaction& tx, LogPrintf("DigiDollar: Invalid DD amount: %d\n", ddAmount); return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-dd-amount"); } - totalDD += ddAmount; + // If totalDD was already set from OP_RETURN, verify consistency + // rather than double-counting the DD amount. + if (totalDD > 0) { + if (ddAmount != totalDD) { + LogPrintf("DigiDollar: DD amount mismatch: token output=%lld, OP_RETURN=%lld\n", + (long long)ddAmount, (long long)totalDD); + return state.Invalid(TxValidationResult::TX_CONSENSUS, "bad-dd-amount-mismatch", + "DD token output amount does not match OP_RETURN amount"); + } + } else { + totalDD += ddAmount; + } } // If we can't extract (cross-node validation), we'll calculate after loop } @@ -1001,8 +1013,9 @@ bool ValidateMintTransaction(const CTransaction& tx, // Verify sufficient collateral if (totalCollateral < requiredCollateral) { - LogPrintf("DigiDollar: Insufficient collateral: provided %d, required %d\n", - 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"); } diff --git a/src/test/digidollar_validation_tests.cpp b/src/test/digidollar_validation_tests.cpp index fea9021140..b788410e73 100644 --- a/src/test/digidollar_validation_tests.cpp +++ b/src/test/digidollar_validation_tests.cpp @@ -52,6 +52,22 @@ struct DigiDollarValidationTestSetup : public TestingSetup { int mockSystemCollateral; int mockHeight; DigiDollar::ValidationContext validationContext; + + // Helper: Build a DD mint OP_RETURN output script + // Format: OP_RETURN <"DD"> + CScript MakeDDMintOpReturn(CAmount ddAmount, int64_t lockHeight, int lockTier) { + CScript script; + script << OP_RETURN; + std::vector dd_marker = {'D', 'D'}; + script << dd_marker; + script << CScriptNum(1); // Type = MINT + script << CScriptNum::serialize(ddAmount); + script << CScriptNum::serialize(lockHeight); + script << CScriptNum(lockTier); + std::vector keyData(testXOnlyKey.begin(), testXOnlyKey.end()); + script << keyData; + return script; + } }; // ============================================================================ @@ -315,43 +331,51 @@ BOOST_FIXTURE_TEST_CASE(script_validation_non_dd_script, DigiDollarValidationTes BOOST_FIXTURE_TEST_CASE(transaction_validation_mint_tx, DigiDollarValidationTestSetup) { - // Create a mock mint transaction + // Create a valid mint transaction with all required components: + // - Collateral input + // - DD OP_RETURN with owner pubkey (required for NUMS verification per T1-04b) + // - Collateral output (P2TR with NUMS internal key) + // - DD token output CMutableTransaction mtx; mtx.nVersion = 0x01000770; // DD_TX_MINT (type=1 in bits 24-31, marker=0x0770 in bits 0-15) - // Add collateral input (simplified for test) + // Add collateral input mtx.vin.resize(1); mtx.vin[0].prevout = COutPoint(uint256S("0x1234"), 0); - // Add collateral output + // Set up mint parameters DigiDollar::MintParams params; params.ddAmount = 10000; // $100.00 - params.lockHeight = mockHeight + 30 * 24 * 60 * 4; + params.lockHeight = mockHeight + 30 * 24 * 60 * 4; // 30-day lock params.ownerKey = testXOnlyKey; - params.internalKey = testXOnlyKey; + params.internalKey = DigiDollar::GetCollateralNUMSKey(); params.oracleKeys = DigiDollar::GetOracleKeys(15); CScript collateralScript = DigiDollar::CreateCollateralP2TR(params); CAmount requiredCollateral = (static_cast(params.ddAmount) * COIN * 500 * 100) / mockOraclePrice; - mtx.vout.resize(2); - mtx.vout[0] = CTxOut(requiredCollateral, collateralScript); + // DD OP_RETURN with owner pubkey (required for NUMS verification) + CScript opReturn = CScript() << OP_RETURN + << std::vector{'D', 'D'} + << CScriptNum(1) + << CScriptNum(params.ddAmount) + << CScriptNum(params.lockHeight) + << CScriptNum(1) // lockTier 1 = 30 days + << std::vector(testXOnlyKey.begin(), testXOnlyKey.end()); + + mtx.vout.resize(3); + mtx.vout[0] = CTxOut(0, opReturn); + mtx.vout[1] = CTxOut(requiredCollateral, collateralScript); // Add DD token output CScript ddScript = DigiDollar::CreateDigiDollarP2TR(testXOnlyKey, params.ddAmount); - mtx.vout[1] = CTxOut(0, ddScript); // DD tokens have no DGB value + mtx.vout[2] = CTxOut(0, ddScript); // DD tokens have no DGB value CTransaction tx(mtx); TxValidationState state; - bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); - BOOST_CHECK_MESSAGE(result, "[transaction_validation_mint_tx] Expected valid, got reject: " + state.GetRejectReason() - + " | collateralScript.size()=" + std::to_string(collateralScript.size()) - + " ddScript.size()=" + std::to_string(ddScript.size()) - + " requiredCollateral=" + std::to_string(requiredCollateral) - + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) - + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); - BOOST_CHECK_MESSAGE(state.IsValid(), "[transaction_validation_mint_tx] state invalid: " + state.GetRejectReason()); + BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); + BOOST_CHECK(state.IsValid()); } BOOST_FIXTURE_TEST_CASE(transaction_validation_invalid_mint_amount, DigiDollarValidationTestSetup) @@ -494,13 +518,9 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_valid_basic_mint, DigiDollarValidationTe // This should pass when implementation is complete bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); - BOOST_CHECK_MESSAGE(result, "[mint_validation_valid_basic_mint] Expected valid, got reject: " + state.GetRejectReason() - + " | collateralScript.size()=" + std::to_string(collateralScript.size()) - + " ddScript.size()=" + std::to_string(ddScript.size()) - + " requiredCollateral=" + std::to_string(requiredCollateral) - + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) - + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); - BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_valid_basic_mint] state invalid: " + state.GetRejectReason()); + BOOST_TEST_MESSAGE("mint_validation_valid_basic_mint result: " + std::to_string(result) + " reason: " + state.GetRejectReason()); + BOOST_CHECK(result); + BOOST_CHECK(state.IsValid()); } BOOST_FIXTURE_TEST_CASE(mint_validation_insufficient_collateral, DigiDollarValidationTestSetup) @@ -779,13 +799,8 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_dca_multiplier_adjustment, DigiDollarVal TxValidationState state; // Should pass with adjusted collateral - bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); - BOOST_CHECK_MESSAGE(result, "[mint_validation_dca_multiplier] Expected valid, got reject: " + state.GetRejectReason() - + " | systemCollateral=" + std::to_string(validationContext.systemCollateral) - + " adjustedCollateral=" + std::to_string(adjustedCollateral) - + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) - + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); - BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_dca_multiplier] state invalid: " + state.GetRejectReason()); + BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); + BOOST_CHECK(state.IsValid()); // Test with original 500% collateral - should fail CAmount originalCollateral = (static_cast(ddAmount) * COIN * 500 * 100) / mockOraclePrice; @@ -962,13 +977,8 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_edge_case_exact_minimum, DigiDollarValid CTransaction tx(mtx); TxValidationState state; - bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); - BOOST_CHECK_MESSAGE(result, "[mint_validation_edge_case_exact_minimum] Expected valid, got reject: " + state.GetRejectReason() - + " | ddAmount=" + std::to_string(ddAmount) - + " exactCollateral=" + std::to_string(exactCollateral) - + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) - + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); - BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_edge_case_exact_minimum] state invalid: " + state.GetRejectReason()); + BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); + BOOST_CHECK(state.IsValid()); } BOOST_FIXTURE_TEST_CASE(mint_validation_edge_case_exact_maximum, DigiDollarValidationTestSetup) @@ -1013,13 +1023,8 @@ BOOST_FIXTURE_TEST_CASE(mint_validation_edge_case_exact_maximum, DigiDollarValid CTransaction tx(mtx); TxValidationState state; - bool result = DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state); - BOOST_CHECK_MESSAGE(result, "[mint_validation_edge_case_exact_maximum] Expected valid, got reject: " + state.GetRejectReason() - + " | ddAmount=" + std::to_string(ddAmount) - + " requiredCollateral=" + std::to_string(requiredCollateral) - + " collateralType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(collateralScript))) - + " ddType=" + std::to_string(static_cast(DigiDollar::IdentifyScriptType(ddScript)))); - BOOST_CHECK_MESSAGE(state.IsValid(), "[mint_validation_edge_case_exact_maximum] state invalid: " + state.GetRejectReason()); + BOOST_CHECK(DigiDollar::ValidateDigiDollarTransaction(tx, validationContext, state)); + BOOST_CHECK(state.IsValid()); } // =============================================================================