Skip to main content

abtc_application/
block_template.rs

1//! Block Template Assembly — Transaction Selection and Template Construction
2//!
3//! This module implements the `BlockTemplateProvider` port trait, assembling
4//! block templates from mempool transactions for mining. It corresponds to
5//! Bitcoin Core's `BlockAssembler` (in `node/miner.cpp`).
6//!
7//! ## Strategy
8//!
9//! 1. Query the mempool for transactions sorted by ancestor fee rate (CPFP-aware).
10//! 2. Fill the block up to `MAX_BLOCK_WEIGHT` (4,000,000 weight units / BIP 141).
11//! 3. Build a coinbase transaction with the correct subsidy + collected fees.
12//! 4. Compute the merkle root and assemble the header.
13//!
14//! The resulting `BlockTemplate` is ready for nonce-grinding by the miner.
15
16use abtc_domain::consensus::{ConsensusParams, MAX_BLOCK_WEIGHT};
17use abtc_domain::primitives::{Amount, Block, BlockHeader, Hash256, Transaction, TxOut};
18use abtc_domain::script::Script;
19use abtc_ports::{BlockTemplate, BlockTemplateProvider, ChainStateStore, MempoolPort};
20use async_trait::async_trait;
21use std::error::Error;
22use std::sync::Arc;
23
24/// Weight reserved for the coinbase transaction (conservative estimate).
25/// A typical coinbase with BIP34 height + single output is ~700 WU.
26const COINBASE_WEIGHT_RESERVE: u32 = 4_000;
27
28/// Block template assembler.
29///
30/// Holds references to the chain state (for tip/height/bits) and mempool
31/// (for transaction selection). Implements `BlockTemplateProvider` to assemble
32/// block templates from mempool transactions for mining.
33pub struct BlockAssembler {
34    chain_state: Arc<dyn ChainStateStore>,
35    mempool: Arc<dyn MempoolPort>,
36}
37
38impl BlockAssembler {
39    /// Create a new block assembler.
40    pub fn new(chain_state: Arc<dyn ChainStateStore>, mempool: Arc<dyn MempoolPort>) -> Self {
41        BlockAssembler {
42            chain_state,
43            mempool,
44        }
45    }
46
47    /// Build a BIP34-compliant coinbase scriptSig encoding the block height.
48    fn build_coinbase_script(height: u32) -> Script {
49        let mut script = Vec::new();
50
51        if height == 0 {
52            script.push(0x00); // OP_0
53        } else if height <= 16 {
54            script.push(0x50 + height as u8); // OP_1..OP_16
55        } else {
56            let mut h = height;
57            let mut buf = Vec::new();
58            while h > 0 {
59                buf.push((h & 0xff) as u8);
60                h >>= 8;
61            }
62            if buf.last().is_some_and(|&b| b & 0x80 != 0) {
63                buf.push(0);
64            }
65            script.push(buf.len() as u8);
66            script.extend_from_slice(&buf);
67        }
68
69        // Pad to minimum 2 bytes.
70        while script.len() < 2 {
71            script.push(0x00);
72        }
73
74        Script::from_bytes(script)
75    }
76
77    /// Calculate the block subsidy for a given height.
78    fn get_block_subsidy(height: u32, params: &ConsensusParams) -> Amount {
79        let interval = params.subsidy_halving_interval;
80        if interval == 0 {
81            return Amount::from_sat(5_000_000_000);
82        }
83        let halvings = height / interval;
84        if halvings >= 64 {
85            return Amount::from_sat(0);
86        }
87        let initial: i64 = 50 * 100_000_000;
88        Amount::from_sat(initial >> halvings)
89    }
90}
91
92#[async_trait]
93impl BlockTemplateProvider for BlockAssembler {
94    /// Create a block template by selecting mempool transactions and building
95    /// a valid block structure ready for mining.
96    async fn create_block_template(
97        &self,
98        coinbase_script: &Script,
99        params: &ConsensusParams,
100    ) -> Result<BlockTemplate, Box<dyn Error + Send + Sync>> {
101        // 1. Get chain tip info.
102        let (prev_hash, tip_height) = self.chain_state.get_best_chain_tip().await?;
103        let new_height = tip_height + 1;
104
105        // 2. Select transactions from mempool by ancestor fee rate.
106        //    Leave room for the coinbase.
107        let available_weight = MAX_BLOCK_WEIGHT - COINBASE_WEIGHT_RESERVE;
108        let selected = self.mempool.get_all_transactions().await?;
109
110        // Sort by fee rate descending and pack into the block greedily.
111        let mut sorted: Vec<_> = selected.into_iter().collect();
112        sorted.sort_by(|a, b| {
113            let rate_a = a.fee.as_sat() as f64 / a.size.max(1) as f64;
114            let rate_b = b.fee.as_sat() as f64 / b.size.max(1) as f64;
115            rate_b
116                .partial_cmp(&rate_a)
117                .unwrap_or(std::cmp::Ordering::Equal)
118        });
119
120        let mut block_txs: Vec<Transaction> = Vec::new();
121        let mut total_fees = Amount::from_sat(0);
122        let mut fees_per_tx: Vec<Amount> = Vec::new();
123        let mut sigops_per_tx: Vec<u64> = Vec::new();
124        let mut total_weight: u32 = 0;
125
126        for entry in &sorted {
127            let tx_weight = (entry.size as u32) * 4; // conservative: size * 4
128            if total_weight + tx_weight > available_weight {
129                continue;
130            }
131            total_weight += tx_weight;
132            total_fees = Amount::from_sat(total_fees.as_sat() + entry.fee.as_sat());
133            fees_per_tx.push(entry.fee);
134            // Estimate sigops conservatively (1 per input + 1 per output).
135            let sigops = (entry.tx.inputs.len() + entry.tx.outputs.len()) as u64;
136            sigops_per_tx.push(sigops);
137            block_txs.push(entry.tx.clone());
138        }
139
140        // 3. Build the coinbase transaction.
141        let subsidy = Self::get_block_subsidy(new_height, params);
142        let coinbase_value = Amount::from_sat(subsidy.as_sat() + total_fees.as_sat());
143
144        let coinbase_scriptsig = Self::build_coinbase_script(new_height);
145        let coinbase = Transaction::coinbase(
146            new_height,
147            coinbase_scriptsig,
148            vec![TxOut::new(coinbase_value, coinbase_script.clone())],
149        );
150
151        // Prepend coinbase (fees/sigops entry at index 0 is the coinbase).
152        let mut all_txs = vec![coinbase];
153        all_txs.extend(block_txs);
154
155        let mut all_fees = vec![Amount::from_sat(0)]; // coinbase has no fee
156        all_fees.extend(fees_per_tx);
157
158        let mut all_sigops = vec![1u64]; // coinbase typically has 1 sigop
159        all_sigops.extend(sigops_per_tx);
160
161        // 4. Compute merkle root and assemble header.
162        let time = std::time::SystemTime::now()
163            .duration_since(std::time::UNIX_EPOCH)
164            .unwrap_or_default()
165            .as_secs() as u32;
166
167        // Use the tip's bits for the template. In production, `get_next_work_required`
168        // would be called, but that requires the previous block's timestamp which
169        // we don't have from the ChainStateStore. Use pow_limit_bits as a safe
170        // default — the miner/caller can override.
171        let bits = params.pow_limit_bits;
172
173        let temp_block = Block::new(
174            BlockHeader::new(0x20000000, prev_hash, Hash256::zero(), time, bits, 0),
175            all_txs.clone(),
176        );
177        let merkle_root = temp_block.compute_merkle_root();
178
179        let header = BlockHeader::new(0x20000000, prev_hash, merkle_root, time, bits, 0);
180        let block = Block::new(header, all_txs);
181
182        Ok(BlockTemplate {
183            block,
184            fees: all_fees,
185            sigops: all_sigops,
186            target: bits,
187            height: new_height,
188        })
189    }
190
191    async fn get_block_height(&self) -> Result<u32, Box<dyn Error + Send + Sync>> {
192        let (_, height) = self.chain_state.get_best_chain_tip().await?;
193        Ok(height + 1) // next block height
194    }
195}
196
197// ── Tests ────────────────────────────────────────────────────────────
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use abtc_domain::primitives::{BlockHash, OutPoint, TxIn, Txid};
203    use abtc_ports::{MempoolEntry, MempoolInfo, UtxoEntry};
204
205    // ── Mock chain state store ───────────────────────────────────
206
207    struct MockChainState {
208        tip_hash: BlockHash,
209        tip_height: u32,
210    }
211
212    #[async_trait]
213    impl ChainStateStore for MockChainState {
214        async fn get_utxo(
215            &self,
216            _txid: &Txid,
217            _vout: u32,
218        ) -> Result<Option<UtxoEntry>, Box<dyn Error + Send + Sync>> {
219            Ok(None)
220        }
221        async fn has_utxo(
222            &self,
223            _txid: &Txid,
224            _vout: u32,
225        ) -> Result<bool, Box<dyn Error + Send + Sync>> {
226            Ok(false)
227        }
228        async fn write_utxo_set(
229            &self,
230            _adds: Vec<(Txid, u32, UtxoEntry)>,
231            _removes: Vec<(Txid, u32)>,
232        ) -> Result<(), Box<dyn Error + Send + Sync>> {
233            Ok(())
234        }
235        async fn get_best_chain_tip(
236            &self,
237        ) -> Result<(BlockHash, u32), Box<dyn Error + Send + Sync>> {
238            Ok((self.tip_hash, self.tip_height))
239        }
240        async fn write_chain_tip(
241            &self,
242            _hash: BlockHash,
243            _height: u32,
244        ) -> Result<(), Box<dyn Error + Send + Sync>> {
245            Ok(())
246        }
247        async fn get_utxo_set_info(
248            &self,
249        ) -> Result<abtc_ports::UtxoSetInfo, Box<dyn Error + Send + Sync>> {
250            Ok(abtc_ports::UtxoSetInfo {
251                txout_count: 0,
252                total_amount: abtc_domain::primitives::Amount::from_sat(0),
253                best_block: self.tip_hash,
254                height: self.tip_height,
255            })
256        }
257    }
258
259    // ── Mock mempool ─────────────────────────────────────────────
260
261    struct MockMempool {
262        entries: Vec<MempoolEntry>,
263    }
264
265    #[async_trait]
266    impl MempoolPort for MockMempool {
267        async fn add_transaction(
268            &self,
269            _tx: &Transaction,
270        ) -> Result<(), Box<dyn Error + Send + Sync>> {
271            Ok(())
272        }
273        async fn remove_transaction(
274            &self,
275            _txid: &Txid,
276            _recursive: bool,
277        ) -> Result<(), Box<dyn Error + Send + Sync>> {
278            Ok(())
279        }
280        async fn get_transaction(
281            &self,
282            _txid: &Txid,
283        ) -> Result<Option<MempoolEntry>, Box<dyn Error + Send + Sync>> {
284            Ok(None)
285        }
286        async fn get_all_transactions(
287            &self,
288        ) -> Result<Vec<MempoolEntry>, Box<dyn Error + Send + Sync>> {
289            Ok(self.entries.clone())
290        }
291        async fn get_transaction_count(&self) -> Result<u32, Box<dyn Error + Send + Sync>> {
292            Ok(self.entries.len() as u32)
293        }
294        async fn estimate_fee(
295            &self,
296            _target_blocks: u32,
297        ) -> Result<f64, Box<dyn Error + Send + Sync>> {
298            Ok(1.0)
299        }
300        async fn get_mempool_info(&self) -> Result<MempoolInfo, Box<dyn Error + Send + Sync>> {
301            Ok(MempoolInfo {
302                size: self.entries.len() as u32,
303                bytes: 0,
304                usage: 0,
305                max_mempool: 300_000_000,
306                min_relay_fee: 0.00001,
307            })
308        }
309        async fn clear(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
310            Ok(())
311        }
312    }
313
314    fn make_test_tx(value: i64) -> Transaction {
315        let input = TxIn::final_input(OutPoint::new(Txid::zero(), 0), Script::new());
316        let output = TxOut::new(Amount::from_sat(value), Script::new());
317        Transaction::v1(vec![input], vec![output], 0)
318    }
319
320    fn make_mempool_entry(tx: &Transaction, fee: i64) -> MempoolEntry {
321        MempoolEntry {
322            tx: tx.clone(),
323            fee: Amount::from_sat(fee),
324            size: 200,
325            time: 0,
326            height: 0,
327            descendant_count: 0,
328            descendant_size: 0,
329            ancestor_count: 0,
330            ancestor_size: 0,
331        }
332    }
333
334    #[tokio::test]
335    async fn test_create_empty_template() {
336        let chain_state = Arc::new(MockChainState {
337            tip_hash: BlockHash::zero(),
338            tip_height: 0,
339        });
340        let mempool = Arc::new(MockMempool {
341            entries: Vec::new(),
342        });
343
344        let assembler = BlockAssembler::new(chain_state, mempool);
345        let params = ConsensusParams::regtest();
346        let script = Script::new();
347
348        let template = assembler
349            .create_block_template(&script, &params)
350            .await
351            .unwrap();
352
353        // Should have just the coinbase
354        assert_eq!(template.block.transactions.len(), 1);
355        assert!(template.block.transactions[0].is_coinbase());
356        assert_eq!(template.height, 1);
357
358        // Coinbase should pay the full subsidy (no fees)
359        let coinbase_value = template.block.transactions[0].total_output_value();
360        assert_eq!(coinbase_value.as_sat(), 5_000_000_000); // 50 BTC regtest
361    }
362
363    #[tokio::test]
364    async fn test_template_includes_mempool_txs() {
365        let chain_state = Arc::new(MockChainState {
366            tip_hash: BlockHash::zero(),
367            tip_height: 100,
368        });
369
370        let tx1 = make_test_tx(50_000);
371        let tx2 = make_test_tx(30_000);
372        let entries = vec![
373            make_mempool_entry(&tx1, 1000),
374            make_mempool_entry(&tx2, 500),
375        ];
376
377        let mempool = Arc::new(MockMempool { entries });
378        let assembler = BlockAssembler::new(chain_state, mempool);
379        let params = ConsensusParams::regtest();
380        let script = Script::new();
381
382        let template = assembler
383            .create_block_template(&script, &params)
384            .await
385            .unwrap();
386
387        // Coinbase + 2 mempool txs
388        assert_eq!(template.block.transactions.len(), 3);
389        assert!(template.block.transactions[0].is_coinbase());
390        assert_eq!(template.height, 101);
391
392        // Coinbase should include subsidy + fees
393        let coinbase_value = template.block.transactions[0].total_output_value();
394        let expected = 5_000_000_000 + 1000 + 500; // subsidy + fees
395        assert_eq!(coinbase_value.as_sat(), expected);
396    }
397
398    #[tokio::test]
399    async fn test_template_sorts_by_fee_rate() {
400        let chain_state = Arc::new(MockChainState {
401            tip_hash: BlockHash::zero(),
402            tip_height: 0,
403        });
404
405        let tx_low = make_test_tx(10_000);
406        let tx_high = make_test_tx(20_000);
407        let entries = vec![
408            make_mempool_entry(&tx_low, 100),   // 0.5 sat/byte
409            make_mempool_entry(&tx_high, 2000), // 10 sat/byte
410        ];
411
412        let mempool = Arc::new(MockMempool { entries });
413        let assembler = BlockAssembler::new(chain_state, mempool);
414        let params = ConsensusParams::regtest();
415        let script = Script::new();
416
417        let template = assembler
418            .create_block_template(&script, &params)
419            .await
420            .unwrap();
421
422        // Higher fee-rate tx should come first (after coinbase)
423        assert_eq!(template.block.transactions.len(), 3);
424        assert_eq!(template.fees[1].as_sat(), 2000); // high-fee first
425        assert_eq!(template.fees[2].as_sat(), 100); // low-fee second
426    }
427
428    #[tokio::test]
429    async fn test_template_has_valid_merkle_root() {
430        let chain_state = Arc::new(MockChainState {
431            tip_hash: BlockHash::zero(),
432            tip_height: 50,
433        });
434        let mempool = Arc::new(MockMempool {
435            entries: Vec::new(),
436        });
437
438        let assembler = BlockAssembler::new(chain_state, mempool);
439        let params = ConsensusParams::regtest();
440        let script = Script::new();
441
442        let template = assembler
443            .create_block_template(&script, &params)
444            .await
445            .unwrap();
446
447        assert!(template.block.has_valid_merkle_root());
448    }
449
450    #[tokio::test]
451    async fn test_template_subsidy_halving() {
452        let chain_state = Arc::new(MockChainState {
453            tip_hash: BlockHash::zero(),
454            tip_height: 149, // Next block is height 150 = first halving on regtest
455        });
456        let mempool = Arc::new(MockMempool {
457            entries: Vec::new(),
458        });
459
460        let assembler = BlockAssembler::new(chain_state, mempool);
461        let params = ConsensusParams::regtest();
462        let script = Script::new();
463
464        let template = assembler
465            .create_block_template(&script, &params)
466            .await
467            .unwrap();
468
469        // At height 150, subsidy should be halved to 25 BTC
470        let coinbase_value = template.block.transactions[0].total_output_value();
471        assert_eq!(coinbase_value.as_sat(), 2_500_000_000);
472    }
473
474    #[tokio::test]
475    async fn test_get_block_height() {
476        let chain_state = Arc::new(MockChainState {
477            tip_hash: BlockHash::zero(),
478            tip_height: 42,
479        });
480        let mempool = Arc::new(MockMempool {
481            entries: Vec::new(),
482        });
483
484        let assembler = BlockAssembler::new(chain_state, mempool);
485        let height = assembler.get_block_height().await.unwrap();
486        assert_eq!(height, 43);
487    }
488
489    #[tokio::test]
490    async fn test_coinbase_script_bip34() {
491        // Height 0 → OP_0
492        let s0 = BlockAssembler::build_coinbase_script(0);
493        assert!(s0.len() >= 2);
494
495        // Height 1 → OP_1
496        let s1 = BlockAssembler::build_coinbase_script(1);
497        assert!(s1.len() >= 2);
498
499        // Height 100 → serialized CScriptNum
500        let s100 = BlockAssembler::build_coinbase_script(100);
501        assert!(s100.len() >= 2);
502        // First byte is push length, second byte should be 100
503        assert_eq!(s100.as_bytes()[0], 1); // 1-byte push
504        assert_eq!(s100.as_bytes()[1], 100);
505
506        // Height 500 → 2-byte push
507        let s500 = BlockAssembler::build_coinbase_script(500);
508        assert_eq!(s500.as_bytes()[0], 2); // 2-byte push
509    }
510}