fix: range proof cache bind to asset and scriptpubkey
What changed, and why it matters
This commit changes how Elements (the software behind the Liquid sidechain) remembers whether it has already checked a 'range proof'—a cryptographic receipt that helps keep transaction amounts private. Previously, the cache key was built only from the proof and the value commitment. The patch adds the asset commitment and the output's scriptPubKey to that key. An independent researcher argues that an earlier version of this same fix introduced a cache-key collision that was exploited to withdraw roughly 4,000 BTC from Liquid's federation wallet. The commit itself is a narrow code change; the exploit claim comes from outside analysis, not from the commit message or a vendor root-cause report.
Treat this commit as security-relevant and ensure all Elements/Liquid nodes are running a version that includes it. Review the full key-serialization logic for length-prefixing or domain separation to rule out remaining collision issues. Await and apply Liquid's official root-cause report and any follow-up patches. Operators should monitor for reorg or consensus-split behavior between patched and unpatched nodes.
Security signals we found
Cache-key construction changed to include additional contextual fields (asset commitment and scriptPubKey)
Independent researcher links this exact commit to a 4,000 BTC Liquid sidechain exploit
Vendor (Liquid) confirms a security incident and withdrawal but does not identify root cause in the supplied reference
Commit message is purely functional ('fix: range proof cache bind to asset and scriptpubkey') and does not disclose security relevance
Change is small (+4/-4) and localized to the range-proof cache
Evidence from the diff
The diff updates SignatureCache::ComputeEntryRangeProof and its call site in CachingRangeProofChecker::VerifyRangeProof. The old key was H(proof || commitment). The new key is H(proof || commitment || asset_commitment || scriptPubKey). The change binds the cached range-proof verification result to the asset and the spending condition. The independent analysis (mononaut) states that a prior attempt to fix a cache-binding weakness (Bug A) produced this new key format by concatenating a variable-length scriptPubKey after unframed fields, creating a new collision (Bug B) where different combinations of proof, amount, asset, and script boundaries can yield the same key. According to that analysis, the exploited block was rejected by official release nodes lacking this commit but accepted by nodes running the newer deployed code. The commit message does not mention security or the exploit.
Changed components
src/script/sigcache.cppsrc/script/sigcache.hCachingRangeProofChecker::VerifyRangeProofSignatureCache::ComputeEntryRangeProofElements/Liquid range-proof verification cacheInspect captured patch +4 / −4
### 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<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;
@@ -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;
### src/script/sigcache.h
@@ -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;
Why this scored 87/100
Evidence and disclosure record
Verified links used to place this patch in context. External claims remain attributed to their publishers.
Liquid Network security incident
Liquid confirms that purported white-hat actors withdrew roughly 4,000 BTC from its federation wallet through the SideSwap PAK, without compromising that PAK or other federation keys. Liquid paused the sidechain and says other issued assets are unaffected. The statement does not identify the root cause.
Liquid exploit: cache-key collision analysis and timeline
Independent code and on-chain analysis identifies this exact September 1 commit as a fix for an older cache-binding weakness (Bug A) that itself introduced the exploited collision (Bug B). By appending the variable-length script directly after other unframed fields, the new key can be reproduced with different proof, amount, asset, and script boundaries. The exploit's padding only collides under this new key format. The analysis says official release nodes without this commit rejected the exploit block and stalled, while nodes running the newer deployed code accepted it. This mechanism remains independent analysis pending Liquid's root-cause report.
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.