From ab0aebe369229fc7f17b54000bbe034b17e9f1c0 Mon Sep 17 00:00:00 2001 From: emekaabraham666 Date: Fri, 16 Oct 2026 05:14:46 +0000 Subject: [PATCH 1/6] fix(ip-registry): restore compiling library after bad merge main no longer built: lib.rs carried a stale DataKey enum that shadowed the one in types.rs, and several definitions from 350aec4 (#974-#977) and the OwnershipShare/expiry/challenge work were dropped. - Merge DataKey into a single enum in lib.rs (remove the types.rs copy) - Restore MerkleProof, MerkleRootRecord, TimeLockRecord, CommitmentAmendment, PrivacyLevel and CommitmentPrivacy in types.rs - Restore missing DataKey variants (OwnershipShares, ExpiryNotified, ...) - Add CommitmentArchived, InvalidShareTotal, ChallengeExpired and ChallengeAlreadyAnswered with new codes 60-63 (36/37/39 were taken) - Rename private verify_merkle_proof helper to verify_merkle_leaf_path to resolve the clash with the public #974 entry point - Replace require! (undefined) with explicit checks, use Symbol::new for event names longer than 9 chars, fix usize/u32 length comparisons --- contracts/ip_registry/src/lib.rs | 59 +++++++++++++----- contracts/ip_registry/src/metadata.rs | 11 ++-- contracts/ip_registry/src/types.rs | 88 ++++++++++++++++++--------- 3 files changed, 109 insertions(+), 49 deletions(-) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 3debe86d..45ba3270 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -159,6 +159,14 @@ pub enum ContractError { InsuranceDisputeMismatch = 58, /// #1073: Dispute must be resolved before the claim can be verified. InsuranceDisputeUnresolved = 59, + /// The commitment has been archived and is no longer available for reveal. + CommitmentArchived = 60, + /// #809: OwnershipShare percentages for an IP do not sum to exactly 100. + InvalidShareTotal = 61, + /// #811: Ownership challenge has expired (TTL elapsed). + ChallengeExpired = 62, + /// #811: Ownership challenge has already been responded to. + ChallengeAlreadyAnswered = 63, } // ── TTL ─────────────────────────────────────────────────────────────────────── @@ -318,6 +326,25 @@ pub enum DataKey { NextClaimId, // monotonic claim ID counter IpInsurancePolicy(u64), // maps ip_id -> policy_id (one policy per IP) PolicyClaim(u64), // maps policy_id -> claim_id (one claim per policy) + /// Marks an old commitment as archived without deleting its immutable ID. + Archived(u64), + IpLineage(u64), // stores parent_ip_id for versioning + /// Issue #973: Maps ip_id -> encrypted metadata (title, description, tags) + IpMetadata(u64), + /// Issue #973: Maps tag hash -> Vec of IP IDs with that tag (for search) + MetadataTagIndex(BytesN<32>), + /// #974: Merkle root metadata for batch commitments + MerkleRootRecord(u64), + /// #975: Time-lock metadata for time-locked commitments + TimeLockRecord(u64), + /// #976: Amendment history for commitment amendments + AmendmentHistory(u64), + /// #977: Privacy level for each commitment + CommitmentPrivacy(u64), + /// #809: OwnershipShare cap table for an IP (sums to exactly 100) + OwnershipShares(u64), + /// Issue #795-era: records that expiry-transition event was already emitted + ExpiryNotified(u64), } // ── Upgrade Compatibility Manifest (#791) ─────────────────────────────────── @@ -1509,10 +1536,9 @@ impl IpRegistry { ) -> u64 { owner.require_auth(); - require!( - commitment_hashes.len() > 0, - ContractError::BatchSizeMismatch - ); + if commitment_hashes.len() == 0 { + panic_with_error!(&env, ContractError::BatchSizeMismatch); + } let root_hash = Self::compute_merkle_root(&env, &commitment_hashes); let timestamp = env.ledger().timestamp(); @@ -1539,7 +1565,7 @@ impl IpRegistry { .extend_ttl(&DataKey::MerkleRootRecord(root_id), LEDGER_BUMP, LEDGER_BUMP); env.events() - .publish((symbol_short!("merkle_root"),), (root_id, timestamp, root_hash)); + .publish((Symbol::new(&env, "merkle_root"),), (root_id, timestamp, root_hash)); root_id } @@ -1641,7 +1667,9 @@ impl IpRegistry { owner.require_auth(); let current_time = env.ledger().timestamp(); - require!(unlock_time > current_time, ContractError::InvalidTimeLock); + if unlock_time <= current_time { + panic_with_error!(&env, ContractError::InvalidTimeLock); + } require_non_zero_commitment(&env, &commitment_hash); require_unique_commitment(&env, &commitment_hash); @@ -1697,7 +1725,7 @@ impl IpRegistry { .extend_ttl(&DataKey::NextId, LEDGER_BUMP, LEDGER_BUMP); env.events().publish( - (symbol_short!("timelock_crt"),), + (Symbol::new(&env, "timelock_crt"),), (id, unlock_time, current_time), ); @@ -1739,7 +1767,7 @@ impl IpRegistry { .set(&DataKey::TimeLockRecord(commitment_id), &lock); env.events() - .publish((symbol_short!("timelock_unlock"),), (commitment_id, current_time)); + .publish((Symbol::new(&env, "timelock_unlock"),), (commitment_id, current_time)); true } @@ -1849,10 +1877,9 @@ impl IpRegistry { privacy_level: u32, allowed_addresses: Vec
, ) -> bool { - require!( - privacy_level <= 3, - ContractError::InvalidPrivacyLevel - ); + if privacy_level > 3 { + panic_with_error!(&env, ContractError::InvalidPrivacyLevel); + } let mut record: Option = env .storage() @@ -1892,7 +1919,7 @@ impl IpRegistry { ); env.events() - .publish((symbol_short!("privacy_set"),), (commitment_id, privacy_level)); + .publish((Symbol::new(&env, "privacy_set"),), (commitment_id, privacy_level)); true } @@ -2919,7 +2946,7 @@ impl IpRegistry { ); env.events().publish( - (symbol_short!("partial"), record.owner), + (symbol_short!("partial"), record.owner.clone()), (ip_id, partial_hash), ); @@ -3674,7 +3701,7 @@ impl IpRegistry { } } - Self::verify_merkle_proof(&env, &record.commitment_hash, &proof, &hashes) + Self::verify_merkle_leaf_path(&env, &record.commitment_hash, &proof, &hashes) } // ── Issue #435: Generate Merkle Proof ───────────────────────────────────── @@ -3825,7 +3852,7 @@ impl IpRegistry { current_level.get(0).unwrap() } - fn verify_merkle_proof( + fn verify_merkle_leaf_path( env: &Env, leaf: &BytesN<32>, proof: &Vec>, diff --git a/contracts/ip_registry/src/metadata.rs b/contracts/ip_registry/src/metadata.rs index 5b5c9346..b9dec3b4 100644 --- a/contracts/ip_registry/src/metadata.rs +++ b/contracts/ip_registry/src/metadata.rs @@ -4,7 +4,8 @@ /// without compromising privacy. Metadata is AES-GCM encrypted and indexed by tags /// for efficient searching. -use crate::types::{DataKey, IpMetadata, MetadataSearchResult}; +use crate::types::{IpMetadata, MetadataSearchResult}; +use crate::DataKey; use soroban_sdk::{Bytes, BytesN, Env, Vec}; /// Maximum number of results per search page @@ -97,7 +98,7 @@ pub fn search_by_metadata( limit }; - let key = DataKey::MetadataTagIndex(tag_hash); + let key = DataKey::MetadataTagIndex(tag_hash.clone()); let ip_ids: Vec = env .storage() .persistent() @@ -108,11 +109,11 @@ pub fn search_by_metadata( // Calculate pagination let start = offset as usize; - let end = ((offset + limit) as usize).min(ip_ids.len()); + let end = ((offset + limit) as usize).min(ip_ids.len() as usize); let mut result_ids = Vec::new(env); - if start < ip_ids.len() { + if start < ip_ids.len() as usize { for i in start..end { if let Some(ip_id) = ip_ids.get(i as u32) { result_ids.push_back(ip_id); @@ -121,7 +122,7 @@ pub fn search_by_metadata( } // Determine if there are more results - let next_cursor = if end < ip_ids.len() { + let next_cursor = if end < ip_ids.len() as usize { // Store cursor as hash of (tag_hash || offset) let mut cursor_bytes = Bytes::new(env); cursor_bytes.append(&Bytes::from(tag_hash.clone())); diff --git a/contracts/ip_registry/src/types.rs b/contracts/ip_registry/src/types.rs index 33c36f61..67756bb7 100644 --- a/contracts/ip_registry/src/types.rs +++ b/contracts/ip_registry/src/types.rs @@ -51,34 +51,6 @@ pub enum CommitmentAlgorithm { // ── Storage Keys ──────────────────────────────────────────────────────────── -#[contracttype] -#[derive(Debug, PartialEq)] -pub enum DataKey { - IpRecord(u64), - OwnerIps(Address), - NextId, - CommitmentOwner(BytesN<32>), // tracks which owner already holds a commitment hash - /// Marks an old commitment as archived without deleting its immutable ID. - Archived(u64), - Admin, - CategoryIps(BytesN<32>), // maps category hash -> Vec of IP IDs - IpLineage(u64), // stores parent_ip_id for versioning - IpVersions(u64), // stores Vec of all version IDs for a given IP - IpCommitmentChecksum, // Issue #346: stores hash of all commitments for rollback protection - IpAccessGrants(u64), // Issue #344: stores Vec of (grantee, access_level) for tiered access - NotarySignature(u64), // Issue #345: stores notary signature for timestamp notarization - IpVersionChain(u64), // stores Vec of the full version chain rooted at a given IP - OwnershipChallenge(u64), // Issue #433: stores OwnershipChallenge for a given challenge_id - NextChallengeId, // Issue #433: monotonic challenge ID counter - EncryptionKeyRotation(u64), // Issue #434: stores Vec> of old commitment hashes - MerkleRoot(Address), // Issue #435: cached Merkle root for an owner's commitment set - NotaryPublicKey, // Issue #428: stores the trusted notary Ed25519 public key (32 bytes) - CommitmentHashes, // Issue #429: stores Vec> of all commitment hashes for rollback protection - /// Issue #973: Maps ip_id -> encrypted metadata (title, description, tags) - IpMetadata(u64), - /// Issue #973: Maps tag hash -> Vec of IP IDs with that tag (for search) - MetadataTagIndex(BytesN<32>), -} // ── Types ──────────────────────────────────────────────────────────────────── @@ -166,3 +138,63 @@ pub struct MetadataSearchResult { /// Cursor for next page (if any) pub next_cursor: Option>, } + +/// #974: Merkle tree proof for batch commitments +#[contracttype] +#[derive(Clone)] +pub struct MerkleProof { + pub leaf_index: u32, + pub proof_path: soroban_sdk::Vec>, +} + +/// #974: Merkle root record for tracking batch commitments +#[contracttype] +#[derive(Clone)] +pub struct MerkleRootRecord { + pub root_id: u64, + pub root_hash: BytesN<32>, + pub owner: Address, + pub timestamp: u64, + pub leaf_count: u32, +} + +/// #975: Time-lock record for time-locked commitments +#[contracttype] +#[derive(Clone)] +pub struct TimeLockRecord { + pub commitment_id: u64, + pub unlock_time: u64, + pub revealed: bool, +} + +/// #976: Amendment record for tracking commitment amendments +#[contracttype] +#[derive(Clone)] +pub struct CommitmentAmendment { + pub amendment_id: u64, + pub commitment_id: u64, + pub old_hash: BytesN<32>, + pub new_hash: BytesN<32>, + pub timestamp: u64, + pub amender: Address, +} + +/// #977: Privacy level enumeration for access control +#[contracttype] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u32)] +pub enum PrivacyLevel { + Public = 0, + Private = 1, + Restricted = 2, + Confidential = 3, +} + +/// #977: Commitment privacy metadata +#[contracttype] +#[derive(Clone)] +pub struct CommitmentPrivacy { + pub commitment_id: u64, + pub privacy_level: u32, + pub allowed_addresses: soroban_sdk::Vec
, +} From 8070dfeb037bd932a061d058ff0ad722ee6cb9cf Mon Sep 17 00:00:00 2001 From: emekaabraham666 Date: Fri, 16 Oct 2026 05:16:32 +0000 Subject: [PATCH 2/6] fix(ip-registry): reject revoked IPs in verify_commitment (#1144) verify_commitment only checked the Archived flag, so a revoked IP still verified for the correct secret and blinding factor. It now returns false when record.revoked is set. - Add lifecycle_tests with verify-before and verify-after-revoke regression tests - Document verify_commitment's on-chain rejection rules in commitment-scheme.md - Add missing unlock_time/privacy_level fields to validation.rs test fixtures --- contracts/ip_registry/src/lib.rs | 6 ++ contracts/ip_registry/src/lifecycle_tests.rs | 63 ++++++++++++++++++++ contracts/ip_registry/src/validation.rs | 8 +++ docs/commitment-scheme.md | 10 ++++ 4 files changed, 87 insertions(+) create mode 100644 contracts/ip_registry/src/lifecycle_tests.rs diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 45ba3270..9c5be5db 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -50,6 +50,9 @@ mod commitment_property_tests; #[cfg(test)] mod commitment_sybil_resistance_tests; +#[cfg(test)] +mod lifecycle_tests; + #[cfg(test)] mod validator_network_tests; @@ -2675,6 +2678,9 @@ impl IpRegistry { blinding_factor: BytesN<32>, ) -> bool { let record = require_ip_exists(&env, ip_id); + if record.revoked { + return false; + } if env .storage() .persistent() diff --git a/contracts/ip_registry/src/lifecycle_tests.rs b/contracts/ip_registry/src/lifecycle_tests.rs new file mode 100644 index 00000000..3ea10b33 --- /dev/null +++ b/contracts/ip_registry/src/lifecycle_tests.rs @@ -0,0 +1,63 @@ +//! Regression tests for IP lifecycle guarantees: verification after revoke +//! (#1144), expiry enforcement in verification (#1145), per-IP state on +//! ownership transfer (#1146) and TTL policy (#1147). + +extern crate std; + +use crate::{IpRegistry, IpRegistryClient}; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::{Address, Bytes, BytesN, Env}; + +struct Fixture { + env: Env, + client: IpRegistryClient<'static>, + ip_id: u64, + secret: BytesN<32>, + blinding: BytesN<32>, +} + +/// Commit an IP whose hash is sha256(secret || blinding), the default +/// scheme that `verify_commitment` recomputes. +fn setup() -> Fixture { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner = Address::generate(&env); + let secret = BytesN::from_array(&env, &[10u8; 32]); + let blinding = BytesN::from_array(&env, &[20u8; 32]); + + let mut preimage = Bytes::new(&env); + preimage.append(&Bytes::from(secret.clone())); + preimage.append(&Bytes::from(blinding.clone())); + let commitment_hash: BytesN<32> = env.crypto().sha256(&preimage).into(); + + let ip_id = client.commit_ip(&owner, &commitment_hash, &0u32); + + Fixture { + env, + client, + ip_id, + secret, + blinding, + } +} + +// ── #1144: verify_commitment must reject revoked IPs ───────────────────────── + +#[test] +fn verify_commitment_accepts_active_ip_with_correct_secret() { + let f = setup(); + assert!(f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); +} + +#[test] +fn verify_commitment_rejects_revoked_ip_with_correct_secret() { + let f = setup(); + f.client.revoke_ip(&f.ip_id); + + // The secret and blinding factor are still correct, but the IP has been + // revoked, so downstream swap logic must not accept the commitment. + assert!(!f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); +} diff --git a/contracts/ip_registry/src/validation.rs b/contracts/ip_registry/src/validation.rs index 8e252819..43cff12b 100644 --- a/contracts/ip_registry/src/validation.rs +++ b/contracts/ip_registry/src/validation.rs @@ -259,6 +259,8 @@ mod tests { notary_signature: None, expiry_timestamp: 0, grace_period_seconds: 0, + unlock_time: 0, + privacy_level: 0, }; // Should not panic require_not_revoked(&env, &record); @@ -280,6 +282,8 @@ mod tests { notary_signature: None, expiry_timestamp: 0, grace_period_seconds: 0, + unlock_time: 0, + privacy_level: 0, }; require_not_revoked(&env, &record); } @@ -299,6 +303,8 @@ mod tests { notary_signature: None, expiry_timestamp: 0, grace_period_seconds: 0, + unlock_time: 0, + privacy_level: 0, }; // Should not panic require_owner(&env, &owner, &record); @@ -322,6 +328,8 @@ mod tests { notary_signature: None, expiry_timestamp: 0, grace_period_seconds: 0, + unlock_time: 0, + privacy_level: 0, }; require_owner(&env, ¬_owner, &record); } diff --git a/docs/commitment-scheme.md b/docs/commitment-scheme.md index e73b1e48..5edaf86e 100644 --- a/docs/commitment-scheme.md +++ b/docs/commitment-scheme.md @@ -171,6 +171,16 @@ fn verify_commitment( } ``` +### On-chain behavior of `verify_commitment` + +The on-chain `verify_commitment` is stricter than the hash comparison above. It returns +`false` without comparing hashes when the IP is no longer valid for verification: + +- **Revoked IPs (#1144).** If the IP record has `revoked = true`, verification returns `false` + even when the secret and blinding factor are correct. Revocation is terminal, so downstream + swap logic cannot accept a revoked commitment. +- **Archived commitments.** If the commitment has been archived, verification returns `false`. + ## Commitment Strength Scoring Every IP commitment is assigned a **strength score** (0–100) that reflects the entropy and complexity of the commitment hash. Weak commitments (e.g. all-same-byte hashes or zero PoW) score low; strong, high-entropy commitments with meaningful PoW score near 100. From 2a4dfa04e5e8e4c7bd6952588a0cf1a06b46297f Mon Sep 17 00:00:00 2001 From: emekaabraham666 Date: Fri, 16 Oct 2026 05:17:17 +0000 Subject: [PATCH 3/6] fix(ip-registry): enforce expiry in verify_commitment and move events out of read path (#1145) verify_commitment wrote ExpiryNotified and emitted EXPIRY_TOPIC, and it returned a hash match for an expired commitment. - verify_commitment is now read-only and returns false once the ledger reaches expiry_timestamp + grace_period_seconds (grace still verifies) - Add notify_ip_expiry, a state-changing entry point that emits the expiry event once per transition - Update expiry event test to use notify_ip_expiry - Add tests for verify before, during and after grace, the no-event guarantee on reads, and once-per-transition notification - Document the rules in commitment-scheme.md --- contracts/ip_registry/src/lib.rs | 61 ++++++++++---- contracts/ip_registry/src/lifecycle_tests.rs | 89 +++++++++++++++++++- contracts/ip_registry/src/test.rs | 15 ++-- docs/commitment-scheme.md | 8 ++ 4 files changed, 146 insertions(+), 27 deletions(-) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 9c5be5db..b8558621 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -2690,23 +2690,16 @@ impl IpRegistry { return false; } - // Emit EXPIRY_TOPIC exactly once per expiry transition so off-chain - // indexers can cheaply detect an IP crossing into its grace period. - if record.expiry_timestamp != 0 && env.ledger().timestamp() >= record.expiry_timestamp { - let already_notified: bool = env - .storage() - .persistent() - .get(&DataKey::ExpiryNotified(ip_id)) - .unwrap_or(false); - if !already_notified { - env.events().publish( - (EXPIRY_TOPIC, ip_id), - (record.owner.clone(), record.expiry_timestamp), - ); - env.storage() - .persistent() - .set(&DataKey::ExpiryNotified(ip_id), &true); - } + // Expired past the grace period: the commitment is no longer verifiable. + // Inside the grace period (expiry..expiry+grace) verification still succeeds. + // Read-only: expiry transition events are emitted by `notify_ip_expiry`. + if record.expiry_timestamp != 0 + && env.ledger().timestamp() + >= record + .expiry_timestamp + .saturating_add(record.grace_period_seconds) + { + return false; } let algorithm: CommitmentAlgorithm = env @@ -6551,6 +6544,40 @@ impl IpRegistry { .remove(&DataKey::ExpiryNotified(ip_id)); } + /// Emit `EXPIRY_TOPIC` once for an IP's expiry transition. Anyone can call. + /// + /// Expiry notifications used to be emitted from `verify_commitment`, which made a + /// read path write state and emit events. This state-changing entry point owns + /// that transition instead. Indexers should call it, or watch for the event. + /// + /// Returns `true` only when this call emitted the event. It returns `false` if + /// the IP has no expiry, has not reached it yet, or was already notified for the + /// current expiry. `set_ip_expiry` and `renew_ip_commitment` reset the flag. + pub fn notify_ip_expiry(env: Env, ip_id: u64) -> bool { + let record = require_ip_exists(&env, ip_id); + if record.expiry_timestamp == 0 || env.ledger().timestamp() < record.expiry_timestamp { + return false; + } + + let already_notified: bool = env + .storage() + .persistent() + .get(&DataKey::ExpiryNotified(ip_id)) + .unwrap_or(false); + if already_notified { + return false; + } + + env.events().publish( + (EXPIRY_TOPIC, ip_id), + (record.owner.clone(), record.expiry_timestamp), + ); + env.storage() + .persistent() + .set(&DataKey::ExpiryNotified(ip_id), &true); + true + } + /// Renew an IP commitment's expiry. Owner-only. /// new_expiry must be strictly greater than the current expiry_timestamp. pub fn renew_ip_commitment(env: Env, ip_id: u64, new_expiry: u64) -> bool { diff --git a/contracts/ip_registry/src/lifecycle_tests.rs b/contracts/ip_registry/src/lifecycle_tests.rs index 3ea10b33..ed4d18d8 100644 --- a/contracts/ip_registry/src/lifecycle_tests.rs +++ b/contracts/ip_registry/src/lifecycle_tests.rs @@ -5,7 +5,7 @@ extern crate std; use crate::{IpRegistry, IpRegistryClient}; -use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::{Address as _, Ledger}; use soroban_sdk::{Address, Bytes, BytesN, Env}; struct Fixture { @@ -61,3 +61,90 @@ fn verify_commitment_rejects_revoked_ip_with_correct_secret() { // revoked, so downstream swap logic must not accept the commitment. assert!(!f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); } + +// ── #1145: expiry is enforced by verify_commitment; events leave the read path ── + +#[test] +fn verify_commitment_valid_before_expiry() { + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 1000), &0); + + assert!(f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); +} + +#[test] +fn verify_commitment_valid_during_grace_period() { + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 100), &500); + + // Past expiry but before expiry + grace: still verifies. + f.env.ledger().with_mut(|l| l.timestamp = now + 150); + assert!(f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); +} + +#[test] +fn verify_commitment_rejects_after_grace_period() { + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 100), &500); + + // At exactly expiry + grace the commitment is no longer verifiable. + f.env.ledger().with_mut(|l| l.timestamp = now + 600); + assert!(!f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); +} + +#[test] +fn verify_commitment_rejects_at_expiry_when_no_grace() { + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 100), &0); + + f.env.ledger().with_mut(|l| l.timestamp = now + 100); + assert!(!f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding)); +} + +#[test] +fn verify_commitment_does_not_emit_expiry_event() { + use soroban_sdk::testutils::Events; + + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 100), &500); + f.env.ledger().with_mut(|l| l.timestamp = now + 600); + + let before = f.env.events().all().events().len(); + f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding); + f.client.verify_commitment(&f.ip_id, &f.secret, &f.blinding); + assert_eq!( + f.env.events().all().events().len(), + before, + "verify_commitment is a read path and must not emit events" + ); +} + +#[test] +fn notify_ip_expiry_is_noop_before_expiry() { + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 1000), &0); + + assert!(!f.client.notify_ip_expiry(&f.ip_id)); +} + +#[test] +fn notify_ip_expiry_fires_once_per_transition() { + let f = setup(); + let now = f.env.ledger().timestamp(); + f.client.set_ip_expiry(&f.ip_id, &(now + 100), &500); + f.env.ledger().with_mut(|l| l.timestamp = now + 150); + + assert!(f.client.notify_ip_expiry(&f.ip_id)); + assert!(!f.client.notify_ip_expiry(&f.ip_id)); + + // Renewing starts a new expiry transition, so it can notify again. + f.client.renew_ip_commitment(&f.ip_id, &(now + 1000)); + f.env.ledger().with_mut(|l| l.timestamp = now + 1100); + assert!(f.client.notify_ip_expiry(&f.ip_id)); +} diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index e1e84ca8..2d9c1014 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -31,6 +31,7 @@ mod tests { secret: BytesN<32>, blinding_factor: BytesN<32>, ) -> bool; + fn notify_ip_expiry(env: Env, ip_id: u64) -> bool; fn list_ip_by_owner(env: Env, owner: Address) -> Vec; fn get_stake(env: Env, ip_id: u64) -> Option; fn transfer_ip(env: Env, ip_id: u64, new_owner: Address); @@ -2871,7 +2872,7 @@ mod expiry_tests { } #[test] - fn test_verify_commitment_emits_expiry_event_exactly_once() { + fn test_notify_ip_expiry_emits_event_exactly_once() { let (env, client, _owner, ip_id) = setup(); let now = env.ledger().timestamp(); client.set_ip_expiry(&ip_id, &(now + 100), &500); @@ -2879,19 +2880,15 @@ mod expiry_tests { // Advance past expiry but still within the grace period. env.ledger().with_mut(|l| l.timestamp = now + 150); - let secret = BytesN::from_array(&env, &[1u8; 32]); - let blinding = BytesN::from_array(&env, &[2u8; 32]); - // No events emitted yet (set_ip_expiry does not publish). assert_eq!(env.events().all().events().len(), 0); - // Calling verify_commitment repeatedly must only fire the expiry - // event once for this expiry transition. - client.verify_commitment(&ip_id, &secret, &blinding); + // The first notification emits EXPIRY_TOPIC; repeats must not. + assert!(client.notify_ip_expiry(&ip_id)); assert_eq!(env.events().all().events().len(), 1); - client.verify_commitment(&ip_id, &secret, &blinding); - client.verify_commitment(&ip_id, &secret, &blinding); + assert!(!client.notify_ip_expiry(&ip_id)); + assert!(!client.notify_ip_expiry(&ip_id)); assert_eq!( env.events().all().events().len(), 1, diff --git a/docs/commitment-scheme.md b/docs/commitment-scheme.md index 5edaf86e..41d03ffd 100644 --- a/docs/commitment-scheme.md +++ b/docs/commitment-scheme.md @@ -180,6 +180,14 @@ The on-chain `verify_commitment` is stricter than the hash comparison above. It even when the secret and blinding factor are correct. Revocation is terminal, so downstream swap logic cannot accept a revoked commitment. - **Archived commitments.** If the commitment has been archived, verification returns `false`. +- **Expired commitments (#1145).** If the IP has an `expiry_timestamp` and the ledger time is at + or past `expiry_timestamp + grace_period_seconds`, verification returns `false`. Between expiry + and the end of the grace period, verification still succeeds. + +`verify_commitment` is read-only. It does not emit events or write state. To record an expiry +transition, call `notify_ip_expiry(ip_id)`. Anyone can call it. It emits `ip_expiry` once per +expiry, and returns `true` only on the call that emitted it. `set_ip_expiry` and +`renew_ip_commitment` reset the notification so the next transition can emit again. ## Commitment Strength Scoring From d69c9ec05a546a24b4e0b6dc9501ebfb29b14dae Mon Sep 17 00:00:00 2001 From: emekaabraham666 Date: Fri, 16 Oct 2026 05:18:07 +0000 Subject: [PATCH 4/6] fix(ip-registry): clear per-IP authority state on ownership transfer (#1146) transfer_ip only moved OwnerIps, CommitmentOwner and IpRecord. Access grants, licenses, co-owner shares and threshold configuration stayed bound to the previous owner, so their grantees kept live access. - Add clear_owner_scoped_state with a documented per-key policy: authority keys are cleared, history and attestation keys are retained - transfer_ip calls it and resets co_owners to the new sole owner - Add tests that grants, licenses, co-owners and threshold config do not survive a transfer, audit history is retained, and the new owner can grant access afterwards - Document the transfer policy in commitment-scheme.md --- contracts/ip_registry/src/lib.rs | 5 ++ contracts/ip_registry/src/lifecycle_tests.rs | 86 ++++++++++++++++++++ contracts/ip_registry/src/validation.rs | 41 ++++++++++ docs/commitment-scheme.md | 14 ++++ 4 files changed, 146 insertions(+) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index b8558621..212c3154 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -2210,6 +2210,11 @@ impl IpRegistry { LEDGER_BUMP, ); + // #1146: authority state belongs to the outgoing owner, so clear it. + // The new owner starts with a single-owner cap table and no grants. + validation::clear_owner_scoped_state(&env, ip_id); + record.co_owners = Vec::new(&env); + record.owner = new_owner.clone(); env.storage() .persistent() diff --git a/contracts/ip_registry/src/lifecycle_tests.rs b/contracts/ip_registry/src/lifecycle_tests.rs index ed4d18d8..081a9fbb 100644 --- a/contracts/ip_registry/src/lifecycle_tests.rs +++ b/contracts/ip_registry/src/lifecycle_tests.rs @@ -148,3 +148,89 @@ fn notify_ip_expiry_fires_once_per_transition() { f.env.ledger().with_mut(|l| l.timestamp = now + 1100); assert!(f.client.notify_ip_expiry(&f.ip_id)); } + +// ── #1146: per-IP authority state does not survive ownership transfer ──────── + +#[test] +fn transfer_clears_access_grants() { + let f = setup(); + let grantee = Address::generate(&f.env); + let new_owner = Address::generate(&f.env); + + f.client.grant_ip_access(&f.ip_id, &grantee, &3u32); + assert!(f.client.check_ip_access(&f.ip_id, &grantee, &1u32)); + + f.client.transfer_ip(&f.ip_id, &new_owner); + + assert_eq!(f.client.get_ip_access_grants(&f.ip_id).len(), 0); + assert!(!f.client.check_ip_access(&f.ip_id, &grantee, &1u32)); +} + +#[test] +fn transfer_clears_licenses() { + let f = setup(); + let licensee = Address::generate(&f.env); + let new_owner = Address::generate(&f.env); + let terms = BytesN::from_array(&f.env, &[7u8; 32]); + + f.client.grant_license(&f.ip_id, &licensee, &terms); + assert_eq!(f.client.get_licenses(&f.ip_id).len(), 1); + + f.client.transfer_ip(&f.ip_id, &new_owner); + + assert_eq!(f.client.get_licenses(&f.ip_id).len(), 0); +} + +#[test] +fn transfer_resets_co_owners_and_cap_table() { + let f = setup(); + let co_owner = Address::generate(&f.env); + let new_owner = Address::generate(&f.env); + + f.client.add_co_owner(&f.ip_id, &co_owner, &20u32); + assert_eq!(f.client.get_ip(&f.ip_id).co_owners.len(), 1); + + f.client.transfer_ip(&f.ip_id, &new_owner); + + assert_eq!(f.client.get_ip(&f.ip_id).co_owners.len(), 0); + assert_eq!(f.client.get_ownership_shares(&f.ip_id).len(), 0); +} + +#[test] +fn transfer_clears_threshold_configuration() { + let f = setup(); + let signer = Address::generate(&f.env); + let new_owner = Address::generate(&f.env); + let signers = soroban_sdk::Vec::from_array(&f.env, [signer]); + + f.client.require_threshold_signatures(&f.ip_id, &1u32, &signers); + assert!(f.client.get_threshold_config(&f.ip_id).is_some()); + + f.client.transfer_ip(&f.ip_id, &new_owner); + + assert!(f.client.get_threshold_config(&f.ip_id).is_none()); +} + +#[test] +fn transfer_retains_audit_history() { + let f = setup(); + let new_owner = Address::generate(&f.env); + let before = f.client.get_ip_audit_trail(&f.ip_id).len(); + + f.client.transfer_ip(&f.ip_id, &new_owner); + + // History is retained and gains an entry for the transfer. + assert_eq!(f.client.get_ip_audit_trail(&f.ip_id).len(), before + 1); +} + +#[test] +fn new_owner_can_grant_access_after_transfer() { + let f = setup(); + let grantee = Address::generate(&f.env); + let new_owner = Address::generate(&f.env); + + f.client.transfer_ip(&f.ip_id, &new_owner); + f.client.grant_ip_access(&f.ip_id, &grantee, &1u32); + + assert!(f.client.check_ip_access(&f.ip_id, &grantee, &1u32)); +} diff --git a/contracts/ip_registry/src/validation.rs b/contracts/ip_registry/src/validation.rs index 43cff12b..c5205b26 100644 --- a/contracts/ip_registry/src/validation.rs +++ b/contracts/ip_registry/src/validation.rs @@ -6,6 +6,47 @@ use crate::{ContractError, DataKey, IpRecord, OwnershipShare}; use soroban_sdk::{symbol_short, Address, BytesN, Env, Error, Vec}; +/// Clears per-IP state that grants authority to the outgoing owner (#1146). +/// +/// Called by `transfer_ip`. Authority state is removed so the new owner starts +/// with a clean slate and the previous owner's grantees, licensees, delegates, +/// co-owners and threshold signers lose access. +/// +/// | Storage key | Policy | +/// |-------------------------------------|----------| +/// | `IpAccessGrants(ip_id)` | clear | +/// | `IpLicenses(ip_id)` | clear | +/// | `AccessDelegationList` + `AccessDelegation(ip_id, _)` | clear | +/// | `OwnershipShares(ip_id)` | clear (co-owners reset in the record) | +/// | `ThresholdConfig(ip_id)` | clear | +/// | `ThresholdSignatures(ip_id)` | clear | +/// +/// Every other per-IP key is history or attestation, not authority, and is +/// retained: `IpVersions`, `IpVersionChain`, `IpLineage`, `IpAuditTrail`, +/// `RenewalCount`, `NotarySignature`, `MultiNotaryRecord`, `PartialDisclosure`, +/// `IpDisputes`, `IpStake`, `CommitmentAlgorithm`, `IpPowDifficulty`, +/// `CommitmentPrivacy`, `EncryptedCommitment`, `CompressedCommitment`, +/// `CompressionSelection`, `EncryptionKeyRotation`, `BatchMetadata`, +/// `CommitmentLinks`, `LinkedCommitments`, `MerkleRootRecord`, `TimeLockRecord`, +/// `AmendmentHistory`, `Archived`, `ExpiryNotified`, `IpMetadata`, +/// `SuggestedPrice` and `IpInsurancePolicy`. +pub fn clear_owner_scoped_state(env: &Env, ip_id: u64) { + let storage = env.storage().persistent(); + storage.remove(&DataKey::IpAccessGrants(ip_id)); + storage.remove(&DataKey::IpLicenses(ip_id)); + storage.remove(&DataKey::OwnershipShares(ip_id)); + storage.remove(&DataKey::ThresholdConfig(ip_id)); + storage.remove(&DataKey::ThresholdSignatures(ip_id)); + + let list_key = DataKey::AccessDelegationList(ip_id); + if let Some(delegates) = storage.get::<_, Vec
>(&list_key) { + for delegate in delegates.iter() { + storage.remove(&DataKey::AccessDelegation(ip_id, delegate)); + } + } + storage.remove(&list_key); +} + /// Retrieves an IP record by ID, panicking if not found. /// /// # Arguments diff --git a/docs/commitment-scheme.md b/docs/commitment-scheme.md index 41d03ffd..b7415022 100644 --- a/docs/commitment-scheme.md +++ b/docs/commitment-scheme.md @@ -189,6 +189,20 @@ transition, call `notify_ip_expiry(ip_id)`. Anyone can call it. It emits `ip_exp expiry, and returns `true` only on the call that emitted it. `set_ip_expiry` and `renew_ip_commitment` reset the notification so the next transition can emit again. +### Ownership transfer (#1146) + +`transfer_ip` moves the IP and its commitment index to the new owner. It also clears the +state that grants authority to the outgoing owner: + +- access grants (`grant_ip_access`) and licenses (`grant_license`) +- access delegations +- co-owners and the ownership cap table. The new owner starts as sole owner at 100%. +- threshold signer configuration and collected threshold signatures + +History and attestations stay with the IP, including the audit trail, version chain, +notary signature, stake, dispute records and insurance policy link. The full per-key policy +is documented next to `clear_owner_scoped_state` in `contracts/ip_registry/src/validation.rs`. + ## Commitment Strength Scoring Every IP commitment is assigned a **strength score** (0–100) that reflects the entropy and complexity of the commitment hash. Weak commitments (e.g. all-same-byte hashes or zero PoW) score low; strong, high-entropy commitments with meaningful PoW score near 100. From f1ae7e9a3149cbe50b5bf7a21d47a002c01776cb Mon Sep 17 00:00:00 2001 From: emekaabraham666 Date: Fri, 16 Oct 2026 05:19:03 +0000 Subject: [PATCH 5/6] fix(ip-registry): use LEDGER_BUMP for every extend_ttl call (#1147) transfer_ip, revoke_ip and grant_ip_access passed the literal 50000 as the TTL, so record lifetimes depended on which path last touched them. Two metadata.rs calls and several commitment paths also passed literal values. - Replace every literal TTL argument with LEDGER_BUMP (8 call sites) - Add extend_ttl_uses_ledger_bump_policy, which scans all src files and fails on numeric literal TTL arguments, with scanner self-tests - Document the TTL policy in soroban-resource-limits.md --- contracts/ip_registry/src/lib.rs | 18 ++-- contracts/ip_registry/src/lifecycle_tests.rs | 96 ++++++++++++++++++++ contracts/ip_registry/src/metadata.rs | 6 +- docs/soroban-resource-limits.md | 13 +++ 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 212c3154..4e48c171 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -1154,8 +1154,8 @@ impl IpRegistry { .set(&DataKey::CommitmentOwner(commitment_hash.clone()), &owner); env.storage().persistent().extend_ttl( &DataKey::CommitmentOwner(commitment_hash.clone()), - 50000, - 50000, + LEDGER_BUMP, + LEDGER_BUMP, ); env.storage().persistent().set(&DataKey::NextId, &(id + 1)); @@ -1293,8 +1293,8 @@ impl IpRegistry { .set(&DataKey::CommitmentOwner(commitment_hash.clone()), &owner); env.storage().persistent().extend_ttl( &DataKey::CommitmentOwner(commitment_hash.clone()), - 50000, - 50000, + LEDGER_BUMP, + LEDGER_BUMP, ); env.events() @@ -1441,8 +1441,8 @@ impl IpRegistry { ); env.storage().persistent().extend_ttl( &DataKey::CommitmentOwner(commitment_hash.clone()), - 50000, - 50000, + LEDGER_BUMP, + LEDGER_BUMP, ); // Record blinded owner mapping for later on-chain/off-chain proof if needed. @@ -2183,7 +2183,7 @@ impl IpRegistry { .set(&DataKey::OwnerIps(old_owner.clone()), &old_ids); env.storage() .persistent() - .extend_ttl(&DataKey::OwnerIps(old_owner.clone()), 50000, 50000); + .extend_ttl(&DataKey::OwnerIps(old_owner.clone()), LEDGER_BUMP, LEDGER_BUMP); // Add to new owner's index let mut new_ids: Vec = env @@ -2197,7 +2197,7 @@ impl IpRegistry { .set(&DataKey::OwnerIps(new_owner.clone()), &new_ids); env.storage() .persistent() - .extend_ttl(&DataKey::OwnerIps(new_owner.clone()), 50000, 50000); + .extend_ttl(&DataKey::OwnerIps(new_owner.clone()), LEDGER_BUMP, LEDGER_BUMP); // Update commitment index env.storage().persistent().set( @@ -2269,7 +2269,7 @@ impl IpRegistry { .set(&DataKey::IpRecord(ip_id), &record); env.storage() .persistent() - .extend_ttl(&DataKey::IpRecord(ip_id), 50000, 50000); + .extend_ttl(&DataKey::IpRecord(ip_id), LEDGER_BUMP, LEDGER_BUMP); env.events().publish( (REVOKE_TOPIC, record.owner.clone()), diff --git a/contracts/ip_registry/src/lifecycle_tests.rs b/contracts/ip_registry/src/lifecycle_tests.rs index 081a9fbb..843ec5b7 100644 --- a/contracts/ip_registry/src/lifecycle_tests.rs +++ b/contracts/ip_registry/src/lifecycle_tests.rs @@ -234,3 +234,99 @@ fn new_owner_can_grant_access_after_transfer() { assert!(f.client.check_ip_access(&f.ip_id, &grantee, &1u32)); } + +// ── #1147: TTL policy — extend_ttl must use LEDGER_BUMP, never a literal ───── + +fn is_numeric_literal(arg: &str) -> bool { + !arg.is_empty() && arg.chars().all(|c| c.is_ascii_digit() || c == '_') +} + +/// Returns `(line, call)` for every `extend_ttl(...)` call in `src` whose last +/// two arguments (the threshold and extend-to TTLs) are numeric literals. +fn literal_ttl_calls(src: &str) -> std::vec::Vec<(usize, std::string::String)> { + const CALL: &str = "extend_ttl("; + let mut found = std::vec::Vec::new(); + + for (start, _) in src.match_indices(CALL) { + let args_start = start + CALL.len(); + + // Find the matching closing parenthesis. + let mut depth = 1usize; + let mut end = args_start; + for (k, c) in src[args_start..].char_indices() { + match c { + '(' | '[' => depth += 1, + ')' | ']' => { + depth -= 1; + if depth == 0 { + end = args_start + k; + break; + } + } + _ => {} + } + } + + // Split the argument list on top-level commas only. + let mut args = std::vec::Vec::new(); + let mut nest = 0usize; + let mut last = args_start; + for (k, c) in src[args_start..end].char_indices() { + match c { + '(' | '[' => nest += 1, + ')' | ']' => nest -= 1, + ',' if nest == 0 => { + args.push(src[last..args_start + k].trim()); + last = args_start + k + 1; + } + _ => {} + } + } + args.push(src[last..end].trim()); + let args: std::vec::Vec<&str> = args.into_iter().filter(|a| !a.is_empty()).collect(); + + if args.len() >= 3 && args[args.len() - 2..].iter().all(|a| is_numeric_literal(a)) { + let line = src[..start].matches('\n').count() + 1; + found.push((line, std::format!("extend_ttl({})", args.join(", ")))); + } + } + + found +} + +#[test] +fn literal_ttl_scanner_detects_literal_values() { + let bad = "env.storage().persistent().extend_ttl(&key, 50000, 50000);"; + let good = "env.storage().persistent().extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP);"; + let multiline = "extend_ttl(\n &DataKey::OwnerIps(owner.clone()),\n 6_307_200,\n 6_307_200,\n);"; + assert_eq!(literal_ttl_calls(bad).len(), 1); + assert_eq!(literal_ttl_calls(multiline).len(), 1); + assert!(literal_ttl_calls(good).is_empty()); +} + +#[test] +fn extend_ttl_uses_ledger_bump_policy() { + let src_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut violations = std::vec::Vec::new(); + + for entry in std::fs::read_dir(&src_dir).expect("read src directory") { + let path = entry.expect("directory entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + // This file contains the scanner's own sample strings. + if path.file_name().and_then(|n| n.to_str()) == Some("lifecycle_tests.rs") { + continue; + } + let text = std::fs::read_to_string(&path).expect("read source file"); + for (line, call) in literal_ttl_calls(&text) { + violations.push(std::format!("{}:{}: {}", path.display(), line, call)); + } + } + + assert!( + violations.is_empty(), + "extend_ttl must use LEDGER_BUMP instead of literal TTLs:\n{}", + violations.join("\n") + ); +} diff --git a/contracts/ip_registry/src/metadata.rs b/contracts/ip_registry/src/metadata.rs index b9dec3b4..f6003f1d 100644 --- a/contracts/ip_registry/src/metadata.rs +++ b/contracts/ip_registry/src/metadata.rs @@ -5,7 +5,7 @@ /// for efficient searching. use crate::types::{IpMetadata, MetadataSearchResult}; -use crate::DataKey; +use crate::{DataKey, LEDGER_BUMP}; use soroban_sdk::{Bytes, BytesN, Env, Vec}; /// Maximum number of results per search page @@ -44,7 +44,7 @@ pub fn store_metadata( env.storage().persistent().set(&key, &metadata); env.storage() .persistent() - .extend_ttl(&key, 6_307_200, 6_307_200); + .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); // Index by each tag for search for i in 0..tag_hashes.len() { @@ -71,7 +71,7 @@ fn index_metadata_by_tag(env: &Env, tag_hash: &BytesN<32>, ip_id: u64) { env.storage().persistent().set(&key, &ip_ids); env.storage() .persistent() - .extend_ttl(&key, 6_307_200, 6_307_200); + .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); } /// Search for commitments by tag with pagination. diff --git a/docs/soroban-resource-limits.md b/docs/soroban-resource-limits.md index fa3d0977..c05004b1 100644 --- a/docs/soroban-resource-limits.md +++ b/docs/soroban-resource-limits.md @@ -43,6 +43,19 @@ This document details the Soroban ledger resource limits relevant to AtomicIP's - Ed25519 signature: 64 bytes + overhead - Full batch of 50 IPs with signatures: ~15-20 KB +## Persistent Storage TTL Policy (#1147) + +Every persistent write extends its entry's TTL through `extend_ttl(key, threshold, extend_to)`. +The contract uses one policy for all of them: + +- Both arguments are the `LEDGER_BUMP` constant (`contracts/ip_registry/src/lib.rs`), which is + `6_307_200` ledgers, about one year at 5 seconds per ledger. +- Numeric literals are not allowed. `transfer_ip`, `revoke_ip` and `grant_ip_access` used to pass + `50000`, which made the lifetime of a record depend on which function last touched it. +- The `ip_registry` test `extend_ttl_uses_ledger_bump_policy` enforces this. It scans every file in + `contracts/ip_registry/src` and fails on any `extend_ttl` call whose last two arguments are + numeric literals. + ## Project Batch Size Constants ### API Server (`api-server/src/handlers.rs`) From 4522124ca77df761edb5b2324c615729ace60d52 Mon Sep 17 00:00:00 2001 From: emekaabraham666 Date: Fri, 16 Oct 2026 05:19:46 +0000 Subject: [PATCH 6/6] docs(ip-registry): correct ExpiryNotified doc comment The comment cited an issue number that does not correspond to this key. --- contracts/ip_registry/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 4e48c171..4fef9abf 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -346,7 +346,7 @@ pub enum DataKey { CommitmentPrivacy(u64), /// #809: OwnershipShare cap table for an IP (sums to exactly 100) OwnershipShares(u64), - /// Issue #795-era: records that expiry-transition event was already emitted + /// Records that the EXPIRY_TOPIC event was already emitted for the current expiry ExpiryNotified(u64), }