Skip to main content

blvm_protocol/
bip158.rs

1//! BIP158: Compact Block Filters for Light Client Discovery
2//!
3//! Specification: https://github.com/bitcoin/bips/blob/master/bip-0158.mediawiki
4//!
5//! Implements Golomb-Rice Coded Sets (GCS) for efficient block filtering.
6//! Allows light clients to determine if a block contains relevant transactions
7//! without downloading the entire block.
8
9use crate::Transaction;
10use sha2::{Digest, Sha256};
11use std::sync::Arc;
12
13/// BIP158 filter parameter: false positive rate (2^(-P))
14/// P = 19 means ~1 in 524,288 false positives
15pub const BIP158_P: u8 = 19;
16
17/// BIP158 filter parameter: multiplier M = 2^P
18pub const BIP158_M: u64 = 1 << BIP158_P; // 2^19 = 524,288
19
20/// Compact block filter (GCS)
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct CompactBlockFilter {
23    /// Golomb-Rice encoded filter data. Shared with a cached `cfilter` message.
24    pub filter_data: Arc<Vec<u8>>,
25    /// Number of elements in the filter
26    pub num_elements: u32,
27}
28
29/// Hash a script to a number in range [0, N*M)
30fn hash_to_range(script: &[u8], n: u64, m: u64) -> u64 {
31    // Hash script with SHA256
32    let mut hasher = Sha256::new();
33    hasher.update(script);
34    let hash = hasher.finalize();
35
36    // Interpret first 8 bytes as u64 (little-endian)
37    let hash_value = u64::from_le_bytes([
38        hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
39    ]);
40
41    // Map to range [0, N*M)
42    hash_value % (n * m)
43}
44
45/// Bit writer for Golomb-Rice encoding
46struct BitWriter {
47    data: Vec<u8>,
48    current_byte: u8,
49    bit_count: u8,
50}
51
52impl BitWriter {
53    fn new() -> Self {
54        Self {
55            data: Vec::new(),
56            current_byte: 0,
57            bit_count: 0,
58        }
59    }
60
61    /// Write a single bit
62    fn write_bit(&mut self, bit: bool) {
63        if bit {
64            self.current_byte |= 1u8 << (7 - self.bit_count);
65        }
66        self.bit_count += 1;
67
68        if self.bit_count == 8 {
69            self.data.push(self.current_byte);
70            self.current_byte = 0;
71            self.bit_count = 0;
72        }
73    }
74
75    /// Write multiple bits (up to 64)
76    fn write_bits(&mut self, value: u64, num_bits: u8) {
77        for i in 0..num_bits {
78            let bit = ((value >> (num_bits - 1 - i)) & 1) != 0;
79            self.write_bit(bit);
80        }
81    }
82
83    /// Finish writing (flush remaining bits)
84    fn finish(mut self) -> Vec<u8> {
85        self.align_byte();
86        self.data
87    }
88
89    /// Flush a partial byte so the next value starts on a byte boundary.
90    /// Independent encodings were concatenated this way.
91    fn align_byte(&mut self) {
92        if self.bit_count > 0 {
93            self.data.push(self.current_byte);
94            self.current_byte = 0;
95            self.bit_count = 0;
96        }
97    }
98
99    /// BIP158: Encode value x as:
100    /// - Write (x / 2^P) in unary (that many 1s, then a 0)
101    /// - Write (x mod 2^P) in binary (P bits)
102    fn write_golomb_rice(&mut self, value: u64, p: u8) {
103        let quotient = value >> p;
104        let remainder = value & ((1u64 << p) - 1);
105        for _ in 0..quotient {
106            self.write_bit(true);
107        }
108        self.write_bit(false);
109        self.write_bits(remainder, p);
110    }
111}
112
113/// Golomb-Rice encode one value. Tests use this as the separate-encoding oracle.
114#[cfg(test)]
115fn golomb_rice_encode(value: u64, p: u8) -> Vec<u8> {
116    let mut writer = BitWriter::new();
117    writer.write_golomb_rice(value, p);
118    writer.finish()
119}
120
121/// Encode sorted hashed values as Golomb-Rice differences from zero.
122/// Each value is byte-aligned, matching concatenation of [`golomb_rice_encode`].
123fn encode_sorted_differences(values: &[u64]) -> Vec<u8> {
124    let mut writer = BitWriter::new();
125    let Some((first, rest)) = values.split_first() else {
126        return writer.finish();
127    };
128    writer.write_golomb_rice(*first, BIP158_P);
129    writer.align_byte();
130    let mut prev = *first;
131    for value in rest {
132        writer.write_golomb_rice(value - prev, BIP158_P);
133        writer.align_byte();
134        prev = *value;
135    }
136    writer.finish()
137}
138
139/// Bit reader for Golomb-Rice decoding
140struct BitReader<'a> {
141    data: &'a [u8],
142    bit_offset: usize,
143}
144
145impl<'a> BitReader<'a> {
146    fn new(data: &'a [u8]) -> Self {
147        Self {
148            data,
149            bit_offset: 0,
150        }
151    }
152
153    /// Read a single bit
154    fn read_bit(&mut self) -> Option<bool> {
155        if self.bit_offset >= self.data.len() * 8 {
156            return None;
157        }
158        let byte_idx = self.bit_offset / 8;
159        let bit_idx = self.bit_offset % 8;
160        let bit = (self.data[byte_idx] >> (7 - bit_idx)) & 1;
161        self.bit_offset += 1;
162        Some(bit == 1)
163    }
164
165    /// Read P bits as a u64
166    fn read_bits(&mut self, p: u8) -> Option<u64> {
167        let mut value = 0u64;
168        for _ in 0..p {
169            if let Some(bit) = self.read_bit() {
170                value = (value << 1) | (if bit { 1 } else { 0 });
171            } else {
172                return None;
173            }
174        }
175        Some(value)
176    }
177}
178
179/// Golomb-Rice decode a value from stream
180///
181/// BIP158: Decode value x by:
182/// - Read unary-encoded quotient (count 1s until 0)
183/// - Read P bits as remainder
184/// - Value = quotient * 2^P + remainder
185fn golomb_rice_decode(reader: &mut BitReader, p: u8) -> Option<u64> {
186    // Read quotient in unary (count 1s until we hit a 0)
187    let mut quotient = 0u64;
188    loop {
189        match reader.read_bit() {
190            Some(true) => quotient += 1,
191            Some(false) => break,
192            None => return None,
193        }
194    }
195
196    // Read remainder in binary (P bits)
197    let remainder = reader.read_bits(p)?;
198
199    // Reconstruct value: quotient * 2^P + remainder
200    Some((quotient << p) | remainder)
201}
202
203/// Build a compact block filter from transaction data
204///
205/// BIP158: Filter contains:
206/// 1. All spendable output scriptPubKeys in the block
207/// 2. All scriptPubKeys from outputs spent by block's inputs
208pub fn build_block_filter(
209    block_transactions: &[Transaction],
210    previous_outpoint_scripts: &[Vec<u8>],
211) -> Result<CompactBlockFilter, String> {
212    let refs: Vec<&[u8]> = previous_outpoint_scripts
213        .iter()
214        .map(Vec::as_slice)
215        .collect();
216    build_block_filter_slices(block_transactions, &refs)
217}
218
219/// Same filter as [`build_block_filter`]. The spent scripts are borrowed.
220pub fn build_block_filter_slices<'a>(
221    block_transactions: &'a [Transaction],
222    previous_outpoint_scripts: &[&'a [u8]],
223) -> Result<CompactBlockFilter, String> {
224    let mut output_scripts = Vec::new();
225    for tx in block_transactions {
226        for output in &tx.outputs {
227            output_scripts.push(output.script_pubkey.as_slice());
228        }
229    }
230    build_block_filter_output_scripts(output_scripts, previous_outpoint_scripts)
231}
232
233/// Same filter as [`build_block_filter_slices`], from output scripts already in hand.
234///
235/// `output_scripts` is reused. Spent scripts are appended to it. Empty scripts are dropped.
236pub fn build_block_filter_output_scripts<'a>(
237    mut scripts: Vec<&'a [u8]>,
238    previous_outpoint_scripts: &[&'a [u8]],
239) -> Result<CompactBlockFilter, String> {
240    scripts.retain(|script| !script.is_empty());
241    scripts.extend(
242        previous_outpoint_scripts
243            .iter()
244            .copied()
245            .filter(|script| !script.is_empty()),
246    );
247
248    // Unique by script bytes. N for the range hash is this count.
249    scripts.sort_unstable();
250    scripts.dedup();
251    let n = scripts.len() as u64;
252    if n == 0 {
253        // Empty filter
254        return Ok(CompactBlockFilter {
255            filter_data: Arc::new(Vec::new()),
256            num_elements: 0,
257        });
258    }
259
260    // Hash each script to range [0, N*M)
261    let mut hashed_values: Vec<u64> = scripts
262        .iter()
263        .map(|script| hash_to_range(script, n, BIP158_M))
264        .collect();
265
266    // Sort and remove duplicates
267    hashed_values.sort_unstable();
268    hashed_values.dedup();
269
270    // Update n after deduplication. The first encoded value is the difference from 0.
271    let n_final = hashed_values.len() as u64;
272    let filter_data = Arc::new(encode_sorted_differences(&hashed_values));
273
274    Ok(CompactBlockFilter {
275        filter_data,
276        num_elements: n_final as u32,
277    })
278}
279
280/// Match a script against a compact block filter
281///
282/// Returns true if the script (or its hash) is likely in the filter.
283/// A filter that does not fully decode returns false.
284///
285/// Algorithm:
286/// 1. Hash the script to get a value in range [0, N*M)
287/// 2. Walk the Golomb-Rice differences, rebuilding each hashed value
288/// 3. The script matches when its hash is one of those values
289pub fn match_filter(filter: &CompactBlockFilter, script: &[u8]) -> bool {
290    if filter.num_elements == 0 {
291        return false;
292    }
293
294    let n = filter.num_elements as u64;
295    let script_hash = hash_to_range(script, n, BIP158_M);
296
297    let mut reader = BitReader::new(filter.filter_data.as_slice());
298    let mut current_value = 0u64;
299    let mut found = false;
300    for _ in 0..filter.num_elements {
301        let Some(diff) = golomb_rice_decode(&mut reader, BIP158_P) else {
302            return false;
303        };
304        current_value += diff;
305        if current_value == script_hash {
306            found = true;
307        }
308    }
309    found
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    #[test]
317    fn test_hash_to_range() {
318        let script = b"test script";
319        let n = 100;
320        let m = BIP158_M;
321        let value = hash_to_range(script, n, m);
322        assert!(value < n * m);
323    }
324
325    #[test]
326    fn test_golomb_rice_encode() {
327        // Test encoding small value
328        let encoded = golomb_rice_encode(0, BIP158_P);
329        assert!(!encoded.is_empty());
330
331        let encoded2 = golomb_rice_encode(1, BIP158_P);
332        assert!(!encoded2.is_empty());
333    }
334
335    #[test]
336    fn test_empty_filter() {
337        let filter = build_block_filter(&[], &[]).unwrap();
338        assert_eq!(filter.num_elements, 0);
339        assert!(filter.filter_data.is_empty());
340    }
341
342    #[test]
343    fn test_joined_golomb_matches_separate_encodings() {
344        let values = [0u64, 1, 100, 10_000];
345        let mut separate = Vec::new();
346        for value in values {
347            separate.extend(golomb_rice_encode(value, BIP158_P));
348        }
349        let mut writer = BitWriter::new();
350        for value in values {
351            writer.write_golomb_rice(value, BIP158_P);
352            writer.align_byte();
353        }
354        assert_eq!(writer.finish(), separate);
355    }
356
357    #[test]
358    fn test_golomb_rice_encode_decode_roundtrip() {
359        let test_values = vec![0, 1, 2, 10, 100, 1000, 10000];
360
361        for value in test_values {
362            let encoded = golomb_rice_encode(value, BIP158_P);
363            let mut reader = BitReader::new(&encoded);
364            let decoded = golomb_rice_decode(&mut reader, BIP158_P);
365
366            assert_eq!(decoded, Some(value), "Roundtrip failed for value {value}");
367        }
368    }
369
370    #[test]
371    fn test_build_and_match_filter() {
372        use crate::{OutPoint, Transaction, TransactionInput, TransactionOutput};
373
374        // Create a test transaction
375        let tx = Transaction {
376            version: 1,
377            inputs: vec![TransactionInput {
378                prevout: OutPoint {
379                    hash: [0u8; 32],
380                    index: 0,
381                },
382                script_sig: vec![],
383                sequence: 0xffffffff,
384            }]
385            .into(),
386            outputs: vec![TransactionOutput {
387                value: 1000,
388                script_pubkey: vec![blvm_consensus::opcodes::OP_1, blvm_consensus::opcodes::OP_2],
389            }]
390            .into(),
391            lock_time: 0,
392        };
393
394        // Build filter
395        let filter = build_block_filter(&[tx.clone()], &[]).unwrap();
396        assert!(filter.num_elements > 0);
397
398        // Match the script that's in the filter
399        let script_in_filter = &tx.outputs[0].script_pubkey;
400        assert!(match_filter(&filter, script_in_filter));
401
402        // Match a script that's not in the filter
403        let script_not_in_filter = vec![0x53, 0x54]; // OP_3 OP_4
404        // Note: May have false positives due to GCS nature, but should generally work
405        let _matched = match_filter(&filter, &script_not_in_filter);
406        // False positives are possible, so we can't assert false
407        // But we can verify the filter works for scripts that are definitely in it
408
409        // One more claimed element than the stream can decode rejects the filter.
410        let truncated = CompactBlockFilter {
411            filter_data: filter.filter_data.clone(),
412            num_elements: filter.num_elements + 1,
413        };
414        assert!(!match_filter(&truncated, script_in_filter));
415    }
416
417    #[test]
418    fn test_filter_bytes_ignore_script_order() {
419        use crate::{Transaction, TransactionInput, TransactionOutput};
420
421        let tx = |scripts: &[&[u8]]| Transaction {
422            version: 1,
423            inputs: Vec::<TransactionInput>::new().into(),
424            outputs: scripts
425                .iter()
426                .map(|script| TransactionOutput {
427                    value: 1,
428                    script_pubkey: script.to_vec(),
429                })
430                .collect::<Vec<_>>()
431                .into(),
432            lock_time: 0,
433        };
434        let first = build_block_filter(&[tx(&[b"\x51\x52", b"\x53\x54"])], &[]).unwrap();
435        let second = build_block_filter(&[tx(&[b"\x53\x54", b"\x51\x52"])], &[]).unwrap();
436        assert_eq!(first, second);
437        assert_eq!(first.num_elements, 2);
438    }
439}