Skip to content
Open
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
6 changes: 3 additions & 3 deletions src/script/sigcache.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ void SignatureCache::ComputeEntrySchnorr(uint256& entry, const uint256& hash, Sp
}

// ELEMENTS:
void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment) const {
void SignatureCache::ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment, const std::vector<unsigned char>& 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<unsigned char>& proof, const std::vector<unsigned char>& commitment) const {
CSHA256 hasher = m_salted_hasher_surjection_proof;
Expand Down Expand Up @@ -131,7 +131,7 @@ bool InitSurjectionproofCache(size_t max_size_bytes)
bool CachingRangeProofChecker::VerifyRangeProof(const std::vector<unsigned char>& vchRangeProof, const std::vector<unsigned char>& vchValueCommitment, const std::vector<unsigned char>& 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;
Expand Down
2 changes: 1 addition & 1 deletion src/script/sigcache.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class SignatureCache
void ComputeEntrySchnorr(uint256& entry, const uint256 &hash, Span<const unsigned char> sig, const XOnlyPubKey& pubkey) const;

// ELEMENTS:
void ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment) const;
void ComputeEntryRangeProof(uint256& entry, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment, const std::vector<unsigned char>& asset_commitment, const CScript& scriptPubKey) const;

void ComputeEntrySurjectionProof(uint256& entry, const uint256 &hash, const std::vector<unsigned char>& proof, const std::vector<unsigned char>& commitment) const;

Expand Down
74 changes: 74 additions & 0 deletions src/test/blind_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
#include <boost/test/unit_test.hpp>

#include <secp256k1.h>
#include <secp256k1_generator.h>
#include <secp256k1_rangeproof.h>

// For elements serialization rules
struct ElementsSetup : public TestingSetup {
Expand Down Expand Up @@ -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<unsigned char> vCommit(commit_ser, commit_ser + 33);
std::vector<unsigned char> 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<unsigned char> 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<unsigned char> 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<unsigned char> 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()
71 changes: 71 additions & 0 deletions test/functional/feature_rangeproof_cache.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions test/functional/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading