diff --git a/crates/chain/src/chain_data.rs b/crates/chain/src/chain_data.rs index 4890b08a7..ea088934b 100644 --- a/crates/chain/src/chain_data.rs +++ b/crates/chain/src/chain_data.rs @@ -27,6 +27,10 @@ pub enum ChainPosition { }, /// 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, /// 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 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::::Unconfirmed { last_seen: Some(10), + first_seen: Some(10), }; let unconf2 = ChainPosition::::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::::Unconfirmed { + first_seen: Some(5), + last_seen: Some(20), + }, + ChainPosition::::Unconfirmed { + first_seen: Some(15), + last_seen: Some(30), + }, + ChainPosition::::Unconfirmed { + first_seen: Some(1), + last_seen: Some(10), + }, + ChainPosition::::Unconfirmed { + first_seen: Some(3), + last_seen: Some(6), + }, + ]; + + v.sort(); + + assert_eq!( + v, + vec![ + ChainPosition::::Unconfirmed { + first_seen: Some(1), + last_seen: Some(10) + }, + ChainPosition::::Unconfirmed { + first_seen: Some(3), + last_seen: Some(6) + }, + ChainPosition::::Unconfirmed { + first_seen: Some(5), + last_seen: Some(20) + }, + ChainPosition::::Unconfirmed { + first_seen: Some(15), + last_seen: Some(30) + }, + ] + ); + } } diff --git a/crates/chain/src/tx_graph.rs b/crates/chain/src/tx_graph.rs index 7916bf954..796c20e2e 100644 --- a/crates/chain/src/tx_graph.rs +++ b/crates/chain/src/tx_graph.rs @@ -177,6 +177,7 @@ pub struct TxGraph { txs: HashMap, spends: BTreeMap>, anchors: HashMap>, + first_seen: HashMap, last_seen: HashMap, last_evicted: HashMap, @@ -195,6 +196,7 @@ impl Default for TxGraph { 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, + /// The first-seen unix timestamp of the transaction as unconfirmed. + pub first_seen: Option, /// The last-seen unix timestamp of the transaction as unconfirmed. - pub last_seen_unconfirmed: Option, + pub last_seen: Option, } impl Deref for TxNode<'_, T, A> { @@ -337,7 +341,8 @@ impl TxGraph { 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 TxGraph { &self, ) -> impl Iterator, 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 TxGraph { 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 TxGraph { 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 { + 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 { + 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::::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 { 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 TxGraph { .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 TxGraph { 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 TxGraph { }, 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 { /// Added timestamps of when a transaction is last evicted from the mempool. #[cfg_attr(feature = "serde", serde(default))] pub last_evicted: BTreeMap, + /// Added first-seen unix timestamps of transactions. + #[cfg_attr(feature = "serde", serde(default))] + pub first_seen: BTreeMap, } impl Default for ChangeSet { @@ -1380,6 +1434,7 @@ impl Default for ChangeSet { 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 Merge for ChangeSet { 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::>(), + ); + // last_seen timestamps should only increase self.last_seen.extend( other @@ -1450,6 +1517,7 @@ impl Merge for ChangeSet { 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 ChangeSet { 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, } diff --git a/crates/chain/tests/test_indexed_tx_graph.rs b/crates/chain/tests/test_indexed_tx_graph.rs index b84642291..4846841c2 100644 --- a/crates/chain/tests/test_indexed_tx_graph.rs +++ b/crates/chain/tests/test_indexed_tx_graph.rs @@ -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() diff --git a/crates/chain/tests/test_tx_graph.rs b/crates/chain/tests/test_tx_graph.rs index 687897548..31c2f1dee 100644 --- a/crates/chain/tests/test_tx_graph.rs +++ b/crates/chain/tests/test_tx_graph.rs @@ -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::::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::::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)); +}