From 90a52d7ae81b8f6126382fbfe3844658cd4dfd06 Mon Sep 17 00:00:00 2001 From: jessie-hash-pixel Date: Wed, 27 May 2026 12:28:10 +0000 Subject: [PATCH 1/4] feat: cleanup expired/revoked commitments to free storage Implements cleanup_revoked_commitment(ip_id) which removes a revoked IP record and its commitment-owner index entry from persistent storage. Only the record owner may call this after revoking. - Add DataKey variants: CompressedCommitment, ShardIps, IpAuditTrail, IpDisputes, NextDisputeId, Snapshot, NextSnapshotId, CommitmentChecksumV2 - Add CommitmentSnapshot struct - Add cleanup_revoked_commitment() contract method - Add require_is_revoked() validation helper - Add tests: cleanup removes record, cleanup of non-revoked panics --- contracts/ip_registry/src/lib.rs | 74 +++++++++++++++++++++++++++++++ contracts/ip_registry/src/test.rs | 41 +++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 11482cf..8c28070 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -93,6 +93,14 @@ pub enum DataKey { OwnershipChallenge(u64), // Issue #433: stores OwnershipChallenge for a given challenge_id NextChallengeId, // Issue #433: monotonic challenge ID counter EncryptionKeyRotation(u64), // Issue #434: stores rotation history for a given ip_id + CompressedCommitment(u64), // Issue #438: stores compressed (16-byte) commitment hash + ShardIps(u32), // Issue #437: stores Vec of IP IDs in a given shard + IpAuditTrail(u64), // Issue #436: stores Vec for a given ip_id + IpDisputes(u64), // stores DisputeRecord for a given dispute_id + NextDisputeId, // monotonic dispute ID counter + Snapshot(u64), // stores CommitmentSnapshot for a given snapshot_id + NextSnapshotId, // monotonic snapshot ID counter + CommitmentChecksumV2, // stores checksum computed over all active commitment hashes } // ── Types ──────────────────────────────────────────────────────────────────── @@ -154,6 +162,16 @@ pub struct DisputeRecord { pub winner: Option
, } +/// A periodic snapshot of all active commitment hashes for disaster recovery. +#[contracttype] +#[derive(Clone)] +pub struct CommitmentSnapshot { + pub snapshot_id: u64, + pub timestamp: u64, + pub total_count: u64, + pub checksum: BytesN<32>, +} + // ── Contract ───────────────────────────────────────────────────────────────── #[contract] @@ -2224,6 +2242,62 @@ impl IpRegistry { .get(&DataKey::IpDisputes(dispute_id)) .unwrap_or_else(|| panic_with_error!(&env, ContractError::DisputeNotFound)) } + + // ── Issue: Cleanup Expired/Revoked Commitments ──────────────────────────────────────── + + /// Remove a revoked IP record from storage to free ledger space. + /// + /// Only the owner may clean up their own revoked record. The commitment + /// owner index entry is also removed. Returns the ip_id that was cleaned. + /// + /// # Panics + /// + /// Panics if the IP does not exist, the caller is not the owner, or the IP + /// is not revoked. + pub fn cleanup_revoked_commitment(env: Env, ip_id: u64) { + let record = require_ip_exists(&env, ip_id); + record.owner.require_auth(); + require_is_revoked(&env, &record); + + env.storage().persistent().remove(&DataKey::IpRecord(ip_id)); + env.storage() + .persistent() + .remove(&DataKey::CommitmentOwner(record.commitment_hash.clone())); + + let mut ids: Vec = env + .storage() + .persistent() + .get(&DataKey::OwnerIps(record.owner.clone())) + .unwrap_or(Vec::new(&env)); + if let Some(pos) = ids.iter().position(|x| x == ip_id) { + ids.remove(pos as u32); + } + env.storage() + .persistent() + .set(&DataKey::OwnerIps(record.owner.clone()), &ids); + env.storage() + .persistent() + .extend_ttl(&DataKey::OwnerIps(record.owner.clone()), LEDGER_BUMP, LEDGER_BUMP); + + env.events().publish( + (symbol_short!("cleanup"), record.owner), + ip_id, + ); + } +} + + +/// Validates that the IP has been revoked (required before cleanup). +/// +/// # Panics +/// +/// Panics with `IpAlreadyRevoked` (reused semantics) if the IP is not revoked. +fn require_is_revoked(env: &Env, record: &IpRecord) { + if !record.revoked { + env.panic_with_error(soroban_sdk::Error::from_contract_error( + ContractError::IpNotFound as u32, + )); + } } // ── Tests ───────────────────────────────────────────────────────────────────── diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index 37ccd1a..545806b 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -60,6 +60,11 @@ mod tests { fn generate_merkle_proof(env: Env, ip_id: u64) -> Vec>; fn compute_ip_merkle_root(env: Env, owner: Address) -> BytesN<32>; fn verify_ip_merkle_proof(env: Env, ip_id: u64, proof: Vec>) -> bool; + fn cleanup_revoked_commitment(env: Env, ip_id: u64); + fn initiate_dispute(env: Env, ip_id: u64, challenger: Address, evidence_hash: BytesN<32>) -> u64; + fn submit_dispute_evidence(env: Env, dispute_id: u64, submitter: Address, evidence_hash: BytesN<32>); + fn resolve_dispute(env: Env, dispute_id: u64, winner: Address); + fn get_dispute(env: Env, dispute_id: u64) -> crate::DisputeRecord; } #[test] @@ -1544,4 +1549,40 @@ mod tests { }); assert!(found, "dispute event must be emitted; dispute_id={dispute_id}"); } + // ── Issue: Cleanup Expired/Revoked Commitments ──────────────────────────────────────── + + #[test] + fn test_cleanup_revoked_commitment_removes_record() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let hash = BytesN::from_array(&env, &[0xC1u8; 32]); + let ip_id = client.commit_ip(&owner, &hash, &0u32); + + client.revoke_ip(&ip_id); + client.cleanup_revoked_commitment(&ip_id); + + let ids = client.list_ip_by_owner(&owner); + assert!(!ids.iter().any(|x| x == ip_id)); + } + + #[test] + #[should_panic] + fn test_cleanup_non_revoked_panics() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let hash = BytesN::from_array(&env, &[0xC2u8; 32]); + let ip_id = client.commit_ip(&owner, &hash, &0u32); + + // Not revoked — must panic + client.cleanup_revoked_commitment(&ip_id); + } + } From 44b4576f78537604abdd2407da0ba6774abfa536 Mon Sep 17 00:00:00 2001 From: jessie-hash-pixel Date: Wed, 27 May 2026 12:29:16 +0000 Subject: [PATCH 2/4] feat: periodic snapshots of commitments for disaster recovery Implements create_snapshot(caller) and get_snapshot(snapshot_id) for lightweight registry state snapshots. Each snapshot records total IP count and a sha256 checksum of the NextId counter as a state fingerprint. Snapshot IDs are monotonically increasing. Admin-only creation. - Add create_snapshot() contract method - Add get_snapshot() contract method - Add snapshot tests: create/get, sequential IDs, nonexistent returns None - Add docs/storage-maintenance.md documentation --- contracts/ip_registry/src/lib.rs | 75 +++++++++++++++++++++++++++++++ contracts/ip_registry/src/test.rs | 45 +++++++++++++++++++ docs/storage-maintenance.md | 70 +++++++++++++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 docs/storage-maintenance.md diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 8c28070..103fce7 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -2292,6 +2292,81 @@ impl IpRegistry { /// # Panics /// /// Panics with `IpAlreadyRevoked` (reused semantics) if the IP is not revoked. + // ── Issue: Periodic Snapshots for Disaster Recovery ──────────────────────────────────────── + + /// Create a snapshot of the current commitment registry state. + /// + /// Records the total number of committed IPs and a checksum of the next + /// available ID as a lightweight state fingerprint. Admin-only. + /// Returns the new snapshot_id. + /// + /// # Panics + /// + /// Panics if the caller is not the admin. + pub fn create_snapshot(env: Env, caller: Address) -> u64 { + caller.require_auth(); + let admin: Option
= env.storage().persistent().get(&DataKey::Admin); + if admin.map_or(true, |a| a != caller) { + env.panic_with_error(soroban_sdk::Error::from_contract_error( + ContractError::Unauthorized as u32, + )); + } + + let next_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextId) + .unwrap_or(1); + + let checksum: BytesN<32> = env + .crypto() + .sha256(&Bytes::from_array(&env, &next_id.to_be_bytes())) + .into(); + + let snapshot_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextSnapshotId) + .unwrap_or(1); + + let snapshot = CommitmentSnapshot { + snapshot_id, + timestamp: env.ledger().timestamp(), + total_count: next_id.saturating_sub(1), + checksum, + }; + + env.storage() + .persistent() + .set(&DataKey::Snapshot(snapshot_id), &snapshot); + env.storage() + .persistent() + .extend_ttl(&DataKey::Snapshot(snapshot_id), LEDGER_BUMP, LEDGER_BUMP); + + env.storage() + .persistent() + .set(&DataKey::NextSnapshotId, &(snapshot_id + 1)); + env.storage() + .persistent() + .extend_ttl(&DataKey::NextSnapshotId, LEDGER_BUMP, LEDGER_BUMP); + + env.events().publish( + (symbol_short!("snapshot"), caller), + (snapshot_id, env.ledger().timestamp()), + ); + + snapshot_id + } + + /// Retrieve a previously created snapshot by ID. + /// + /// Returns `None` if no snapshot with that ID exists. + pub fn get_snapshot(env: Env, snapshot_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Snapshot(snapshot_id)) + } + fn require_is_revoked(env: &Env, record: &IpRecord) { if !record.revoked { env.panic_with_error(soroban_sdk::Error::from_contract_error( diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index 545806b..7a9f35e 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -61,6 +61,8 @@ mod tests { fn compute_ip_merkle_root(env: Env, owner: Address) -> BytesN<32>; fn verify_ip_merkle_proof(env: Env, ip_id: u64, proof: Vec>) -> bool; fn cleanup_revoked_commitment(env: Env, ip_id: u64); + fn create_snapshot(env: Env, caller: Address) -> u64; + fn get_snapshot(env: Env, snapshot_id: u64) -> Option; fn initiate_dispute(env: Env, ip_id: u64, challenger: Address, evidence_hash: BytesN<32>) -> u64; fn submit_dispute_evidence(env: Env, dispute_id: u64, submitter: Address, evidence_hash: BytesN<32>); fn resolve_dispute(env: Env, dispute_id: u64, winner: Address); @@ -1585,4 +1587,47 @@ mod tests { client.cleanup_revoked_commitment(&ip_id); } + // ── Issue: Periodic Snapshots for Disaster Recovery ──────────────────────────────────────── + + #[test] + fn test_create_and_get_snapshot() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + client.commit_ip(&owner, &BytesN::from_array(&env, &[0xD1u8; 32]), &0u32); + client.commit_ip(&owner, &BytesN::from_array(&env, &[0xD2u8; 32]), &0u32); + + let snap_id = client.create_snapshot(&owner); + assert_eq!(snap_id, 1u64); + + let snap = client.get_snapshot(&snap_id).unwrap(); + assert_eq!(snap.snapshot_id, 1u64); + assert_eq!(snap.total_count, 2u64); + } + + #[test] + fn test_snapshot_ids_are_sequential() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let s1 = client.create_snapshot(&owner); + let s2 = client.create_snapshot(&owner); + assert_eq!(s1, 1u64); + assert_eq!(s2, 2u64); + } + + #[test] + fn test_get_snapshot_nonexistent_returns_none() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + assert!(client.get_snapshot(&999u64).is_none()); + } + } diff --git a/docs/storage-maintenance.md b/docs/storage-maintenance.md new file mode 100644 index 0000000..fe6b4a8 --- /dev/null +++ b/docs/storage-maintenance.md @@ -0,0 +1,70 @@ +# Storage Maintenance + +This document describes the four storage maintenance features added to the IP Registry contract. + +## 1. Cleanup Expired/Revoked Commitments + +**Function:** `cleanup_revoked_commitment(ip_id)` + +Removes a revoked IP record and its commitment-owner index entry from persistent storage, freeing ledger space. Only the record owner may call this. + +- The IP must already be revoked before cleanup is allowed. +- The owner's ID list is updated to remove the cleaned-up entry. +- Emits a `cleanup` event on success. + +```rust +// Revoke first, then clean up +registry.revoke_ip(&ip_id); +registry.cleanup_revoked_commitment(&ip_id); +``` + +## 2. Periodic Snapshots for Disaster Recovery + +**Functions:** `create_snapshot(caller)`, `get_snapshot(snapshot_id)` + +Creates a lightweight snapshot of the registry state for disaster recovery. Each snapshot records: + +- `snapshot_id` — monotonically increasing ID +- `timestamp` — ledger timestamp at creation +- `total_count` — number of IPs committed so far +- `checksum` — sha256 of the current `NextId` counter (state fingerprint) + +Admin-only. Snapshot IDs start at 1 and increment with each call. + +```rust +let snap_id = registry.create_snapshot(&admin); +let snap = registry.get_snapshot(&snap_id).unwrap(); +assert_eq!(snap.total_count, expected_count); +``` + +## 3. Cryptographic Checksum Integrity Verification + +**Functions:** `compute_integrity_checksum(caller)`, `verify_integrity_checksum()` + +Computes a sha256 checksum over all **active** (non-revoked) commitment hashes in ID order and stores it under `CommitmentChecksumV2`. Admin-only for computation. + +`verify_integrity_checksum()` recomputes the checksum and compares it to the stored value. Returns `true` if they match or no checksum has been stored yet. + +```rust +// After any state change, recompute and verify +let checksum = registry.compute_integrity_checksum(&admin); +assert!(registry.verify_integrity_checksum()); +``` + +Revoked commitments are excluded from the checksum, so revoking an IP changes the checksum. + +## 4. Batch Expire Commitments + +**Function:** `batch_revoke_commitments(owner, ip_ids)` + +Revokes multiple IP commitments in a single transaction. The caller must own every IP in the list. All IPs are revoked atomically — if any check fails the entire transaction panics. + +Returns the number of IPs revoked. + +```rust +let ids = Vec::from_array(&env, [id1, id2, id3]); +let count = registry.batch_revoke_commitments(&owner, &ids); +assert_eq!(count, 3); +``` + +Each revoked IP emits a `revoked` event and an immutable audit entry. From 8a5a9b7c0cdc9b14fc0f316f9d4333177d78047c Mon Sep 17 00:00:00 2001 From: jessie-hash-pixel Date: Wed, 27 May 2026 12:32:29 +0000 Subject: [PATCH 3/4] feat: verify commitment data integrity using cryptographic checksums Implements compute_integrity_checksum(caller) and verify_integrity_checksum() to detect data corruption or unauthorized state changes. The checksum is sha256 over all active (non-revoked) commitment hashes in ID order, stored under CommitmentChecksumV2. Revoked commitments are excluded so revoking an IP changes the checksum. Admin-only for computation. - Add compute_integrity_checksum() contract method - Add verify_integrity_checksum() contract method - Add tests: compute+verify, no stored checksum returns true, revoked IPs change the checksum --- contracts/ip_registry/src/lib.rs | 88 +++++++++++++++++++++++++++++++ contracts/ip_registry/src/test.rs | 49 +++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 103fce7..56f0998 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -2367,6 +2367,94 @@ impl IpRegistry { .get(&DataKey::Snapshot(snapshot_id)) } + // ── Issue: Cryptographic Checksum Integrity Verification ──────────────────────────────────────── + + /// Compute and store a sha256 checksum over all active (non-revoked) + /// commitment hashes in ID order. Admin-only. + /// + /// Returns the computed checksum. Stored under `CommitmentChecksumV2`. + /// + /// # Panics + /// + /// Panics if the caller is not the admin. + pub fn compute_integrity_checksum(env: Env, caller: Address) -> BytesN<32> { + caller.require_auth(); + let admin: Option
= env.storage().persistent().get(&DataKey::Admin); + if admin.map_or(true, |a| a != caller) { + env.panic_with_error(soroban_sdk::Error::from_contract_error( + ContractError::Unauthorized as u32, + )); + } + + let next_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextId) + .unwrap_or(1); + + let mut preimage = Bytes::new(&env); + for id in 1..next_id { + if let Some(record) = env + .storage() + .persistent() + .get::(&DataKey::IpRecord(id)) + { + if !record.revoked { + preimage.append(&record.commitment_hash.into()); + } + } + } + + let checksum: BytesN<32> = env.crypto().sha256(&preimage).into(); + + env.storage() + .persistent() + .set(&DataKey::CommitmentChecksumV2, &checksum); + env.storage() + .persistent() + .extend_ttl(&DataKey::CommitmentChecksumV2, LEDGER_BUMP, LEDGER_BUMP); + + checksum + } + + /// Verify that the stored integrity checksum matches a freshly computed one. + /// + /// Returns `true` if they match or no checksum has been stored yet. + /// Returns `false` if the stored checksum differs from the recomputed one. + pub fn verify_integrity_checksum(env: Env) -> bool { + let stored: Option> = env + .storage() + .persistent() + .get(&DataKey::CommitmentChecksumV2); + + let stored = match stored { + Some(s) => s, + None => return true, + }; + + let next_id: u64 = env + .storage() + .persistent() + .get(&DataKey::NextId) + .unwrap_or(1); + + let mut preimage = Bytes::new(&env); + for id in 1..next_id { + if let Some(record) = env + .storage() + .persistent() + .get::(&DataKey::IpRecord(id)) + { + if !record.revoked { + preimage.append(&record.commitment_hash.into()); + } + } + } + + let recomputed: BytesN<32> = env.crypto().sha256(&preimage).into(); + stored == recomputed + } + fn require_is_revoked(env: &Env, record: &IpRecord) { if !record.revoked { env.panic_with_error(soroban_sdk::Error::from_contract_error( diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index 7a9f35e..b1a738d 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -63,6 +63,8 @@ mod tests { fn cleanup_revoked_commitment(env: Env, ip_id: u64); fn create_snapshot(env: Env, caller: Address) -> u64; fn get_snapshot(env: Env, snapshot_id: u64) -> Option; + fn compute_integrity_checksum(env: Env, caller: Address) -> BytesN<32>; + fn verify_integrity_checksum(env: Env) -> bool; fn initiate_dispute(env: Env, ip_id: u64, challenger: Address, evidence_hash: BytesN<32>) -> u64; fn submit_dispute_evidence(env: Env, dispute_id: u64, submitter: Address, evidence_hash: BytesN<32>); fn resolve_dispute(env: Env, dispute_id: u64, winner: Address); @@ -1630,4 +1632,51 @@ mod tests { assert!(client.get_snapshot(&999u64).is_none()); } + // ── Issue: Cryptographic Checksum Integrity Verification ──────────────────────────────────────── + + #[test] + fn test_compute_and_verify_integrity_checksum() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + client.commit_ip(&owner, &BytesN::from_array(&env, &[0xE1u8; 32]), &0u32); + client.commit_ip(&owner, &BytesN::from_array(&env, &[0xE2u8; 32]), &0u32); + + let checksum = client.compute_integrity_checksum(&owner); + let zero = BytesN::from_array(&env, &[0u8; 32]); + assert_ne!(checksum, zero); + assert!(client.verify_integrity_checksum()); + } + + #[test] + fn test_verify_integrity_checksum_no_stored_returns_true() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + assert!(client.verify_integrity_checksum()); + } + + #[test] + fn test_checksum_excludes_revoked_commitments() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let id1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0xF1u8; 32]), &0u32); + client.commit_ip(&owner, &BytesN::from_array(&env, &[0xF2u8; 32]), &0u32); + + let checksum_before = client.compute_integrity_checksum(&owner); + + client.revoke_ip(&id1); + let checksum_after = client.compute_integrity_checksum(&owner); + + assert_ne!(checksum_before, checksum_after); + assert!(client.verify_integrity_checksum()); + } + } From 3a758f5f2d8fd0879a8ab861ad0767169dedb7d2 Mon Sep 17 00:00:00 2001 From: jessie-hash-pixel Date: Wed, 27 May 2026 12:35:29 +0000 Subject: [PATCH 4/4] feat: expire multiple commitments in a single transaction Implements batch_revoke_commitments(owner, ip_ids) which revokes a list of IP commitments atomically. The caller must own every IP in the list. Each revoked IP emits a revoked event and an immutable audit entry. Returns the count of IPs revoked. - Add batch_revoke_commitments() contract method - Add tests: all revoked, correct count, already-revoked panics, wrong owner panics --- contracts/ip_registry/src/lib.rs | 45 +++++++++++++++++++ contracts/ip_registry/src/test.rs | 72 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 56f0998..72b0c94 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -2455,6 +2455,51 @@ impl IpRegistry { stored == recomputed } + // ── Issue: Batch Expire Commitments ───────────────────────────────────────────────────────────────────────────────── + + /// Revoke multiple IP commitments in a single transaction. + /// + /// The caller must be the owner of every IP in the list. All IPs are + /// revoked atomically — if any check fails the entire transaction panics. + /// Returns the number of IPs revoked. + /// + /// # Panics + /// + /// Panics if any IP does not exist, the caller is not its owner, or it is + /// already revoked. + pub fn batch_revoke_commitments(env: Env, owner: Address, ip_ids: Vec) -> u32 { + owner.require_auth(); + + let mut count: u32 = 0; + for ip_id in ip_ids.iter() { + let mut record = require_ip_exists(&env, ip_id); + if record.owner != owner { + env.panic_with_error(soroban_sdk::Error::from_contract_error( + ContractError::Unauthorized as u32, + )); + } + require_not_revoked(&env, &record); + + record.revoked = true; + env.storage() + .persistent() + .set(&DataKey::IpRecord(ip_id), &record); + env.storage() + .persistent() + .extend_ttl(&DataKey::IpRecord(ip_id), 50000, 50000); + + env.events().publish( + (symbol_short!("revoked"), owner.clone()), + (ip_id, env.ledger().timestamp()), + ); + + Self::append_audit_entry(&env, ip_id, symbol_short!("revoked"), owner.clone()); + count += 1; + } + + count + } + fn require_is_revoked(env: &Env, record: &IpRecord) { if !record.revoked { env.panic_with_error(soroban_sdk::Error::from_contract_error( diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index b1a738d..a2dca16 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -65,6 +65,7 @@ mod tests { fn get_snapshot(env: Env, snapshot_id: u64) -> Option; fn compute_integrity_checksum(env: Env, caller: Address) -> BytesN<32>; fn verify_integrity_checksum(env: Env) -> bool; + fn batch_revoke_commitments(env: Env, owner: Address, ip_ids: Vec) -> u32; fn initiate_dispute(env: Env, ip_id: u64, challenger: Address, evidence_hash: BytesN<32>) -> u64; fn submit_dispute_evidence(env: Env, dispute_id: u64, submitter: Address, evidence_hash: BytesN<32>); fn resolve_dispute(env: Env, dispute_id: u64, winner: Address); @@ -1679,4 +1680,75 @@ mod tests { assert!(client.verify_integrity_checksum()); } + // ── Issue: Batch Expire Commitments ───────────────────────────────────────────────────────────────────────────────── + + #[test] + fn test_batch_revoke_commitments_marks_all_revoked() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let id1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x01u8; 32]), &0u32); + let id2 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x02u8; 32]), &0u32); + let id3 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x03u8; 32]), &0u32); + + let ids = Vec::from_array(&env, [id1, id2, id3]); + let count = client.batch_revoke_commitments(&owner, &ids); + + assert_eq!(count, 3u32); + assert!(client.get_ip(&id1).revoked); + assert!(client.get_ip(&id2).revoked); + assert!(client.get_ip(&id3).revoked); + } + + #[test] + fn test_batch_revoke_returns_correct_count() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let id1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x04u8; 32]), &0u32); + let id2 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x05u8; 32]), &0u32); + + let ids = Vec::from_array(&env, [id1, id2]); + let count = client.batch_revoke_commitments(&owner, &ids); + assert_eq!(count, 2u32); + } + + #[test] + #[should_panic] + fn test_batch_revoke_already_revoked_panics() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let id1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x06u8; 32]), &0u32); + client.revoke_ip(&id1); + + let ids = Vec::from_array(&env, [id1]); + client.batch_revoke_commitments(&owner, &ids); + } + + #[test] + #[should_panic] + fn test_batch_revoke_wrong_owner_panics() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let attacker =
::generate(&env); + let id1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[0x07u8; 32]), &0u32); + + let ids = Vec::from_array(&env, [id1]); + client.batch_revoke_commitments(&attacker, &ids); + } + }