]> Untitled Git - bdk/commitdiff
feat!(chain): implement `first_seen` tracking
authoruvuvuwu <2003danielsu@gmail.com>
Thu, 24 Apr 2025 04:34:11 +0000 (16:34 +1200)
committeruvuvuwu <2003danielsu@gmail.com>
Tue, 6 May 2025 00:35:19 +0000 (12:35 +1200)
crates/chain/src/chain_data.rs
crates/chain/src/tx_graph.rs
crates/chain/tests/test_indexed_tx_graph.rs
crates/chain/tests/test_tx_graph.rs

index 4890b08a7961797802c5e2f58420edd6f8b4425a..ea088934bcf8ccf8874f6e5ac35024eaf51b956a 100644 (file)
@@ -27,6 +27,10 @@ pub enum ChainPosition<A> {
     },
     /// The chain data is not confirmed.
     Unconfirmed {
+        /// When the chain data was first seen in the mempool.
+        ///
+        /// This value will be `None` if the chain data was never seen in the mempool.
+        first_seen: Option<u64>,
         /// When the chain data is last seen in the mempool.
         ///
         /// This value will be `None` if the chain data was never seen in the mempool and only seen
@@ -58,7 +62,13 @@ impl<A: Clone> ChainPosition<&A> {
                 anchor: anchor.clone(),
                 transitively,
             },
-            ChainPosition::Unconfirmed { last_seen } => ChainPosition::Unconfirmed { last_seen },
+            ChainPosition::Unconfirmed {
+                last_seen,
+                first_seen,
+            } => ChainPosition::Unconfirmed {
+                last_seen,
+                first_seen,
+            },
         }
     }
 }
@@ -165,9 +175,11 @@ mod test {
     fn chain_position_ord() {
         let unconf1 = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
             last_seen: Some(10),
+            first_seen: Some(10),
         };
         let unconf2 = ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
             last_seen: Some(20),
+            first_seen: Some(20),
         };
         let conf1 = ChainPosition::Confirmed {
             anchor: ConfirmationBlockTime {
@@ -197,4 +209,50 @@ mod test {
             "confirmation_height is higher then it should be higher ord"
         );
     }
+
+    #[test]
+    fn test_sort_unconfirmed_chain_position() {
+        let mut v = vec![
+            ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                first_seen: Some(5),
+                last_seen: Some(20),
+            },
+            ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                first_seen: Some(15),
+                last_seen: Some(30),
+            },
+            ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                first_seen: Some(1),
+                last_seen: Some(10),
+            },
+            ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                first_seen: Some(3),
+                last_seen: Some(6),
+            },
+        ];
+
+        v.sort();
+
+        assert_eq!(
+            v,
+            vec![
+                ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                    first_seen: Some(1),
+                    last_seen: Some(10)
+                },
+                ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                    first_seen: Some(3),
+                    last_seen: Some(6)
+                },
+                ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                    first_seen: Some(5),
+                    last_seen: Some(20)
+                },
+                ChainPosition::<ConfirmationBlockTime>::Unconfirmed {
+                    first_seen: Some(15),
+                    last_seen: Some(30)
+                },
+            ]
+        );
+    }
 }
index 7916bf954e1dbd22b6d923a7fa1074f3b8d5bb85..796c20e2e3a137a9f35042bcd21f2c3bbd96f173 100644 (file)
@@ -177,6 +177,7 @@ pub struct TxGraph<A = ConfirmationBlockTime> {
     txs: HashMap<Txid, TxNodeInternal>,
     spends: BTreeMap<OutPoint, HashSet<Txid>>,
     anchors: HashMap<Txid, BTreeSet<A>>,
+    first_seen: HashMap<Txid, u64>,
     last_seen: HashMap<Txid, u64>,
     last_evicted: HashMap<Txid, u64>,
 
@@ -195,6 +196,7 @@ impl<A> Default for TxGraph<A> {
             txs: Default::default(),
             spends: Default::default(),
             anchors: Default::default(),
+            first_seen: Default::default(),
             last_seen: Default::default(),
             last_evicted: Default::default(),
             txs_by_highest_conf_heights: Default::default(),
@@ -214,8 +216,10 @@ pub struct TxNode<'a, T, A> {
     pub tx: T,
     /// The blocks that the transaction is "anchored" in.
     pub anchors: &'a BTreeSet<A>,
+    /// The first-seen unix timestamp of the transaction as unconfirmed.
+    pub first_seen: Option<u64>,
     /// The last-seen unix timestamp of the transaction as unconfirmed.
-    pub last_seen_unconfirmed: Option<u64>,
+    pub last_seen: Option<u64>,
 }
 
 impl<T, A> Deref for TxNode<'_, T, A> {
@@ -337,7 +341,8 @@ impl<A> TxGraph<A> {
                 txid,
                 tx: tx.clone(),
                 anchors: self.anchors.get(&txid).unwrap_or(&self.empty_anchors),
-                last_seen_unconfirmed: self.last_seen.get(&txid).copied(),
+                first_seen: self.first_seen.get(&txid).copied(),
+                last_seen: self.last_seen.get(&txid).copied(),
             }),
             TxNodeInternal::Partial(_) => None,
         })
@@ -348,7 +353,7 @@ impl<A> TxGraph<A> {
         &self,
     ) -> impl Iterator<Item = TxNode<'_, Arc<Transaction>, A>> {
         self.full_txs().filter_map(|tx| {
-            if tx.anchors.is_empty() && tx.last_seen_unconfirmed.is_none() {
+            if tx.anchors.is_empty() && tx.last_seen.is_none() {
                 Some(tx)
             } else {
                 None
@@ -372,7 +377,8 @@ impl<A> TxGraph<A> {
                 txid,
                 tx: tx.clone(),
                 anchors: self.anchors.get(&txid).unwrap_or(&self.empty_anchors),
-                last_seen_unconfirmed: self.last_seen.get(&txid).copied(),
+                first_seen: self.first_seen.get(&txid).copied(),
+                last_seen: self.last_seen.get(&txid).copied(),
             }),
             _ => None,
         }
@@ -787,10 +793,48 @@ impl<A: Anchor> TxGraph<A> {
         changeset
     }
 
-    /// Inserts the given `seen_at` for `txid` into [`TxGraph`].
+    /// Updates the first-seen and last-seen timestamps for a given `txid` in the [`TxGraph`].
     ///
-    /// Note that [`TxGraph`] only keeps track of the latest `seen_at`.
+    /// This method records the time a transaction was observed by updating both:
+    /// - the **first-seen** timestamp, which only changes if `seen_at` is earlier than the current value, and
+    /// - the **last-seen** timestamp, which only changes if `seen_at` is later than the current value.
+    ///
+    /// `seen_at` is a UNIX timestamp in seconds.
+    ///
+    /// Returns a [`ChangeSet`] representing any changes applied.
     pub fn insert_seen_at(&mut self, txid: Txid, seen_at: u64) -> ChangeSet<A> {
+        let mut changeset_first_seen = self.update_first_seen(txid, seen_at);
+        let changeset_last_seen = self.update_last_seen(txid, seen_at);
+        changeset_first_seen.merge(changeset_last_seen);
+        changeset_first_seen
+    }
+
+    /// Updates `first_seen` given a new `seen_at`.
+    fn update_first_seen(&mut self, txid: Txid, seen_at: u64) -> ChangeSet<A> {
+        let is_changed = match self.first_seen.entry(txid) {
+            hash_map::Entry::Occupied(mut e) => {
+                let first_seen = e.get_mut();
+                let change = *first_seen > seen_at;
+                if change {
+                    *first_seen = seen_at;
+                }
+                change
+            }
+            hash_map::Entry::Vacant(e) => {
+                e.insert(seen_at);
+                true
+            }
+        };
+
+        let mut changeset = ChangeSet::<A>::default();
+        if is_changed {
+            changeset.first_seen.insert(txid, seen_at);
+        }
+        changeset
+    }
+
+    /// Updates `last_seen` given a new `seen_at`.
+    fn update_last_seen(&mut self, txid: Txid, seen_at: u64) -> ChangeSet<A> {
         let mut old_last_seen = None;
         let is_changed = match self.last_seen.entry(txid) {
             hash_map::Entry::Occupied(mut e) => {
@@ -904,6 +948,7 @@ impl<A: Anchor> TxGraph<A> {
                 .iter()
                 .flat_map(|(txid, anchors)| anchors.iter().map(|a| (a.clone(), *txid)))
                 .collect(),
+            first_seen: self.first_seen.iter().map(|(&k, &v)| (k, v)).collect(),
             last_seen: self.last_seen.iter().map(|(&k, &v)| (k, v)).collect(),
             last_evicted: self.last_evicted.iter().map(|(&k, &v)| (k, v)).collect(),
         }
@@ -978,11 +1023,13 @@ impl<A: Anchor> TxGraph<A> {
                                     transitively: None,
                                 },
                                 None => ChainPosition::Unconfirmed {
-                                    last_seen: tx_node.last_seen_unconfirmed,
+                                    first_seen: tx_node.first_seen,
+                                    last_seen: tx_node.last_seen,
                                 },
                             },
                             None => ChainPosition::Unconfirmed {
-                                last_seen: tx_node.last_seen_unconfirmed,
+                                first_seen: tx_node.first_seen,
+                                last_seen: tx_node.last_seen,
                             },
                         },
                         CanonicalReason::Anchor { anchor, descendant } => match descendant {
@@ -1003,9 +1050,13 @@ impl<A: Anchor> TxGraph<A> {
                         },
                         CanonicalReason::ObservedIn { observed_in, .. } => match observed_in {
                             ObservedIn::Mempool(last_seen) => ChainPosition::Unconfirmed {
+                                first_seen: tx_node.first_seen,
                                 last_seen: Some(last_seen),
                             },
-                            ObservedIn::Block(_) => ChainPosition::Unconfirmed { last_seen: None },
+                            ObservedIn::Block(_) => ChainPosition::Unconfirmed {
+                                first_seen: tx_node.first_seen,
+                                last_seen: None,
+                            },
                         },
                     };
                     Ok(CanonicalTx {
@@ -1372,6 +1423,9 @@ pub struct ChangeSet<A = ()> {
     /// Added timestamps of when a transaction is last evicted from the mempool.
     #[cfg_attr(feature = "serde", serde(default))]
     pub last_evicted: BTreeMap<Txid, u64>,
+    /// Added first-seen unix timestamps of transactions.
+    #[cfg_attr(feature = "serde", serde(default))]
+    pub first_seen: BTreeMap<Txid, u64>,
 }
 
 impl<A> Default for ChangeSet<A> {
@@ -1380,6 +1434,7 @@ impl<A> Default for ChangeSet<A> {
             txs: Default::default(),
             txouts: Default::default(),
             anchors: Default::default(),
+            first_seen: Default::default(),
             last_seen: Default::default(),
             last_evicted: Default::default(),
         }
@@ -1428,6 +1483,18 @@ impl<A: Ord> Merge for ChangeSet<A> {
         self.txouts.extend(other.txouts);
         self.anchors.extend(other.anchors);
 
+        // first_seen timestamps should only decrease
+        self.first_seen.extend(
+            other
+                .first_seen
+                .into_iter()
+                .filter(|(txid, update_fs)| match self.first_seen.get(txid) {
+                    Some(existing) => update_fs < existing,
+                    None => true,
+                })
+                .collect::<Vec<_>>(),
+        );
+
         // last_seen timestamps should only increase
         self.last_seen.extend(
             other
@@ -1450,6 +1517,7 @@ impl<A: Ord> Merge for ChangeSet<A> {
         self.txs.is_empty()
             && self.txouts.is_empty()
             && self.anchors.is_empty()
+            && self.first_seen.is_empty()
             && self.last_seen.is_empty()
             && self.last_evicted.is_empty()
     }
@@ -1470,6 +1538,7 @@ impl<A: Ord> ChangeSet<A> {
             anchors: BTreeSet::<(A2, Txid)>::from_iter(
                 self.anchors.into_iter().map(|(a, txid)| (f(a), txid)),
             ),
+            first_seen: self.first_seen,
             last_seen: self.last_seen,
             last_evicted: self.last_evicted,
         }
index b84642291b8efc61baabd832b1fa0c6eef1b6e17..4846841c2e890011b78a197fd7a2f6c655ea34a3 100644 (file)
@@ -632,7 +632,10 @@ fn test_get_chain_position() {
             },
             anchor: None,
             last_seen: Some(2),
-            exp_pos: Some(ChainPosition::Unconfirmed { last_seen: Some(2) }),
+            exp_pos: Some(ChainPosition::Unconfirmed {
+                last_seen: Some(2),
+                first_seen: Some(2),
+            }),
         },
         TestCase {
             name: "tx anchor in best chain - confirmed",
@@ -661,7 +664,10 @@ fn test_get_chain_position() {
             },
             anchor: Some(block_id!(2, "B'")),
             last_seen: Some(2),
-            exp_pos: Some(ChainPosition::Unconfirmed { last_seen: Some(2) }),
+            exp_pos: Some(ChainPosition::Unconfirmed {
+                last_seen: Some(2),
+                first_seen: Some(2),
+            }),
         },
         TestCase {
             name: "tx unknown anchor - unconfirmed",
@@ -674,7 +680,10 @@ fn test_get_chain_position() {
             },
             anchor: Some(block_id!(2, "B'")),
             last_seen: None,
-            exp_pos: Some(ChainPosition::Unconfirmed { last_seen: None }),
+            exp_pos: Some(ChainPosition::Unconfirmed {
+                last_seen: None,
+                first_seen: None,
+            }),
         },
     ]
     .into_iter()
index 6878975481cfd0206e75c5d1c1e49159b5082d87..31c2f1deee9749c0ce5fa05ef6bc9332c8a2c0d2 100644 (file)
@@ -117,6 +117,7 @@ fn insert_txouts() {
             txs: [Arc::new(update_tx.clone())].into(),
             txouts: update_ops.clone().into(),
             anchors: [(conf_anchor, update_tx.compute_txid()),].into(),
+            first_seen: [(hash!("tx2"), 1000000)].into(),
             last_seen: [(hash!("tx2"), 1000000)].into(),
             last_evicted: [].into(),
         }
@@ -171,6 +172,7 @@ fn insert_txouts() {
             txs: [Arc::new(update_tx.clone())].into(),
             txouts: update_ops.into_iter().chain(original_ops).collect(),
             anchors: [(conf_anchor, update_tx.compute_txid()),].into(),
+            first_seen: [(hash!("tx2"), 1000000)].into(),
             last_seen: [(hash!("tx2"), 1000000)].into(),
             last_evicted: [].into(),
         }
@@ -1076,7 +1078,8 @@ fn test_chain_spends() {
                 .cloned(),
             Some((
                 ChainPosition::Unconfirmed {
-                    last_seen: Some(1234567)
+                    last_seen: Some(1234567),
+                    first_seen: Some(1234567)
                 },
                 tx_2.compute_txid()
             ))
@@ -1122,7 +1125,8 @@ fn test_chain_spends() {
                 .get(&tx_2_conflict.compute_txid())
                 .cloned(),
             Some(ChainPosition::Unconfirmed {
-                last_seen: Some(1234568)
+                last_seen: Some(1234568),
+                first_seen: Some(1234568)
             })
         );
 
@@ -1133,7 +1137,8 @@ fn test_chain_spends() {
                 .cloned(),
             Some((
                 ChainPosition::Unconfirmed {
-                    last_seen: Some(1234568)
+                    last_seen: Some(1234568),
+                    first_seen: Some(1234568)
                 },
                 tx_2_conflict.compute_txid()
             ))
@@ -1321,10 +1326,7 @@ fn call_map_anchors_with_non_deterministic_anchor() {
         let new_txnode = new_txs.next().unwrap();
         assert_eq!(new_txnode.txid, tx_node.txid);
         assert_eq!(new_txnode.tx, tx_node.tx);
-        assert_eq!(
-            new_txnode.last_seen_unconfirmed,
-            tx_node.last_seen_unconfirmed
-        );
+        assert_eq!(new_txnode.last_seen, tx_node.last_seen);
         assert_eq!(new_txnode.anchors.len(), tx_node.anchors.len());
 
         let mut new_anchors: Vec<_> = new_txnode.anchors.iter().map(|a| a.anchor_block).collect();
@@ -1470,3 +1472,74 @@ fn tx_graph_update_conversion() {
         );
     }
 }
+
+#[test]
+fn test_seen_at_updates() {
+    // Update both first_seen and last_seen
+    let seen_at = 1000000_u64;
+    let mut graph = TxGraph::<BlockId>::default();
+    let mut changeset = graph.insert_seen_at(hash!("tx1"), seen_at);
+    assert_eq!(
+        changeset,
+        ChangeSet {
+            first_seen: [(hash!("tx1"), 1000000)].into(),
+            last_seen: [(hash!("tx1"), 1000000)].into(),
+            ..Default::default()
+        }
+    );
+
+    // Update first_seen but not last_seen
+    let earlier_seen_at = 999_999_u64;
+    changeset = graph.insert_seen_at(hash!("tx1"), earlier_seen_at);
+    assert_eq!(
+        changeset,
+        ChangeSet {
+            first_seen: [(hash!("tx1"), 999999)].into(),
+            ..Default::default()
+        }
+    );
+
+    // Update last_seen but not first_seen
+    let later_seen_at = 1_000_001_u64;
+    changeset = graph.insert_seen_at(hash!("tx1"), later_seen_at);
+    assert_eq!(
+        changeset,
+        ChangeSet {
+            last_seen: [(hash!("tx1"), 1000001)].into(),
+            ..Default::default()
+        }
+    );
+
+    // Should not change anything
+    changeset = graph.insert_seen_at(hash!("tx1"), 1000000);
+    assert!(changeset.first_seen.is_empty());
+    assert!(changeset.last_seen.is_empty());
+}
+
+#[test]
+fn test_get_first_seen_of_a_tx() {
+    let mut graph = TxGraph::<BlockId>::default();
+
+    let tx = Transaction {
+        version: transaction::Version::ONE,
+        lock_time: absolute::LockTime::ZERO,
+        input: vec![TxIn {
+            previous_output: OutPoint::null(),
+            ..Default::default()
+        }],
+        output: vec![TxOut {
+            value: Amount::from_sat(50_000),
+            script_pubkey: ScriptBuf::new(),
+        }],
+    };
+    let txid = tx.compute_txid();
+    let seen_at = 1_000_000_u64;
+
+    let changeset_tx = graph.insert_tx(Arc::new(tx));
+    graph.apply_changeset(changeset_tx);
+    let changeset_seen = graph.insert_seen_at(txid, seen_at);
+    graph.apply_changeset(changeset_seen);
+
+    let first_seen = graph.get_tx_node(txid).unwrap().first_seen;
+    assert_eq!(first_seen, Some(seen_at));
+}