diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index a4f8391..5044354 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -1,6 +1,7 @@ use clap::Subcommand; mod inspect; +mod purge; mod stamp; mod upgrade; mod verify; @@ -15,6 +16,8 @@ pub enum Commands { Stamp(stamp::Stamp), /// Upgrade timestamp Upgrade(upgrade::Upgrade), + /// Purge stale pending attestations + Purge(purge::Purge), } impl Commands { @@ -24,6 +27,7 @@ impl Commands { Commands::Verify(cmd) => cmd.run().await, Commands::Stamp(cmd) => cmd.run().await, Commands::Upgrade(cmd) => cmd.run().await, + Commands::Purge(cmd) => cmd.run().await, } } } diff --git a/crates/cli/src/commands/purge.rs b/crates/cli/src/commands/purge.rs new file mode 100644 index 0000000..3c66bcc --- /dev/null +++ b/crates/cli/src/commands/purge.rs @@ -0,0 +1,114 @@ +use clap::Args; +use std::{collections::HashSet, path::PathBuf}; +use tracing::{error, info, warn}; +use uts_core::codec::{Decode, Encode, VersionedProof, v1::DetachedTimestamp}; +use uts_sdk::Sdk; + +#[derive(Debug, Args)] +pub struct Purge { + /// Files to purge pending attestations from. May be specified multiple times. + #[arg(value_name = "FILE", num_args = 1..)] + files: Vec, + /// Skip the interactive confirmation prompt and purge all pending attestations. + #[arg(short = 'y', long = "yes", default_value_t = false)] + yes: bool, +} + +impl Purge { + pub async fn run(self) -> eyre::Result<()> { + for path in &self.files { + if let Err(e) = self.purge_one(path).await { + error!("[{}] failed to purge: {e}", path.display()); + } + } + Ok(()) + } + + async fn purge_one(&self, path: &PathBuf) -> eyre::Result<()> { + let file = tokio::fs::read(path).await?; + let mut proof = VersionedProof::::decode(&mut &*file)?; + + let pending = Sdk::list_pending(&proof); + if pending.is_empty() { + info!( + "[{}] no pending attestations found, skipping", + path.display() + ); + return Ok(()); + } + + info!( + "[{}] found {} pending attestation(s):", + path.display(), + pending.len() + ); + for (i, uri) in pending.iter().enumerate() { + info!(" [{}] {uri}", i + 1); + } + + let uris_to_purge = if self.yes { + // Purge all when --yes flag is used + None + } else { + // Interactive selection + print!("Enter numbers to purge (comma-separated), 'all', or 'none' to skip: "); + use std::io::Write; + std::io::stdout().flush()?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + let input = input.trim(); + + if input.eq_ignore_ascii_case("none") || input.is_empty() { + info!("[{}] skipped", path.display()); + return Ok(()); + } + + if input.eq_ignore_ascii_case("all") { + None + } else { + let mut selected = HashSet::new(); + for part in input.split(',') { + let part = part.trim(); + match part.parse::() { + Ok(n) if n >= 1 && n <= pending.len() => { + selected.insert(pending[n - 1].clone()); + } + _ => { + warn!("ignoring invalid selection: {part}"); + } + } + } + if selected.is_empty() { + info!("[{}] no valid selections, skipping", path.display()); + return Ok(()); + } + Some(selected) + } + }; + + let result = Sdk::purge_pending_by_uris(&mut proof, uris_to_purge.as_ref()); + + if result.purged.is_empty() { + info!("[{}] nothing to purge", path.display()); + return Ok(()); + } + + if !result.has_remaining { + warn!( + "[{}] purging would leave no attestations in the file, skipping", + path.display() + ); + return Ok(()); + } + + let mut buf = Vec::new(); + proof.encode(&mut buf)?; + tokio::fs::write(path, buf).await?; + info!( + "[{}] purged {} pending attestation(s)", + path.display(), + result.purged.len() + ); + Ok(()) + } +} diff --git a/crates/core/src/codec/v1/timestamp.rs b/crates/core/src/codec/v1/timestamp.rs index 6980cca..02472a3 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -165,6 +165,145 @@ impl Timestamp { pub fn pending_attestations_mut(&mut self) -> PendingAttestationIterMut<'_, A> { PendingAttestationIterMut { stack: vec![self] } } + + /// Retains only the attestations for which the predicate returns `true`, + /// removing all others from this timestamp tree. + /// + /// The predicate receives each [`RawAttestation`] and should return `true` + /// to keep the attestation or `false` to remove it. This is analogous to + /// [`Vec::retain`] but operates on the attestation leaves of the timestamp + /// tree. + /// + /// Returns `Some(count)` where `count` is the number of attestations removed, + /// or `None` if the entire timestamp would be empty after filtering (all + /// attestations were removed). + /// + /// When a FORK node is left with only one remaining branch after filtering, + /// it is collapsed into that branch to maintain the invariant that FORKs + /// have at least two children. + pub fn retain_attestations(&mut self, mut f: F) -> Option + where + F: FnMut(&RawAttestation) -> bool, + { + self.retain_attestations_inner(&mut f) + } + + fn retain_attestations_inner(&mut self, f: &mut F) -> Option + where + F: FnMut(&RawAttestation) -> bool, + { + // Phase 1: recursively filter children and compute result + let (removed_count, should_collapse) = match self { + Timestamp::Attestation(attestation) => { + return if f(attestation) { Some(0) } else { None }; + } + Timestamp::Step(step) if step.op == OpCode::FORK => { + let mut removed = 0usize; + step.next + .retain_mut(|child| match child.retain_attestations_inner(f) { + None => { + removed += 1; + false + } + Some(count) => { + removed += count; + true + } + }); + + if step.next.is_empty() { + return None; + } + + (removed, step.next.len() == 1) + } + Timestamp::Step(step) => { + debug_assert!(step.next.len() == 1, "non-FORK must have exactly one child"); + return step.next[0].retain_attestations_inner(f); + } + }; + + // Phase 2: collapse single-branch FORK + if should_collapse && let Timestamp::Step(step) = self { + let remaining = step.next.pop().unwrap(); + *self = remaining; + } + + Some(removed_count) + } + + /// Retains only the attestations for which the predicate returns `true`, + /// removing all others from this timestamp tree. + /// + /// Unlike [`retain_attestations`](Self::retain_attestations), this variant + /// provides mutable access to each [`RawAttestation`] in the predicate, + /// allowing in-place modification of attestations during filtering. + /// + /// Returns `Some(count)` where `count` is the number of attestations removed, + /// or `None` if the entire timestamp would be empty after filtering. + /// + /// When a FORK node is left with only one remaining branch after filtering, + /// it is collapsed into that branch to maintain the invariant that FORKs + /// have at least two children. + pub fn retain_attestations_mut(&mut self, mut f: F) -> Option + where + F: FnMut(&mut RawAttestation) -> bool, + { + self.retain_attestations_mut_inner(&mut f) + } + + fn retain_attestations_mut_inner(&mut self, f: &mut F) -> Option + where + F: FnMut(&mut RawAttestation) -> bool, + { + let (removed_count, should_collapse) = match self { + Timestamp::Attestation(attestation) => { + return if f(attestation) { Some(0) } else { None }; + } + Timestamp::Step(step) if step.op == OpCode::FORK => { + let mut removed = 0usize; + step.next + .retain_mut(|child| match child.retain_attestations_mut_inner(f) { + None => { + removed += 1; + false + } + Some(count) => { + removed += count; + true + } + }); + + if step.next.is_empty() { + return None; + } + + (removed, step.next.len() == 1) + } + Timestamp::Step(step) => { + debug_assert!(step.next.len() == 1, "non-FORK must have exactly one child"); + return step.next[0].retain_attestations_mut_inner(f); + } + }; + + if should_collapse && let Timestamp::Step(step) = self { + let remaining = step.next.pop().unwrap(); + *self = remaining; + } + + Some(removed_count) + } + + /// Purges all pending attestations from this timestamp tree. + /// + /// This is a convenience wrapper around [`retain_attestations`](Self::retain_attestations) + /// that removes all attestations tagged as pending. + /// + /// Returns `Some(count)` where `count` is the number of pending attestations removed, + /// or `None` if the entire timestamp consists only of pending attestations. + pub fn purge_pending(&mut self) -> Option { + self.retain_attestations(|att| att.tag != PendingAttestation::TAG) + } } impl Timestamp { @@ -357,3 +496,141 @@ impl<'a, A: Allocator> Iterator for PendingAttestationIterMut<'a, A> { None } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + alloc::vec as alloc_vec, + codec::v1::{BitcoinAttestation, PendingAttestation}, + }; + use std::borrow::Cow; + + fn make_pending(uri: &str) -> Timestamp { + Timestamp::builder() + .attest(PendingAttestation { + uri: Cow::Borrowed(uri), + }) + .unwrap() + } + + fn make_bitcoin(height: u32) -> Timestamp { + Timestamp::builder() + .attest(BitcoinAttestation { height }) + .unwrap() + } + + #[test] + fn purge_pending_single_pending() { + let mut ts = make_pending("https://example.com"); + assert!( + ts.purge_pending().is_none(), + "all-pending should return None" + ); + } + + #[test] + fn purge_pending_single_confirmed() { + let mut ts = make_bitcoin(100); + assert_eq!(ts.purge_pending(), Some(0)); + } + + #[test] + fn purge_pending_fork_mixed() { + // FORK with one pending and one confirmed branch + let pending = make_pending("https://example.com"); + let confirmed = make_bitcoin(100); + let mut ts = Timestamp::merge(alloc_vec![pending, confirmed]); + + let result = ts.purge_pending(); + assert_eq!(result, Some(1)); + // After purge, the FORK should be collapsed since only 1 branch remains + assert!( + !matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK), + "FORK with 1 branch should be collapsed" + ); + } + + #[test] + fn purge_pending_fork_all_pending() { + let p1 = make_pending("https://a.example.com"); + let p2 = make_pending("https://b.example.com"); + let mut ts = Timestamp::merge(alloc_vec![p1, p2]); + + assert!(ts.purge_pending().is_none()); + } + + #[test] + fn purge_pending_fork_all_confirmed() { + let c1 = make_bitcoin(100); + let c2 = make_bitcoin(200); + let mut ts = Timestamp::merge(alloc_vec![c1, c2]); + + assert_eq!(ts.purge_pending(), Some(0)); + // FORK should remain since both branches are kept + assert!(matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK)); + } + + #[test] + fn purge_pending_nested_fork() { + // Outer FORK: [inner FORK: [pending, confirmed], confirmed] + let inner_pending = make_pending("https://inner.example.com"); + let inner_confirmed = make_bitcoin(100); + let inner_fork = Timestamp::merge(alloc_vec![inner_pending, inner_confirmed]); + let outer_confirmed = make_bitcoin(200); + let mut ts = Timestamp::merge(alloc_vec![inner_fork, outer_confirmed]); + + let result = ts.purge_pending(); + assert_eq!(result, Some(1)); + // Outer FORK remains (2 branches), inner FORK collapsed + assert!(matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK)); + } + + #[test] + fn retain_attestations_selective() { + // FORK with two different pending attestations and one confirmed + let p1 = make_pending("https://a.example.com"); + let p2 = make_pending("https://b.example.com"); + let confirmed = make_bitcoin(100); + let mut ts = Timestamp::merge(alloc_vec![p1, p2, confirmed]); + + // Retain confirmed + second pending, removing first pending + let result = ts.retain_attestations(|att| { + if att.tag != PendingAttestation::TAG { + return true; + } + let p = PendingAttestation::from_raw(att).unwrap(); + p.uri != "https://a.example.com" + }); + assert_eq!(result, Some(1)); + // FORK should remain since 2 branches are still present (p2 + confirmed) + assert!(matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK)); + } + + #[test] + fn retain_attestations_keep_all() { + let p1 = make_pending("https://a.example.com"); + let confirmed = make_bitcoin(100); + let mut ts = Timestamp::merge(alloc_vec![p1, confirmed]); + + // Retain everything + let result = ts.retain_attestations(|_| true); + assert_eq!(result, Some(0)); + // FORK should remain unchanged + assert!(matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK)); + } + + #[test] + fn retain_attestations_remove_by_type() { + // Test removing confirmed attestations (not just pending) + let pending = make_pending("https://example.com"); + let confirmed = make_bitcoin(100); + let mut ts = Timestamp::merge(alloc_vec![pending, confirmed]); + + // Remove bitcoin attestations, keep pending + let result = ts.retain_attestations(|att| att.tag == PendingAttestation::TAG); + assert_eq!(result, Some(1)); + // FORK collapsed since only 1 branch remains + assert!(!matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK)); + } +} diff --git a/packages/sdk-go/sdk.go b/packages/sdk-go/sdk.go index 3d1e664..3bbb9ab 100644 --- a/packages/sdk-go/sdk.go +++ b/packages/sdk-go/sdk.go @@ -711,3 +711,134 @@ func (s *SDK) Upgrade(ctx context.Context, stamp *types.DetachedTimestamp, keepP s.logger.Debug(ctx, "Upgrade: complete", "results", len(results)) return results, nil } + +// ListPending returns all pending attestation URIs in the given detached timestamp. +func (s *SDK) ListPending(stamp *types.DetachedTimestamp) []string { + return collectPendingAttestations(stamp.Timestamp) +} + +func collectPendingAttestations(ts types.Timestamp) []string { + var uris []string + for _, step := range ts { + switch st := step.(type) { + case *types.AttestationStep: + if pending, ok := st.Attestation.(*types.PendingAttestation); ok { + uris = append(uris, pending.URI) + } + case *types.ForkStep: + for _, branch := range st.Branches { + uris = append(uris, collectPendingAttestations(branch)...) + } + } + } + return uris +} + +// PurgeResult contains the result of a purge operation. +type PurgeResult struct { + // Purged contains the URIs of the pending attestations that were removed. + Purged []string + // HasRemaining is true if the timestamp still has non-pending attestations. + HasRemaining bool +} + +// RetainAttestations retains only the attestations for which the predicate returns true, +// removing all others from the timestamp tree. This is analogous to Go's slices.DeleteFunc +// but operates on the attestation leaves of the timestamp tree. +// FORK nodes left with a single branch after filtering are collapsed. +// Returns true if the timestamp still has attestations, false if all were removed. +func (s *SDK) RetainAttestations(stamp *types.DetachedTimestamp, shouldRetain func(types.Attestation) bool) bool { + return retainAttestations(&stamp.Timestamp, shouldRetain) +} + +// PurgePending removes all pending attestations from the given detached timestamp. +// This is a convenience wrapper around RetainAttestations. +// It returns a PurgeResult with the purged URIs and whether any attestations remain. +func (s *SDK) PurgePending(stamp *types.DetachedTimestamp) *PurgeResult { + return s.PurgePendingByURIs(stamp, nil) +} + +// PurgePendingByURIs removes selected pending attestations from the given detached timestamp. +// If urisToPurge is nil, all pending attestations are removed. +// If urisToPurge is non-nil, only pending attestations whose URI is in the set are removed. +// This is a convenience wrapper around RetainAttestations. +func (s *SDK) PurgePendingByURIs(stamp *types.DetachedTimestamp, urisToPurge map[string]bool) *PurgeResult { + allPending := s.ListPending(stamp) + if len(allPending) == 0 { + return &PurgeResult{Purged: nil, HasRemaining: true} + } + + var purgedURIs []string + if urisToPurge != nil { + for _, uri := range allPending { + if urisToPurge[uri] { + purgedURIs = append(purgedURIs, uri) + } + } + } else { + purgedURIs = allPending + } + + if len(purgedURIs) == 0 { + return &PurgeResult{Purged: nil, HasRemaining: true} + } + + hasRemaining := s.RetainAttestations(stamp, func(att types.Attestation) bool { + pending, ok := att.(*types.PendingAttestation) + if !ok { + return true // keep non-pending + } + if urisToPurge == nil { + return false // purge all pending + } + return !urisToPurge[pending.URI] // keep if NOT in purge set + }) + + return &PurgeResult{ + Purged: purgedURIs, + HasRemaining: hasRemaining, + } +} + +// retainAttestations recursively retains only attestations matching the predicate. +// It modifies the timestamp slice in place. +// Returns true if the timestamp still has content, false if it should be removed. +func retainAttestations(ts *types.Timestamp, shouldRetain func(types.Attestation) bool) bool { + i := 0 + for i < len(*ts) { + step := (*ts)[i] + switch st := step.(type) { + case *types.AttestationStep: + if !shouldRetain(st.Attestation) { + *ts = append((*ts)[:i], (*ts)[i+1:]...) + continue + } + i++ + case *types.ForkStep: + j := 0 + for j < len(st.Branches) { + if !retainAttestations(&st.Branches[j], shouldRetain) { + st.Branches = append(st.Branches[:j], st.Branches[j+1:]...) + } else { + j++ + } + } + if len(st.Branches) == 0 { + *ts = append((*ts)[:i], (*ts)[i+1:]...) + continue + } else if len(st.Branches) == 1 { + remaining := st.Branches[0] + newTs := make(types.Timestamp, 0, len(*ts)-1+len(remaining)) + newTs = append(newTs, (*ts)[:i]...) + newTs = append(newTs, remaining...) + newTs = append(newTs, (*ts)[i+1:]...) + *ts = newTs + continue + } + i++ + default: + i++ + } + } + return len(*ts) > 0 +} diff --git a/packages/sdk-py/src/uts_sdk/__init__.py b/packages/sdk-py/src/uts_sdk/__init__.py index cb9934a..668c0ac 100644 --- a/packages/sdk-py/src/uts_sdk/__init__.py +++ b/packages/sdk-py/src/uts_sdk/__init__.py @@ -69,6 +69,7 @@ OpCode, PendingAttestation, PrependStep, + PurgeResult, ReverseStep, RIPEMD160Step, SHA1Step, @@ -106,6 +107,7 @@ "AttestationStatusKind", "UpgradeStatus", "UpgradeResult", + "PurgeResult", "StampPhase", "NodePosition", "AttestationStep", diff --git a/packages/sdk-py/src/uts_sdk/_types/__init__.py b/packages/sdk-py/src/uts_sdk/_types/__init__.py index 252d497..9e1526a 100644 --- a/packages/sdk-py/src/uts_sdk/_types/__init__.py +++ b/packages/sdk-py/src/uts_sdk/_types/__init__.py @@ -18,6 +18,7 @@ AttestationStatus, AttestationStatusKind, NodePosition, + PurgeResult, StampPhase, UpgradeResult, UpgradeStatus, @@ -66,6 +67,7 @@ "OpCode", "PendingAttestation", "PrependStep", + "PurgeResult", "RIPEMD160Step", "ReverseStep", "SHA1Step", diff --git a/packages/sdk-py/src/uts_sdk/_types/status.py b/packages/sdk-py/src/uts_sdk/_types/status.py index a1ce83f..984add2 100644 --- a/packages/sdk-py/src/uts_sdk/_types/status.py +++ b/packages/sdk-py/src/uts_sdk/_types/status.py @@ -76,3 +76,11 @@ class UpgradeResult: original: PendingAttestation upgraded: Timestamp | None = None error: Exception | None = None + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PurgeResult: + """Result of purging pending attestations from a timestamp.""" + + purged: list[str] + has_remaining: bool diff --git a/packages/sdk-py/src/uts_sdk/sdk.py b/packages/sdk-py/src/uts_sdk/sdk.py index 8cb5986..9e96d0e 100644 --- a/packages/sdk-py/src/uts_sdk/sdk.py +++ b/packages/sdk-py/src/uts_sdk/sdk.py @@ -41,6 +41,7 @@ NodePosition, PendingAttestation, PrependStep, + PurgeResult, RIPEMD160Step, SHA1Step, SHA256Step, @@ -360,6 +361,118 @@ async def upgrade( stamp.header.digest, stamp.timestamp, keep_pending ) + def list_pending(self, stamp: DetachedTimestamp) -> list[str]: + """List all pending attestation URLs in the given detached timestamp.""" + return self._collect_pending_attestations(stamp.timestamp) + + @staticmethod + def _collect_pending_attestations(timestamp: Timestamp) -> list[str]: + """Collect all pending attestation URIs from a timestamp.""" + uris: list[str] = [] + for step in timestamp: + match step: + case AttestationStep(attestation=att): + if isinstance(att, PendingAttestation): + uris.append(att.url) + case ForkStep(steps=branches): + for branch in branches: + uris.extend(SDK._collect_pending_attestations(branch)) + return uris + + def retain_attestations( + self, + stamp: DetachedTimestamp, + should_retain: Callable[[Attestation], bool], + ) -> bool: + """Retain only attestations matching the predicate, removing all others. + + This is analogous to Python's ``filter`` but operates on the attestation + leaves of the timestamp tree. FORK nodes left with a single branch after + filtering are collapsed. + + Args: + stamp: The detached timestamp to filter. + should_retain: Predicate receiving each attestation; return True to keep. + + Returns True if the timestamp still has attestations, False if all were removed. + """ + return self._retain_attestations_in_timestamp(stamp.timestamp, should_retain) + + @staticmethod + def _retain_attestations_in_timestamp( + timestamp: Timestamp, + should_retain: Callable[[Attestation], bool], + ) -> bool: + """Recursively retain attestations matching the predicate. + + Modifies the timestamp list in place. + Returns True if the timestamp still has content. + """ + i = len(timestamp) - 1 + while i >= 0: + step = timestamp[i] + match step: + case AttestationStep(attestation=att): + if not should_retain(att): + del timestamp[i] + case ForkStep(steps=branches): + j = len(branches) - 1 + while j >= 0: + if not SDK._retain_attestations_in_timestamp( + branches[j], should_retain + ): + del branches[j] + j -= 1 + if len(branches) == 0: + del timestamp[i] + elif len(branches) == 1: + # Collapse: expand the single remaining branch in place + timestamp[i : i + 1] = branches[0] + i -= 1 + return len(timestamp) > 0 + + def purge_pending( + self, + stamp: DetachedTimestamp, + *, + uris_to_purge: set[str] | None = None, + ) -> PurgeResult: + """Purge pending attestations from the given detached timestamp. + + This is a convenience wrapper around :meth:`retain_attestations` that + removes pending attestation branches from the timestamp tree in place. + + Args: + stamp: The detached timestamp to purge. + uris_to_purge: Optional set of URIs to selectively purge. If None, + all pending attestations are purged. + + Returns a PurgeResult with purged URIs and whether any non-pending attestations + remain. If all attestations were pending, the timestamp will be empty. + """ + all_pending = self.list_pending(stamp) + if not all_pending: + return PurgeResult(purged=[], has_remaining=True) + + purged_uris = ( + [u for u in all_pending if u in uris_to_purge] + if uris_to_purge is not None + else all_pending + ) + + if not purged_uris: + return PurgeResult(purged=[], has_remaining=True) + + def should_retain(att: Attestation) -> bool: + if not isinstance(att, PendingAttestation): + return True + if uris_to_purge is None: + return False + return att.url not in uris_to_purge + + has_remaining = self.retain_attestations(stamp, should_retain) + return PurgeResult(purged=purged_uris, has_remaining=has_remaining) + async def _upgrade_timestamp( self, current: bytes, diff --git a/packages/sdk-rs/src/lib.rs b/packages/sdk-rs/src/lib.rs index 9cc2c6d..82ee28f 100644 --- a/packages/sdk-rs/src/lib.rs +++ b/packages/sdk-rs/src/lib.rs @@ -50,11 +50,13 @@ use {alloy_primitives::ChainId, alloy_provider::DynProvider, std::collections::B mod builder; mod error; +mod purge; mod stamp; mod upgrade; mod verify; pub use error::Error; +pub use purge::PurgeResult; pub use upgrade::UpgradeResult; /// Alias `Result` to use the crate's error type by default. diff --git a/packages/sdk-rs/src/purge.rs b/packages/sdk-rs/src/purge.rs new file mode 100644 index 0000000..6f99a61 --- /dev/null +++ b/packages/sdk-rs/src/purge.rs @@ -0,0 +1,114 @@ +use std::collections::HashSet; +use tracing::info; +use uts_core::{ + alloc::Allocator, + codec::v1::{Attestation, DetachedTimestamp, PendingAttestation}, +}; + +use crate::Sdk; + +/// Result of a purge operation on a detached timestamp. +#[derive(Debug)] +pub struct PurgeResult { + /// URIs of the pending attestations that were purged. + pub purged: Vec, + /// Whether the timestamp still has any attestations remaining. + pub has_remaining: bool, +} + +impl Sdk { + /// Lists all pending attestation URIs in the given detached timestamp. + pub fn list_pending(stamp: &DetachedTimestamp) -> Vec { + stamp + .attestations() + .filter_map(|att| { + PendingAttestation::from_raw(att) + .ok() + .map(|p| p.uri.to_string()) + }) + .collect() + } + + /// Purges all pending attestations from the given detached timestamp. + /// + /// Returns a [`PurgeResult`] containing the URIs of purged attestations + /// and whether the timestamp still has remaining (non-pending) attestations. + /// + /// If all attestations were pending, the timestamp becomes invalid and + /// `has_remaining` will be `false` — callers should handle this case + /// (e.g., by not writing the file). + pub fn purge_pending(stamp: &mut DetachedTimestamp) -> PurgeResult { + Self::purge_pending_by_uris(stamp, None) + } + + /// Purges selected pending attestations from the given detached timestamp. + /// + /// If `uris_to_purge` is `None`, all pending attestations are purged. + /// If `uris_to_purge` is `Some(set)`, only pending attestations whose URI + /// is in the set are purged. + /// + /// This is implemented using [`Timestamp::retain_attestations`] under the hood. + /// + /// Returns a [`PurgeResult`] containing the URIs of purged attestations + /// and whether the timestamp still has remaining (non-pending) attestations. + pub fn purge_pending_by_uris( + stamp: &mut DetachedTimestamp, + uris_to_purge: Option<&HashSet>, + ) -> PurgeResult { + let pending_uris = Self::list_pending(stamp); + + if pending_uris.is_empty() { + info!("no pending attestations found"); + return PurgeResult { + purged: Vec::new(), + has_remaining: true, + }; + } + + let purged_uris: Vec = match &uris_to_purge { + Some(set) => pending_uris + .iter() + .filter(|u| set.contains(*u)) + .cloned() + .collect(), + None => pending_uris, + }; + + if purged_uris.is_empty() { + info!("no matching pending attestations to purge"); + return PurgeResult { + purged: Vec::new(), + has_remaining: true, + }; + } + + let result = stamp.retain_attestations(|att| { + if att.tag != PendingAttestation::TAG { + return true; // keep non-pending attestations + } + match &uris_to_purge { + None => false, // purge all pending + Some(set) => { + let uri = PendingAttestation::from_raw(att) + .map(|p| p.uri.to_string()) + .unwrap_or_default(); + !set.contains(&uri) // keep if NOT in the purge set + } + } + }); + + if let Some(purged) = result { + info!("purged {purged} pending attestation(s)"); + PurgeResult { + purged: purged_uris, + has_remaining: true, + } + } else { + info!("all attestations were pending, timestamp is now empty"); + PurgeResult { + purged: purged_uris, + has_remaining: false, + } + } + } +} diff --git a/packages/sdk-ts/src/sdk.ts b/packages/sdk-ts/src/sdk.ts index 3b31863..ca296d4 100644 --- a/packages/sdk-ts/src/sdk.ts +++ b/packages/sdk-ts/src/sdk.ts @@ -573,6 +573,105 @@ export default class SDK { return decoder.readTimestamp() } + /** + * List all pending attestation URLs in the given detached timestamp. + * @param stamp The detached timestamp to inspect. + * @returns An array of pending attestation URLs found in the timestamp. + */ + listPending(stamp: DetachedTimestamp): URL[] { + return SDK.collectPendingAttestations(stamp.timestamp) + } + + private static collectPendingAttestations(timestamp: Timestamp): URL[] { + const pending: URL[] = [] + for (const step of timestamp) { + if (step.op === 'ATTESTATION' && step.attestation.kind === 'pending') { + pending.push(step.attestation.url) + } else if (step.op === 'FORK') { + for (const branch of step.steps) { + pending.push(...SDK.collectPendingAttestations(branch)) + } + } + } + return pending + } + + /** + * Retain only attestations matching the predicate, removing all others from the timestamp tree. + * This is analogous to `Array.filter` but operates on the attestation leaves of the timestamp tree. + * FORK nodes left with a single branch after filtering are collapsed. + * + * @param stamp The detached timestamp to filter. + * @param shouldRetain Predicate that receives each attestation and returns true to keep it. + * @returns true if the timestamp still has attestations, false if all were removed. + */ + retainAttestations( + stamp: DetachedTimestamp, + shouldRetain: (attestation: Attestation) => boolean, + ): boolean { + return SDK.retainAttestationsInTimestamp(stamp.timestamp, shouldRetain) + } + + private static retainAttestationsInTimestamp( + timestamp: Timestamp, + shouldRetain: (attestation: Attestation) => boolean, + ): boolean { + for (let i = timestamp.length - 1; i >= 0; i--) { + const step = timestamp[i] + if (step.op === 'ATTESTATION') { + if (!shouldRetain(step.attestation)) { + timestamp.splice(i, 1) + } + } else if (step.op === 'FORK') { + for (let j = step.steps.length - 1; j >= 0; j--) { + if (!SDK.retainAttestationsInTimestamp(step.steps[j], shouldRetain)) { + step.steps.splice(j, 1) + } + } + if (step.steps.length === 0) { + timestamp.splice(i, 1) + } else if (step.steps.length === 1) { + timestamp.splice(i, 1, ...step.steps[0]) + } + } + } + return timestamp.length > 0 + } + + /** + * Purge pending attestations from the given detached timestamp, modifying it in place. + * This is a convenience wrapper around {@link retainAttestations} that removes + * pending attestations. + * + * @param stamp The detached timestamp to purge pending attestations from. + * @param urlsToPurge Optional set of URL strings to selectively purge. If not provided, all pending attestations are purged. + * @returns An object containing the purged URLs and whether any non-pending attestations remain. + */ + purgePending( + stamp: DetachedTimestamp, + urlsToPurge?: Set, + ): { + purged: URL[] + hasRemaining: boolean + } { + const allPending = this.listPending(stamp) + if (allPending.length === 0) { + return { purged: [], hasRemaining: true } + } + const purged = urlsToPurge + ? allPending.filter((u) => urlsToPurge.has(u.toString())) + : allPending + if (purged.length === 0) { + return { purged: [], hasRemaining: true } + } + const hasRemaining = this.retainAttestations(stamp, (attestation) => { + if (attestation.kind !== 'pending') return true + if (!urlsToPurge) return false + return !urlsToPurge.has(attestation.url.toString()) + }) + return { purged, hasRemaining } + } + /** * Verify the provided detached timestamp by replaying the timestamp steps and validating the attestations. * @@ -783,10 +882,10 @@ export default class SDK { `Decoded EAS attestation data for UID ${hexlify(attestation.uid)}:`, contentHash, ) - } catch (e) { + } catch (error) { console.debug( `Failed to decode EAS attestation data for UID ${hexlify(attestation.uid)}:`, - e, + error, ) return { attestation, diff --git a/packages/sdk-ts/src/types/v8.d.ts b/packages/sdk-ts/src/types/v8.d.ts index 50410f5..9eea723 100644 --- a/packages/sdk-ts/src/types/v8.d.ts +++ b/packages/sdk-ts/src/types/v8.d.ts @@ -1,3 +1,3 @@ interface ErrorConstructor { - captureStackTrace?(targetObject: object, constructorOpt?: Function): void + captureStackTrace?(object: object, constructor?: () => void): void } diff --git a/packages/sdk-ts/src/utils.ts b/packages/sdk-ts/src/utils.ts index cd0a23b..ec0b877 100644 --- a/packages/sdk-ts/src/utils.ts +++ b/packages/sdk-ts/src/utils.ts @@ -11,8 +11,8 @@ export function getBytes(value: BytesLike): Uint8Array { if (value instanceof Uint8Array) return value try { return hexToBytes(value) - } catch (e) { - throw new Error(`Invalid hex string: ${value}`, { cause: e }) + } catch (error) { + throw new Error(`Invalid hex string: ${value}`, { cause: error }) } }