Skip to content

feat!(chain): implement first_seen tracking #1950

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
May 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 59 additions & 1 deletion crates/chain/src/chain_data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
}
}
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
},
]
);
}
}
87 changes: 78 additions & 9 deletions crates/chain/src/tx_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,

Expand All @@ -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(),
Expand All @@ -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> {
Expand Down Expand Up @@ -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,
})
Expand All @@ -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
Expand All @@ -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,
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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(),
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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> {
Expand All @@ -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(),
}
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
Expand All @@ -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,
}
Expand Down
15 changes: 12 additions & 3 deletions crates/chain/tests/test_indexed_tx_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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()
Expand Down
Loading
Loading