script: add SIGHASH_ALL_WITH_RANGEPROOF and DefaultSighashType; strip rangeproof bit for Taproot signing
What changed, and why it matters
This commit adds a new default signature mode for the Elements sidechain that includes 'rangeproof' data in signatures for older-style (pre-Taproot) transactions. It also makes sure that when Taproot/Schnorr signing is used, the extra rangeproof bit is stripped away because Taproot already commits to rangeproofs in a different way and would reject the bit. The change is defensive: it closes a 'rangeproof malleability gap' where transaction witnesses could potentially be altered without invalidating signatures, but only on chains where the relevant feature (dynafed) is active. It is not a fix for an active exploit, but a hardening/correctness improvement.
Review and merge as a defensive hardening change. Ensure wallet and RPC callers use DefaultSighashType() where appropriate, and verify that stripping the rangeproof bit for Taproot does not conflict with any custom signing flows. No urgent incident response is indicated by the commit itself.
Security signals we found
Adds SIGHASH_ALL_WITH_RANGEPROOF constant to commit rangeproofs in pre-Taproot signatures
Introduces DefaultSighashType() chain-aware default sighash selection
Strips SIGHASH_RANGEPROOF bit for Taproot/Schnorr signing to maintain validity
Includes unit tests for the new default sighash behavior
Closes pre-Taproot rangeproof witness malleability gap on dynafed-active chains
Evidence from the diff
The patch introduces SIGHASH_ALL_WITH_RANGEPROOF (SIGHASH_ALL | SIGHASH_RANGEPROOF) and a DefaultSighashType() helper that selects SIGHASH_ALL_WITH_RANGEPROOF when SIGHASH_RANGEPROOF is active for the chain, otherwise SIGHASH_DEFAULT. In CreateSchnorrSig, the SIGHASH_RANGEPROOF bit is masked off before computing the BIP341-style Schnorr sighash and appended sighash byte, because BIP341 already commits to rangeproofs and treats the 0x40 bit as invalid. Tests verify the constant and helper behavior. The change hardens pre-Taproot signing against rangeproof witness malleability once dynafed/SCRIPT_SIGHASH_RANGEPROOF is active.
Changed components
src/script/interpreter.hsrc/script/sign.cppsrc/script/sign.hsrc/test/sighash_tests.cppInspect captured patch +57 / −2
diff --git a/src/script/interpreter.h b/src/script/interpreter.h
index b1a510b..1189f08 100644
--- a/src/script/interpreter.h
+++ b/src/script/interpreter.h
@@ -39,6 +39,15 @@ enum
// ELEMENTS:
// A flag that means the rangeproofs should be included in the sighash.
SIGHASH_RANGEPROOF = 0x40,
+
+ // ELEMENTS:
+ // The default sighash used by wallets/tools when signing pre-Taproot
+ // (BASE/WITNESS_V0) inputs on chains where SIGHASH_RANGEPROOF is active.
+ // This commits to the output rangeproofs, closing the pre-Taproot
+ // rangeproof (witness) malleability gap. Note this must only be used once
+ // dynafed (which enables SCRIPT_SIGHASH_RANGEPROOF) is active for the target
+ // chain; otherwise the resulting signatures are non-standard and invalid.
+ SIGHASH_ALL_WITH_RANGEPROOF = SIGHASH_ALL | SIGHASH_RANGEPROOF,
};
/** Script verification flags.
diff --git a/src/script/sign.cpp b/src/script/sign.cpp
index cea4ee1..9db8ce7 100644
--- a/src/script/sign.cpp
+++ b/src/script/sign.cpp
@@ -38,6 +38,15 @@ MutableTransactionSignatureCreator::MutableTransactionSignatureCreator(const CMu
{
}
+int DefaultSighashType(bool sighash_rangeproof_active)
+{
+ // When SIGHASH_RANGEPROOF is active for the chain, default to committing to
+ // rangeproofs for pre-Taproot inputs. The 0x40 bit is stripped for Taproot
+ // signing (see CreateSchnorrSig), so this is a safe universal default.
+ // Otherwise fall back to SIGHASH_DEFAULT (== SIGHASH_ALL for pre-Taproot).
+ return sighash_rangeproof_active ? SIGHASH_ALL_WITH_RANGEPROOF : SIGHASH_DEFAULT;
+}
+
bool MutableTransactionSignatureCreator::CreateSig(const SigningProvider& provider, std::vector<unsigned char>& vchSig, const CKeyID& address, const CScript& scriptCode, SigVersion sigversion, unsigned int flags) const
{
assert(sigversion == SigVersion::BASE || sigversion == SigVersion::WITNESS_V0);
@@ -85,12 +94,17 @@ bool MutableTransactionSignatureCreator::CreateSchnorrSig(const SigningProvider&
execdata.m_tapleaf_hash_init = true;
execdata.m_tapleaf_hash = *leaf_hash;
}
+ // ELEMENTS: SIGHASH_RANGEPROOF is a pre-Taproot-only flag; the BIP341-style
+ // sighash always commits to rangeproofs and rejects the 0x40 bit. Strip it so
+ // that a universal default of SIGHASH_ALL_WITH_RANGEPROOF still produces valid
+ // Taproot signatures.
+ const int taproot_hashtype = nHashType & ~SIGHASH_RANGEPROOF;
uint256 hash;
- if (!SignatureHashSchnorr(hash, execdata, m_txto, nIn, nHashType, sigversion, *m_txdata, MissingDataBehavior::FAIL)) return false;
+ if (!SignatureHashSchnorr(hash, execdata, m_txto, nIn, taproot_hashtype, sigversion, *m_txdata, MissingDataBehavior::FAIL)) return false;
sig.resize(64);
// Use uint256{} as aux_rnd for now.
if (!key.SignSchnorr(hash, sig, merkle_root, {})) return false;
- if (nHashType) sig.push_back(nHashType);
+ if (taproot_hashtype) sig.push_back(taproot_hashtype);
return true;
}
diff --git a/src/script/sign.h b/src/script/sign.h
index 5820080..f98a89a 100644
--- a/src/script/sign.h
+++ b/src/script/sign.h
@@ -107,4 +107,18 @@ bool IsSegWitOutput(const SigningProvider& provider, const CScript& script);
/** Sign the CMutableTransaction */
bool SignTransaction(CMutableTransaction& mtx, const SigningProvider* provider, const std::map<COutPoint, Coin>& coins, int sighash, const uint256& hash_genesis_block, std::map<int, bilingual_str>& input_errors);
+/**
+ * ELEMENTS: Return the default sighash type to use when the caller did not
+ * specify one. When SIGHASH_RANGEPROOF is active for the target chain, the
+ * default commits to output rangeproofs (SIGHASH_ALL_WITH_RANGEPROOF for
+ * pre-Taproot inputs); otherwise the historical default (SIGHASH_DEFAULT, which
+ * is equivalent to SIGHASH_ALL for pre-Taproot) is used so that signatures stay
+ * standard and valid on chains where dynafed is not active.
+ *
+ * Note: for Taproot inputs the sighash byte's rangeproof bit is ignored (the
+ * BIP341-style sighash always commits to rangeproofs), so this default is only
+ * meaningful for BASE/WITNESS_V0 signing.
+ */
+int DefaultSighashType(bool sighash_rangeproof_active);
+
#endif // BITCOIN_SCRIPT_SIGN_H
diff --git a/src/test/sighash_tests.cpp b/src/test/sighash_tests.cpp
index 5db1987..90760b9 100644
--- a/src/test/sighash_tests.cpp
+++ b/src/test/sighash_tests.cpp
@@ -8,6 +8,7 @@
#include <hash.h>
#include <script/interpreter.h>
#include <script/script.h>
+#include <script/sign.h>
#include <serialize.h>
#include <streams.h>
#include <test/data/sighash.json.h>
@@ -208,4 +209,21 @@ BOOST_AUTO_TEST_CASE(sighash_from_data)
BOOST_CHECK_MESSAGE(sh.GetHex() == sigHashHex, strTest);
}
}
+
+// ELEMENTS: verify the default sighash selection helper.
+BOOST_AUTO_TEST_CASE(sighash_default_type)
+{
+ // The named constant sets both the ALL and RANGEPROOF bits.
+ BOOST_CHECK_EQUAL(SIGHASH_ALL_WITH_RANGEPROOF, SIGHASH_ALL | SIGHASH_RANGEPROOF);
+ BOOST_CHECK(SIGHASH_ALL_WITH_RANGEPROOF & SIGHASH_RANGEPROOF);
+
+ // When rangeproof signing is not active, fall back to SIGHASH_DEFAULT
+ // (== SIGHASH_ALL for pre-Taproot); do not set the rangeproof bit.
+ BOOST_CHECK_EQUAL(DefaultSighashType(/*sighash_rangeproof_active=*/false), SIGHASH_DEFAULT);
+ BOOST_CHECK((DefaultSighashType(false) & SIGHASH_RANGEPROOF) == 0);
+
+ // When active, default to committing to rangeproofs.
+ BOOST_CHECK_EQUAL(DefaultSighashType(/*sighash_rangeproof_active=*/true), SIGHASH_ALL_WITH_RANGEPROOF);
+ BOOST_CHECK((DefaultSighashType(true) & SIGHASH_RANGEPROOF) == SIGHASH_RANGEPROOF);
+}
BOOST_AUTO_TEST_SUITE_END()
Why this scored 35/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.