Skip to main content

ark_core/
history.rs

1use crate::server::VirtualTxOutPoint;
2use crate::Error;
3use bitcoin::Amount;
4use bitcoin::SignedAmount;
5use bitcoin::Txid;
6use std::collections::hash_map::Entry;
7use std::collections::HashMap;
8
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub enum Transaction {
11    /// A transaction that transforms a UTXO into a boarding output.
12    Boarding {
13        txid: Txid,
14        /// We use [`Amount`] because boarding transactions are always incoming i.e. we receive a
15        /// boarding output.
16        amount: Amount,
17        confirmed_at: Option<i64>,
18    },
19    /// A transaction that confirms VTXOs.
20    Commitment {
21        txid: Txid,
22        /// We use [`SignedAmount`] because commitment transactions may be incoming or outgoing
23        /// i.e. we can send or receive VTXOs.
24        amount: SignedAmount,
25        created_at: i64,
26    },
27    /// A transaction that has VTXOs as outputs.
28    Ark {
29        txid: Txid,
30        /// We use [`SignedAmount`] because Ark transactions may be incoming or outgoing i.e.
31        /// we can send or receive VTXOs.
32        amount: SignedAmount,
33        /// An Ark transaction is settled if our outputs in it have been spent. Thus, if we have no
34        /// _outputs_ in it, it is considered settled too.
35        is_settled: bool,
36        created_at: i64,
37    },
38    /// A transaction that offboards VTXOs to an onchain output.
39    Offboard {
40        /// The commitment TXID that settles the VTXOs.
41        commitment_txid: Txid,
42        /// We use [`Amount`] because offboarding transactions are always outgoing.
43        amount: Amount,
44        /// Confirmation time of the commitment transaction. This information must be provided by
45        /// an external source (e.g., esplora).
46        confirmed_at: Option<i64>,
47    },
48}
49
50impl Transaction {
51    /// The creation time of the [`Transaction`]. This value can be used for sorting.
52    ///
53    /// - The creation time of a boarding transaction is based on its confirmation time. If it is
54    ///   pending, we return [`None`].
55    ///
56    /// - The creation time of a commitment transaction is based on the `created_at` of our VTXO
57    ///   produced by it.
58    ///
59    /// - The creation time of an Ark transaction is based on the `created_at` of our VTXO produced
60    ///   by it.
61    ///
62    /// - The creation time of an offboard transaction is based on its confirmation time. If it is
63    ///   pending, we return [`None`].
64    pub fn created_at(&self) -> Option<i64> {
65        match self {
66            Transaction::Boarding { confirmed_at, .. }
67            | Transaction::Offboard { confirmed_at, .. } => *confirmed_at,
68            Transaction::Commitment { created_at, .. } | Transaction::Ark { created_at, .. } => {
69                Some(*created_at)
70            }
71        }
72    }
73
74    pub fn txid(&self) -> Txid {
75        match self {
76            Transaction::Boarding { txid, .. }
77            | Transaction::Commitment { txid, .. }
78            | Transaction::Ark { txid, .. } => *txid,
79            Transaction::Offboard {
80                commitment_txid, ..
81            } => *commitment_txid,
82        }
83    }
84}
85
86/// Sorts a slice of [`Transaction`] in descending order by creation time.
87///
88/// Transactions with no creation time (None) are placed first, followed by transactions
89/// sorted by creation time in descending order (newest first).
90pub fn sort_transactions_by_created_at(txs: &mut [Transaction]) {
91    txs.sort_by(|a, b| match (a.created_at(), b.created_at()) {
92        (None, None) => std::cmp::Ordering::Equal,
93        (None, Some(_)) => std::cmp::Ordering::Less,
94        (Some(_), None) => std::cmp::Ordering::Greater,
95        (Some(a_time), Some(b_time)) => b_time.cmp(&a_time),
96    });
97}
98
99/// Generate a list of transactions where we receive VTXOs.
100///
101/// This list excludes settlements or transactions where we receive a change VTXO.
102pub fn generate_incoming_vtxo_transaction_history(
103    spent_vtxos: &[VirtualTxOutPoint],
104    spendable_vtxos: &[VirtualTxOutPoint],
105    // Commitment transactions which take a boarding output of ours as an input.
106    boarding_commitment_txs: &[Txid],
107) -> Result<Vec<Transaction>, Error> {
108    let mut txs = Vec::new();
109
110    let all_vtxos = spent_vtxos.iter().chain(spendable_vtxos.iter());
111
112    let mut spent_vtxos_left_to_check = spent_vtxos.to_vec();
113
114    // We iterate through every VTXO because all VTXOs were incoming at some point.
115    for vtxo in all_vtxos {
116        // Confirmed settlement of boarding output into VTXO => IGNORED.
117        if !vtxo.is_preconfirmed
118            && boarding_commitment_txs.contains(
119                // There should only be one commitment TXID for confirmed VTXOs.
120                &vtxo.commitment_txids[0],
121            )
122        {
123            continue;
124        }
125
126        // An incoming VTXO that deserves an entry in the transaction history is the result of an
127        // incoming payment. We may receive a VTXO as part of a commitment transaction or through an
128        // Ark transaction.
129
130        if vtxo.is_preconfirmed {
131            // We compute how much we spent in that Ark transaction.
132            let spent_amount = {
133                let mut spent_amount = Amount::ZERO;
134                let mut remaining_spent_vtxos = Vec::new();
135                for spent_vtxo in spent_vtxos_left_to_check.iter() {
136                    if spent_vtxo.ark_txid == Some(vtxo.outpoint.txid) {
137                        spent_amount += spent_vtxo.amount;
138                    } else {
139                        remaining_spent_vtxos.push(spent_vtxo.clone());
140                    }
141                }
142
143                spent_vtxos_left_to_check = remaining_spent_vtxos;
144
145                spent_amount
146            };
147
148            let receive_amount = vtxo.amount.to_signed().map_err(Error::ad_hoc)?;
149            let spent_amount = spent_amount.to_signed().map_err(Error::ad_hoc)?;
150
151            let net_amount = receive_amount - spent_amount;
152
153            // If net amount is zero, it's a self-payment => IGNORED.
154            //
155            // If net amount is negative, it's a change VTXO => IGNORED.
156            if net_amount.is_positive() {
157                txs.push(Transaction::Ark {
158                    txid: vtxo.outpoint.txid,
159                    amount: net_amount,
160                    is_settled: vtxo.spent_by.is_some() ||
161                        // To include settled dust outputs too!
162                        vtxo.settled_by.is_some(),
163                    created_at: vtxo.created_at,
164                })
165            }
166        } else {
167            // We compute how much we spent in that batch.
168            let spent_amount = {
169                let mut spent_amount = Amount::ZERO;
170                let mut remaining_spent_vtxos = Vec::new();
171                for spent_vtxo in spent_vtxos_left_to_check.iter() {
172                    // There should only be one commitment TXID for confirmed VTXOs.
173                    let commitment_txid = vtxo.commitment_txids[0];
174
175                    if spent_vtxo.settled_by == Some(commitment_txid) {
176                        spent_amount += spent_vtxo.amount;
177                    } else {
178                        remaining_spent_vtxos.push(spent_vtxo.clone());
179                    }
180                }
181
182                spent_vtxos_left_to_check = remaining_spent_vtxos;
183
184                spent_amount
185            };
186
187            let receive_amount = vtxo.amount.to_signed().map_err(Error::ad_hoc)?;
188            let spent_amount = spent_amount.to_signed().map_err(Error::ad_hoc)?;
189
190            let net_amount = receive_amount - spent_amount;
191
192            // If net amount received is zero, it's a VTXO being settled => IGNORED.
193            //
194            // If net amount received is negative, it's a change VTXO => IGNORED.
195            if net_amount.is_positive() {
196                txs.push(Transaction::Commitment {
197                    txid: vtxo.outpoint.txid,
198                    amount: receive_amount,
199                    created_at: vtxo.created_at,
200                })
201            }
202        }
203    }
204
205    Ok(txs)
206}
207
208/// Generate a list of outgoing transactions.
209///
210/// This includes:
211/// - Outgoing Ark transactions (offchain payments)
212/// - Offboarding transactions (collaborative redeem to onchain)
213///
214/// Pure settlements (VTXO refreshes with no net outflow) are excluded.
215///
216/// # Returns
217///
218/// An iterator of [`OutgoingTransaction`]s.
219///
220/// We do not return a list of [`Transaction`]s directly because some outgoing transactions may need
221/// additional data to be constructed:
222/// - [`OutgoingTransaction::Incomplete`]: needs a [`VirtualTxOutPoint`] to complete.
223/// - [`OutgoingTransaction::IncompleteOffboard`]: needs confirmation data from an external source.
224///
225/// # Example
226///
227/// ```rust
228/// # use ark_core::history::OutgoingTransaction;
229/// # use ark_core::history::generate_outgoing_vtxo_transaction_history;
230/// # use ark_core::server::VirtualTxOutPoint;
231/// # use ark_core::Error;
232/// # use bitcoin::OutPoint;
233/// # use bitcoin::Txid;
234/// # fn fetch_virtual_tx_outpoint(_outpoint: OutPoint) -> Result<Option<VirtualTxOutPoint>, Error> {
235/// #     Ok(None)
236/// # }
237/// # fn fetch_tx_confirmation_time(_txid: Txid) -> Result<Option<i64>, Error> {
238/// #     Ok(None)
239/// # }
240/// #
241/// # let spent_vtxos = vec![];
242/// # let spendable_vtxos = vec![];
243/// let outgoing_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos).unwrap();
244///
245/// let mut complete_outgoing_txs = vec![];
246/// for outgoing_tx in outgoing_txs {
247///     match outgoing_tx {
248///         OutgoingTransaction::Complete(complete_tx) => {
249///             complete_outgoing_txs.push(complete_tx);
250///         }
251///         OutgoingTransaction::Incomplete(incomplete_tx) => {
252///             // Need to fetch additional VTXO data to complete.
253///             let virtual_tx_outpoint = fetch_virtual_tx_outpoint(incomplete_tx.first_outpoint()).unwrap();
254///             if let Some(virtual_tx_outpoint) = virtual_tx_outpoint {
255///                 let complete_tx = incomplete_tx.finish(&virtual_tx_outpoint).unwrap();
256///                 complete_outgoing_txs.push(complete_tx);
257///             }
258///         }
259///         OutgoingTransaction::IncompleteOffboard(incomplete_offboard) => {
260///             // Need to fetch confirmation time from an external source (e.g., esplora).
261///             let confirmed_at = fetch_tx_confirmation_time(incomplete_offboard.commitment_txid()).unwrap();
262///             let complete_tx = incomplete_offboard.finish(confirmed_at);
263///             complete_outgoing_txs.push(complete_tx);
264///         }
265///     }
266/// }
267/// ```
268pub fn generate_outgoing_vtxo_transaction_history(
269    spent_vtxos: &[VirtualTxOutPoint],
270    spendable_vtxos: &[VirtualTxOutPoint],
271) -> Result<impl Iterator<Item = OutgoingTransaction>, Error> {
272    let all_vtxos = [spent_vtxos, spendable_vtxos].concat();
273
274    // We collect all the transactions where one or more VTXOs of ours are spent.
275    let mut vtxos_by_spent_by = HashMap::<Txid, Vec<VirtualTxOutPoint>>::new();
276    // We collect all the VTXOs that are settled (forfeited) by a commitment transaction.
277    let mut vtxos_by_settled_by = HashMap::<Txid, Vec<VirtualTxOutPoint>>::new();
278
279    for spent_vtxo in spent_vtxos.iter() {
280        if let Some(settled_by) = spent_vtxo.settled_by {
281            // Track settlements to detect offboarding.
282            match vtxos_by_settled_by.entry(settled_by) {
283                Entry::Occupied(mut occupied_entry) => {
284                    occupied_entry.get_mut().push(spent_vtxo.clone());
285                }
286                Entry::Vacant(e) => {
287                    e.insert(vec![spent_vtxo.clone()]);
288                }
289            }
290        } else if let Some(ark_txid) = spent_vtxo.ark_txid {
291            if spent_vtxo.spent_by.is_some() {
292                match vtxos_by_spent_by.entry(ark_txid) {
293                    Entry::Occupied(mut occupied_entry) => {
294                        occupied_entry.get_mut().push(spent_vtxo.clone());
295                    }
296                    Entry::Vacant(e) => {
297                        e.insert(vec![spent_vtxo.clone()]);
298                    }
299                }
300            }
301        }
302    }
303
304    // An outgoing VTXO that warrants an entry in the transaction history is the input to an
305    // outgoing payment. We may send a VTXO as part of a commitment transaction or through an Ark
306    // transaction.
307    let mut outgoing_txs = Vec::new();
308
309    // Process regular outgoing transactions (Ark transactions).
310    for (spend_txid, spent_vtxos) in vtxos_by_spent_by.iter() {
311        let spent_amount = spent_vtxos
312            .iter()
313            .fold(Amount::ZERO, |acc, x| acc + x.amount)
314            .to_signed()
315            .map_err(Error::ad_hoc)?;
316
317        let produced_virtual_tx_outpoints = all_vtxos
318            .iter()
319            .filter(|v| v.outpoint.txid == *spend_txid)
320            .collect::<Vec<_>>();
321
322        let produced_amount = produced_virtual_tx_outpoints
323            .iter()
324            .fold(Amount::ZERO, |acc, x| acc + x.amount)
325            .to_signed()
326            .map_err(Error::ad_hoc)?;
327
328        let net_amount = produced_amount - spent_amount;
329
330        if !net_amount.is_negative() {
331            // Ignore settlements and self-payments.
332            continue;
333        }
334
335        let tx = match produced_virtual_tx_outpoints.first() {
336            Some(virtual_tx_change_outpoint) => {
337                OutgoingTransaction::with_change(virtual_tx_change_outpoint, net_amount)
338            }
339            None => OutgoingTransaction::without_change(*spend_txid, net_amount),
340        };
341
342        outgoing_txs.push(tx);
343    }
344
345    // Process settlements to detect offboarding transactions.
346    //
347    // When VTXOs are settled by a commitment transaction, the inputs may be:
348    // 1. Refreshed into new VTXOs of equal value (pure settlement) - ignore.
349    // 2. Partially offboarded with some change VTXO remaining - track the offboarded amount.
350    // 3. Fully offboarded with no change VTXO - track the entire amount.
351    //
352    // NOTE: I believe this may not tell the whole story, but it's good enough for now.
353    for (commitment_txid, settled_vtxos) in vtxos_by_settled_by.iter() {
354        let input_amount = settled_vtxos
355            .iter()
356            .fold(Amount::ZERO, |acc, x| acc + x.amount)
357            .to_signed()
358            .map_err(Error::ad_hoc)?;
359
360        // Find VTXOs that were produced by this settlement (have commitment_txid in their
361        // commitment_txids).
362        let produced_vtxos = all_vtxos
363            .iter()
364            .filter(|v| v.commitment_txids.contains(commitment_txid))
365            .collect::<Vec<_>>();
366
367        let output_amount = produced_vtxos
368            .iter()
369            .fold(Amount::ZERO, |acc, x| acc + x.amount)
370            .to_signed()
371            .map_err(Error::ad_hoc)?;
372
373        let offboarded_amount = input_amount - output_amount;
374
375        if offboarded_amount.is_positive() {
376            // Some or all of the input was offboarded onchain.
377            outgoing_txs.push(OutgoingTransaction::IncompleteOffboard(
378                IncompleteOffboardTransaction {
379                    commitment_txid: *commitment_txid,
380                    amount: offboarded_amount.to_unsigned().map_err(Error::ad_hoc)?,
381                },
382            ));
383        }
384        // If offboarded_amount <= 0, it's a pure settlement (refresh) - ignore.
385    }
386
387    Ok(OutgoingTransactionIter::new(outgoing_txs))
388}
389
390/// An outgoing transaction.
391///
392/// If the transaction is [`OutgoingTransaction::Complete`], it can be used as is. If the
393/// transaction is [`OutgoingTransaction::Incomplete`], you will need to complete it with a
394/// [`VirtualTxOutPoint`]. If the transaction is [`OutgoingTransaction::IncompleteOffboard`], you
395/// will need to complete it with confirmation data.
396///
397/// Refer to [`generate_outgoing_vtxo_transaction_history`] for more info on how to use this type.
398#[derive(Clone, Copy, Debug, PartialEq)]
399pub enum OutgoingTransaction {
400    Complete(Transaction),
401    Incomplete(IncompleteOutgoingTransaction),
402    IncompleteOffboard(IncompleteOffboardTransaction),
403}
404
405impl OutgoingTransaction {
406    /// Build an outgoing transaction with a change output of ours.
407    ///
408    /// With the change [`VirtualTxOutPoint`], we can go ahead and build the corresponding
409    /// [`Transaction`].
410    fn with_change(
411        virtual_tx_change_outpoint: &VirtualTxOutPoint,
412        net_amount: SignedAmount,
413    ) -> Self {
414        Self::Complete(build_outgoing_transaction(
415            virtual_tx_change_outpoint,
416            net_amount,
417        ))
418    }
419
420    /// Build outgoing transaction data, without a change output of ours.
421    ///
422    /// Without a change output, we need to look for a foreign [`VirtualTxOutPoint`] to be able to
423    /// build the corresponding [`Transaction`].
424    fn without_change(txid: Txid, net_amount: SignedAmount) -> Self {
425        Self::Incomplete(IncompleteOutgoingTransaction {
426            first_outpoint: bitcoin::OutPoint { txid, vout: 0 },
427            net_amount,
428        })
429    }
430}
431
432/// An outgoing transaction that is missing data about one of its [`VirtualTxOutPoint`]s so that it
433/// can be completed.
434#[derive(Clone, Copy, Debug, PartialEq)]
435pub struct IncompleteOutgoingTransaction {
436    // We take the first one because:
437    //
438    // - Any outpoint will work.
439    // - Every transaction has at least one outpoint.
440    first_outpoint: bitcoin::OutPoint,
441    net_amount: SignedAmount,
442}
443
444/// An offboard transaction that is missing confirmation data so that it can be completed.
445///
446/// Use [`IncompleteOffboardTransaction::finish`] to complete the transaction with confirmation
447/// data from an external source (e.g., esplora).
448#[derive(Clone, Copy, Debug, PartialEq)]
449pub struct IncompleteOffboardTransaction {
450    commitment_txid: Txid,
451    amount: Amount,
452}
453
454impl IncompleteOffboardTransaction {
455    /// The commitment TXID of this offboard transaction.
456    ///
457    /// Use this value to query an external source (e.g., esplora) for confirmation data.
458    pub fn commitment_txid(&self) -> Txid {
459        self.commitment_txid
460    }
461
462    /// Transform this incomplete offboard transaction into a [`Transaction`].
463    ///
464    /// # Arguments
465    ///
466    /// * `confirmed_at`: The confirmation time of the commitment transaction, or [`None`] if
467    ///   unconfirmed.
468    pub fn finish(self, confirmed_at: Option<i64>) -> Transaction {
469        Transaction::Offboard {
470            commitment_txid: self.commitment_txid,
471            amount: self.amount,
472            confirmed_at,
473        }
474    }
475}
476
477impl IncompleteOutgoingTransaction {
478    /// The first [`bitcoin::OutPoint`] of this transaction.
479    ///
480    /// Use this value to find the corresponding [`VirtualTxOutPoint`], to be able to call
481    /// [`IncompleteOutgoingTransaction::finish`] and build a [`Transaction`].
482    pub fn first_outpoint(&self) -> bitcoin::OutPoint {
483        self.first_outpoint
484    }
485
486    /// Transform this incomplete outgoing transaction into a [`Transaction`].
487    ///
488    /// # Arguments
489    ///
490    /// * `virtual_tx_outpoint`: a [`VirtualTxOutPoint`].
491    ///
492    /// # Returns
493    ///
494    /// A complete [`Transaction`].
495    ///
496    /// # Errors
497    ///
498    /// If the TXID of the provided `virtual_tx_outpoint` does not match that of the
499    /// `first_outpoint` field, we return an error.
500    pub fn finish(self, virtual_tx_outpoint: &VirtualTxOutPoint) -> Result<Transaction, Error> {
501        if self.first_outpoint.txid != virtual_tx_outpoint.outpoint.txid {
502            return Err(Error::ad_hoc(format!(
503                "cannot finish outgoing transaction with unrelated \
504                virtual TX outpoint: expected {}, got {}",
505                self.first_outpoint.txid, virtual_tx_outpoint.outpoint.txid
506            )));
507        }
508
509        Ok(build_outgoing_transaction(
510            virtual_tx_outpoint,
511            self.net_amount,
512        ))
513    }
514}
515
516/// An iterator of [`OutgoingTransaction`]s.
517struct OutgoingTransactionIter {
518    inner: std::vec::IntoIter<OutgoingTransaction>,
519}
520
521impl OutgoingTransactionIter {
522    /// Build a new iterator of [`OutgoingTransaction`]s.
523    fn new(txs: Vec<OutgoingTransaction>) -> Self {
524        Self {
525            inner: txs.into_iter(),
526        }
527    }
528}
529
530impl Iterator for OutgoingTransactionIter {
531    type Item = OutgoingTransaction;
532
533    fn next(&mut self) -> Option<Self::Item> {
534        self.inner.next()
535    }
536}
537
538/// Build an outgoing [`Transaction`].
539fn build_outgoing_transaction(
540    // A virtual TX outpoint of the outgoing transaction.
541    vtxo_outpoint: &VirtualTxOutPoint,
542    // A negative amount representing coins received minus coins sent in the transaction.
543    net_amount: SignedAmount,
544) -> Transaction {
545    let created_at = vtxo_outpoint.created_at;
546    match vtxo_outpoint.is_preconfirmed {
547        true => Transaction::Ark {
548            txid: vtxo_outpoint.outpoint.txid,
549            amount: net_amount,
550            // For a pre-confirmed outgoing Ark transaction, the sender always considers the
551            // transaction settled.
552            is_settled: true,
553            created_at,
554        },
555        false => Transaction::Commitment {
556            txid: vtxo_outpoint.commitment_txids[0],
557            amount: net_amount,
558            created_at,
559        },
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566    use bitcoin::OutPoint;
567    use bitcoin::ScriptBuf;
568
569    // These tests are taken straight from the Go client.
570    // NOTE: The go tests disappeared when the client was moved to a different repository.
571
572    #[test]
573    fn alice_before_sending() {
574        let boarding_commitment_txs = [
575            "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
576                .parse()
577                .unwrap(),
578        ];
579
580        let spendable_vtxos = [VirtualTxOutPoint {
581            outpoint: OutPoint {
582                txid: "2646aea682389e1739a33a617d1f3ee28ccc7e4e16210936cece7a823e37527e"
583                    .parse()
584                    .unwrap(),
585                vout: 0,
586            },
587            created_at: 1730330127,
588            expires_at: 1730934927,
589            amount: Amount::from_sat(20_000),
590            script: ScriptBuf::new(),
591            is_preconfirmed: false,
592            is_swept: false,
593            is_unrolled: false,
594            is_spent: false,
595            spent_by: None,
596            commitment_txids: vec![
597                "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
598                    .parse()
599                    .unwrap(),
600            ],
601            settled_by: None,
602            ark_txid: None,
603            assets: Vec::new(),
604        }];
605
606        let inc_txs = generate_incoming_vtxo_transaction_history(
607            &[],
608            &spendable_vtxos,
609            &boarding_commitment_txs,
610        )
611        .unwrap();
612
613        let out_txs = generate_outgoing_vtxo_transaction_history(&[], &spendable_vtxos)
614            .unwrap()
615            .collect::<Vec<_>>();
616
617        assert!(inc_txs.is_empty());
618        assert!(out_txs.is_empty());
619    }
620
621    #[test]
622    fn alice_after_sending() {
623        let boarding_commitment_txs = [
624            "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
625                .parse()
626                .unwrap(),
627        ];
628
629        let spendable_vtxos = [VirtualTxOutPoint {
630            outpoint: OutPoint {
631                txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
632                    .parse()
633                    .unwrap(),
634                vout: 1,
635            },
636            created_at: 1730330256,
637            expires_at: 1730934927,
638            amount: Amount::from_sat(18_784),
639            script: ScriptBuf::new(),
640            is_preconfirmed: true,
641            is_swept: false,
642            is_unrolled: false,
643            is_spent: false,
644            spent_by: None,
645            commitment_txids: vec![
646                "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
647                    .parse()
648                    .unwrap(),
649            ],
650            settled_by: None,
651            ark_txid: None,
652            assets: Vec::new(),
653        }];
654
655        let spent_vtxos = [VirtualTxOutPoint {
656            outpoint: OutPoint {
657                txid: "2646aea682389e1739a33a617d1f3ee28ccc7e4e16210936cece7a823e37527e"
658                    .parse()
659                    .unwrap(),
660                vout: 0,
661            },
662            created_at: 1730330127,
663            expires_at: 1730934927,
664            amount: Amount::from_sat(20_000),
665            script: ScriptBuf::new(),
666            is_preconfirmed: false,
667            is_swept: false,
668            is_unrolled: false,
669            is_spent: true,
670            spent_by: Some(
671                "e3c4f18d0418935db8000c5b8c8fc8d776b5741cd625369eceea9aebb8bcee03"
672                    .parse()
673                    .unwrap(),
674            ),
675            commitment_txids: vec![
676                "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
677                    .parse()
678                    .unwrap(),
679            ],
680            settled_by: None,
681            ark_txid: Some(
682                "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
683                    .parse()
684                    .unwrap(),
685            ),
686            assets: Vec::new(),
687        }];
688
689        let inc_txs = generate_incoming_vtxo_transaction_history(
690            &spent_vtxos,
691            &spendable_vtxos,
692            &boarding_commitment_txs,
693        )
694        .unwrap();
695
696        let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos)
697            .unwrap()
698            .filter_map(|tx| {
699                if let OutgoingTransaction::Complete(tx) = tx {
700                    Some(tx)
701                } else {
702                    None
703                }
704            })
705            .collect::<Vec<_>>();
706
707        assert!(inc_txs.is_empty());
708
709        assert_eq!(
710            out_txs,
711            [Transaction::Ark {
712                txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
713                    .parse()
714                    .unwrap(),
715                amount: SignedAmount::from_sat(-1_216),
716                is_settled: true,
717                created_at: 1730330256,
718            }]
719        );
720    }
721
722    #[test]
723    fn bob_before_settling() {
724        let spendable_vtxos = [
725            VirtualTxOutPoint {
726                outpoint: OutPoint {
727                    txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
728                        .parse()
729                        .unwrap(),
730                    vout: 0,
731                },
732                created_at: 1730330256,
733                expires_at: 1730934927,
734                amount: Amount::from_sat(1_000),
735                script: ScriptBuf::new(),
736                is_preconfirmed: true,
737                is_swept: false,
738                is_unrolled: false,
739                is_spent: false,
740                spent_by: None,
741                commitment_txids: vec![
742                    "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
743                        .parse()
744                        .unwrap(),
745                ],
746                settled_by: None,
747                ark_txid: None,
748                assets: Vec::new(),
749            },
750            VirtualTxOutPoint {
751                outpoint: OutPoint {
752                    txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169"
753                        .parse()
754                        .unwrap(),
755                    vout: 0,
756                },
757                created_at: 1730330748,
758                expires_at: 1730935548,
759                amount: Amount::from_sat(2_000),
760                script: ScriptBuf::new(),
761                is_preconfirmed: true,
762                is_swept: false,
763                is_unrolled: false,
764                is_spent: false,
765                spent_by: None,
766                commitment_txids: vec![
767                    "a4e91c211398e0be0edad322fb74a739b1c77bb82b9e4ea94b0115b8e4dfe645"
768                        .parse()
769                        .unwrap(),
770                ],
771                settled_by: None,
772                ark_txid: None,
773                assets: Vec::new(),
774            },
775        ];
776
777        let spent_vtxos = [];
778
779        let mut inc_txs =
780            generate_incoming_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos, &[])
781                .unwrap();
782
783        sort_transactions_by_created_at(&mut inc_txs);
784
785        let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos)
786            .unwrap()
787            .collect::<Vec<_>>();
788
789        assert_eq!(
790            inc_txs,
791            [
792                Transaction::Ark {
793                    txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169"
794                        .parse()
795                        .unwrap(),
796                    amount: SignedAmount::from_sat(2_000),
797                    is_settled: false,
798                    created_at: 1730330748,
799                },
800                Transaction::Ark {
801                    txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
802                        .parse()
803                        .unwrap(),
804                    amount: SignedAmount::from_sat(1_000),
805                    is_settled: false,
806                    created_at: 1730330256,
807                }
808            ]
809        );
810
811        assert!(out_txs.is_empty());
812    }
813
814    #[test]
815    fn bob_after_settling() {
816        let spendable_vtxos = [VirtualTxOutPoint {
817            outpoint: OutPoint {
818                txid: "d9c95372c0c419fd007005edd54e21dabac0375a37fc5f17c313bc1e5f483af9"
819                    .parse()
820                    .unwrap(),
821                vout: 0,
822            },
823            created_at: 1730331035,
824            expires_at: 1730935835,
825            amount: Amount::from_sat(3_000),
826            script: ScriptBuf::new(),
827            is_preconfirmed: false,
828            is_swept: false,
829            is_unrolled: false,
830            is_spent: false,
831            spent_by: None,
832            commitment_txids: vec![
833                "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
834                    .parse()
835                    .unwrap(),
836            ],
837            settled_by: None,
838            ark_txid: None,
839            assets: Vec::new(),
840        }];
841
842        let spent_vtxos = [
843            VirtualTxOutPoint {
844                outpoint: OutPoint {
845                    txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
846                        .parse()
847                        .unwrap(),
848                    vout: 0,
849                },
850                created_at: 1730330256,
851                expires_at: 1730934927,
852                amount: Amount::from_sat(1_000),
853                script: ScriptBuf::new(),
854                is_preconfirmed: true,
855                is_swept: false,
856                is_unrolled: false,
857                is_spent: true,
858                spent_by: Some(
859                    "c9bdde5595c5479394e805a8c468657cd94ae75a504172e514030b3c549f3646"
860                        .parse()
861                        .unwrap(),
862                ),
863                commitment_txids: vec![
864                    "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
865                        .parse()
866                        .unwrap(),
867                ],
868                settled_by: Some(
869                    "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
870                        .parse()
871                        .unwrap(),
872                ),
873                ark_txid: None,
874                assets: Vec::new(),
875            },
876            VirtualTxOutPoint {
877                outpoint: OutPoint {
878                    txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169"
879                        .parse()
880                        .unwrap(),
881                    vout: 0,
882                },
883                created_at: 1730330748,
884                expires_at: 1730935548,
885                amount: Amount::from_sat(2_000),
886                script: ScriptBuf::new(),
887                is_preconfirmed: true,
888                is_swept: false,
889                is_unrolled: false,
890                is_spent: true,
891                spent_by: Some(
892                    "a7c06a495dd145fd95693a5190b26ffa391aa4440c1af26f9ff293166d97d807"
893                        .parse()
894                        .unwrap(),
895                ),
896                commitment_txids: vec![
897                    "a4e91c211398e0be0edad322fb74a739b1c77bb82b9e4ea94b0115b8e4dfe645"
898                        .parse()
899                        .unwrap(),
900                ],
901                settled_by: Some(
902                    "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
903                        .parse()
904                        .unwrap(),
905                ),
906                ark_txid: None,
907                assets: Vec::new(),
908            },
909        ];
910
911        let mut inc_txs =
912            generate_incoming_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos, &[])
913                .unwrap();
914
915        sort_transactions_by_created_at(&mut inc_txs);
916
917        let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos)
918            .unwrap()
919            .collect::<Vec<_>>();
920
921        assert_eq!(
922            inc_txs,
923            [
924                Transaction::Ark {
925                    txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169"
926                        .parse()
927                        .unwrap(),
928                    amount: SignedAmount::from_sat(2_000),
929                    is_settled: true,
930                    created_at: 1730330748,
931                },
932                Transaction::Ark {
933                    txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
934                        .parse()
935                        .unwrap(),
936                    amount: SignedAmount::from_sat(1_000),
937                    is_settled: true,
938                    created_at: 1730330256,
939                }
940            ]
941        );
942
943        assert!(out_txs.is_empty());
944    }
945
946    #[test]
947    fn bob_after_sending() {
948        let spendable_vtxos = [VirtualTxOutPoint {
949            outpoint: OutPoint {
950                txid: "c59004f8c468a922216f513ec7d63d9b6a13571af0bacd51910709351d27fe55"
951                    .parse()
952                    .unwrap(),
953                vout: 1,
954            },
955            created_at: 1730331198,
956            expires_at: 1730935835,
957            amount: Amount::from_sat(684),
958            script: ScriptBuf::new(),
959            is_preconfirmed: true,
960            is_swept: false,
961            is_unrolled: false,
962            is_spent: false,
963            spent_by: None,
964            commitment_txids: vec![
965                "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
966                    .parse()
967                    .unwrap(),
968            ],
969            settled_by: None,
970            ark_txid: None,
971            assets: Vec::new(),
972        }];
973
974        let spent_vtxos = [
975            VirtualTxOutPoint {
976                outpoint: OutPoint {
977                    txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
978                        .parse()
979                        .unwrap(),
980                    vout: 0,
981                },
982                created_at: 1730330256,
983                expires_at: 1730934927,
984                amount: Amount::from_sat(1_000),
985                script: ScriptBuf::new(),
986                is_preconfirmed: true,
987                is_swept: false,
988                is_unrolled: false,
989                is_spent: true,
990                spent_by: Some(
991                    "c9bdde5595c5479394e805a8c468657cd94ae75a504172e514030b3c549f3646"
992                        .parse()
993                        .unwrap(),
994                ),
995                commitment_txids: vec![
996                    "c16ae0d917ac400790da18456015975521bec6e1d1962ad728c0070808c564e8"
997                        .parse()
998                        .unwrap(),
999                ],
1000                settled_by: Some(
1001                    "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
1002                        .parse()
1003                        .unwrap(),
1004                ),
1005                ark_txid: None,
1006                assets: Vec::new(),
1007            },
1008            VirtualTxOutPoint {
1009                outpoint: OutPoint {
1010                    txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169"
1011                        .parse()
1012                        .unwrap(),
1013                    vout: 0,
1014                },
1015                created_at: 1730330748,
1016                expires_at: 1730935548,
1017                amount: Amount::from_sat(2_000),
1018                script: ScriptBuf::new(),
1019                is_preconfirmed: true,
1020                is_swept: false,
1021                is_unrolled: false,
1022                is_spent: true,
1023                spent_by: Some(
1024                    "a7c06a495dd145fd95693a5190b26ffa391aa4440c1af26f9ff293166d97d807"
1025                        .parse()
1026                        .unwrap(),
1027                ),
1028                commitment_txids: vec![
1029                    "a4e91c211398e0be0edad322fb74a739b1c77bb82b9e4ea94b0115b8e4dfe645"
1030                        .parse()
1031                        .unwrap(),
1032                ],
1033                settled_by: Some(
1034                    "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
1035                        .parse()
1036                        .unwrap(),
1037                ),
1038                ark_txid: None,
1039                assets: Vec::new(),
1040            },
1041            VirtualTxOutPoint {
1042                outpoint: OutPoint {
1043                    txid: "d9c95372c0c419fd007005edd54e21dabac0375a37fc5f17c313bc1e5f483af9"
1044                        .parse()
1045                        .unwrap(),
1046                    vout: 0,
1047                },
1048                created_at: 1730331035,
1049                expires_at: 1730935835,
1050                amount: Amount::from_sat(3_000),
1051                script: ScriptBuf::new(),
1052                is_preconfirmed: false,
1053                is_swept: false,
1054                is_unrolled: false,
1055                is_spent: true,
1056                spent_by: Some(
1057                    "cfcfec99c9767162fc2432fac7cac6240eae2ce344d2d0e1600284399f5dd493"
1058                        .parse()
1059                        .unwrap(),
1060                ),
1061                commitment_txids: vec![
1062                    "7fd65ce87e0f9a7af583593d5b0124aabd65c97e05159525d0a98201d6ae95a4"
1063                        .parse()
1064                        .unwrap(),
1065                ],
1066                settled_by: None,
1067                ark_txid: Some(
1068                    "c59004f8c468a922216f513ec7d63d9b6a13571af0bacd51910709351d27fe55"
1069                        .parse()
1070                        .unwrap(),
1071                ),
1072                assets: Vec::new(),
1073            },
1074        ];
1075
1076        let inc_txs =
1077            generate_incoming_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos, &[])
1078                .unwrap();
1079
1080        let out_txs = generate_outgoing_vtxo_transaction_history(&spent_vtxos, &spendable_vtxos)
1081            .unwrap()
1082            .filter_map(|tx| {
1083                if let OutgoingTransaction::Complete(tx) = tx {
1084                    Some(tx)
1085                } else {
1086                    None
1087                }
1088            })
1089            .collect::<Vec<_>>();
1090
1091        let mut txs = [inc_txs, out_txs].concat();
1092        sort_transactions_by_created_at(&mut txs);
1093
1094        assert_eq!(
1095            txs,
1096            [
1097                Transaction::Ark {
1098                    txid: "c59004f8c468a922216f513ec7d63d9b6a13571af0bacd51910709351d27fe55"
1099                        .parse()
1100                        .unwrap(),
1101                    amount: SignedAmount::from_sat(-2_316),
1102                    is_settled: true,
1103                    created_at: 1730331198,
1104                },
1105                Transaction::Ark {
1106                    txid: "884d85c0db6b52139c39337d54c1f20cd8c5c0d2e83109d69246a345ccc9d169"
1107                        .parse()
1108                        .unwrap(),
1109                    amount: SignedAmount::from_sat(2_000),
1110                    is_settled: true,
1111                    created_at: 1730330748,
1112                },
1113                Transaction::Ark {
1114                    txid: "33fd8ca9ea9cfb53802c42be10ae428573e19fb89484dfe536d06d43efa82034"
1115                        .parse()
1116                        .unwrap(),
1117                    amount: SignedAmount::from_sat(1_000),
1118                    is_settled: true,
1119                    created_at: 1730330256,
1120                }
1121            ]
1122        );
1123    }
1124}