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>,
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(),
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> {
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,
})
&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
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,
}
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) => {
.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(),
}
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 {
},
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 {
/// 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> {
txs: Default::default(),
txouts: Default::default(),
anchors: Default::default(),
+ first_seen: Default::default(),
last_seen: Default::default(),
last_evicted: Default::default(),
}
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
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()
}
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,
}
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(),
}
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(),
}
.cloned(),
Some((
ChainPosition::Unconfirmed {
- last_seen: Some(1234567)
+ last_seen: Some(1234567),
+ first_seen: Some(1234567)
},
tx_2.compute_txid()
))
.get(&tx_2_conflict.compute_txid())
.cloned(),
Some(ChainPosition::Unconfirmed {
- last_seen: Some(1234568)
+ last_seen: Some(1234568),
+ first_seen: Some(1234568)
})
);
.cloned(),
Some((
ChainPosition::Unconfirmed {
- last_seen: Some(1234568)
+ last_seen: Some(1234568),
+ first_seen: Some(1234568)
},
tx_2_conflict.compute_txid()
))
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();
);
}
}
+
+#[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));
+}