Skip to main content

abtc_application/
mempool_acceptance.rs

1//! Accept-to-Mempool — Transaction validation and acceptance pipeline
2//!
3//! Implements the full validation pipeline for accepting transactions into the
4//! mempool, corresponding to Bitcoin Core's `AcceptToMemoryPool()`. This bridges:
5//!
6//! - **Consensus validation**: Basic transaction structure checks
7//! - **UTXO verification**: All inputs reference unspent outputs
8//! - **Fee calculation**: Compute fee from input values minus output values
9//! - **Policy checks**: Dust, min relay fee, tx weight, standard checks
10//! - **Script verification**: Full signature validation (ECDSA/SegWit)
11//! - **Mempool admission**: Add to mempool with computed fee
12//!
13//! This is the gateway for all new transactions entering the mempool, whether
14//! received from peers via `tx` messages or submitted locally via RPC.
15
16use abtc_domain::consensus::rules;
17use abtc_domain::crypto::signing::TransactionSignatureChecker;
18use abtc_domain::policy::limits::MempoolLimits;
19use abtc_domain::primitives::{Amount, Sequence, Transaction};
20use abtc_domain::script::{verify_script_with_witness, ScriptFlags};
21use abtc_ports::{ChainStateStore, MempoolPort, UtxoEntry};
22use std::sync::Arc;
23
24/// BIP113: lock_time values below this threshold are interpreted as block
25/// heights; at or above it they are interpreted as Unix timestamps.
26const LOCKTIME_THRESHOLD: u32 = 500_000_000;
27
28/// Errors that can occur during mempool acceptance.
29#[derive(Debug)]
30pub enum AcceptError {
31    /// Transaction fails consensus validation.
32    ConsensusViolation(String),
33    /// A referenced UTXO does not exist (double-spend or missing input).
34    MissingInput(String),
35    /// Fee is below the minimum relay threshold.
36    InsufficientFee { fee: i64, required: i64 },
37    /// Transaction fails policy checks (dust, oversized, etc.).
38    PolicyViolation(String),
39    /// Script verification failed.
40    ScriptFailure(String),
41    /// Mempool rejected the transaction (duplicate, limits, etc.).
42    MempoolRejection(String),
43}
44
45impl std::fmt::Display for AcceptError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            AcceptError::ConsensusViolation(e) => write!(f, "consensus: {}", e),
49            AcceptError::MissingInput(e) => write!(f, "missing input: {}", e),
50            AcceptError::InsufficientFee { fee, required } => {
51                write!(f, "insufficient fee: {} < {}", fee, required)
52            }
53            AcceptError::PolicyViolation(e) => write!(f, "policy: {}", e),
54            AcceptError::ScriptFailure(e) => write!(f, "script: {}", e),
55            AcceptError::MempoolRejection(e) => write!(f, "mempool: {}", e),
56        }
57    }
58}
59
60impl std::error::Error for AcceptError {}
61
62/// Result of a successful mempool acceptance.
63#[derive(Debug, Clone)]
64pub struct AcceptResult {
65    /// The transaction ID.
66    pub txid: abtc_domain::primitives::Txid,
67    /// Computed fee in satoshis.
68    pub fee: Amount,
69    /// Virtual size (weight / 4).
70    pub vsize: u32,
71    /// Fee rate in sat/vB.
72    pub fee_rate: f64,
73}
74
75/// Accept-to-mempool validator.
76///
77/// Orchestrates the full pipeline of checks before admitting a transaction
78/// to the mempool.
79pub struct MempoolAcceptor {
80    chain_state: Arc<dyn ChainStateStore>,
81    mempool: Arc<dyn MempoolPort>,
82    /// Configurable policy limits for ancestor/descendant checks.
83    _limits: MempoolLimits,
84    /// Whether to verify scripts (can be disabled for testing).
85    verify_scripts: bool,
86}
87
88impl MempoolAcceptor {
89    /// Create a new mempool acceptor.
90    pub fn new(chain_state: Arc<dyn ChainStateStore>, mempool: Arc<dyn MempoolPort>) -> Self {
91        MempoolAcceptor {
92            chain_state,
93            mempool,
94            _limits: MempoolLimits::default(),
95            verify_scripts: true,
96        }
97    }
98
99    /// Create a new mempool acceptor with custom limits.
100    pub fn with_limits(
101        chain_state: Arc<dyn ChainStateStore>,
102        mempool: Arc<dyn MempoolPort>,
103        limits: MempoolLimits,
104    ) -> Self {
105        MempoolAcceptor {
106            chain_state,
107            mempool,
108            _limits: limits,
109            verify_scripts: true,
110        }
111    }
112
113    /// Enable or disable script verification (useful for testing).
114    pub fn set_verify_scripts(&mut self, verify: bool) {
115        self.verify_scripts = verify;
116    }
117
118    /// Validate and accept a transaction into the mempool.
119    ///
120    /// This is the main entry point — the equivalent of Bitcoin Core's
121    /// `AcceptToMemoryPool()`.
122    pub async fn accept_transaction(&self, tx: &Transaction) -> Result<AcceptResult, AcceptError> {
123        let txid = tx.txid();
124
125        // 1. Consensus validation (structure, sizes, amounts)
126        rules::check_transaction(tx)
127            .map_err(|e| AcceptError::ConsensusViolation(format!("{}", e)))?;
128
129        // 2. Reject coinbase transactions
130        if tx.is_coinbase() {
131            return Err(AcceptError::ConsensusViolation(
132                "coinbase transactions cannot enter the mempool".into(),
133            ));
134        }
135
136        // 3. Verify all inputs exist in the UTXO set and compute fee
137        let mut total_input_value: i64 = 0;
138        let mut input_utxos = Vec::with_capacity(tx.inputs.len());
139
140        for input in &tx.inputs {
141            let utxo = self
142                .chain_state
143                .get_utxo(&input.previous_output.txid, input.previous_output.vout)
144                .await
145                .map_err(|e| AcceptError::MissingInput(e.to_string()))?
146                .ok_or_else(|| {
147                    AcceptError::MissingInput(format!(
148                        "{}:{}",
149                        input.previous_output.txid, input.previous_output.vout
150                    ))
151                })?;
152
153            total_input_value += utxo.output.value.as_sat();
154            input_utxos.push(utxo);
155        }
156
157        let total_output_value = tx.total_output_value().as_sat();
158        if total_input_value < total_output_value {
159            return Err(AcceptError::ConsensusViolation(format!(
160                "outputs ({}) exceed inputs ({})",
161                total_output_value, total_input_value
162            )));
163        }
164
165        let fee = total_input_value - total_output_value;
166
167        // 3b. BIP65 — absolute locktime validation
168        //     If lock_time > 0, at least one input must be non-final.
169        //     If lock_time is a block height (< 500M), it must not exceed
170        //     the current chain tip height + 1 (the next block).
171        if tx.lock_time > 0 {
172            let all_final = tx.inputs.iter().all(|inp| inp.sequence == Sequence::FINAL);
173            if all_final {
174                return Err(AcceptError::ConsensusViolation(
175                    "non-zero lock_time but all inputs are final".into(),
176                ));
177            }
178
179            let (_tip_hash, tip_height) = self
180                .chain_state
181                .get_best_chain_tip()
182                .await
183                .map_err(|e| AcceptError::ConsensusViolation(e.to_string()))?;
184
185            if tx.lock_time < LOCKTIME_THRESHOLD {
186                // Block-height lock: tx is valid for inclusion in the *next* block.
187                let next_height = tip_height + 1;
188                if tx.lock_time > next_height {
189                    return Err(AcceptError::PolicyViolation(format!(
190                        "locktime {} exceeds next block height {}",
191                        tx.lock_time, next_height
192                    )));
193                }
194            }
195            // Timestamp-based locktimes (>= 500M) would need MTP comparison.
196            // TODO: add MTP access to ChainStateStore and check here.
197        }
198
199        // 3c. BIP68 — relative locktime (sequence) validation
200        //     For tx version >= 2, each input whose sequence does not have
201        //     the LOCKTIME_DISABLE_FLAG set encodes a relative lock. If the
202        //     type flag is clear, the masked value is a block count; the
203        //     input's UTXO must be buried by at least that many blocks.
204        if tx.version >= 2 {
205            let (_tip_hash, tip_height) = self
206                .chain_state
207                .get_best_chain_tip()
208                .await
209                .map_err(|e| AcceptError::ConsensusViolation(e.to_string()))?;
210
211            Self::check_sequence_locks(tx, &input_utxos, tip_height)?;
212        }
213
214        // 4. Estimate tx weight/vsize and check policy
215        let vsize = Self::estimate_vsize(tx);
216        let fee_rate = fee as f64 / vsize.max(1) as f64;
217
218        // Check standard tx policy (dust, min fee, oversized)
219        let output_values: Vec<i64> = tx.outputs.iter().map(|o| o.value.as_sat()).collect();
220        let tx_weight = vsize * 4; // simplified
221        MempoolLimits::check_standard_tx(tx_weight, Amount::from_sat(fee), vsize, &output_values)
222            .map_err(|e| AcceptError::PolicyViolation(format!("{}", e)))?;
223
224        // 5. Script verification (full ECDSA/SegWit signature checks)
225        if self.verify_scripts {
226            let script_flags = ScriptFlags::standard();
227
228            for (input_idx, input) in tx.inputs.iter().enumerate() {
229                let utxo = &input_utxos[input_idx];
230                let script_pubkey = &utxo.output.script_pubkey;
231                let spent_amount = utxo.output.value;
232
233                let checker =
234                    if script_pubkey.is_witness_program() || is_p2sh_witness(tx, input_idx) {
235                        TransactionSignatureChecker::new_witness_v0(tx, input_idx, spent_amount)
236                    } else {
237                        TransactionSignatureChecker::new(tx, input_idx, spent_amount)
238                    };
239
240                verify_script_with_witness(
241                    &input.script_sig,
242                    script_pubkey,
243                    &input.witness,
244                    script_flags,
245                    &checker,
246                )
247                .map_err(|e| {
248                    AcceptError::ScriptFailure(format!(
249                        "input {} of tx {}: {:?}",
250                        input_idx, txid, e
251                    ))
252                })?;
253            }
254        }
255
256        // 6. Add to mempool
257        self.mempool
258            .add_transaction(tx)
259            .await
260            .map_err(|e| AcceptError::MempoolRejection(e.to_string()))?;
261
262        tracing::info!(
263            "Accepted tx {} to mempool (fee={}, vsize={}, rate={:.1} sat/vB)",
264            txid,
265            fee,
266            vsize,
267            fee_rate
268        );
269
270        Ok(AcceptResult {
271            txid,
272            fee: Amount::from_sat(fee),
273            vsize,
274            fee_rate,
275        })
276    }
277
278    /// Check BIP68 relative locktime constraints for all inputs.
279    ///
280    /// For each input whose sequence does not have `LOCKTIME_DISABLE_FLAG` set:
281    /// - If `LOCKTIME_TYPE_FLAG` is clear, the masked value is a block count.
282    ///   The UTXO must be at least that many blocks deep (tip_height - utxo_height >= required).
283    /// - If `LOCKTIME_TYPE_FLAG` is set, the masked value is in 512-second units.
284    ///   Full validation requires MTP; for now we only enforce block-based locks.
285    fn check_sequence_locks(
286        tx: &Transaction,
287        input_utxos: &[UtxoEntry],
288        tip_height: u32,
289    ) -> Result<(), AcceptError> {
290        for (idx, input) in tx.inputs.iter().enumerate() {
291            let seq = input.sequence;
292
293            // Skip if disable flag is set or sequence is final
294            if seq & Sequence::LOCKTIME_DISABLE_FLAG != 0 || seq == Sequence::FINAL {
295                continue;
296            }
297
298            let utxo_height = input_utxos[idx].height;
299
300            if seq & Sequence::LOCKTIME_TYPE_FLAG == 0 {
301                // Block-based relative lock
302                let required_blocks = seq & Sequence::LOCKTIME_MASK;
303                let depth = tip_height.saturating_sub(utxo_height);
304                if depth < required_blocks {
305                    return Err(AcceptError::PolicyViolation(format!(
306                        "input {} requires {} blocks of depth but UTXO only has {} (BIP68)",
307                        idx, required_blocks, depth
308                    )));
309                }
310            }
311            // Time-based relative locks (LOCKTIME_TYPE_FLAG set) require MTP.
312            // TODO: enforce when MTP is available through ChainStateStore.
313        }
314        Ok(())
315    }
316
317    /// Estimate virtual size (vsize) of a transaction.
318    ///
319    /// vsize = weight / 4, where weight = base_size * 3 + total_size.
320    fn estimate_vsize(tx: &Transaction) -> u32 {
321        let mut base_size = 10u32; // version(4) + input_count(~1) + output_count(~1) + locktime(4)
322        let mut witness_size = 0u32;
323
324        for input in &tx.inputs {
325            base_size += 41 + input.script_sig.len() as u32; // outpoint(36) + seq(4) + script_len(~1)
326            if !input.witness.is_empty() {
327                witness_size += 2; // witness item count varint
328                for item in input.witness.stack() {
329                    witness_size += 1 + item.len() as u32; // item length varint + data
330                }
331            }
332        }
333
334        for output in &tx.outputs {
335            base_size += 9 + output.script_pubkey.len() as u32; // value(8) + script_len(~1)
336        }
337
338        if witness_size > 0 {
339            witness_size += 2; // marker + flag bytes
340        }
341
342        let weight = base_size * 4 + witness_size;
343        weight.div_ceil(4)
344    }
345}
346
347/// Detect if a transaction input is a P2SH-wrapped witness program.
348fn is_p2sh_witness(tx: &Transaction, input_idx: usize) -> bool {
349    let script_sig = &tx.inputs[input_idx].script_sig;
350    let bytes = script_sig.as_bytes();
351
352    if bytes.is_empty() {
353        return false;
354    }
355
356    // P2SH-P2WPKH scriptSig: 0x16 0x0014{20} (23 bytes total)
357    // P2SH-P2WSH scriptSig:  0x22 0x0020{32} (35 bytes total)
358    #[allow(clippy::if_same_then_else)]
359    let inner = if bytes.len() == 23 && bytes[0] == 0x16 {
360        &bytes[1..]
361    } else if bytes.len() == 35 && bytes[0] == 0x22 {
362        &bytes[1..]
363    } else {
364        return false;
365    };
366
367    if inner.len() >= 2 {
368        let version_byte = inner[0];
369        let push_len = inner[1] as usize;
370        let is_valid_version =
371            version_byte == 0x00 || (0x51..=0x60).contains(&version_byte);
372        let is_valid_program = (push_len == 20 || push_len == 32) && inner.len() == 2 + push_len;
373        return is_valid_version && is_valid_program;
374    }
375
376    false
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use abtc_domain::primitives::{OutPoint, TxIn, TxOut, Txid};
383    use abtc_domain::Script;
384    use abtc_ports::{MempoolEntry, MempoolInfo, UtxoEntry};
385    use async_trait::async_trait;
386    use std::collections::HashMap;
387    use tokio::sync::RwLock;
388
389    // ── Mock ChainStateStore ─────────────────────────────────
390
391    struct MockChainState {
392        utxos: RwLock<HashMap<(Txid, u32), UtxoEntry>>,
393    }
394
395    impl MockChainState {
396        fn new() -> Self {
397            MockChainState {
398                utxos: RwLock::new(HashMap::new()),
399            }
400        }
401
402        async fn add_utxo(&self, txid: Txid, vout: u32, entry: UtxoEntry) {
403            self.utxos.write().await.insert((txid, vout), entry);
404        }
405    }
406
407    #[async_trait]
408    impl ChainStateStore for MockChainState {
409        async fn get_utxo(
410            &self,
411            txid: &Txid,
412            vout: u32,
413        ) -> Result<Option<UtxoEntry>, Box<dyn std::error::Error + Send + Sync>> {
414            Ok(self.utxos.read().await.get(&(*txid, vout)).cloned())
415        }
416
417        async fn has_utxo(
418            &self,
419            txid: &Txid,
420            vout: u32,
421        ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
422            Ok(self.utxos.read().await.contains_key(&(*txid, vout)))
423        }
424
425        async fn write_utxo_set(
426            &self,
427            _adds: Vec<(Txid, u32, UtxoEntry)>,
428            _removes: Vec<(Txid, u32)>,
429        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
430            Ok(())
431        }
432
433        async fn get_best_chain_tip(
434            &self,
435        ) -> Result<
436            (abtc_domain::primitives::BlockHash, u32),
437            Box<dyn std::error::Error + Send + Sync>,
438        > {
439            Ok((abtc_domain::primitives::BlockHash::zero(), 0))
440        }
441
442        async fn write_chain_tip(
443            &self,
444            _hash: abtc_domain::primitives::BlockHash,
445            _height: u32,
446        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
447            Ok(())
448        }
449        async fn get_utxo_set_info(
450            &self,
451        ) -> Result<abtc_ports::UtxoSetInfo, Box<dyn std::error::Error + Send + Sync>> {
452            Ok(abtc_ports::UtxoSetInfo {
453                txout_count: 0,
454                total_amount: abtc_domain::primitives::Amount::from_sat(0),
455                best_block: abtc_domain::primitives::BlockHash::zero(),
456                height: 0,
457            })
458        }
459    }
460
461    // ── Mock MempoolPort ─────────────────────────────────────
462
463    struct MockMempool {
464        txs: RwLock<HashMap<Txid, Transaction>>,
465    }
466
467    impl MockMempool {
468        fn new() -> Self {
469            MockMempool {
470                txs: RwLock::new(HashMap::new()),
471            }
472        }
473    }
474
475    #[async_trait]
476    impl MempoolPort for MockMempool {
477        async fn add_transaction(
478            &self,
479            tx: &Transaction,
480        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
481            let txid = tx.txid();
482            let mut txs = self.txs.write().await;
483            if txs.contains_key(&txid) {
484                return Err("already in mempool".into());
485            }
486            txs.insert(txid, tx.clone());
487            Ok(())
488        }
489
490        async fn remove_transaction(
491            &self,
492            txid: &Txid,
493            _recursive: bool,
494        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
495            self.txs.write().await.remove(txid);
496            Ok(())
497        }
498
499        async fn get_transaction(
500            &self,
501            txid: &Txid,
502        ) -> Result<Option<MempoolEntry>, Box<dyn std::error::Error + Send + Sync>> {
503            let txs = self.txs.read().await;
504            Ok(txs.get(txid).map(|tx| MempoolEntry {
505                tx: tx.clone(),
506                fee: Amount::from_sat(0),
507                size: 100,
508                time: 0,
509                height: 0,
510                descendant_count: 0,
511                descendant_size: 0,
512                ancestor_count: 0,
513                ancestor_size: 0,
514            }))
515        }
516
517        async fn get_all_transactions(
518            &self,
519        ) -> Result<Vec<MempoolEntry>, Box<dyn std::error::Error + Send + Sync>> {
520            Ok(vec![])
521        }
522
523        async fn get_transaction_count(
524            &self,
525        ) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
526            Ok(self.txs.read().await.len() as u32)
527        }
528
529        async fn estimate_fee(
530            &self,
531            _target_blocks: u32,
532        ) -> Result<f64, Box<dyn std::error::Error + Send + Sync>> {
533            Ok(1.0)
534        }
535
536        async fn get_mempool_info(
537            &self,
538        ) -> Result<MempoolInfo, Box<dyn std::error::Error + Send + Sync>> {
539            Ok(MempoolInfo {
540                size: 0,
541                bytes: 0,
542                usage: 0,
543                max_mempool: 300_000_000,
544                min_relay_fee: 0.00001,
545            })
546        }
547
548        async fn clear(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
549            self.txs.write().await.clear();
550            Ok(())
551        }
552    }
553
554    // ── Helper ───────────────────────────────────────────────
555
556    fn make_funding_utxo(value: i64) -> UtxoEntry {
557        UtxoEntry {
558            output: TxOut::new(Amount::from_sat(value), Script::new()),
559            height: 1,
560            is_coinbase: false,
561        }
562    }
563
564    // ── Tests ────────────────────────────────────────────────
565
566    #[tokio::test]
567    async fn test_accept_valid_transaction() {
568        let chain_state = Arc::new(MockChainState::new());
569        let mempool = Arc::new(MockMempool::new());
570
571        // Create a funding UTXO
572        let funding_txid =
573            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x01; 32]));
574        chain_state
575            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
576            .await;
577
578        let mut acceptor = MempoolAcceptor::new(chain_state, mempool.clone());
579        acceptor.set_verify_scripts(false); // Skip script checks for unit test
580
581        let tx = Transaction::v1(
582            vec![TxIn::final_input(
583                OutPoint::new(funding_txid, 0),
584                Script::new(),
585            )],
586            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
587            0,
588        );
589
590        let result = acceptor.accept_transaction(&tx).await;
591        assert!(result.is_ok());
592
593        let accept = result.unwrap();
594        assert_eq!(accept.fee.as_sat(), 10_000);
595        assert!(accept.fee_rate > 0.0);
596
597        // Verify it's now in the mempool
598        let count = mempool.get_transaction_count().await.unwrap();
599        assert_eq!(count, 1);
600    }
601
602    #[tokio::test]
603    async fn test_reject_missing_input() {
604        let chain_state = Arc::new(MockChainState::new());
605        let mempool = Arc::new(MockMempool::new());
606
607        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
608        acceptor.set_verify_scripts(false);
609
610        let tx = Transaction::v1(
611            vec![TxIn::final_input(
612                OutPoint::new(Txid::zero(), 0),
613                Script::new(),
614            )],
615            vec![TxOut::new(Amount::from_sat(1000), Script::new())],
616            0,
617        );
618
619        let result = acceptor.accept_transaction(&tx).await;
620        assert!(result.is_err());
621        assert!(matches!(result.unwrap_err(), AcceptError::MissingInput(_)));
622    }
623
624    #[tokio::test]
625    async fn test_reject_coinbase() {
626        let chain_state = Arc::new(MockChainState::new());
627        let mempool = Arc::new(MockMempool::new());
628
629        let acceptor = MempoolAcceptor::new(chain_state, mempool);
630
631        let tx = Transaction::coinbase(
632            0,
633            Script::from_bytes(vec![0x01, 0x00]),
634            vec![TxOut::new(Amount::from_sat(5_000_000_000), Script::new())],
635        );
636
637        let result = acceptor.accept_transaction(&tx).await;
638        assert!(result.is_err());
639        assert!(matches!(
640            result.unwrap_err(),
641            AcceptError::ConsensusViolation(_)
642        ));
643    }
644
645    #[tokio::test]
646    async fn test_reject_outputs_exceed_inputs() {
647        let chain_state = Arc::new(MockChainState::new());
648        let mempool = Arc::new(MockMempool::new());
649
650        let funding_txid =
651            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x02; 32]));
652        chain_state
653            .add_utxo(funding_txid, 0, make_funding_utxo(1_000))
654            .await;
655
656        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
657        acceptor.set_verify_scripts(false);
658
659        let tx = Transaction::v1(
660            vec![TxIn::final_input(
661                OutPoint::new(funding_txid, 0),
662                Script::new(),
663            )],
664            vec![TxOut::new(Amount::from_sat(2_000), Script::new())],
665            0,
666        );
667
668        let result = acceptor.accept_transaction(&tx).await;
669        assert!(result.is_err());
670        assert!(matches!(
671            result.unwrap_err(),
672            AcceptError::ConsensusViolation(_)
673        ));
674    }
675
676    #[tokio::test]
677    async fn test_reject_dust_output() {
678        let chain_state = Arc::new(MockChainState::new());
679        let mempool = Arc::new(MockMempool::new());
680
681        let funding_txid =
682            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x03; 32]));
683        chain_state
684            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
685            .await;
686
687        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
688        acceptor.set_verify_scripts(false);
689
690        // Output of 100 satoshis is below the dust threshold (546 sat)
691        let tx = Transaction::v1(
692            vec![TxIn::final_input(
693                OutPoint::new(funding_txid, 0),
694                Script::new(),
695            )],
696            vec![TxOut::new(Amount::from_sat(100), Script::new())],
697            0,
698        );
699
700        let result = acceptor.accept_transaction(&tx).await;
701        assert!(result.is_err());
702        assert!(matches!(
703            result.unwrap_err(),
704            AcceptError::PolicyViolation(_)
705        ));
706    }
707
708    #[tokio::test]
709    async fn test_fee_calculation() {
710        let chain_state = Arc::new(MockChainState::new());
711        let mempool = Arc::new(MockMempool::new());
712
713        let funding_txid =
714            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x04; 32]));
715        chain_state
716            .add_utxo(funding_txid, 0, make_funding_utxo(500_000))
717            .await;
718
719        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
720        acceptor.set_verify_scripts(false);
721
722        let tx = Transaction::v1(
723            vec![TxIn::final_input(
724                OutPoint::new(funding_txid, 0),
725                Script::new(),
726            )],
727            vec![TxOut::new(Amount::from_sat(450_000), Script::new())],
728            0,
729        );
730
731        let result = acceptor.accept_transaction(&tx).await.unwrap();
732        assert_eq!(result.fee.as_sat(), 50_000);
733        assert!(result.vsize > 0);
734    }
735
736    #[tokio::test]
737    async fn test_vsize_estimation() {
738        // Simple legacy transaction
739        let tx = Transaction::v1(
740            vec![TxIn::final_input(
741                OutPoint::new(Txid::zero(), 0),
742                Script::new(),
743            )],
744            vec![TxOut::new(Amount::from_sat(1000), Script::new())],
745            0,
746        );
747        let vsize = MempoolAcceptor::estimate_vsize(&tx);
748        assert!(vsize > 0);
749        // A simple 1-in 1-out legacy tx should be ~60-70 vbytes
750        assert!(vsize >= 50 && vsize <= 100, "vsize was {}", vsize);
751    }
752
753    #[tokio::test]
754    async fn test_duplicate_rejection() {
755        let chain_state = Arc::new(MockChainState::new());
756        let mempool = Arc::new(MockMempool::new());
757
758        let funding_txid =
759            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x05; 32]));
760        chain_state
761            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
762            .await;
763
764        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
765        acceptor.set_verify_scripts(false);
766
767        let tx = Transaction::v1(
768            vec![TxIn::final_input(
769                OutPoint::new(funding_txid, 0),
770                Script::new(),
771            )],
772            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
773            0,
774        );
775
776        // First should succeed
777        assert!(acceptor.accept_transaction(&tx).await.is_ok());
778
779        // Second should fail (duplicate)
780        let result = acceptor.accept_transaction(&tx).await;
781        assert!(result.is_err());
782        assert!(matches!(
783            result.unwrap_err(),
784            AcceptError::MempoolRejection(_)
785        ));
786    }
787
788    // ═══════════════════════════════════════════════════════════════════
789    // Regression tests — Session 15 (review finding #22: locktime/sequence)
790    //
791    // These tests verify BIP65 absolute locktime and BIP68 relative
792    // locktime enforcement during mempool acceptance.
793    // ═══════════════════════════════════════════════════════════════════
794
795    #[tokio::test]
796    async fn regression_locktime_future_block_rejected() {
797        let chain_state = Arc::new(MockChainState::new());
798        let mempool = Arc::new(MockMempool::new());
799
800        let funding_txid =
801            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x10; 32]));
802        chain_state
803            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
804            .await;
805
806        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
807        acceptor.set_verify_scripts(false);
808
809        // lock_time = 1000 but chain tip is at height 0 → next block is 1
810        let tx = Transaction::new(
811            1,
812            vec![TxIn::new(
813                OutPoint::new(funding_txid, 0),
814                Script::new(),
815                Sequence::MAX_NONFINAL, // non-final to activate locktime
816            )],
817            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
818            1000, // lock_time = block 1000
819        );
820
821        let result = acceptor.accept_transaction(&tx).await;
822        assert!(result.is_err());
823        let err = format!("{}", result.unwrap_err());
824        assert!(err.contains("locktime"), "error was: {}", err);
825    }
826
827    #[tokio::test]
828    async fn regression_locktime_all_final_rejected() {
829        let chain_state = Arc::new(MockChainState::new());
830        let mempool = Arc::new(MockMempool::new());
831
832        let funding_txid =
833            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x11; 32]));
834        chain_state
835            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
836            .await;
837
838        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
839        acceptor.set_verify_scripts(false);
840
841        // lock_time > 0 but all inputs are final → invalid
842        let tx = Transaction::new(
843            1,
844            vec![TxIn::final_input(
845                OutPoint::new(funding_txid, 0),
846                Script::new(),
847            )],
848            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
849            100, // non-zero locktime
850        );
851
852        let result = acceptor.accept_transaction(&tx).await;
853        assert!(result.is_err());
854        let err = format!("{}", result.unwrap_err());
855        assert!(err.contains("all inputs are final"), "error was: {}", err);
856    }
857
858    #[tokio::test]
859    async fn regression_locktime_zero_accepted() {
860        let chain_state = Arc::new(MockChainState::new());
861        let mempool = Arc::new(MockMempool::new());
862
863        let funding_txid =
864            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x12; 32]));
865        chain_state
866            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
867            .await;
868
869        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
870        acceptor.set_verify_scripts(false);
871
872        // lock_time = 0 → no locktime check
873        let tx = Transaction::v1(
874            vec![TxIn::final_input(
875                OutPoint::new(funding_txid, 0),
876                Script::new(),
877            )],
878            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
879            0,
880        );
881
882        assert!(acceptor.accept_transaction(&tx).await.is_ok());
883    }
884
885    #[tokio::test]
886    async fn regression_bip68_relative_lock_rejected() {
887        let chain_state = Arc::new(MockChainState::new());
888        let mempool = Arc::new(MockMempool::new());
889
890        let funding_txid =
891            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x13; 32]));
892        // UTXO confirmed at height 0; chain tip is also 0 → depth = 0
893        chain_state
894            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
895            .await;
896
897        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
898        acceptor.set_verify_scripts(false);
899
900        // Version 2 tx with sequence encoding "10 blocks relative lock"
901        // (no disable flag, no type flag, masked value = 10)
902        let relative_lock_sequence = 10u32; // 10 blocks
903        let tx = Transaction::new(
904            2,
905            vec![TxIn::new(
906                OutPoint::new(funding_txid, 0),
907                Script::new(),
908                relative_lock_sequence,
909            )],
910            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
911            0,
912        );
913
914        let result = acceptor.accept_transaction(&tx).await;
915        assert!(result.is_err());
916        let err = format!("{}", result.unwrap_err());
917        assert!(err.contains("BIP68"), "error was: {}", err);
918    }
919
920    #[tokio::test]
921    async fn regression_bip68_disabled_flag_accepted() {
922        let chain_state = Arc::new(MockChainState::new());
923        let mempool = Arc::new(MockMempool::new());
924
925        let funding_txid =
926            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x14; 32]));
927        chain_state
928            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
929            .await;
930
931        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
932        acceptor.set_verify_scripts(false);
933
934        // Sequence with disable flag set → BIP68 doesn't apply
935        let seq_disabled = Sequence::LOCKTIME_DISABLE_FLAG | 10;
936        let tx = Transaction::new(
937            2,
938            vec![TxIn::new(
939                OutPoint::new(funding_txid, 0),
940                Script::new(),
941                seq_disabled,
942            )],
943            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
944            0,
945        );
946
947        assert!(acceptor.accept_transaction(&tx).await.is_ok());
948    }
949
950    #[tokio::test]
951    async fn regression_bip68_v1_tx_skips_sequence_check() {
952        let chain_state = Arc::new(MockChainState::new());
953        let mempool = Arc::new(MockMempool::new());
954
955        let funding_txid =
956            Txid::from_hash(abtc_domain::primitives::Hash256::from_bytes([0x15; 32]));
957        chain_state
958            .add_utxo(funding_txid, 0, make_funding_utxo(100_000))
959            .await;
960
961        let mut acceptor = MempoolAcceptor::new(chain_state, mempool);
962        acceptor.set_verify_scripts(false);
963
964        // Version 1 tx — BIP68 relative locks don't apply regardless of sequence
965        let tx = Transaction::new(
966            1,
967            vec![TxIn::new(
968                OutPoint::new(funding_txid, 0),
969                Script::new(),
970                10, // Would be a 10-block relative lock in v2
971            )],
972            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
973            0,
974        );
975
976        assert!(acceptor.accept_transaction(&tx).await.is_ok());
977    }
978}