Skip to main content

abtc_application/
package_relay.rs

1//! Package Relay — Application-layer package acceptance service
2//!
3//! Orchestrates the acceptance of transaction packages into the mempool.
4//! A package is a group of related transactions (typically parent + child
5//! for CPFP) that are validated and submitted as a unit.
6//!
7//! ## Design
8//!
9//! Unlike individual transaction acceptance (`MempoolAcceptor`), package
10//! acceptance uses aggregate fee evaluation: a child with high fees can
11//! compensate for parents with low/zero individual fees, as long as the
12//! combined package fee rate meets the minimum.
13//!
14//! ## Acceptance Pipeline
15//!
16//! 1. Validate package structure (topological order, no conflicts, limits)
17//! 2. For each transaction: verify inputs against UTXO set + earlier package txs
18//! 3. Compute aggregate fee across the package
19//! 4. Check aggregate package fee rate against minimum
20//! 5. Submit each transaction to the mempool in topological order
21//!
22//! ## Relationship to Net Processing
23//!
24//! `SyncManager` dispatches incoming package messages to `PackageAcceptor`.
25//! The acceptor returns `PackageResult` which the sync manager uses to
26//! decide whether to relay the package to other peers.
27
28use abtc_domain::policy::packages::{self, PackageError, PackageType, MAX_PACKAGE_VSIZE};
29use abtc_domain::primitives::{Amount, Transaction, Txid};
30use abtc_ports::{ChainStateStore, MempoolPort};
31use std::collections::HashMap;
32use std::sync::Arc;
33
34/// Result of accepting a package into the mempool.
35#[derive(Debug, Clone)]
36pub struct PackageResult {
37    /// Transactions that were accepted (in submission order).
38    pub accepted: Vec<PackageAcceptedTx>,
39    /// Package type that was detected.
40    pub package_type: PackageType,
41    /// Aggregate fee for all accepted transactions.
42    pub total_fee: Amount,
43    /// Aggregate virtual size.
44    pub total_vsize: u32,
45    /// Aggregate fee rate (sat/vB).
46    pub package_fee_rate: f64,
47}
48
49/// Information about a single accepted transaction within a package.
50#[derive(Debug, Clone)]
51pub struct PackageAcceptedTx {
52    /// Transaction ID.
53    pub txid: Txid,
54    /// Individual fee in satoshis.
55    pub fee: Amount,
56    /// Individual virtual size in bytes.
57    pub vsize: u32,
58}
59
60/// Errors from package acceptance.
61#[derive(Debug)]
62pub enum PackageAcceptError {
63    /// Package validation (structure, topology) failed.
64    PackageValidation(PackageError),
65    /// A transaction in the package failed UTXO lookup.
66    MissingInput { txid: Txid, detail: String },
67    /// A transaction's outputs don't cover inputs (negative fee).
68    NegativeFee { txid: Txid },
69    /// Aggregate package fee rate too low.
70    InsufficientPackageFeeRate { fee_rate: f64, min_rate: f64 },
71    /// A transaction failed mempool submission.
72    MempoolRejection { txid: Txid, reason: String },
73    /// Package total vsize exceeded.
74    PackageTooLarge { vsize: u32, limit: u32 },
75}
76
77impl std::fmt::Display for PackageAcceptError {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        match self {
80            PackageAcceptError::PackageValidation(e) => write!(f, "package validation: {}", e),
81            PackageAcceptError::MissingInput { txid, detail } => {
82                write!(f, "missing input for tx {}: {}", txid, detail)
83            }
84            PackageAcceptError::NegativeFee { txid } => {
85                write!(f, "tx {} has negative fee", txid)
86            }
87            PackageAcceptError::InsufficientPackageFeeRate { fee_rate, min_rate } => {
88                write!(
89                    f,
90                    "package fee rate {:.2} sat/vB below minimum {:.2}",
91                    fee_rate, min_rate
92                )
93            }
94            PackageAcceptError::MempoolRejection { txid, reason } => {
95                write!(f, "mempool rejected tx {}: {}", txid, reason)
96            }
97            PackageAcceptError::PackageTooLarge { vsize, limit } => {
98                write!(f, "package {} vB exceeds limit {} vB", vsize, limit)
99            }
100        }
101    }
102}
103
104impl std::error::Error for PackageAcceptError {}
105
106/// Package acceptance orchestrator.
107///
108/// Validates and submits transaction packages to the mempool, using
109/// aggregate fee evaluation to support CPFP fee-bumping.
110pub struct PackageAcceptor {
111    chain_state: Arc<dyn ChainStateStore>,
112    mempool: Arc<dyn MempoolPort>,
113    /// Whether to verify scripts (can be disabled for testing).
114    verify_scripts: bool,
115}
116
117impl PackageAcceptor {
118    /// Create a new package acceptor.
119    pub fn new(chain_state: Arc<dyn ChainStateStore>, mempool: Arc<dyn MempoolPort>) -> Self {
120        PackageAcceptor {
121            chain_state,
122            mempool,
123            verify_scripts: true,
124        }
125    }
126
127    /// Enable or disable script verification (useful for testing).
128    pub fn set_verify_scripts(&mut self, verify: bool) {
129        self.verify_scripts = verify;
130    }
131
132    /// Accept a package of transactions into the mempool.
133    ///
134    /// Transactions should be in topological order (parents before children).
135    /// If not, the package validator will reject them.
136    pub async fn accept_package(
137        &self,
138        transactions: &[Transaction],
139    ) -> Result<PackageResult, PackageAcceptError> {
140        // 1. Validate package structure
141        let package_type = packages::validate_package(transactions)
142            .map_err(PackageAcceptError::PackageValidation)?;
143
144        // 2. Compute fees and vsizes for each transaction
145        //    We track outputs created by earlier package transactions so
146        //    children can reference their parents' outputs.
147        let mut package_outputs: HashMap<(Txid, u32), Amount> = HashMap::new();
148        let mut tx_fees: Vec<(Txid, Amount, u32)> = Vec::new();
149        let mut total_fee = Amount::from_sat(0);
150        let mut total_vsize: u32 = 0;
151
152        for tx in transactions {
153            let txid = tx.txid();
154            let vsize = packages::estimate_package_tx_vsize(tx);
155
156            // Resolve input values from UTXO set + package-internal outputs
157            let mut input_total: i64 = 0;
158            for input in &tx.inputs {
159                let prev_txid = input.previous_output.txid;
160                let prev_vout = input.previous_output.vout;
161
162                // First check package-internal outputs
163                if let Some(value) = package_outputs.get(&(prev_txid, prev_vout)) {
164                    input_total += value.as_sat();
165                } else {
166                    // Fall back to the UTXO set
167                    let utxo = self
168                        .chain_state
169                        .get_utxo(&prev_txid, prev_vout)
170                        .await
171                        .map_err(|e| PackageAcceptError::MissingInput {
172                            txid,
173                            detail: e.to_string(),
174                        })?
175                        .ok_or_else(|| PackageAcceptError::MissingInput {
176                            txid,
177                            detail: format!("{}:{}", prev_txid, prev_vout),
178                        })?;
179                    input_total += utxo.output.value.as_sat();
180                }
181            }
182
183            let output_total: i64 = tx.outputs.iter().map(|o| o.value.as_sat()).sum();
184
185            if input_total < output_total {
186                return Err(PackageAcceptError::NegativeFee { txid });
187            }
188
189            let fee = Amount::from_sat(input_total - output_total);
190
191            // Register this transaction's outputs for later children
192            for (vout, output) in tx.outputs.iter().enumerate() {
193                package_outputs.insert((txid, vout as u32), output.value);
194            }
195
196            total_fee = Amount::from_sat(total_fee.as_sat() + fee.as_sat());
197            total_vsize += vsize;
198            tx_fees.push((txid, fee, vsize));
199        }
200
201        // 3. Check total package size
202        if total_vsize > MAX_PACKAGE_VSIZE {
203            return Err(PackageAcceptError::PackageTooLarge {
204                vsize: total_vsize,
205                limit: MAX_PACKAGE_VSIZE,
206            });
207        }
208
209        // 4. Check aggregate fee rate
210        let package_fee_rate =
211            packages::check_package_fee_rate(total_fee, total_vsize).map_err(|e| match e {
212                PackageError::InsufficientPackageFeeRate { fee_rate, min_rate } => {
213                    PackageAcceptError::InsufficientPackageFeeRate { fee_rate, min_rate }
214                }
215                _ => PackageAcceptError::PackageValidation(e),
216            })?;
217
218        // 5. Submit each transaction to the mempool in topological order
219        let mut accepted = Vec::new();
220
221        for (i, tx) in transactions.iter().enumerate() {
222            let (txid, fee, vsize) = &tx_fees[i];
223
224            self.mempool.add_transaction(tx).await.map_err(|e| {
225                PackageAcceptError::MempoolRejection {
226                    txid: *txid,
227                    reason: e.to_string(),
228                }
229            })?;
230
231            tracing::info!(
232                "Package: accepted tx {} ({}/{}, fee={}, vsize={})",
233                txid,
234                i + 1,
235                transactions.len(),
236                fee.as_sat(),
237                vsize,
238            );
239
240            accepted.push(PackageAcceptedTx {
241                txid: *txid,
242                fee: *fee,
243                vsize: *vsize,
244            });
245        }
246
247        tracing::info!(
248            "Package accepted: {} txs, total_fee={}, total_vsize={}, rate={:.1} sat/vB, type={:?}",
249            accepted.len(),
250            total_fee.as_sat(),
251            total_vsize,
252            package_fee_rate,
253            package_type,
254        );
255
256        Ok(PackageResult {
257            accepted,
258            package_type,
259            total_fee,
260            total_vsize,
261            package_fee_rate,
262        })
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269    use abtc_domain::primitives::{BlockHash, Hash256, OutPoint, TxIn, TxOut};
270    use abtc_domain::Script;
271    use abtc_ports::{MempoolEntry, MempoolInfo, UtxoEntry, UtxoSetInfo};
272    use async_trait::async_trait;
273    use std::collections::HashMap;
274    use tokio::sync::RwLock;
275
276    // ── Mock Chain State ────────────────────────────────────
277
278    struct MockChainState {
279        utxos: RwLock<HashMap<(Txid, u32), UtxoEntry>>,
280    }
281
282    impl MockChainState {
283        fn new() -> Self {
284            MockChainState {
285                utxos: RwLock::new(HashMap::new()),
286            }
287        }
288
289        async fn add_utxo(&self, txid: Txid, vout: u32, entry: UtxoEntry) {
290            self.utxos.write().await.insert((txid, vout), entry);
291        }
292    }
293
294    #[async_trait]
295    impl ChainStateStore for MockChainState {
296        async fn get_utxo(
297            &self,
298            txid: &Txid,
299            vout: u32,
300        ) -> Result<Option<UtxoEntry>, Box<dyn std::error::Error + Send + Sync>> {
301            Ok(self.utxos.read().await.get(&(*txid, vout)).cloned())
302        }
303
304        async fn has_utxo(
305            &self,
306            txid: &Txid,
307            vout: u32,
308        ) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
309            Ok(self.utxos.read().await.contains_key(&(*txid, vout)))
310        }
311
312        async fn write_utxo_set(
313            &self,
314            _adds: Vec<(Txid, u32, UtxoEntry)>,
315            _removes: Vec<(Txid, u32)>,
316        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
317            Ok(())
318        }
319
320        async fn get_best_chain_tip(
321            &self,
322        ) -> Result<(BlockHash, u32), Box<dyn std::error::Error + Send + Sync>> {
323            Ok((BlockHash::zero(), 100))
324        }
325
326        async fn write_chain_tip(
327            &self,
328            _hash: BlockHash,
329            _height: u32,
330        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
331            Ok(())
332        }
333
334        async fn get_utxo_set_info(
335            &self,
336        ) -> Result<UtxoSetInfo, Box<dyn std::error::Error + Send + Sync>> {
337            Ok(UtxoSetInfo {
338                txout_count: 0,
339                total_amount: Amount::from_sat(0),
340                best_block: BlockHash::zero(),
341                height: 100,
342            })
343        }
344    }
345
346    // ── Mock Mempool ────────────────────────────────────────
347
348    struct MockMempool {
349        txs: RwLock<HashMap<Txid, Transaction>>,
350    }
351
352    impl MockMempool {
353        fn new() -> Self {
354            MockMempool {
355                txs: RwLock::new(HashMap::new()),
356            }
357        }
358    }
359
360    #[async_trait]
361    impl MempoolPort for MockMempool {
362        async fn add_transaction(
363            &self,
364            tx: &Transaction,
365        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
366            let txid = tx.txid();
367            let mut txs = self.txs.write().await;
368            if txs.contains_key(&txid) {
369                return Err("already in mempool".into());
370            }
371            txs.insert(txid, tx.clone());
372            Ok(())
373        }
374
375        async fn remove_transaction(
376            &self,
377            txid: &Txid,
378            _recursive: bool,
379        ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
380            self.txs.write().await.remove(txid);
381            Ok(())
382        }
383
384        async fn get_transaction(
385            &self,
386            txid: &Txid,
387        ) -> Result<Option<MempoolEntry>, Box<dyn std::error::Error + Send + Sync>> {
388            let txs = self.txs.read().await;
389            Ok(txs.get(txid).map(|tx| MempoolEntry {
390                tx: tx.clone(),
391                fee: Amount::from_sat(0),
392                size: 100,
393                time: 0,
394                height: 0,
395                descendant_count: 0,
396                descendant_size: 0,
397                ancestor_count: 0,
398                ancestor_size: 0,
399            }))
400        }
401
402        async fn get_all_transactions(
403            &self,
404        ) -> Result<Vec<MempoolEntry>, Box<dyn std::error::Error + Send + Sync>> {
405            Ok(vec![])
406        }
407
408        async fn get_transaction_count(
409            &self,
410        ) -> Result<u32, Box<dyn std::error::Error + Send + Sync>> {
411            Ok(self.txs.read().await.len() as u32)
412        }
413
414        async fn estimate_fee(
415            &self,
416            _target_blocks: u32,
417        ) -> Result<f64, Box<dyn std::error::Error + Send + Sync>> {
418            Ok(1.0)
419        }
420
421        async fn get_mempool_info(
422            &self,
423        ) -> Result<MempoolInfo, Box<dyn std::error::Error + Send + Sync>> {
424            Ok(MempoolInfo {
425                size: 0,
426                bytes: 0,
427                usage: 0,
428                max_mempool: 300_000_000,
429                min_relay_fee: 0.00001,
430            })
431        }
432
433        async fn clear(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
434            self.txs.write().await.clear();
435            Ok(())
436        }
437    }
438
439    // ── Helper ──────────────────────────────────────────────
440
441    fn funding_txid(byte: u8) -> Txid {
442        Txid::from_hash(Hash256::from_bytes([byte; 32]))
443    }
444
445    fn make_utxo(value: i64) -> UtxoEntry {
446        UtxoEntry {
447            output: TxOut::new(Amount::from_sat(value), Script::new()),
448            height: 1,
449            is_coinbase: false,
450        }
451    }
452
453    // ── Tests ───────────────────────────────────────────────
454
455    #[tokio::test]
456    async fn test_accept_simple_package() {
457        let chain_state = Arc::new(MockChainState::new());
458        let mempool = Arc::new(MockMempool::new());
459
460        // Funding UTXO for the parent
461        let ftxid = funding_txid(0x01);
462        chain_state.add_utxo(ftxid, 0, make_utxo(100_000)).await;
463
464        let parent = Transaction::v1(
465            vec![TxIn::final_input(OutPoint::new(ftxid, 0), Script::new())],
466            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
467            0,
468        );
469        let parent_txid = parent.txid();
470
471        let child = Transaction::v1(
472            vec![TxIn::final_input(
473                OutPoint::new(parent_txid, 0),
474                Script::new(),
475            )],
476            vec![TxOut::new(Amount::from_sat(80_000), Script::new())],
477            0,
478        );
479
480        let mut acceptor = PackageAcceptor::new(chain_state, mempool.clone());
481        acceptor.set_verify_scripts(false);
482
483        let result = acceptor.accept_package(&[parent, child]).await.unwrap();
484
485        assert_eq!(result.accepted.len(), 2);
486        assert_eq!(result.package_type, PackageType::ChildWithParents);
487        assert_eq!(result.total_fee.as_sat(), 20_000); // 10k + 10k
488        assert!(result.package_fee_rate > 0.0);
489
490        // Both should be in the mempool
491        assert_eq!(mempool.get_transaction_count().await.unwrap(), 2);
492    }
493
494    #[tokio::test]
495    async fn test_cpfp_low_parent_high_child() {
496        let chain_state = Arc::new(MockChainState::new());
497        let mempool = Arc::new(MockMempool::new());
498
499        let ftxid = funding_txid(0x02);
500        chain_state.add_utxo(ftxid, 0, make_utxo(100_000)).await;
501
502        // Parent: very low fee (100 sat for ~60 vB = ~1.7 sat/vB)
503        let parent = Transaction::v1(
504            vec![TxIn::final_input(OutPoint::new(ftxid, 0), Script::new())],
505            vec![TxOut::new(Amount::from_sat(99_900), Script::new())],
506            0,
507        );
508        let parent_txid = parent.txid();
509
510        // Child: high fee (10,000 sat for ~60 vB = ~167 sat/vB)
511        let child = Transaction::v1(
512            vec![TxIn::final_input(
513                OutPoint::new(parent_txid, 0),
514                Script::new(),
515            )],
516            vec![TxOut::new(Amount::from_sat(89_900), Script::new())],
517            0,
518        );
519
520        let mut acceptor = PackageAcceptor::new(chain_state, mempool.clone());
521        acceptor.set_verify_scripts(false);
522
523        let result = acceptor.accept_package(&[parent, child]).await.unwrap();
524
525        // Parent fee: 100_000 - 99_900 = 100
526        // Child fee: 99_900 - 89_900 = 10_000
527        // Total: 10_100 over ~120 vB
528        assert_eq!(result.total_fee.as_sat(), 10_100);
529        assert!(result.package_fee_rate > 1.0); // Above minimum
530    }
531
532    #[tokio::test]
533    async fn test_missing_input_rejected() {
534        let chain_state = Arc::new(MockChainState::new());
535        let mempool = Arc::new(MockMempool::new());
536
537        // No funding UTXO — parent's input is missing
538        let parent = Transaction::v1(
539            vec![TxIn::final_input(
540                OutPoint::new(funding_txid(0x99), 0),
541                Script::new(),
542            )],
543            vec![TxOut::new(Amount::from_sat(50_000), Script::new())],
544            0,
545        );
546
547        let mut acceptor = PackageAcceptor::new(chain_state, mempool);
548        acceptor.set_verify_scripts(false);
549
550        let result = acceptor.accept_package(&[parent]).await;
551        assert!(result.is_err());
552        assert!(matches!(
553            result.unwrap_err(),
554            PackageAcceptError::MissingInput { .. }
555        ));
556    }
557
558    #[tokio::test]
559    async fn test_empty_package_rejected() {
560        let chain_state = Arc::new(MockChainState::new());
561        let mempool = Arc::new(MockMempool::new());
562
563        let acceptor = PackageAcceptor::new(chain_state, mempool);
564        let result = acceptor.accept_package(&[]).await;
565        assert!(result.is_err());
566        assert!(matches!(
567            result.unwrap_err(),
568            PackageAcceptError::PackageValidation(_)
569        ));
570    }
571
572    #[tokio::test]
573    async fn test_negative_fee_rejected() {
574        let chain_state = Arc::new(MockChainState::new());
575        let mempool = Arc::new(MockMempool::new());
576
577        let ftxid = funding_txid(0x03);
578        chain_state.add_utxo(ftxid, 0, make_utxo(1_000)).await;
579
580        // Outputs exceed input — negative fee
581        let tx = Transaction::v1(
582            vec![TxIn::final_input(OutPoint::new(ftxid, 0), Script::new())],
583            vec![TxOut::new(Amount::from_sat(2_000), Script::new())],
584            0,
585        );
586
587        let mut acceptor = PackageAcceptor::new(chain_state, mempool);
588        acceptor.set_verify_scripts(false);
589
590        let result = acceptor.accept_package(&[tx]).await;
591        assert!(result.is_err());
592        assert!(matches!(
593            result.unwrap_err(),
594            PackageAcceptError::NegativeFee { .. }
595        ));
596    }
597
598    #[tokio::test]
599    async fn test_two_parents_one_child_package() {
600        let chain_state = Arc::new(MockChainState::new());
601        let mempool = Arc::new(MockMempool::new());
602
603        let ftxid_a = funding_txid(0x10);
604        let ftxid_b = funding_txid(0x11);
605        chain_state.add_utxo(ftxid_a, 0, make_utxo(50_000)).await;
606        chain_state.add_utxo(ftxid_b, 0, make_utxo(50_000)).await;
607
608        let parent_a = Transaction::v1(
609            vec![TxIn::final_input(OutPoint::new(ftxid_a, 0), Script::new())],
610            vec![TxOut::new(Amount::from_sat(49_000), Script::new())],
611            0,
612        );
613        let parent_a_txid = parent_a.txid();
614
615        let parent_b = Transaction::v1(
616            vec![TxIn::final_input(OutPoint::new(ftxid_b, 0), Script::new())],
617            vec![TxOut::new(Amount::from_sat(49_000), Script::new())],
618            0,
619        );
620        let parent_b_txid = parent_b.txid();
621
622        let child = Transaction::v1(
623            vec![
624                TxIn::final_input(OutPoint::new(parent_a_txid, 0), Script::new()),
625                TxIn::final_input(OutPoint::new(parent_b_txid, 0), Script::new()),
626            ],
627            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
628            0,
629        );
630
631        let mut acceptor = PackageAcceptor::new(chain_state, mempool.clone());
632        acceptor.set_verify_scripts(false);
633
634        let result = acceptor
635            .accept_package(&[parent_a, parent_b, child])
636            .await
637            .unwrap();
638
639        assert_eq!(result.accepted.len(), 3);
640        assert_eq!(result.package_type, PackageType::ChildWithParents);
641        // Parent A fee: 1000, Parent B fee: 1000, Child fee: 8000
642        assert_eq!(result.total_fee.as_sat(), 10_000);
643        assert_eq!(mempool.get_transaction_count().await.unwrap(), 3);
644    }
645
646    // ── Regression tests ────────────────────────────────────
647
648    #[tokio::test]
649    async fn regression_child_references_parent_output() {
650        // Ensures the package acceptor correctly resolves outputs from
651        // earlier transactions in the package (not just UTXO set).
652        let chain_state = Arc::new(MockChainState::new());
653        let mempool = Arc::new(MockMempool::new());
654
655        let ftxid = funding_txid(0x20);
656        chain_state.add_utxo(ftxid, 0, make_utxo(100_000)).await;
657
658        let parent = Transaction::v1(
659            vec![TxIn::final_input(OutPoint::new(ftxid, 0), Script::new())],
660            vec![
661                TxOut::new(Amount::from_sat(40_000), Script::new()),
662                TxOut::new(Amount::from_sat(50_000), Script::new()),
663            ],
664            0,
665        );
666        let parent_txid = parent.txid();
667
668        // Child spends parent output index 1 (50,000 sat)
669        let child = Transaction::v1(
670            vec![TxIn::final_input(
671                OutPoint::new(parent_txid, 1),
672                Script::new(),
673            )],
674            vec![TxOut::new(Amount::from_sat(45_000), Script::new())],
675            0,
676        );
677
678        let mut acceptor = PackageAcceptor::new(chain_state, mempool.clone());
679        acceptor.set_verify_scripts(false);
680
681        let result = acceptor.accept_package(&[parent, child]).await.unwrap();
682
683        // Parent fee: 100_000 - 90_000 = 10_000
684        // Child fee: 50_000 - 45_000 = 5_000
685        assert_eq!(result.total_fee.as_sat(), 15_000);
686        assert_eq!(result.accepted.len(), 2);
687    }
688
689    #[tokio::test]
690    async fn regression_duplicate_mempool_submission_fails() {
691        // If a tx is already in the mempool, submitting it again
692        // as part of a package should fail gracefully.
693        let chain_state = Arc::new(MockChainState::new());
694        let mempool = Arc::new(MockMempool::new());
695
696        let ftxid = funding_txid(0x30);
697        chain_state.add_utxo(ftxid, 0, make_utxo(100_000)).await;
698
699        let tx = Transaction::v1(
700            vec![TxIn::final_input(OutPoint::new(ftxid, 0), Script::new())],
701            vec![TxOut::new(Amount::from_sat(90_000), Script::new())],
702            0,
703        );
704
705        // Pre-add to mempool
706        mempool.add_transaction(&tx).await.unwrap();
707
708        let mut acceptor = PackageAcceptor::new(chain_state, mempool);
709        acceptor.set_verify_scripts(false);
710
711        let result = acceptor.accept_package(&[tx]).await;
712        assert!(result.is_err());
713        assert!(matches!(
714            result.unwrap_err(),
715            PackageAcceptError::MempoolRejection { .. }
716        ));
717    }
718
719    #[tokio::test]
720    async fn regression_package_fee_components() {
721        // Verify individual fee tracking is correct
722        let chain_state = Arc::new(MockChainState::new());
723        let mempool = Arc::new(MockMempool::new());
724
725        let ftxid = funding_txid(0x40);
726        chain_state.add_utxo(ftxid, 0, make_utxo(100_000)).await;
727
728        let parent = Transaction::v1(
729            vec![TxIn::final_input(OutPoint::new(ftxid, 0), Script::new())],
730            vec![TxOut::new(Amount::from_sat(95_000), Script::new())],
731            0,
732        );
733        let parent_txid = parent.txid();
734
735        let child = Transaction::v1(
736            vec![TxIn::final_input(
737                OutPoint::new(parent_txid, 0),
738                Script::new(),
739            )],
740            vec![TxOut::new(Amount::from_sat(85_000), Script::new())],
741            0,
742        );
743
744        let mut acceptor = PackageAcceptor::new(chain_state, mempool);
745        acceptor.set_verify_scripts(false);
746
747        let result = acceptor.accept_package(&[parent, child]).await.unwrap();
748
749        // Parent fee = 100_000 - 95_000 = 5_000
750        assert_eq!(result.accepted[0].fee.as_sat(), 5_000);
751        // Child fee = 95_000 - 85_000 = 10_000
752        assert_eq!(result.accepted[1].fee.as_sat(), 10_000);
753        // Total = 15_000
754        assert_eq!(result.total_fee.as_sat(), 15_000);
755    }
756}