Return error for invalid rangproof amounts
What changed, and why it matters
This commit fixes a crash bug in the Elements blockchain wallet software. When creating a confidential (blinded) transaction, the code could hit an internal 'assert' and abort the program if it tried to generate a rangeproof for a zero-amount output sent to a normal spendable address. The patch turns those hard crashes into ordinary error returns, and adds an explicit early check that rejects zero-value spendable outputs before blinding. It also adds tests proving the boundary between valid and invalid cases. The change is defensive and improves robustness, but it is a partial patch: it does not add the same explicit zero-amount guard to the older BlindTransaction path, only to the newer PSBT blinding path.
Treat as a low-to-moderate reliability/security hardening fix. Review whether the older BlindTransaction path should also add an explicit pre-check for zero-valued spendable outputs, or whether all callers are already constrained. Monitor for any related crash reports or CVE assignments; no immediate emergency response is indicated because the crash requires a specific caller-supplied invalid amount and results in denial of service rather than funds loss.
Security signals we found
Removal of assert() on cryptographic operation result in transaction blinding code
Conversion of abort-on-failure into error-return for rangeproof generation failures
New explicit validation rejecting zero-amount spendable outputs in PSBT blinding
Addition of regression tests covering zero-value spendable vs unspendable rangeproof boundaries
Comment explaining that failed surjection proof is a foreseeable condition, distinguishing it from rangeproof failure
Evidence from the diff
The patch removes assert(rangeresult) calls in blind.cpp’s BlindTransaction and blindpsbt.cpp’s BlindPSBT after GenerateRangeproof/CreateValueRangeProof/CreateBlindValueProof, returning -1 or BlindingStatus::RANGEPROOF_UNABLE instead. It adds BlindingStatus::INVALID_AMOUNT for zero-valued outputs to spendable scripts in BlindPSBT, because rangeproofs for spendable outputs use min_value=1 and secp256k1_rangeproof_sign fails when value < min_value. A regression test demonstrates that CreateValueRangeProof and GenerateRangeproof fail for amount=0+spendable script, succeed for amount=0+unspendable script (fees/issuances/OP_RETURN), and succeed for amount=1+spendable script. The BlindTransaction path still lacks an explicit pre-check for zero spendable outputs, relying on the caller to avoid the condition.
Changed components
src/blind.cpp - BlindTransaction issuance and output rangeproof generationsrc/blindpsbt.cpp - BlindPSBT output rangeproof and blind value proof generationsrc/blindpsbt.h - BlindingStatus enumsrc/test/blind_tests.cpp - regression testsInspect captured patch +99 / −5
### src/blind.cpp
@@ -546,7 +546,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++;
@@ -621,9 +623,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;
}
### src/blindpsbt.cpp
@@ -30,6 +30,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);
}
@@ -497,6 +501,12 @@ 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;
@@ -559,12 +569,16 @@ BlindingStatus BlindPSBT(PartiallySignedTransaction& psbt, std::map<uint32_t, st
// 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)) {
### src/blindpsbt.h
@@ -28,6 +28,8 @@ enum class BlindingStatus
INVALID_BLINDER,
ASP_UNABLE,
NO_BLIND_OUTPUTS,
+ RANGEPROOF_UNABLE,
+ INVALID_AMOUNT,
};
enum class BlindProofResult {
### src/test/blind_tests.cpp
@@ -4,6 +4,7 @@
#include <arith_uint256.h>
#include <blind.h>
+#include <blindpsbt.h>
#include <coins.h>
#include <random.h>
#include <uint256.h>
@@ -372,4 +373,75 @@ BOOST_AUTO_TEST_CASE(naive_blinding_test)
BOOST_CHECK(!VerifyAmounts(inputs, CTransaction(txtemp), nullptr, false));
}
}
+BOOST_AUTO_TEST_CASE(rangeproof_zero_value_spendable_script)
+{
+ // A rangeproof over a spendable script uses min_value = 1
+ // (`min_value = scriptPubKey.IsUnspendable() ? 0 : 1`), and
+ // secp256k1_rangeproof_sign returns 0 when min_value > value. A zero-valued
+ // output to a spendable script therefore has no valid rangeproof, and the
+ // creation helpers must report that rather than assert on it.
+
+ const CAsset asset(GetRandHash());
+ const uint256 asset_blinder = GetRandHash();
+ const uint256 value_blinder = GetRandHash();
+ const uint256 nonce = GetRandHash();
+
+ const CScript spendable = CScript() << OP_TRUE;
+ const CScript unspendable = CScript() << OP_RETURN;
+ BOOST_CHECK(!spendable.IsUnspendable());
+ BOOST_CHECK(unspendable.IsUnspendable());
+
+ // Asset generator, shared by every case below
+ CConfidentialAsset conf_asset;
+ secp256k1_generator asset_gen;
+ CreateAssetCommitment(conf_asset, asset_gen, asset, asset_blinder);
+
+ // Commitments to 0 and to 1 under that generator
+ CConfidentialValue conf_value_zero, conf_value_one;
+ secp256k1_pedersen_commitment value_commit_zero, value_commit_one;
+ CreateValueCommitment(conf_value_zero, value_commit_zero, value_blinder, asset_gen, 0);
+ CreateValueCommitment(conf_value_one, value_commit_one, value_blinder, asset_gen, 1);
+
+ std::vector<unsigned char> rangeproof;
+
+ // Zero to a spendable script is unprovable. Before the fix, the caller at
+ // blindpsbt.cpp:562 turns this false into assert(rangeresult) -> SIGABRT.
+ BOOST_CHECK(!CreateValueRangeProof(rangeproof, value_blinder, nonce, 0, spendable,
+ value_commit_zero, asset_gen, asset, asset_blinder));
+
+ // Zero to an unspendable script gives min_value = 0 and must keep working:
+ // this is the fee / issuance / OP_RETURN shape.
+ BOOST_CHECK(CreateValueRangeProof(rangeproof, value_blinder, nonce, 0, unspendable,
+ value_commit_zero, asset_gen, asset, asset_blinder));
+
+ // The ordinary case is unaffected.
+ BOOST_CHECK(CreateValueRangeProof(rangeproof, value_blinder, nonce, 1, spendable,
+ value_commit_one, asset_gen, asset, asset_blinder));
+
+ // Confirm the boundary is min_value and not something incidental, mirroring
+ // the rangeproof_info check in naive_blinding_test.
+ {
+ secp256k1_context* ctx = secp256k1_context_create(SECP256K1_CONTEXT_VERIFY);
+ int exp = 0;
+ int mantissa = 0;
+ uint64_t min_value = 0;
+ uint64_t max_value = 0;
+ BOOST_CHECK(secp256k1_rangeproof_info(ctx, &exp, &mantissa, &min_value, &max_value,
+ rangeproof.data(), rangeproof.size()) == 1);
+ BOOST_CHECK_EQUAL(min_value, 1ULL);
+ secp256k1_context_destroy(ctx);
+ }
+
+ std::vector<unsigned char*> value_blindptrs;
+ std::vector<const unsigned char*> asset_blindptrs;
+ value_blindptrs.push_back(const_cast<unsigned char*>(value_blinder.begin()));
+ asset_blindptrs.push_back(asset_blinder.begin());
+
+ BOOST_CHECK(!GenerateRangeproof(rangeproof, value_blindptrs, nonce, 0, spendable,
+ value_commit_zero, asset_gen, asset, asset_blindptrs));
+ BOOST_CHECK(GenerateRangeproof(rangeproof, value_blindptrs, nonce, 0, unspendable,
+ value_commit_zero, asset_gen, asset, asset_blindptrs));
+ BOOST_CHECK(GenerateRangeproof(rangeproof, value_blindptrs, nonce, 1, spendable,
+ value_commit_one, asset_gen, asset, asset_blindptrs));
+}
BOOST_AUTO_TEST_SUITE_END()Why this scored 60/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.