Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions doc/release-notes-20861.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
Wallet and signing
------------------

- ELEMENTS: The default sighash used when signing pre-Taproot (legacy and
segwit v0) inputs now commits to the output rangeproofs on chains where
`SIGHASH_RANGEPROOF` is active (i.e. dynafed is active). Concretely, when no
sighash type is specified, the wallet, the `signrawtransactionwithkey` /
`signrawtransactionwithwallet` / `walletprocesspsbt` / `descriptorprocesspsbt`
RPCs, and `elements-tx` now default to `SIGHASH_ALL|RANGEPROOF` instead of
`SIGHASH_ALL`. This removes the previous default's third-party rangeproof
(witness) malleability and matches the rangeproof coverage that Taproot inputs
already have.

The new default is gated on activation: node-backed signing checks live
dynafed activation at the current tip, while the offline `elements-tx` tool
gates on the selected chain's parameters (only chains where dynafed is always
active). On chains where `SIGHASH_RANGEPROOF` is not active, the historical
`SIGHASH_ALL` default is used so that signatures remain standard and valid.
Taproot signing is unaffected: the `SIGHASH_RANGEPROOF` bit is ignored for
Taproot (which always commits to rangeproofs). Users can still request any
specific sighash type explicitly to override the default.

Updated RPCs
------------

Expand Down
13 changes: 12 additions & 1 deletion src/bitcoin-tx.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
#endif

#include <asset.h>
#include <chainparams.h>
#include <chainparamsbase.h>
#include <clientversion.h>
#include <coins.h>
#include <consensus/amount.h>
Expand Down Expand Up @@ -602,7 +604,16 @@ static CAmount AmountFromValue(const UniValue& value)

static void MutateTxSign(CMutableTransaction& tx, const std::string& flagStr)
{
int nHashType = SIGHASH_ALL;
// ELEMENTS: bitcoin-tx has no chainstate, so we cannot check live dynafed
// activation. Gate the default on chain parameters instead: commit to
// rangeproofs by default on chains where dynafed (which enables
// SCRIPT_SIGHASH_RANGEPROOF) is known to be active. Otherwise use the
// historical SIGHASH_ALL default so offline-built txs stay standard and valid.
// See CChainParams::SighashRangeproofActiveByParams() for the liquidv1 nuance.
int nHashType = DefaultSighashType(Params().SighashRangeproofActiveByParams());
// DefaultSighashType may return SIGHASH_DEFAULT (0); for the legacy tool path
// treat that as SIGHASH_ALL.
if (nHashType == SIGHASH_DEFAULT) nHashType = SIGHASH_ALL;

if (flagStr.size() > 0)
if (!findSighashFlags(nHashType, flagStr))
Expand Down
19 changes: 14 additions & 5 deletions src/blind.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -206,9 +206,12 @@ bool SurjectOutput(CTxOutWitness& txoutwit, const std::vector<secp256k1_fixed_as
// with more than 256 inputs. The Elements verification code will always try to give
// secp-zkp the complete list of inputs, and if this exceeds 256 then surjectionproof_verify
// will always return false, so there is no way to work around this situation at signing time
if (surjection_targets.size() > SECP256K1_SURJECTIONPROOF_MAX_N_INPUTS) {
if (surjection_targets.empty() || surjection_targets.size() > SECP256K1_SURJECTIONPROOF_MAX_N_INPUTS) {
// We must return false here to avoid triggering an assertion within
// secp256k1_surjectionproof_initialize on the next line.
// secp256k1_surjectionproof_initialize on the next line: the
// cryptographic API requires a non-empty set of surjection targets,
// and the raw-blinding path can reach us with an empty vector
// (zero-input tx with multiple blindable outputs).
return false;
}
// Find correlation between asset tag and listed input tags
Expand Down Expand Up @@ -546,7 +549,9 @@ int BlindTransaction(std::vector<uint256 >& input_value_blinding_factors, const

// Generate rangeproof, no script committed for issuances
bool rangeresult = GenerateRangeproof((nPseudo ? txinwit.vchInflationKeysRangeproof : txinwit.vchIssuanceAmountRangeproof), value_blindptrs, nonce, amount, CScript(), value_commit, asset_gen, asset, asset_blindptrs);
assert(rangeresult);
if (!rangeresult) {
return -1;
}

// Successfully blinded this issuance
num_blinded++;
Expand Down Expand Up @@ -621,9 +626,13 @@ int BlindTransaction(std::vector<uint256 >& input_value_blinding_factors, const

// Generate rangeproof
bool rangeresult = GenerateRangeproof(txoutwit.vchRangeproof, value_blindptrs, nonce, amount, out.scriptPubKey, value_commit, asset_gen, asset, asset_blindptrs);
assert(rangeresult);
if (!rangeresult) {
return -1;
}

// Create surjection proof for this output
// Failed surjection proof is a foreseeable condition
// (no suitable input asset to prove against) and is reported to the
// caller via the returned count. See naive_blinding_test.
if (!SurjectOutput(txoutwit, surjection_targets, target_asset_generators, target_asset_blinders, asset_blindptrs, asset_gen, asset)) {
continue;
}
Expand Down
56 changes: 50 additions & 6 deletions src/blindpsbt.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ std::string GetBlindingStatusError(const BlindingStatus& status)
return "Unable to create an asset surjection proof";
case BlindingStatus::NO_BLIND_OUTPUTS:
return "Transaction has blind inputs belonging to this blinder but does not have outputs to blind";
case BlindingStatus::RANGEPROOF_UNABLE:
return "Unable to create a value rangeproof for an output";
case BlindingStatus::INVALID_AMOUNT:
return "Zero-valued output to a spendable script cannot be blinded";
}
assert(false);
}
Expand All @@ -53,10 +57,17 @@ bool CreateAssetSurjectionProof(std::vector<unsigned char>& output_proof, const
}
// Using the input chosen, build proof
ret = secp256k1_surjectionproof_generate(secp256k1_blind_context, &proof, &ephemeral_input_tags[0], ephemeral_input_tags.size(), &output_asset_tag, input_index, input_asset_blinders[input_index].begin(), output_asset_blinder.begin());
assert(ret == 1);
if (ret != 1) {
// Attacker-selected tags/generators without a known discrete-log
// relationship cause generation to fail; this must be a recoverable
// PSET error, not a process abort.
return false;
}
// Double-check answer
ret = secp256k1_surjectionproof_verify(secp256k1_blind_context, &proof, &ephemeral_input_tags[0], ephemeral_input_tags.size(), &output_asset_tag);
assert(ret == 1);
if (ret != 1) {
return false;
}

// Serialize into output witness structure
size_t output_len = secp256k1_surjectionproof_serialized_size(secp256k1_blind_context, &proof);
Expand Down Expand Up @@ -190,7 +201,11 @@ bool CreateBlindAssetProof(std::vector<unsigned char>& assetproof, const CAsset&

bool VerifyBlindValueProof(CAmount value, const CConfidentialValue& conf_value, const std::vector<unsigned char>& proof, const CConfidentialAsset& conf_asset)
{
if (conf_value.IsNull() || conf_asset.IsNull()) {
// The value and asset must be genuine commitments (33-byte, PrefixA/B)
// before their buffers are handed to libsecp256k1, which consumes exactly
// 33 serialized bytes. An explicit 9-byte value (or a null field) must not
// reach the parser, which would otherwise read out of bounds.
if (!conf_value.IsCommitment() || !conf_asset.IsCommitment()) {
return false;
}

Expand All @@ -209,7 +224,11 @@ bool VerifyBlindValueProof(CAmount value, const CConfidentialValue& conf_value,
if (secp256k1_rangeproof_verify(secp256k1_blind_context, &min_value, &max_value, &value_commit, proof.data(), proof.size(), /* extra_commit */ nullptr, /* extra_commit_len */ 0, &gen) == 0) {
return false;
}
return min_value == (uint64_t)value;
// A range-membership proof is only meaningful as an equality proof if the
// proven interval collapses to the claimed amount. Comparing solely the
// lower bound would accept a proof whose committed value is larger than
// the displayed amount. Require both bounds to equal `value`.
return min_value == (uint64_t)value && max_value == (uint64_t)value;
}

BlindProofResult VerifyBlindProofs(const PSBTOutput& o) {
Expand Down Expand Up @@ -498,9 +517,23 @@ BlindingStatus BlindPSBT(PartiallySignedTransaction& psbt, std::map<uint32_t, st
continue;
}

// A rangeproof over a spendable script uses min_value = 1, so a zero
// amount cannot be proven. Reject.
if (*output.amount == 0 && !output.script->IsUnspendable()) {
return BlindingStatus::INVALID_AMOUNT;
}

// Check this is our output to blind
if (output.m_blinder_index == std::nullopt || our_input_data.count(*output.m_blinder_index) == 0) continue;

// PSET v0 does not require an output amount (it is only enforced for
// m_psbt_version >= 2), so a crafted v0 PSET can reach the blinding
// loop with output.amount == nullopt. Dereferencing it is undefined
// behaviour. Refuse to blind such an output.
if (output.amount == std::nullopt) {
return BlindingStatus::INVALID_BLINDER;
}

// Things we are going to stuff into the PSBTOutput if everything is successful
CConfidentialValue value_commitment;
CConfidentialAsset asset_commitment;
Expand Down Expand Up @@ -556,16 +589,27 @@ BlindingStatus BlindPSBT(PartiallySignedTransaction& psbt, std::map<uint32_t, st
CreateValueCommitment(value_commitment, value_commit, value_blinder, asset_generator, *output.amount);

// Generate rangproof nonce
if (!output.m_blinding_pubkey.IsFullyValid()) {
// An attacker-controlled (off-curve) blinding pubkey would otherwise
// reach CKey::ECDH, whose only validation is an assert on the peer
// key, aborting the process. The non-PSET path (blind.cpp) requires
// IsFullyValid() before ECDH; mirror it here.
return BlindingStatus::INVALID_BLINDER;
}
uint256 nonce = GenerateRangeproofECDHKey(ecdh_key, output.m_blinding_pubkey);

// Generate rangeproof
bool rangeresult = CreateValueRangeProof(rangeproof, value_blinder, nonce, *output.amount, *output.script, value_commit, asset_generator, asset, asset_blinder);
assert(rangeresult);
if (!rangeresult) {
return BlindingStatus::RANGEPROOF_UNABLE;
}

// Create explicit value rangeproof
std::vector<unsigned char> blind_value_proof;
rangeresult = CreateBlindValueProof(blind_value_proof, value_blinder, *output.amount, value_commit, asset_generator);
assert(rangeresult);
if (!rangeresult) {
return BlindingStatus::RANGEPROOF_UNABLE;
}

// Create surjection proof for this output
if (!CreateAssetSurjectionProof(asp, fixed_input_tags, ephemeral_input_tags, input_asset_blinders, asset_blinder, asset_generator, asset)) {
Expand Down
2 changes: 2 additions & 0 deletions src/blindpsbt.h
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ enum class BlindingStatus
INVALID_BLINDER,
ASP_UNABLE,
NO_BLIND_OUTPUTS,
RANGEPROOF_UNABLE,
INVALID_AMOUNT,
};

enum class BlindProofResult {
Expand Down
10 changes: 8 additions & 2 deletions src/chain.h
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,10 @@ class CBlockIndex
block.hashPrevBlock = pprev->GetBlockHash();
block.hashMerkleRoot = hashMerkleRoot;
block.nTime = nTime;
if (g_con_blockheightinheader) {
// Dynafed headers always serialize block_height as part of their
// identity (see CBlockHeader::Serialize), so it must be reconstructed
// regardless of the legacy -con_blockheightinheader option.
if (g_con_blockheightinheader || is_dynafed_block()) {
block.block_height = nHeight;
}
block.nBits = nBits;
Expand Down Expand Up @@ -542,7 +545,10 @@ class CDiskBlockIndex : public CBlockIndex
block.hashPrevBlock = hashPrev;
block.hashMerkleRoot = hashMerkleRoot;
block.nTime = nTime;
if (g_con_blockheightinheader) {
// Dynafed headers always serialize block_height as part of their
// identity (see CBlockHeader::Serialize), so it must be reconstructed
// regardless of the legacy -con_blockheightinheader option.
if (g_con_blockheightinheader || is_dynafed_block()) {
block.block_height = nHeight;
}
block.nBits = nBits;
Expand Down
18 changes: 18 additions & 0 deletions src/chainparams.h
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ class CChainParams
bool MineBlocksOnDemand() const { return consensus.fPowNoRetargeting; }
/** Return the network string */
std::string NetworkIDString() const { return strNetworkID; }
/**
* ELEMENTS: Whether SIGHASH_RANGEPROOF can be assumed active for this chain
* from the chain parameters alone (i.e. without inspecting the current tip).
* Used by offline tooling such as elements-tx that has no chainstate to
* decide the default sighash. This is true when dynafed (which enables
* SCRIPT_SIGHASH_RANGEPROOF) is configured ALWAYS_ACTIVE (e.g. elementsregtest
* with dynafed enabled, or liquidv1test), or on liquidv1 where dynafed is
* height-activated (nStartTime is a block height, not the ALWAYS_ACTIVE
* sentinel) but is long since active on the live chain.
*/
bool SighashRangeproofActiveByParams() const
{
return consensus.vDeployments[Consensus::DEPLOYMENT_DYNA_FED].nStartTime == Consensus::BIP9Deployment::ALWAYS_ACTIVE
|| NetworkIDString() == CBaseChainParams::LIQUID1;
}
/** Return the list of hostnames to look up for DNS seeds */
const std::vector<std::string>& DNSSeeds() const { return vSeeds; }
const std::vector<unsigned char>& Base58Prefix(Base58Type type) const { return base58Prefixes[type]; }
Expand Down Expand Up @@ -162,6 +177,9 @@ class CChainParams
PeginSubsidy GetPeginSubsidy() const { return pegin_subsidy; }
PeginMinimum GetPeginMinimum() const { return pegin_minimum; }

// ELEMENTS: Elements adds classes with their own members so the base pointer needs a virtual destructor.
virtual ~CChainParams() = default;

protected:
CChainParams() {}

Expand Down
7 changes: 5 additions & 2 deletions src/dynafed.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ bool NextBlockIsParameterTransition(const CBlockIndex* pindexPrev, const Consens
}
std::map<uint256, uint32_t> vote_tally;
assert(next_height >= consensus.dynamic_epoch_length);
// Require at least four-fifths of the epoch's votes. (epoch_length*4)/5
// floor-divides, under-approximating the 80% threshold for epoch lengths
// not divisible by 5; N - N/5 is the overflow-safe ceiling of N*4/5.
const uint32_t threshold = consensus.dynamic_epoch_length - consensus.dynamic_epoch_length / 5;
for (int32_t height = next_height - 1; height >= (int32_t)(next_height - consensus.dynamic_epoch_length); --height) {
const CBlockIndex* p_epoch_walk = pindexPrev->GetAncestor(height);
assert(p_epoch_walk);
Expand All @@ -25,8 +29,7 @@ bool NextBlockIsParameterTransition(const CBlockIndex* pindexPrev, const Consens
const uint256 proposal_root = proposal.CalculateRoot();
vote_tally[proposal_root]++;
// Short-circuit once 4/5 threshold is reached
if (!proposal_root.IsNull() && vote_tally[proposal_root] >=
(consensus.dynamic_epoch_length*4)/5) {
if (!proposal_root.IsNull() && vote_tally[proposal_root] >= threshold) {
winning_entry = proposal;
return true;
}
Expand Down
7 changes: 7 additions & 0 deletions src/init.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,13 @@ bool AppInitParameterInteraction(const ArgsManager& args)
LogPrintf("Increasing minrelaytxfee to %s to match incrementalrelayfee\n",::minRelayTxFee.ToString());
}

if (chainparams.GetConsensus().has_parent_chain && !chainparams.GetConsensus().ParentChainHasPow()) {
LogPrintf("This chain is configured with a signed-blocks parent chain. "
"Peg-ins referencing a parent block that has activated dynamic "
"federations will be rejected: such headers cannot be "
"authenticated. See doc/ for details.\n");
}

// Sanity check argument for min fee for including tx in block
// TODO: Harmonize which arguments need sanity checking and where that happens
if (args.IsArgSet("-blockmintxfee")) {
Expand Down
7 changes: 7 additions & 0 deletions src/interfaces/chain.h
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,13 @@ class Chain
//! Check if transaction is RBF opt in.
virtual RBFTransactionState isRBFOptIn(const CTransaction& tx) = 0;

//! ELEMENTS: Check whether SIGHASH_RANGEPROOF is active for signing at the
//! current chain tip (i.e. dynafed, which enables SCRIPT_SIGHASH_RANGEPROOF,
//! is active). Used to decide the default pre-Taproot sighash so that we only
//! commit to output rangeproofs when doing so yields standard, valid
//! signatures.
virtual bool isSighashRangeproofActive() = 0;

//! Check if transaction is in mempool.
virtual bool isInMempool(const uint256& txid) = 0;

Expand Down
8 changes: 8 additions & 0 deletions src/node/interfaces.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,14 @@ class ChainImpl : public Chain
LOCK(m_node.mempool->cs);
return IsRBFOptIn(tx, *m_node.mempool);
}
bool isSighashRangeproofActive() override
{
// Mirror the mempool standardness check in MemPoolAccept: dynafed being
// active after the current tip enables SCRIPT_SIGHASH_RANGEPROOF, which
// is what makes SIGHASH_RANGEPROOF signatures standard and valid.
LOCK(::cs_main);
return DeploymentActiveAfter(chainman().ActiveChain().Tip(), Params().GetConsensus(), Consensus::DEPLOYMENT_DYNA_FED);
}
bool isInMempool(const uint256& txid) override
{
if (!m_node.mempool) return false;
Expand Down
Loading
Loading