From fc9295379a68c5e2f6408a68b546021f40d57383 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:30:43 +0000 Subject: [PATCH 1/9] Initial plan From dec12cf0ea2bd35bb92ed94b72a88f1a0571deb8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:51:28 +0000 Subject: [PATCH 2/9] Add purge_pending method to core and SDK, add CLI purge command Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com> --- crates/cli/src/commands.rs | 4 + crates/cli/src/commands/purge.rs | 82 +++++++++++++++ crates/core/src/codec/v1/timestamp.rs | 140 ++++++++++++++++++++++++++ packages/sdk-rs/src/lib.rs | 2 + packages/sdk-rs/src/purge.rs | 73 ++++++++++++++ 5 files changed, 301 insertions(+) create mode 100644 crates/cli/src/commands/purge.rs create mode 100644 packages/sdk-rs/src/purge.rs 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..006f608 --- /dev/null +++ b/crates/cli/src/commands/purge.rs @@ -0,0 +1,82 @@ +use clap::Args; +use std::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 immediately. + #[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 uri in &pending { + info!(" - {uri}"); + } + + if !self.yes { + eprint!( + "Purge {} pending attestation(s) from {}? [y/N] ", + pending.len(), + path.display() + ); + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + if !input.trim().eq_ignore_ascii_case("y") { + info!("[{}] skipped", path.display()); + return Ok(()); + } + } + + let result = Sdk::purge_pending(&mut proof); + + if !result.has_remaining { + warn!( + "[{}] all attestations were pending — the file is now empty and should be removed", + path.display() + ); + tokio::fs::remove_file(path).await?; + info!("[{}] removed empty file", 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..c0131a9 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -165,6 +165,61 @@ impl Timestamp { pub fn pending_attestations_mut(&mut self) -> PendingAttestationIterMut<'_, A> { PendingAttestationIterMut { stack: vec![self] } } + + /// Purges all pending attestations from this timestamp tree. + /// + /// Returns `Some(count)` where `count` is the number of pending attestations removed, + /// or `None` if the entire timestamp consists only of pending attestations and + /// would be empty after purging. + /// + /// When a FORK node is left with only one remaining branch after purging, + /// it is collapsed into that branch to maintain the invariant that FORKs + /// have at least two children. + pub fn purge_pending(&mut self) -> Option { + // Phase 1: recursively purge children and compute result + let (purge_count, should_collapse) = match self { + Timestamp::Attestation(attestation) => { + return if attestation.tag == PendingAttestation::TAG { + None + } else { + Some(0) + }; + } + Timestamp::Step(step) if step.op == OpCode::FORK => { + let mut purged = 0usize; + step.next.retain_mut(|child| match child.purge_pending() { + None => { + purged += 1; + false + } + Some(count) => { + purged += count; + true + } + }); + + if step.next.is_empty() { + return None; + } + + (purged, step.next.len() == 1) + } + Timestamp::Step(step) => { + debug_assert!(step.next.len() == 1, "non-FORK must have exactly one child"); + return step.next[0].purge_pending(); + } + }; + + // Phase 2: collapse single-branch FORK + if should_collapse { + if let Timestamp::Step(step) = self { + let remaining = step.next.pop().unwrap(); + *self = remaining; + } + } + + Some(purge_count) + } } impl Timestamp { @@ -357,3 +412,88 @@ impl<'a, A: Allocator> Iterator for PendingAttestationIterMut<'a, A> { None } } + +#[cfg(test)] +mod tests { + use crate::alloc::vec as alloc_vec; + use crate::codec::v1::{BitcoinAttestation, PendingAttestation}; + use super::*; + 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)); + } +} 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..7d18015 --- /dev/null +++ b/packages/sdk-rs/src/purge.rs @@ -0,0 +1,73 @@ +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 deleting the file). + pub fn purge_pending( + stamp: &mut DetachedTimestamp, + ) -> PurgeResult { + let pending_uris = Self::list_pending(stamp); + let count = pending_uris.len(); + + if count == 0 { + info!("no pending attestations found"); + return PurgeResult { + purged: Vec::new(), + has_remaining: true, + }; + } + + let result = stamp.purge_pending(); + match result { + Some(purged) => { + info!("purged {purged} pending attestation(s)"); + PurgeResult { + purged: pending_uris, + has_remaining: true, + } + } + None => { + info!("all attestations were pending, timestamp is now empty"); + PurgeResult { + purged: pending_uris, + has_remaining: false, + } + } + } + } +} From cf9c0567dd1588494ece060f946e845eb073bf56 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 00:54:34 +0000 Subject: [PATCH 3/9] Add purge_pending to TypeScript, Go, and Python SDKs Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com> --- packages/sdk-go/sdk.go | 92 +++++++++++++++++++ packages/sdk-py/src/uts_sdk/__init__.py | 2 + .../sdk-py/src/uts_sdk/_types/__init__.py | 2 + packages/sdk-py/src/uts_sdk/_types/status.py | 8 ++ packages/sdk-py/src/uts_sdk/sdk.py | 63 +++++++++++++ packages/sdk-ts/src/sdk.ts | 77 ++++++++++++++++ 6 files changed, 244 insertions(+) diff --git a/packages/sdk-go/sdk.go b/packages/sdk-go/sdk.go index 3d1e664..51d56f6 100644 --- a/packages/sdk-go/sdk.go +++ b/packages/sdk-go/sdk.go @@ -711,3 +711,95 @@ 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 +} + +// PurgePending removes all pending attestations from the given detached timestamp. +// It returns a PurgeResult with the purged URIs and whether any attestations remain. +func (s *SDK) PurgePending(stamp *types.DetachedTimestamp) *PurgeResult { + pending := s.ListPending(stamp) + if len(pending) == 0 { + return &PurgeResult{Purged: nil, HasRemaining: true} + } + + hasRemaining := purgeTimestamp(&stamp.Timestamp) + return &PurgeResult{ + Purged: pending, + HasRemaining: hasRemaining, + } +} + +// purgeTimestamp recursively removes all pending attestation branches from a timestamp. +// It modifies the timestamp slice in place. +// Returns true if the timestamp still has non-pending content, false if it should be removed. +func purgeTimestamp(ts *types.Timestamp) bool { + i := 0 + for i < len(*ts) { + step := (*ts)[i] + switch st := step.(type) { + case *types.AttestationStep: + if _, ok := st.Attestation.(*types.PendingAttestation); ok { + // Remove pending attestation + *ts = append((*ts)[:i], (*ts)[i+1:]...) + continue + } + i++ + case *types.ForkStep: + // Recursively purge each branch, remove empty ones + j := 0 + for j < len(st.Branches) { + if !purgeTimestamp(&st.Branches[j]) { + st.Branches = append(st.Branches[:j], st.Branches[j+1:]...) + } else { + j++ + } + } + if len(st.Branches) == 0 { + // All branches removed, remove the FORK step + *ts = append((*ts)[:i], (*ts)[i+1:]...) + continue + } else if len(st.Branches) == 1 { + // Collapse: replace FORK with its single remaining branch + 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..c9e3f98 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,68 @@ 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 purge_pending(self, stamp: DetachedTimestamp) -> PurgeResult: + """Purge all pending attestations from the given detached timestamp. + + This removes all pending attestation branches from the timestamp tree in place. + FORK nodes that are left with a single branch after purging are collapsed. + + Returns a PurgeResult with purged URIs and whether any non-pending attestations + remain. If all attestations were pending, the timestamp will be empty. + """ + pending = self.list_pending(stamp) + if not pending: + return PurgeResult(purged=[], has_remaining=True) + + has_remaining = self._purge_timestamp(stamp.timestamp) + return PurgeResult(purged=pending, has_remaining=has_remaining) + + @staticmethod + def _purge_timestamp(timestamp: Timestamp) -> bool: + """Recursively remove pending attestation branches from a timestamp. + + Modifies the timestamp list in place. + Returns True if the timestamp still has non-pending content. + """ + i = len(timestamp) - 1 + while i >= 0: + step = timestamp[i] + match step: + case AttestationStep(attestation=att): + if isinstance(att, PendingAttestation): + del timestamp[i] + case ForkStep(steps=branches): + j = len(branches) - 1 + while j >= 0: + if not SDK._purge_timestamp(branches[j]): + del branches[j] + j -= 1 + if len(branches) == 0: + del timestamp[i] + elif len(branches) == 1: + # Collapse: replace FORK with its single remaining branch + timestamp[i : i + 1] = branches[0] + i -= 1 + return len(timestamp) > 0 + async def _upgrade_timestamp( self, current: bytes, diff --git a/packages/sdk-ts/src/sdk.ts b/packages/sdk-ts/src/sdk.ts index 3b31863..69770ed 100644 --- a/packages/sdk-ts/src/sdk.ts +++ b/packages/sdk-ts/src/sdk.ts @@ -573,6 +573,83 @@ 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 + } + + /** + * Purge all pending attestations from the given detached timestamp, modifying it in place. + * + * This removes all pending attestation branches from the timestamp tree. FORK nodes + * that are left with a single branch after purging are collapsed. If all attestations + * in the timestamp are pending, the timestamp will be left empty. + * + * @param stamp The detached timestamp to purge pending attestations from. + * @returns An object containing the purged URLs and whether any non-pending attestations remain. + */ + purgePending(stamp: DetachedTimestamp): { + purged: URL[] + hasRemaining: boolean + } { + const purged = this.listPending(stamp) + if (purged.length === 0) { + return { purged: [], hasRemaining: true } + } + const hasRemaining = SDK.purgeTimestamp(stamp.timestamp) + return { purged, hasRemaining } + } + + /** + * Recursively remove pending attestation branches from a timestamp. + * Modifies the timestamp array in place. + * @returns true if the timestamp still has non-pending content, false if it should be removed entirely. + */ + private static purgeTimestamp(timestamp: Timestamp): boolean { + for (let i = timestamp.length - 1; i >= 0; i--) { + const step = timestamp[i] + if (step.op === 'ATTESTATION') { + if (step.attestation.kind === 'pending') { + timestamp.splice(i, 1) + } + } else if (step.op === 'FORK') { + // Recursively purge each branch, remove empty branches + for (let j = step.steps.length - 1; j >= 0; j--) { + if (!SDK.purgeTimestamp(step.steps[j])) { + step.steps.splice(j, 1) + } + } + if (step.steps.length === 0) { + // All branches removed, remove the FORK step + timestamp.splice(i, 1) + } else if (step.steps.length === 1) { + // Collapse: replace FORK with its single remaining branch + timestamp.splice(i, 1, ...step.steps[0]) + } + } + } + // Return true if there's still content in the timestamp + return timestamp.length > 0 + } + /** * Verify the provided detached timestamp by replaying the timestamp steps and validating the attestations. * From 9c565329da2413e38bc649ff1f37ed20c0795df9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:06:19 +0000 Subject: [PATCH 4/9] Add interactive selection for purge, selective purge support across all SDKs Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com> --- crates/cli/src/commands/purge.rs | 56 +++++++++++++++++++------ crates/core/src/codec/v1/timestamp.rs | 59 +++++++++++++++++++++++---- packages/sdk-go/sdk.go | 45 ++++++++++++++++---- packages/sdk-py/src/uts_sdk/sdk.py | 48 +++++++++++++++++----- packages/sdk-rs/src/purge.rs | 44 +++++++++++++++++--- packages/sdk-ts/src/sdk.ts | 32 ++++++++++++--- 6 files changed, 234 insertions(+), 50 deletions(-) diff --git a/crates/cli/src/commands/purge.rs b/crates/cli/src/commands/purge.rs index 006f608..044a7fe 100644 --- a/crates/cli/src/commands/purge.rs +++ b/crates/cli/src/commands/purge.rs @@ -1,4 +1,5 @@ use clap::Args; +use std::collections::HashSet; use std::path::PathBuf; use tracing::{error, info, warn}; use uts_core::codec::{Decode, Encode, VersionedProof, v1::DetachedTimestamp}; @@ -9,7 +10,7 @@ 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 immediately. + /// Skip the interactive confirmation prompt and purge all pending attestations. #[arg(short = 'y', long = "yes", default_value_t = false)] yes: bool, } @@ -39,33 +40,62 @@ impl Purge { path.display(), pending.len() ); - for uri in &pending { - info!(" - {uri}"); + for (i, uri) in pending.iter().enumerate() { + info!(" [{}] {uri}", i + 1); } - if !self.yes { + let uris_to_purge = if self.yes { + // Purge all when --yes flag is used + None + } else { + // Interactive selection eprint!( - "Purge {} pending attestation(s) from {}? [y/N] ", - pending.len(), - path.display() + "Enter numbers to purge (comma-separated), 'all', or 'none' to skip: " ); let mut input = String::new(); std::io::stdin().read_line(&mut input)?; - if !input.trim().eq_ignore_ascii_case("y") { + let input = input.trim(); + + if input.eq_ignore_ascii_case("none") || input.is_empty() { info!("[{}] skipped", path.display()); return Ok(()); } - } - let result = Sdk::purge_pending(&mut proof); + 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!( - "[{}] all attestations were pending — the file is now empty and should be removed", + "[{}] purging would leave no attestations in the file, skipping", path.display() ); - tokio::fs::remove_file(path).await?; - info!("[{}] removed empty file", path.display()); return Ok(()); } diff --git a/crates/core/src/codec/v1/timestamp.rs b/crates/core/src/codec/v1/timestamp.rs index c0131a9..3861b36 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -176,18 +176,35 @@ impl Timestamp { /// it is collapsed into that branch to maintain the invariant that FORKs /// have at least two children. pub fn purge_pending(&mut self) -> Option { + self.purge_pending_if(&|_| true) + } + + /// Purges pending attestations matching the given predicate from this timestamp tree. + /// + /// The predicate receives the URI of each pending attestation and should return + /// `true` if the attestation should be purged. + /// + /// Returns `Some(count)` where `count` is the number of pending attestations removed, + /// or `None` if the entire timestamp would be empty after purging. + /// + /// When a FORK node is left with only one remaining branch after purging, + /// it is collapsed into that branch to maintain the invariant that FORKs + /// have at least two children. + pub fn purge_pending_if(&mut self, should_purge: &dyn Fn(&str) -> bool) -> Option { // Phase 1: recursively purge children and compute result let (purge_count, should_collapse) = match self { Timestamp::Attestation(attestation) => { - return if attestation.tag == PendingAttestation::TAG { - None - } else { - Some(0) - }; + if attestation.tag == PendingAttestation::TAG { + let uri = PendingAttestation::from_raw(&*attestation) + .map(|p| p.uri.to_string()) + .unwrap_or_default(); + return if should_purge(&uri) { None } else { Some(0) }; + } + return Some(0); } Timestamp::Step(step) if step.op == OpCode::FORK => { let mut purged = 0usize; - step.next.retain_mut(|child| match child.purge_pending() { + step.next.retain_mut(|child| match child.purge_pending_if(should_purge) { None => { purged += 1; false @@ -206,7 +223,7 @@ impl Timestamp { } Timestamp::Step(step) => { debug_assert!(step.next.len() == 1, "non-FORK must have exactly one child"); - return step.next[0].purge_pending(); + return step.next[0].purge_pending_if(should_purge); } }; @@ -496,4 +513,32 @@ mod tests { // Outer FORK remains (2 branches), inner FORK collapsed assert!(matches!(ts, Timestamp::Step(ref s) if s.op == OpCode::FORK)); } + + #[test] + fn purge_pending_if_selective() { + // FORK with two different pending attestations + 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]); + + // Only purge the first one + let result = ts.purge_pending_if(&|uri| 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 purge_pending_if_none_match() { + let p1 = make_pending("https://a.example.com"); + let confirmed = make_bitcoin(100); + let mut ts = Timestamp::merge(alloc_vec![p1, confirmed]); + + // Predicate matches nothing + let result = ts.purge_pending_if(&|_| false); + assert_eq!(result, Some(0)); + // FORK should remain unchanged + 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 51d56f6..5ab77f3 100644 --- a/packages/sdk-go/sdk.go +++ b/packages/sdk-go/sdk.go @@ -745,28 +745,57 @@ type PurgeResult struct { // PurgePending removes all pending attestations from the given detached timestamp. // It returns a PurgeResult with the purged URIs and whether any attestations remain. func (s *SDK) PurgePending(stamp *types.DetachedTimestamp) *PurgeResult { - pending := s.ListPending(stamp) - if len(pending) == 0 { + 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. +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} } - hasRemaining := purgeTimestamp(&stamp.Timestamp) + 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} + } + + shouldPurge := func(uri string) bool { + if urisToPurge == nil { + return true + } + return urisToPurge[uri] + } + + hasRemaining := purgeTimestamp(&stamp.Timestamp, shouldPurge) return &PurgeResult{ - Purged: pending, + Purged: purgedURIs, HasRemaining: hasRemaining, } } -// purgeTimestamp recursively removes all pending attestation branches from a timestamp. +// purgeTimestamp recursively removes pending attestation branches matching the predicate from a timestamp. // It modifies the timestamp slice in place. // Returns true if the timestamp still has non-pending content, false if it should be removed. -func purgeTimestamp(ts *types.Timestamp) bool { +func purgeTimestamp(ts *types.Timestamp, shouldPurge func(string) bool) bool { i := 0 for i < len(*ts) { step := (*ts)[i] switch st := step.(type) { case *types.AttestationStep: - if _, ok := st.Attestation.(*types.PendingAttestation); ok { + if pending, ok := st.Attestation.(*types.PendingAttestation); ok && shouldPurge(pending.URI) { // Remove pending attestation *ts = append((*ts)[:i], (*ts)[i+1:]...) continue @@ -776,7 +805,7 @@ func purgeTimestamp(ts *types.Timestamp) bool { // Recursively purge each branch, remove empty ones j := 0 for j < len(st.Branches) { - if !purgeTimestamp(&st.Branches[j]) { + if !purgeTimestamp(&st.Branches[j], shouldPurge) { st.Branches = append(st.Branches[:j], st.Branches[j+1:]...) } else { j++ diff --git a/packages/sdk-py/src/uts_sdk/sdk.py b/packages/sdk-py/src/uts_sdk/sdk.py index c9e3f98..e8c84cf 100644 --- a/packages/sdk-py/src/uts_sdk/sdk.py +++ b/packages/sdk-py/src/uts_sdk/sdk.py @@ -379,24 +379,52 @@ def _collect_pending_attestations(timestamp: Timestamp) -> list[str]: uris.extend(SDK._collect_pending_attestations(branch)) return uris - def purge_pending(self, stamp: DetachedTimestamp) -> PurgeResult: - """Purge all pending attestations from the given detached timestamp. + def purge_pending( + self, + stamp: DetachedTimestamp, + *, + uris_to_purge: set[str] | None = None, + ) -> PurgeResult: + """Purge pending attestations from the given detached timestamp. - This removes all pending attestation branches from the timestamp tree in place. + This removes pending attestation branches from the timestamp tree in place. FORK nodes that are left with a single branch after purging are collapsed. + 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. """ - pending = self.list_pending(stamp) - if not pending: + 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) - has_remaining = self._purge_timestamp(stamp.timestamp) - return PurgeResult(purged=pending, has_remaining=has_remaining) + should_purge = ( + (lambda uri: uri in uris_to_purge) + if uris_to_purge is not None + else (lambda _: True) + ) + + has_remaining = self._purge_timestamp(stamp.timestamp, should_purge) + return PurgeResult(purged=purged_uris, has_remaining=has_remaining) @staticmethod - def _purge_timestamp(timestamp: Timestamp) -> bool: + def _purge_timestamp( + timestamp: Timestamp, + should_purge: Callable[[str], bool], + ) -> bool: """Recursively remove pending attestation branches from a timestamp. Modifies the timestamp list in place. @@ -407,12 +435,12 @@ def _purge_timestamp(timestamp: Timestamp) -> bool: step = timestamp[i] match step: case AttestationStep(attestation=att): - if isinstance(att, PendingAttestation): + if isinstance(att, PendingAttestation) and should_purge(att.url): del timestamp[i] case ForkStep(steps=branches): j = len(branches) - 1 while j >= 0: - if not SDK._purge_timestamp(branches[j]): + if not SDK._purge_timestamp(branches[j], should_purge): del branches[j] j -= 1 if len(branches) == 0: diff --git a/packages/sdk-rs/src/purge.rs b/packages/sdk-rs/src/purge.rs index 7d18015..7f2c826 100644 --- a/packages/sdk-rs/src/purge.rs +++ b/packages/sdk-rs/src/purge.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use tracing::info; use uts_core::{ alloc::Allocator, @@ -37,14 +38,28 @@ impl Sdk { /// /// If all attestations were pending, the timestamp becomes invalid and /// `has_remaining` will be `false` — callers should handle this case - /// (e.g., by deleting the file). + /// (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. + /// + /// 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); - let count = pending_uris.len(); - if count == 0 { + if pending_uris.is_empty() { info!("no pending attestations found"); return PurgeResult { purged: Vec::new(), @@ -52,19 +67,36 @@ impl Sdk { }; } - let result = stamp.purge_pending(); + 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 = match &uris_to_purge { + Some(set) => stamp.purge_pending_if(&|uri| set.contains(uri)), + None => stamp.purge_pending(), + }; + match result { Some(purged) => { info!("purged {purged} pending attestation(s)"); PurgeResult { - purged: pending_uris, + purged: purged_uris, has_remaining: true, } } None => { info!("all attestations were pending, timestamp is now empty"); PurgeResult { - purged: pending_uris, + purged: purged_uris, has_remaining: false, } } diff --git a/packages/sdk-ts/src/sdk.ts b/packages/sdk-ts/src/sdk.ts index 69770ed..bb86f02 100644 --- a/packages/sdk-ts/src/sdk.ts +++ b/packages/sdk-ts/src/sdk.ts @@ -604,36 +604,56 @@ export default class SDK { * in the timestamp are pending, the timestamp will be left empty. * * @param stamp The detached timestamp to purge pending attestations from. + * @param urlsToPurge Optional set of URLs 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): { + purgePending( + stamp: DetachedTimestamp, + urlsToPurge?: Set, + ): { purged: URL[] hasRemaining: boolean } { - const purged = this.listPending(stamp) + 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 = SDK.purgeTimestamp(stamp.timestamp) + const shouldPurge = urlsToPurge + ? (url: string) => urlsToPurge.has(url) + : () => true + const hasRemaining = SDK.purgeTimestamp(stamp.timestamp, shouldPurge) return { purged, hasRemaining } } /** * Recursively remove pending attestation branches from a timestamp. * Modifies the timestamp array in place. + * @param shouldPurge Predicate that receives a pending attestation URL and returns true if it should be purged. * @returns true if the timestamp still has non-pending content, false if it should be removed entirely. */ - private static purgeTimestamp(timestamp: Timestamp): boolean { + private static purgeTimestamp( + timestamp: Timestamp, + shouldPurge: (url: string) => boolean, + ): boolean { for (let i = timestamp.length - 1; i >= 0; i--) { const step = timestamp[i] if (step.op === 'ATTESTATION') { - if (step.attestation.kind === 'pending') { + if ( + step.attestation.kind === 'pending' && + shouldPurge(step.attestation.url.toString()) + ) { timestamp.splice(i, 1) } } else if (step.op === 'FORK') { // Recursively purge each branch, remove empty branches for (let j = step.steps.length - 1; j >= 0; j--) { - if (!SDK.purgeTimestamp(step.steps[j])) { + if (!SDK.purgeTimestamp(step.steps[j], shouldPurge)) { step.steps.splice(j, 1) } } From e108a0f4fe0c2d4769408fff2f3c21e3b70f5565 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:11:40 +0000 Subject: [PATCH 5/9] Rename core API to retain_attestations for generic attestation filtering Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com> --- crates/core/src/codec/v1/timestamp.rs | 124 +++++++++++++++----------- packages/sdk-go/sdk.go | 38 +++++--- packages/sdk-py/src/uts_sdk/sdk.py | 102 +++++++++++++-------- packages/sdk-rs/src/purge.rs | 20 ++++- packages/sdk-ts/src/sdk.ts | 96 ++++++++++---------- 5 files changed, 227 insertions(+), 153 deletions(-) diff --git a/crates/core/src/codec/v1/timestamp.rs b/crates/core/src/codec/v1/timestamp.rs index 3861b36..576d0cc 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -166,64 +166,57 @@ impl Timestamp { PendingAttestationIterMut { stack: vec![self] } } - /// Purges all pending attestations from this timestamp tree. - /// - /// Returns `Some(count)` where `count` is the number of pending attestations removed, - /// or `None` if the entire timestamp consists only of pending attestations and - /// would be empty after purging. - /// - /// When a FORK node is left with only one remaining branch after purging, - /// it is collapsed into that branch to maintain the invariant that FORKs - /// have at least two children. - pub fn purge_pending(&mut self) -> Option { - self.purge_pending_if(&|_| true) - } - - /// Purges pending attestations matching the given predicate from this timestamp tree. + /// Retains only the attestations for which the predicate returns `true`, + /// removing all others from this timestamp tree. /// - /// The predicate receives the URI of each pending attestation and should return - /// `true` if the attestation should be purged. + /// 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 pending attestations removed, - /// or `None` if the entire timestamp would be empty after purging. + /// 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 purging, + /// 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 purge_pending_if(&mut self, should_purge: &dyn Fn(&str) -> bool) -> Option { - // Phase 1: recursively purge children and compute result - let (purge_count, should_collapse) = match self { + pub fn retain_attestations( + &mut self, + should_retain: &dyn Fn(&RawAttestation) -> bool, + ) -> Option { + // Phase 1: recursively filter children and compute result + let (removed_count, should_collapse) = match self { Timestamp::Attestation(attestation) => { - if attestation.tag == PendingAttestation::TAG { - let uri = PendingAttestation::from_raw(&*attestation) - .map(|p| p.uri.to_string()) - .unwrap_or_default(); - return if should_purge(&uri) { None } else { Some(0) }; - } - return Some(0); + return if should_retain(attestation) { + Some(0) + } else { + None + }; } Timestamp::Step(step) if step.op == OpCode::FORK => { - let mut purged = 0usize; - step.next.retain_mut(|child| match child.purge_pending_if(should_purge) { - None => { - purged += 1; - false - } - Some(count) => { - purged += count; - true - } - }); + let mut removed = 0usize; + step.next + .retain_mut(|child| match child.retain_attestations(should_retain) { + None => { + removed += 1; + false + } + Some(count) => { + removed += count; + true + } + }); if step.next.is_empty() { return None; } - (purged, step.next.len() == 1) + (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].purge_pending_if(should_purge); + return step.next[0].retain_attestations(should_retain); } }; @@ -235,7 +228,18 @@ impl Timestamp { } } - Some(purge_count) + 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) } } @@ -515,30 +519,50 @@ mod tests { } #[test] - fn purge_pending_if_selective() { - // FORK with two different pending attestations + 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]); - // Only purge the first one - let result = ts.purge_pending_if(&|uri| uri == "https://a.example.com"); + // 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 purge_pending_if_none_match() { + 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]); - // Predicate matches nothing - let result = ts.purge_pending_if(&|_| false); + // 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 5ab77f3..3bbb9ab 100644 --- a/packages/sdk-go/sdk.go +++ b/packages/sdk-go/sdk.go @@ -742,7 +742,17 @@ type PurgeResult struct { 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) @@ -751,6 +761,7 @@ func (s *SDK) PurgePending(stamp *types.DetachedTimestamp) *PurgeResult { // 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 { @@ -772,51 +783,50 @@ func (s *SDK) PurgePendingByURIs(stamp *types.DetachedTimestamp, urisToPurge map return &PurgeResult{Purged: nil, HasRemaining: true} } - shouldPurge := func(uri string) bool { + 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 true + return false // purge all pending } - return urisToPurge[uri] - } + return !urisToPurge[pending.URI] // keep if NOT in purge set + }) - hasRemaining := purgeTimestamp(&stamp.Timestamp, shouldPurge) return &PurgeResult{ Purged: purgedURIs, HasRemaining: hasRemaining, } } -// purgeTimestamp recursively removes pending attestation branches matching the predicate from a timestamp. +// retainAttestations recursively retains only attestations matching the predicate. // It modifies the timestamp slice in place. -// Returns true if the timestamp still has non-pending content, false if it should be removed. -func purgeTimestamp(ts *types.Timestamp, shouldPurge func(string) bool) bool { +// 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 pending, ok := st.Attestation.(*types.PendingAttestation); ok && shouldPurge(pending.URI) { - // Remove pending attestation + if !shouldRetain(st.Attestation) { *ts = append((*ts)[:i], (*ts)[i+1:]...) continue } i++ case *types.ForkStep: - // Recursively purge each branch, remove empty ones j := 0 for j < len(st.Branches) { - if !purgeTimestamp(&st.Branches[j], shouldPurge) { + if !retainAttestations(&st.Branches[j], shouldRetain) { st.Branches = append(st.Branches[:j], st.Branches[j+1:]...) } else { j++ } } if len(st.Branches) == 0 { - // All branches removed, remove the FORK step *ts = append((*ts)[:i], (*ts)[i+1:]...) continue } else if len(st.Branches) == 1 { - // Collapse: replace FORK with its single remaining branch remaining := st.Branches[0] newTs := make(types.Timestamp, 0, len(*ts)-1+len(remaining)) newTs = append(newTs, (*ts)[:i]...) diff --git a/packages/sdk-py/src/uts_sdk/sdk.py b/packages/sdk-py/src/uts_sdk/sdk.py index e8c84cf..7846742 100644 --- a/packages/sdk-py/src/uts_sdk/sdk.py +++ b/packages/sdk-py/src/uts_sdk/sdk.py @@ -379,78 +379,102 @@ def _collect_pending_attestations(timestamp: Timestamp) -> list[str]: uris.extend(SDK._collect_pending_attestations(branch)) return uris - def purge_pending( + def retain_attestations( self, stamp: DetachedTimestamp, - *, - uris_to_purge: set[str] | None = None, - ) -> PurgeResult: - """Purge pending attestations from the given detached timestamp. + should_retain: Callable[[Attestation], bool], + ) -> bool: + """Retain only attestations matching the predicate, removing all others. - This removes pending attestation branches from the timestamp tree in place. - FORK nodes that are left with a single branch after purging are collapsed. + 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 purge. - uris_to_purge: Optional set of URIs to selectively purge. If None, - all pending attestations are purged. + stamp: The detached timestamp to filter. + should_retain: Predicate receiving each attestation; return True to keep. - Returns a PurgeResult with purged URIs and whether any non-pending attestations - remain. If all attestations were pending, the timestamp will be empty. + Returns True if the timestamp still has attestations, False if all were removed. """ - 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 + return self._retain_attestations_in_timestamp( + stamp.timestamp, should_retain ) - if not purged_uris: - return PurgeResult(purged=[], has_remaining=True) - - should_purge = ( - (lambda uri: uri in uris_to_purge) - if uris_to_purge is not None - else (lambda _: True) - ) - - has_remaining = self._purge_timestamp(stamp.timestamp, should_purge) - return PurgeResult(purged=purged_uris, has_remaining=has_remaining) - @staticmethod - def _purge_timestamp( + def _retain_attestations_in_timestamp( timestamp: Timestamp, - should_purge: Callable[[str], bool], + should_retain: Callable[[Attestation], bool], ) -> bool: - """Recursively remove pending attestation branches from a timestamp. + """Recursively retain attestations matching the predicate. Modifies the timestamp list in place. - Returns True if the timestamp still has non-pending content. + 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 isinstance(att, PendingAttestation) and should_purge(att.url): + if not should_retain(att): del timestamp[i] case ForkStep(steps=branches): j = len(branches) - 1 while j >= 0: - if not SDK._purge_timestamp(branches[j], should_purge): + 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: replace FORK with its single remaining branch + # 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/purge.rs b/packages/sdk-rs/src/purge.rs index 7f2c826..e01b1df 100644 --- a/packages/sdk-rs/src/purge.rs +++ b/packages/sdk-rs/src/purge.rs @@ -51,6 +51,8 @@ impl Sdk { /// 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( @@ -80,10 +82,20 @@ impl Sdk { }; } - let result = match &uris_to_purge { - Some(set) => stamp.purge_pending_if(&|uri| set.contains(uri)), - None => stamp.purge_pending(), - }; + 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 + } + } + }); match result { Some(purged) => { diff --git a/packages/sdk-ts/src/sdk.ts b/packages/sdk-ts/src/sdk.ts index bb86f02..7d0f358 100644 --- a/packages/sdk-ts/src/sdk.ts +++ b/packages/sdk-ts/src/sdk.ts @@ -597,79 +597,83 @@ export default class SDK { } /** - * Purge all pending attestations from the given detached timestamp, modifying it in place. + * 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. * - * This removes all pending attestation branches from the timestamp tree. FORK nodes - * that are left with a single branch after purging are collapsed. If all attestations - * in the timestamp are pending, the timestamp will be left empty. - * - * @param stamp The detached timestamp to purge pending attestations from. - * @param urlsToPurge Optional set of URLs 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. + * @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. */ - purgePending( + retainAttestations( 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 shouldPurge = urlsToPurge - ? (url: string) => urlsToPurge.has(url) - : () => true - const hasRemaining = SDK.purgeTimestamp(stamp.timestamp, shouldPurge) - return { purged, hasRemaining } + shouldRetain: (attestation: Attestation) => boolean, + ): boolean { + return SDK.retainAttestationsInTimestamp(stamp.timestamp, shouldRetain) } - /** - * Recursively remove pending attestation branches from a timestamp. - * Modifies the timestamp array in place. - * @param shouldPurge Predicate that receives a pending attestation URL and returns true if it should be purged. - * @returns true if the timestamp still has non-pending content, false if it should be removed entirely. - */ - private static purgeTimestamp( + private static retainAttestationsInTimestamp( timestamp: Timestamp, - shouldPurge: (url: string) => boolean, + shouldRetain: (attestation: Attestation) => boolean, ): boolean { for (let i = timestamp.length - 1; i >= 0; i--) { const step = timestamp[i] if (step.op === 'ATTESTATION') { - if ( - step.attestation.kind === 'pending' && - shouldPurge(step.attestation.url.toString()) - ) { + if (!shouldRetain(step.attestation)) { timestamp.splice(i, 1) } } else if (step.op === 'FORK') { - // Recursively purge each branch, remove empty branches for (let j = step.steps.length - 1; j >= 0; j--) { - if (!SDK.purgeTimestamp(step.steps[j], shouldPurge)) { + if ( + !SDK.retainAttestationsInTimestamp(step.steps[j], shouldRetain) + ) { step.steps.splice(j, 1) } } if (step.steps.length === 0) { - // All branches removed, remove the FORK step timestamp.splice(i, 1) } else if (step.steps.length === 1) { - // Collapse: replace FORK with its single remaining branch timestamp.splice(i, 1, ...step.steps[0]) } } } - // Return true if there's still content in the timestamp 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. * From ee14a89511ce0b174cdc923e486fb61bc15863e2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 01:16:01 +0000 Subject: [PATCH 6/9] Fix CLI prompt to use stdout instead of stderr for interactive prompts Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com> --- crates/cli/src/commands/purge.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/commands/purge.rs b/crates/cli/src/commands/purge.rs index 044a7fe..beec214 100644 --- a/crates/cli/src/commands/purge.rs +++ b/crates/cli/src/commands/purge.rs @@ -49,9 +49,11 @@ impl Purge { None } else { // Interactive selection - eprint!( + 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(); From a7c14af17c9e97100b5e4d8d2f7b920ef99d1eb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Mar 2026 02:48:45 +0000 Subject: [PATCH 7/9] Refactor retain_attestations to use generic FnMut, add retain_attestations_mut variant Co-authored-by: lightsing <15951701+lightsing@users.noreply.github.com> --- crates/core/src/codec/v1/timestamp.rs | 97 ++++++++++++++++++++++----- packages/sdk-rs/src/purge.rs | 2 +- 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/crates/core/src/codec/v1/timestamp.rs b/crates/core/src/codec/v1/timestamp.rs index 576d0cc..5562407 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -181,23 +181,26 @@ impl Timestamp { /// 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, - should_retain: &dyn Fn(&RawAttestation) -> bool, - ) -> Option { + 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 should_retain(attestation) { - Some(0) - } else { - None - }; + 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(should_retain) { + .retain_mut(|child| match child.retain_attestations_inner(f) { None => { removed += 1; false @@ -216,7 +219,7 @@ impl Timestamp { } Timestamp::Step(step) => { debug_assert!(step.next.len() == 1, "non-FORK must have exactly one child"); - return step.next[0].retain_attestations(should_retain); + return step.next[0].retain_attestations_inner(f); } }; @@ -231,6 +234,70 @@ impl Timestamp { 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 { + if 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) @@ -239,7 +306,7 @@ impl Timestamp { /// 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) + self.retain_attestations(|att| att.tag != PendingAttestation::TAG) } } @@ -527,7 +594,7 @@ mod tests { let mut ts = Timestamp::merge(alloc_vec![p1, p2, confirmed]); // Retain confirmed + second pending, removing first pending - let result = ts.retain_attestations(&|att| { + let result = ts.retain_attestations(|att| { if att.tag != PendingAttestation::TAG { return true; } @@ -546,7 +613,7 @@ mod tests { let mut ts = Timestamp::merge(alloc_vec![p1, confirmed]); // Retain everything - let result = ts.retain_attestations(&|_| true); + 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)); @@ -560,7 +627,7 @@ mod tests { let mut ts = Timestamp::merge(alloc_vec![pending, confirmed]); // Remove bitcoin attestations, keep pending - let result = ts.retain_attestations(&|att| att.tag == PendingAttestation::TAG); + 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-rs/src/purge.rs b/packages/sdk-rs/src/purge.rs index e01b1df..02f5147 100644 --- a/packages/sdk-rs/src/purge.rs +++ b/packages/sdk-rs/src/purge.rs @@ -82,7 +82,7 @@ impl Sdk { }; } - let result = stamp.retain_attestations(&|att| { + let result = stamp.retain_attestations(|att| { if att.tag != PendingAttestation::TAG { return true; // keep non-pending attestations } From 7a30d3f3067cea72aad40a3c5f09bc4641854dde Mon Sep 17 00:00:00 2001 From: lightsing Date: Wed, 18 Mar 2026 13:05:30 +0800 Subject: [PATCH 8/9] fmt --- crates/cli/src/commands/purge.rs | 12 ++++++------ crates/core/src/codec/v1/timestamp.rs | 11 ++++++++--- packages/sdk-py/src/uts_sdk/sdk.py | 4 +--- packages/sdk-rs/src/purge.rs | 14 +++++++------- packages/sdk-ts/src/sdk.ts | 8 +++----- packages/sdk-ts/src/types/v8.d.ts | 2 +- packages/sdk-ts/src/utils.ts | 4 ++-- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/crates/cli/src/commands/purge.rs b/crates/cli/src/commands/purge.rs index beec214..3c66bcc 100644 --- a/crates/cli/src/commands/purge.rs +++ b/crates/cli/src/commands/purge.rs @@ -1,6 +1,5 @@ use clap::Args; -use std::collections::HashSet; -use std::path::PathBuf; +use std::{collections::HashSet, path::PathBuf}; use tracing::{error, info, warn}; use uts_core::codec::{Decode, Encode, VersionedProof, v1::DetachedTimestamp}; use uts_sdk::Sdk; @@ -31,7 +30,10 @@ impl Purge { let pending = Sdk::list_pending(&proof); if pending.is_empty() { - info!("[{}] no pending attestations found, skipping", path.display()); + info!( + "[{}] no pending attestations found, skipping", + path.display() + ); return Ok(()); } @@ -49,9 +51,7 @@ impl Purge { None } else { // Interactive selection - print!( - "Enter numbers to purge (comma-separated), 'all', or 'none' to skip: " - ); + 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(); diff --git a/crates/core/src/codec/v1/timestamp.rs b/crates/core/src/codec/v1/timestamp.rs index 5562407..09c9511 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -503,9 +503,11 @@ impl<'a, A: Allocator> Iterator for PendingAttestationIterMut<'a, A> { #[cfg(test)] mod tests { - use crate::alloc::vec as alloc_vec; - use crate::codec::v1::{BitcoinAttestation, PendingAttestation}; use super::*; + use crate::{ + alloc::vec as alloc_vec, + codec::v1::{BitcoinAttestation, PendingAttestation}, + }; use std::borrow::Cow; fn make_pending(uri: &str) -> Timestamp { @@ -525,7 +527,10 @@ mod tests { #[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"); + assert!( + ts.purge_pending().is_none(), + "all-pending should return None" + ); } #[test] diff --git a/packages/sdk-py/src/uts_sdk/sdk.py b/packages/sdk-py/src/uts_sdk/sdk.py index 7846742..9e96d0e 100644 --- a/packages/sdk-py/src/uts_sdk/sdk.py +++ b/packages/sdk-py/src/uts_sdk/sdk.py @@ -396,9 +396,7 @@ def retain_attestations( Returns True if the timestamp still has attestations, False if all were removed. """ - return self._retain_attestations_in_timestamp( - stamp.timestamp, should_retain - ) + return self._retain_attestations_in_timestamp(stamp.timestamp, should_retain) @staticmethod def _retain_attestations_in_timestamp( diff --git a/packages/sdk-rs/src/purge.rs b/packages/sdk-rs/src/purge.rs index 02f5147..a70e13d 100644 --- a/packages/sdk-rs/src/purge.rs +++ b/packages/sdk-rs/src/purge.rs @@ -18,9 +18,7 @@ pub struct PurgeResult { impl Sdk { /// Lists all pending attestation URIs in the given detached timestamp. - pub fn list_pending( - stamp: &DetachedTimestamp, - ) -> Vec { + pub fn list_pending(stamp: &DetachedTimestamp) -> Vec { stamp .attestations() .filter_map(|att| { @@ -39,9 +37,7 @@ impl Sdk { /// 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 { + pub fn purge_pending(stamp: &mut DetachedTimestamp) -> PurgeResult { Self::purge_pending_by_uris(stamp, None) } @@ -70,7 +66,11 @@ impl Sdk { } let purged_uris: Vec = match &uris_to_purge { - Some(set) => pending_uris.iter().filter(|u| set.contains(*u)).cloned().collect(), + Some(set) => pending_uris + .iter() + .filter(|u| set.contains(*u)) + .cloned() + .collect(), None => pending_uris, }; diff --git a/packages/sdk-ts/src/sdk.ts b/packages/sdk-ts/src/sdk.ts index 7d0f358..ca296d4 100644 --- a/packages/sdk-ts/src/sdk.ts +++ b/packages/sdk-ts/src/sdk.ts @@ -624,9 +624,7 @@ export default class SDK { } } else if (step.op === 'FORK') { for (let j = step.steps.length - 1; j >= 0; j--) { - if ( - !SDK.retainAttestationsInTimestamp(step.steps[j], shouldRetain) - ) { + if (!SDK.retainAttestationsInTimestamp(step.steps[j], shouldRetain)) { step.steps.splice(j, 1) } } @@ -884,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 }) } } From 6d14e16d7aa87df77b107fb2150b1f56d3accb6c Mon Sep 17 00:00:00 2001 From: lightsing Date: Wed, 18 Mar 2026 13:14:01 +0800 Subject: [PATCH 9/9] clippy --- crates/core/src/codec/v1/timestamp.rs | 16 ++++++---------- packages/sdk-rs/src/purge.rs | 23 ++++++++++------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/crates/core/src/codec/v1/timestamp.rs b/crates/core/src/codec/v1/timestamp.rs index 09c9511..02472a3 100644 --- a/crates/core/src/codec/v1/timestamp.rs +++ b/crates/core/src/codec/v1/timestamp.rs @@ -224,11 +224,9 @@ impl Timestamp { }; // Phase 2: collapse single-branch FORK - if should_collapse { - if let Timestamp::Step(step) = self { - let remaining = step.next.pop().unwrap(); - *self = remaining; - } + if should_collapse && let Timestamp::Step(step) = self { + let remaining = step.next.pop().unwrap(); + *self = remaining; } Some(removed_count) @@ -288,11 +286,9 @@ impl Timestamp { } }; - if should_collapse { - if let Timestamp::Step(step) = self { - let remaining = step.next.pop().unwrap(); - *self = remaining; - } + if should_collapse && let Timestamp::Step(step) = self { + let remaining = step.next.pop().unwrap(); + *self = remaining; } Some(removed_count) diff --git a/packages/sdk-rs/src/purge.rs b/packages/sdk-rs/src/purge.rs index a70e13d..6f99a61 100644 --- a/packages/sdk-rs/src/purge.rs +++ b/packages/sdk-rs/src/purge.rs @@ -97,20 +97,17 @@ impl Sdk { } }); - match result { - Some(purged) => { - info!("purged {purged} pending attestation(s)"); - PurgeResult { - purged: purged_uris, - has_remaining: true, - } + if let Some(purged) = result { + info!("purged {purged} pending attestation(s)"); + PurgeResult { + purged: purged_uris, + has_remaining: true, } - None => { - info!("all attestations were pending, timestamp is now empty"); - PurgeResult { - purged: purged_uris, - has_remaining: false, - } + } else { + info!("all attestations were pending, timestamp is now empty"); + PurgeResult { + purged: purged_uris, + has_remaining: false, } } }