Skip to main content

zinc_core/
history.rs

1use crate::builder::ZincWallet;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeSet;
4
5/// Minimal inscription metadata attached to a transaction entry.
6#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
7pub struct InscriptionDetails {
8    /// Unique inscription identifier.
9    pub id: String,
10    /// Global inscription number.
11    pub number: i64,
12    /// Optional MIME content type.
13    pub content_type: Option<String>,
14}
15
16/// Normalized wallet transaction item used by API/wasm callers.
17#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
18pub struct TxItem {
19    /// Transaction id.
20    pub txid: String,
21    /// Net amount in sats (positive receive, negative send).
22    pub amount_sats: i64,
23    /// Computed transaction fee in sats.
24    pub fee_sats: u64,
25    /// Confirmation timestamp (unix seconds) when confirmed.
26    pub confirmation_time: Option<u64>,
27    /// Transaction direction label (`send` or `receive`).
28    pub tx_type: String, // "send" or "receive"
29    #[serde(default)]
30    /// Inscriptions observed in transaction outputs.
31    pub inscriptions: Vec<InscriptionDetails>,
32    #[serde(default)]
33    /// Parent transaction ids spent by this transaction.
34    pub parent_txids: Vec<String>,
35    /// Source-local index used for stable tie-breaking.
36    pub index: usize,
37}
38
39impl ZincWallet {
40    /// Return up to `limit` merged transactions across vault/payment wallets.
41    pub fn get_transactions(&self, limit: usize) -> Vec<TxItem> {
42        let mut items = Vec::new();
43
44        // 1. Collect from Vault
45        self.collect_txs_from_wallet(&self.vault_wallet, &mut items);
46
47        // 2. Collect from Payment (if exists)
48        if let Some(payment_wallet) = &self.payment_wallet {
49            self.collect_txs_from_wallet(payment_wallet, &mut items);
50        }
51
52        // 3. Deduplicate
53        let mut combined: std::collections::HashMap<String, TxItem> =
54            std::collections::HashMap::new();
55        for item in items {
56            combined
57                .entry(item.txid.clone())
58                .and_modify(|existing| {
59                    existing.amount_sats += item.amount_sats;
60                    if item.confirmation_time > existing.confirmation_time {
61                        existing.confirmation_time = item.confirmation_time;
62                    }
63                    if item.index > existing.index {
64                        existing.index = item.index;
65                    }
66                    // Merge inscription info
67                    // We combine lists and deduplicate by ID
68                    for new_ins in &item.inscriptions {
69                        if !existing.inscriptions.iter().any(|e| e.id == new_ins.id) {
70                            existing.inscriptions.push(new_ins.clone());
71                        }
72                    }
73                    // Merge parent txids and keep deterministic order
74                    let mut merged_parent_txids: BTreeSet<String> =
75                        existing.parent_txids.iter().cloned().collect();
76                    merged_parent_txids.extend(item.parent_txids.iter().cloned());
77                    existing.parent_txids = merged_parent_txids.into_iter().collect();
78                })
79                .or_insert(item);
80        }
81
82        let mut final_items: Vec<TxItem> = combined.into_values().collect();
83
84        // 4. Sort: Unconfirmed at the top, then newest confirmed first
85        final_items.sort_by(|a, b| {
86            let a_pending = a.confirmation_time.is_none();
87            let b_pending = b.confirmation_time.is_none();
88
89            if a_pending != b_pending {
90                return if a_pending {
91                    std::cmp::Ordering::Less
92                } else {
93                    std::cmp::Ordering::Greater
94                };
95            }
96
97
98            match (a.confirmation_time, b.confirmation_time) {
99                (Some(ta), Some(tb)) if ta != tb => tb.cmp(&ta), // Time descending
100                _ => {
101                    // Deterministic tie-breakers
102                    let idx_order = b.index.cmp(&a.index);
103                    if idx_order != std::cmp::Ordering::Equal {
104                        idx_order
105                    } else {
106                        b.txid.cmp(&a.txid)
107                    }
108                }
109            }
110        });
111
112        // 5. Limit
113        final_items.into_iter().take(limit).collect()
114    }
115
116    fn collect_txs_from_wallet(&self, wallet: &bdk_wallet::Wallet, items: &mut Vec<TxItem>) {
117        for (i, tx) in wallet.transactions().enumerate() {
118            let (sent, received) = wallet.sent_and_received(&tx.tx_node.tx);
119            #[allow(clippy::cast_possible_wrap)]
120            let amount_sats = received.to_sat() as i64 - sent.to_sat() as i64;
121
122            let fee_sats = wallet
123                .calculate_fee(&tx.tx_node.tx)
124                .map(|f| f.to_sat())
125                .unwrap_or(0);
126
127            let confirmation_time = match tx.chain_position {
128                bdk_chain::ChainPosition::Confirmed { anchor, .. } => {
129                    Some(anchor.confirmation_time)
130                }
131                bdk_chain::ChainPosition::Unconfirmed { .. } => None,
132            };
133
134            let inscriptions = self.get_inscription_details(&tx.tx_node.tx, tx.tx_node.txid);
135            let parent_txids = tx
136                .tx_node
137                .tx
138                .input
139                .iter()
140                .map(|input| input.previous_output.txid.to_string())
141                .collect::<BTreeSet<_>>()
142                .into_iter()
143                .collect::<Vec<_>>();
144
145            items.push(TxItem {
146                txid: tx.tx_node.txid.to_string(),
147                amount_sats,
148                fee_sats,
149                confirmation_time,
150                tx_type: if amount_sats >= 0 {
151                    "receive".to_string()
152                } else {
153                    "send".to_string()
154                },
155                inscriptions,
156                parent_txids,
157                index: i,
158            });
159        }
160    }
161
162    fn get_inscription_details(
163        &self,
164        tx: &bitcoin::Transaction,
165        txid: bitcoin::Txid,
166    ) -> Vec<InscriptionDetails> {
167        let mut results = Vec::new();
168        for (i, _) in tx.output.iter().enumerate() {
169            let outpoint = bitcoin::OutPoint::new(txid, u32::try_from(i).unwrap());
170            // Find the inscription that matches this outpoint in our cache
171            if let Some(ins) = self
172                .inscriptions
173                .iter()
174                .find(|ins| ins.satpoint.outpoint == outpoint)
175            {
176                results.push(InscriptionDetails {
177                    id: ins.id.clone(),
178                    number: ins.number,
179                    content_type: ins.content_type.clone(),
180                });
181            }
182        }
183        results
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use crate::builder::{Seed64, WalletBuilder};
190    use bitcoin::Network;
191
192    #[test]
193    fn test_get_transactions_empty() {
194        let seed = [0u8; 64];
195        let wallet = WalletBuilder::from_seed(Network::Regtest, Seed64::from_array(seed))
196            .build()
197            .unwrap();
198        let txs = wallet.get_transactions(50);
199        assert_eq!(txs.len(), 0);
200    }
201}