Skip to main content

abtc_application/
compact_blocks.rs

1//! Compact Block Relay (BIP152)
2//!
3//! Implements the compact block protocol that allows peers to relay blocks
4//! using short transaction IDs rather than full transactions. Since most
5//! transactions in a new block are already in the receiver's mempool,
6//! only a compact summary needs to be sent.
7//!
8//! ## Protocol Flow
9//!
10//! 1. Sender constructs a `CompactBlock` from a full block:
11//!    - Header + nonce
12//!    - Short transaction IDs (first 6 bytes of SipHash)
13//!    - A few "prefilled" transactions (always the coinbase, plus any the
14//!      sender guesses the receiver doesn't have)
15//!
16//! 2. Receiver matches short IDs against its mempool:
17//!    - If all transactions are found → reconstruct the full block
18//!    - If some are missing → send `GetBlockTxn` to request them
19//!    - Sender replies with `BlockTxn` containing the missing transactions
20//!
21//! ## References
22//!
23//! - BIP152: <https://github.com/bitcoin/bips/blob/master/bip-0152.mediawiki>
24
25use abtc_domain::primitives::{Block, BlockHeader, Transaction};
26use std::collections::HashMap;
27
28// ── SipHash for short IDs ───────────────────────────────────────────
29
30/// Compute a SipHash-2-4 based short ID for a transaction.
31///
32/// BIP152 uses SipHash with a key derived from the block header hash and a
33/// nonce. We implement a simplified version here.
34fn siphash_2_4(key0: u64, key1: u64, data: &[u8]) -> u64 {
35    // Simplified SipHash-2-4 implementation
36    let mut v0: u64 = 0x736f6d6570736575 ^ key0;
37    let mut v1: u64 = 0x646f72616e646f6d ^ key1;
38    let mut v2: u64 = 0x6c7967656e657261 ^ key0;
39    let mut v3: u64 = 0x7465646279746573 ^ key1;
40
41    // Process 8-byte blocks
42    let blocks = data.len() / 8;
43    for i in 0..blocks {
44        let mut m = 0u64;
45        for j in 0..8 {
46            m |= (data[i * 8 + j] as u64) << (j * 8);
47        }
48        v3 ^= m;
49        for _ in 0..2 {
50            sipround(&mut v0, &mut v1, &mut v2, &mut v3);
51        }
52        v0 ^= m;
53    }
54
55    // Process remaining bytes + length
56    let mut last = ((data.len() & 0xff) as u64) << 56;
57    let remaining = data.len() % 8;
58    for i in 0..remaining {
59        last |= (data[blocks * 8 + i] as u64) << (i * 8);
60    }
61
62    v3 ^= last;
63    for _ in 0..2 {
64        sipround(&mut v0, &mut v1, &mut v2, &mut v3);
65    }
66    v0 ^= last;
67
68    // Finalization
69    v2 ^= 0xff;
70    for _ in 0..4 {
71        sipround(&mut v0, &mut v1, &mut v2, &mut v3);
72    }
73
74    v0 ^ v1 ^ v2 ^ v3
75}
76
77#[inline]
78fn sipround(v0: &mut u64, v1: &mut u64, v2: &mut u64, v3: &mut u64) {
79    *v0 = v0.wrapping_add(*v1);
80    *v1 = v1.rotate_left(13);
81    *v1 ^= *v0;
82    *v0 = v0.rotate_left(32);
83    *v2 = v2.wrapping_add(*v3);
84    *v3 = v3.rotate_left(16);
85    *v3 ^= *v2;
86    *v0 = v0.wrapping_add(*v3);
87    *v3 = v3.rotate_left(21);
88    *v3 ^= *v0;
89    *v2 = v2.wrapping_add(*v1);
90    *v1 = v1.rotate_left(17);
91    *v1 ^= *v2;
92    *v2 = v2.rotate_left(32);
93}
94
95// ── Short ID computation ────────────────────────────────────────────
96
97/// A 6-byte short transaction ID used in compact blocks.
98pub type ShortTxId = [u8; 6];
99
100/// Derive SipHash keys from the block header hash and a nonce.
101///
102/// key0 and key1 are the first and second 8-byte little-endian words of
103/// SHA256(SHA256(header) || nonce).
104fn compute_siphash_keys(header: &BlockHeader, nonce: u64) -> (u64, u64) {
105    use sha2::{Digest, Sha256};
106
107    // SHA256(SHA256(header) || nonce)
108    let header_hash = header.block_hash();
109    let mut hasher = Sha256::new();
110    hasher.update(header_hash.as_bytes());
111    hasher.update(nonce.to_le_bytes());
112    let first_hash = hasher.finalize();
113
114    let mut hasher2 = Sha256::new();
115    hasher2.update(first_hash);
116    let result = hasher2.finalize();
117
118    let key0 = u64::from_le_bytes(result[0..8].try_into().unwrap());
119    let key1 = u64::from_le_bytes(result[8..16].try_into().unwrap());
120
121    (key0, key1)
122}
123
124/// Compute a 6-byte short transaction ID for a given txid.
125pub fn compute_short_txid(key0: u64, key1: u64, txid_bytes: &[u8]) -> ShortTxId {
126    let hash = siphash_2_4(key0, key1, txid_bytes);
127    let bytes = hash.to_le_bytes();
128    let mut short_id = [0u8; 6];
129    short_id.copy_from_slice(&bytes[0..6]);
130    short_id
131}
132
133// ── Compact Block types ─────────────────────────────────────────────
134
135/// A prefilled transaction: index in the block + the full transaction.
136#[derive(Clone, Debug)]
137pub struct PrefilledTransaction {
138    /// Differential index (gap from last prefilled index).
139    pub index: u16,
140    /// The full transaction.
141    pub tx: Transaction,
142}
143
144/// A compact block (BIP152 `cmpctblock`).
145///
146/// Contains the header, a nonce, short transaction IDs for most transactions,
147/// and a few prefilled transactions (always at least the coinbase).
148#[derive(Clone, Debug)]
149pub struct CompactBlock {
150    /// The block header.
151    pub header: BlockHeader,
152    /// Random nonce used for short ID computation.
153    pub nonce: u64,
154    /// Short transaction IDs for non-prefilled transactions.
155    pub short_ids: Vec<ShortTxId>,
156    /// Prefilled transactions (coinbase + any extras).
157    pub prefilled_txs: Vec<PrefilledTransaction>,
158}
159
160/// A request for missing transactions from a compact block.
161#[derive(Clone, Debug)]
162pub struct GetBlockTransactions {
163    /// Hash of the block.
164    pub block_hash: abtc_domain::primitives::BlockHash,
165    /// Indices of requested transactions.
166    pub indices: Vec<u16>,
167}
168
169/// Response with the missing transactions.
170#[derive(Clone, Debug)]
171pub struct BlockTransactions {
172    /// Hash of the block.
173    pub block_hash: abtc_domain::primitives::BlockHash,
174    /// The requested transactions in order.
175    pub transactions: Vec<Transaction>,
176}
177
178/// Result of attempting to reconstruct a block from a compact block.
179#[derive(Debug)]
180pub enum ReconstructResult {
181    /// Successfully reconstructed the full block.
182    Success(Block),
183    /// Some transactions are missing — need to request them.
184    NeedTransactions(GetBlockTransactions),
185}
186
187// ── CompactBlock construction ───────────────────────────────────────
188
189impl CompactBlock {
190    /// Build a compact block from a full block.
191    ///
192    /// The coinbase transaction is always prefilled. Additional transactions
193    /// may be prefilled if the sender thinks the receiver won't have them.
194    pub fn from_block(block: &Block, nonce: u64) -> Self {
195        let (key0, key1) = compute_siphash_keys(&block.header, nonce);
196
197        let mut short_ids = Vec::new();
198        let mut prefilled_txs = Vec::new();
199
200        for (i, tx) in block.transactions.iter().enumerate() {
201            if i == 0 {
202                // Always prefill the coinbase.
203                prefilled_txs.push(PrefilledTransaction {
204                    index: 0,
205                    tx: tx.clone(),
206                });
207            } else {
208                let txid = tx.txid();
209                let short_id = compute_short_txid(key0, key1, txid.as_bytes());
210                short_ids.push(short_id);
211            }
212        }
213
214        CompactBlock {
215            header: block.header.clone(),
216            nonce,
217            short_ids,
218            prefilled_txs,
219        }
220    }
221
222    /// Attempt to reconstruct the full block using a mempool lookup.
223    ///
224    /// `mempool_lookup` maps short IDs → transactions. If all transactions
225    /// can be resolved, returns the full block. Otherwise, returns a request
226    /// for the missing transactions.
227    pub fn reconstruct(
228        &self,
229        mempool_lookup: &HashMap<ShortTxId, Transaction>,
230    ) -> ReconstructResult {
231        let total_tx_count = self.prefilled_txs.len() + self.short_ids.len();
232        let mut transactions: Vec<Option<Transaction>> = vec![None; total_tx_count];
233        let mut missing_indices = Vec::new();
234
235        // Place prefilled transactions.
236        let mut prefill_offset = 0u16;
237        for prefill in &self.prefilled_txs {
238            let actual_index = (prefill.index + prefill_offset) as usize;
239            if actual_index < total_tx_count {
240                transactions[actual_index] = Some(prefill.tx.clone());
241            }
242            prefill_offset = prefill.index + prefill_offset + 1;
243        }
244
245        // Fill in from mempool using short IDs.
246        let mut short_id_idx = 0;
247        #[allow(clippy::needless_range_loop)]
248        for i in 0..total_tx_count {
249            if transactions[i].is_some() {
250                continue; // already prefilled
251            }
252
253            if short_id_idx >= self.short_ids.len() {
254                missing_indices.push(i as u16);
255                continue;
256            }
257
258            let short_id = &self.short_ids[short_id_idx];
259            short_id_idx += 1;
260
261            if let Some(tx) = mempool_lookup.get(short_id) {
262                transactions[i] = Some(tx.clone());
263            } else {
264                missing_indices.push(i as u16);
265            }
266        }
267
268        if missing_indices.is_empty() {
269            // All transactions found — build the block.
270            let txs: Vec<Transaction> = transactions
271                .into_iter()
272                .map(|t| t.expect("all slots filled"))
273                .collect();
274
275            ReconstructResult::Success(Block {
276                header: self.header.clone(),
277                transactions: txs,
278            })
279        } else {
280            ReconstructResult::NeedTransactions(GetBlockTransactions {
281                block_hash: self.header.block_hash(),
282                indices: missing_indices,
283            })
284        }
285    }
286
287    /// The number of transactions represented (prefilled + short IDs).
288    pub fn transaction_count(&self) -> usize {
289        self.prefilled_txs.len() + self.short_ids.len()
290    }
291
292    /// Build a mempool lookup table for compact block reconstruction.
293    ///
294    /// Takes an iterator of mempool transactions and returns a map from
295    /// short ID → transaction, keyed using this compact block's header and nonce.
296    pub fn build_mempool_lookup<'a, I>(&self, mempool_txs: I) -> HashMap<ShortTxId, Transaction>
297    where
298        I: IntoIterator<Item = &'a Transaction>,
299    {
300        let (key0, key1) = compute_siphash_keys(&self.header, self.nonce);
301        let mut lookup = HashMap::new();
302
303        for tx in mempool_txs {
304            let txid = tx.txid();
305            let short_id = compute_short_txid(key0, key1, txid.as_bytes());
306            lookup.insert(short_id, tx.clone());
307        }
308
309        lookup
310    }
311}
312
313// ── Tests ───────────────────────────────────────────────────────────
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use abtc_domain::primitives::{Amount, BlockHash, Hash256, OutPoint, TxIn, TxOut, Txid};
319    use abtc_domain::script::Script;
320
321    fn make_test_tx(value: i64) -> Transaction {
322        Transaction::v1(
323            vec![TxIn::final_input(
324                OutPoint::new(Txid::zero(), 0),
325                Script::new(),
326            )],
327            vec![TxOut::new(Amount::from_sat(value), Script::new())],
328            0,
329        )
330    }
331
332    fn make_test_block(num_txs: usize) -> Block {
333        let coinbase = Transaction::v1(
334            vec![TxIn::final_input(OutPoint::coinbase(), Script::new())],
335            vec![TxOut::new(Amount::from_sat(50_0000_0000), Script::new())],
336            0,
337        );
338
339        let mut txs = vec![coinbase];
340        for i in 1..=num_txs {
341            txs.push(make_test_tx(i as i64 * 1000));
342        }
343
344        Block {
345            header: BlockHeader {
346                version: 1,
347                prev_block_hash: BlockHash::zero(),
348                merkle_root: Hash256::zero(),
349                time: 1234567890,
350                bits: 0x207fffff,
351                nonce: 0,
352            },
353            transactions: txs,
354        }
355    }
356
357    #[test]
358    fn test_compact_block_from_block() {
359        let block = make_test_block(5);
360        let compact = CompactBlock::from_block(&block, 42);
361
362        assert_eq!(compact.transaction_count(), 6); // coinbase + 5
363        assert_eq!(compact.prefilled_txs.len(), 1); // just coinbase
364        assert_eq!(compact.short_ids.len(), 5);
365    }
366
367    #[test]
368    fn test_compact_block_roundtrip_all_in_mempool() {
369        let block = make_test_block(3);
370        let compact = CompactBlock::from_block(&block, 42);
371
372        // Build mempool lookup from the non-coinbase transactions.
373        let mempool_txs: Vec<&Transaction> = block.transactions[1..].iter().collect();
374        let lookup = compact.build_mempool_lookup(mempool_txs);
375
376        match compact.reconstruct(&lookup) {
377            ReconstructResult::Success(reconstructed) => {
378                assert_eq!(reconstructed.transactions.len(), 4);
379                // Verify all txids match.
380                for (orig, recon) in block
381                    .transactions
382                    .iter()
383                    .zip(reconstructed.transactions.iter())
384                {
385                    assert_eq!(orig.txid(), recon.txid());
386                }
387            }
388            ReconstructResult::NeedTransactions(_) => {
389                panic!("Expected successful reconstruction");
390            }
391        }
392    }
393
394    #[test]
395    fn test_compact_block_missing_transactions() {
396        let block = make_test_block(3);
397        let compact = CompactBlock::from_block(&block, 42);
398
399        // Empty mempool — all non-coinbase txs will be missing.
400        let empty_lookup = HashMap::new();
401
402        match compact.reconstruct(&empty_lookup) {
403            ReconstructResult::NeedTransactions(req) => {
404                assert_eq!(req.indices.len(), 3); // 3 missing
405                assert_eq!(req.block_hash, block.header.block_hash());
406            }
407            ReconstructResult::Success(_) => {
408                panic!("Expected missing transactions");
409            }
410        }
411    }
412
413    #[test]
414    fn test_compact_block_partial_mempool() {
415        let block = make_test_block(3);
416        let compact = CompactBlock::from_block(&block, 42);
417
418        // Only have the first non-coinbase tx in mempool.
419        let partial_mempool = vec![&block.transactions[1]];
420        let lookup = compact.build_mempool_lookup(partial_mempool);
421
422        match compact.reconstruct(&lookup) {
423            ReconstructResult::NeedTransactions(req) => {
424                assert_eq!(req.indices.len(), 2); // 2 missing
425            }
426            ReconstructResult::Success(_) => {
427                panic!("Expected partial miss");
428            }
429        }
430    }
431
432    #[test]
433    fn test_short_txid_deterministic() {
434        let block = make_test_block(1);
435        let (key0, key1) = compute_siphash_keys(&block.header, 42);
436        let txid = block.transactions[1].txid();
437
438        let id1 = compute_short_txid(key0, key1, txid.as_bytes());
439        let id2 = compute_short_txid(key0, key1, txid.as_bytes());
440        assert_eq!(id1, id2);
441    }
442
443    #[test]
444    fn test_short_txid_different_nonces() {
445        let block = make_test_block(1);
446        let txid = block.transactions[1].txid();
447
448        let (k0a, k1a) = compute_siphash_keys(&block.header, 42);
449        let (k0b, k1b) = compute_siphash_keys(&block.header, 99);
450
451        let id_a = compute_short_txid(k0a, k1a, txid.as_bytes());
452        let id_b = compute_short_txid(k0b, k1b, txid.as_bytes());
453
454        // Different nonces should (almost certainly) produce different short IDs.
455        assert_ne!(id_a, id_b);
456    }
457
458    #[test]
459    fn test_short_txid_different_txids() {
460        let block = make_test_block(2);
461        let (key0, key1) = compute_siphash_keys(&block.header, 42);
462
463        let id1 = compute_short_txid(key0, key1, block.transactions[1].txid().as_bytes());
464        let id2 = compute_short_txid(key0, key1, block.transactions[2].txid().as_bytes());
465
466        assert_ne!(id1, id2);
467    }
468
469    #[test]
470    fn test_coinbase_always_prefilled() {
471        let block = make_test_block(10);
472        let compact = CompactBlock::from_block(&block, 0);
473
474        assert!(!compact.prefilled_txs.is_empty());
475        let coinbase_prefill = &compact.prefilled_txs[0];
476        assert_eq!(coinbase_prefill.index, 0);
477        assert_eq!(coinbase_prefill.tx.txid(), block.transactions[0].txid());
478    }
479
480    #[test]
481    fn test_empty_block_compact() {
482        // Block with only coinbase.
483        let block = make_test_block(0);
484        let compact = CompactBlock::from_block(&block, 42);
485
486        assert_eq!(compact.short_ids.len(), 0);
487        assert_eq!(compact.prefilled_txs.len(), 1);
488        assert_eq!(compact.transaction_count(), 1);
489
490        // Reconstruct with empty mempool should succeed.
491        let empty = HashMap::new();
492        match compact.reconstruct(&empty) {
493            ReconstructResult::Success(recon) => {
494                assert_eq!(recon.transactions.len(), 1);
495                assert_eq!(recon.transactions[0].txid(), block.transactions[0].txid());
496            }
497            _ => panic!("Empty block should reconstruct without mempool"),
498        }
499    }
500
501    #[test]
502    fn test_siphash_basic() {
503        // Verify siphash produces non-zero output for non-empty data.
504        let result = siphash_2_4(0, 0, b"hello");
505        assert_ne!(result, 0);
506
507        // Deterministic.
508        let result2 = siphash_2_4(0, 0, b"hello");
509        assert_eq!(result, result2);
510
511        // Different data → different hash.
512        let result3 = siphash_2_4(0, 0, b"world");
513        assert_ne!(result, result3);
514    }
515}