From 36873ddcd368375024c983089f9819828910db0f Mon Sep 17 00:00:00 2001 From: Byron Hambly Date: Mon, 3 Aug 2026 12:53:50 +0200 Subject: [PATCH 1/3] fix: range proof cache bind to asset and scriptpubkey --- src/script/sigcache.cpp | 6 +++--- src/script/sigcache.h | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/script/sigcache.cpp b/src/script/sigcache.cpp index 94e9b893c8..30a34f4b23 100644 --- a/src/script/sigcache.cpp +++ b/src/script/sigcache.cpp @@ -55,9 +55,9 @@ void SignatureCache::ComputeEntrySchnorr(uint256& entry, const uint256& hash, Sp } // ELEMENTS: -void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector& proof, const std::vector& commitment) const { +void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector& proof, const std::vector& commitment, const std::vector& asset_commitment, const CScript& scriptPubKey) const { CSHA256 hasher = m_salted_hasher_range_proof; - hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Finalize(entry.begin()); + hasher.Write(proof.data(), proof.size()).Write(commitment.data(), commitment.size()).Write(asset_commitment.data(), asset_commitment.size()).Write(scriptPubKey.data(), scriptPubKey.size()).Finalize(entry.begin()); } void SignatureCache::ComputeEntrySurjectionProof(uint256& entry, const uint256 &hash, const std::vector& proof, const std::vector& commitment) const { CSHA256 hasher = m_salted_hasher_surjection_proof; @@ -131,7 +131,7 @@ bool InitSurjectionproofCache(size_t max_size_bytes) bool CachingRangeProofChecker::VerifyRangeProof(const std::vector& vchRangeProof, const std::vector& vchValueCommitment, const std::vector& vchAssetCommitment, const CScript& scriptPubKey, const secp256k1_context* secp256k1_ctx_verify_amounts) const { uint256 entry; - rangeProofCache.ComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment); + rangeProofCache.ComputeEntryRangeProof(entry, vchRangeProof, vchValueCommitment, vchAssetCommitment, scriptPubKey); if (rangeProofCache.Get(entry, !store)) { return true; diff --git a/src/script/sigcache.h b/src/script/sigcache.h index 157258721d..d95b3274d9 100644 --- a/src/script/sigcache.h +++ b/src/script/sigcache.h @@ -84,7 +84,7 @@ class SignatureCache void ComputeEntrySchnorr(uint256& entry, const uint256 &hash, Span sig, const XOnlyPubKey& pubkey) const; // ELEMENTS: - void ComputeEntryRangeProof(uint256& entry, const std::vector& proof, const std::vector& commitment) const; + void ComputeEntryRangeProof(uint256& entry, const std::vector& proof, const std::vector& commitment, const std::vector& asset_commitment, const CScript& scriptPubKey) const; void ComputeEntrySurjectionProof(uint256& entry, const uint256 &hash, const std::vector& proof, const std::vector& commitment) const; From a7944065deb9967da2202e5dd8e2614a26eff057 Mon Sep 17 00:00:00 2001 From: Byron Hambly Date: Mon, 31 Aug 2026 18:32:50 +0200 Subject: [PATCH 2/3] test: unit test for range proof cache fix --- src/test/blind_tests.cpp | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/test/blind_tests.cpp b/src/test/blind_tests.cpp index 42dded0ddc..159f3efe3f 100644 --- a/src/test/blind_tests.cpp +++ b/src/test/blind_tests.cpp @@ -15,6 +15,8 @@ #include #include +#include +#include // For elements serialization rules struct ElementsSetup : public TestingSetup { @@ -372,4 +374,76 @@ BOOST_AUTO_TEST_CASE(naive_blinding_test) BOOST_CHECK(!VerifyAmounts(inputs, CTransaction(txtemp), nullptr, false)); } } + +// The rangeproof verification cache entry must bind everything +// secp256k1_rangeproof_verify binds: the proof, the value commitment, the +// asset generator, and the scriptPubKey (the proof's extra commitment). +// Regression test: a (proof, commitment) pair verified under one script must +// not be accepted under another via a cache hit, and the min_value==0 guard +// for spendable outputs must not be skipped on cache hits. +BOOST_AUTO_TEST_CASE(rangeproof_cache_binding_test) +{ + // May already be initialized by a previous test case in this suite. + BOOST_CHECK(InitRangeproofCache(DEFAULT_VALIDATION_CACHE_BYTES / 4)); + + secp256k1_context* ctx = secp256k1_blind_context; + BOOST_REQUIRE(ctx != nullptr); + + unsigned char asset32[32] = {0}; asset32[31] = 0x01; + unsigned char blind[32] = {0}; blind[31] = 0x02; + unsigned char nonce[32] = {0}; nonce[31] = 0x03; + + secp256k1_generator gen; + BOOST_REQUIRE(secp256k1_generator_generate(ctx, &gen, asset32) == 1); + + const uint64_t value = 1000; + secp256k1_pedersen_commitment commit; + BOOST_REQUIRE(secp256k1_pedersen_commit(ctx, &commit, blind, value, &gen) == 1); + + unsigned char commit_ser[33], gen_ser[33]; + secp256k1_pedersen_commitment_serialize(ctx, commit_ser, &commit); + secp256k1_generator_serialize(ctx, gen_ser, &gen); + std::vector vCommit(commit_ser, commit_ser + 33); + std::vector vAsset(gen_ser, gen_ser + 33); + + CScript scriptA; scriptA << OP_TRUE; // spendable + CScript scriptB; scriptB << OP_RETURN << 1; // different script + CScript scriptUnsp; scriptUnsp << OP_RETURN; // unspendable + + // Honest proof (min_value=1) with extra commitment scriptA. + std::vector proof(5134); + size_t plen = proof.size(); + BOOST_REQUIRE(secp256k1_rangeproof_sign(ctx, proof.data(), &plen, /*min_value=*/1, + &commit, blind, nonce, /*exp=*/0, /*min_bits=*/52, value, + /*message=*/nullptr, /*msg_len=*/0, scriptA.data(), scriptA.size(), &gen) == 1); + proof.resize(plen); + + CachingRangeProofChecker checker(/*storeIn=*/true); + BOOST_CHECK(checker.VerifyRangeProof(proof, vCommit, vAsset, scriptA, ctx)); + // After the first call cached (proof, commitment) under scriptA, the same + // pair must still be rejected under a different script. + BOOST_CHECK(!checker.VerifyRangeProof(proof, vCommit, vAsset, scriptB, ctx)); + + // Same for the asset generator, which is the proof's verification tag: + // the cached pair must not validate under a different generator either. + unsigned char asset32b[32] = {0}; asset32b[31] = 0x09; + secp256k1_generator gen2; + BOOST_REQUIRE(secp256k1_generator_generate(ctx, &gen2, asset32b) == 1); + unsigned char gen2_ser[33]; + secp256k1_generator_serialize(ctx, gen2_ser, &gen2); + std::vector vAsset2(gen2_ser, gen2_ser + 33); + BOOST_CHECK(!checker.VerifyRangeProof(proof, vCommit, vAsset2, scriptA, ctx)); + + // min_value=0 proof, valid only for unspendable scripts. + std::vector proof0(5134); + size_t plen0 = proof0.size(); + BOOST_REQUIRE(secp256k1_rangeproof_sign(ctx, proof0.data(), &plen0, /*min_value=*/0, + &commit, blind, nonce, /*exp=*/0, /*min_bits=*/52, value, + /*message=*/nullptr, /*msg_len=*/0, scriptUnsp.data(), scriptUnsp.size(), &gen) == 1); + proof0.resize(plen0); + + BOOST_CHECK(checker.VerifyRangeProof(proof0, vCommit, vAsset, scriptUnsp, ctx)); + // The anti-zero-token guard must not be bypassable via a cache hit. + BOOST_CHECK(!checker.VerifyRangeProof(proof0, vCommit, vAsset, scriptA, ctx)); +} BOOST_AUTO_TEST_SUITE_END() From 1e73e597f156a0cc4223724e0f12a261e190c400 Mon Sep 17 00:00:00 2001 From: Byron Hambly Date: Mon, 31 Aug 2026 18:32:50 +0200 Subject: [PATCH 3/3] test: functional test for range proof cache fix --- test/functional/feature_rangeproof_cache.py | 71 +++++++++++++++++++++ test/functional/test_runner.py | 1 + 2 files changed, 72 insertions(+) create mode 100755 test/functional/feature_rangeproof_cache.py diff --git a/test/functional/feature_rangeproof_cache.py b/test/functional/feature_rangeproof_cache.py new file mode 100755 index 0000000000..ab4efd5d9a --- /dev/null +++ b/test/functional/feature_rangeproof_cache.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Elements Core developers +# Distributed under the MIT/X11 software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Regression test: the rangeproof verification cache must bind the +scriptPubKey (and asset generator), not just (proof, value commitment). + +A blinded output's rangeproof cryptographically binds its scriptPubKey as +the proof's extra commitment. A cache keyed on (proof, commitment) alone +would accept a previously-seen pair under any script on a cache hit, and +would skip the min_value==0 guard for spendable outputs. +""" + +from test_framework.test_framework import BitcoinTestFramework +from test_framework.messages import CTransaction, tx_from_hex +from test_framework.util import assert_equal + + +class RangeproofCacheTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.setup_clean_chain = True + args = ["-blindedaddresses=1", "-initialfreecoins=2100000000000000", + "-con_blocksubsidy=0", "-con_connect_genesis_outputs=1", + "-anyonecanspendaremine=1"] + self.extra_args = [args] + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + + def add_options(self, parser): + self.add_wallet_options(parser) + + def run_test(self): + node = self.nodes[0] + self.generate(node, 1) + + # A valid blinded transaction; the recipient output carries value + # commitment C, rangeproof P, and scriptPubKey script_a. + addr = node.getnewaddress() + script_a = bytes.fromhex(node.validateaddress(addr)["scriptPubKey"]) + tx_hex = node.createrawtransaction([], [{addr: 1}]) + tx_hex = node.fundrawtransaction(tx_hex)["hex"] + # Coming from initial free coins: no need to sign + tx_hex = node.blindrawtransaction(tx_hex) + + # Poisoned copy: identical (C, P) and witnesses, but the blinded + # output's scriptPubKey is replaced. Balance and surjection proofs + # still hold; only the rangeproof is invalid under the new script. + tx = tx_from_hex(tx_hex) + poison = CTransaction(tx) + idx = next(i for i, o in enumerate(tx.vout) if o.scriptPubKey == script_a) + poison.vout[idx].scriptPubKey = b"\x51" # OP_TRUE + poison_hex = poison.serialize().hex() + + # Validating the honest tx warms the rangeproof cache. The poisoned + # copy must still be rejected afterwards. + assert_equal(node.testmempoolaccept([tx_hex])[0]["allowed"], True) + res = node.testmempoolaccept([poison_hex])[0] + assert_equal(res["allowed"], False) + assert_equal(res["reject-reason"], "bad-txns-in-ne-out") + + # Acceptance must not depend on cache contents: also rejected cold. + self.restart_node(0) + res = node.testmempoolaccept([poison_hex])[0] + assert_equal(res["allowed"], False) + assert_equal(res["reject-reason"], "bad-txns-in-ne-out") + + +if __name__ == "__main__": + RangeproofCacheTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 529c80a9a2..51c2b95fd7 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -112,6 +112,7 @@ 'rpc_tweakfedpeg.py --legacy-wallet', 'feature_issuance.py --legacy-wallet', 'feature_confidential_transactions.py --legacy-wallet', + 'feature_rangeproof_cache.py', 'feature_default_asset_name.py --legacy-wallet', 'feature_assetsdir.py --legacy-wallet', 'feature_initial_reissuance_token.py --legacy-wallet',