diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index bcf2d786..a4e6199a 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -1192,8 +1192,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)); @@ -1336,8 +1336,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() @@ -1498,8 +1498,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. @@ -1596,10 +1596,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(); @@ -1626,7 +1625,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 } @@ -1728,7 +1727,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); @@ -1784,7 +1785,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), ); @@ -1832,7 +1833,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 } @@ -1951,10 +1952,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() @@ -2000,7 +2000,7 @@ impl IpRegistry { ip_record.owner.clone(), ); env.events() - .publish((symbol_short!("privacy_set"),), (commitment_id, privacy_level)); + .publish((Symbol::new(&env, "privacy_set"),), (commitment_id, privacy_level)); true } @@ -2261,7 +2261,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 @@ -2275,7 +2275,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( @@ -2288,6 +2288,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() @@ -2342,7 +2347,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()), @@ -2760,6 +2765,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() @@ -2769,23 +2777,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 @@ -3041,7 +3042,7 @@ impl IpRegistry { ); env.events().publish( - (symbol_short!("partial"), record.owner), + (symbol_short!("partial"), record.owner.clone()), (ip_id, partial_hash), ); @@ -3814,7 +3815,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 ───────────────────────────────────── @@ -3965,7 +3966,7 @@ impl IpRegistry { current_level.get(0).unwrap() } - fn verify_merkle_proof( + fn verify_merkle_leaf_path( env: &Env, leaf: &BytesN<32>, proof: &Vec>, @@ -6862,6 +6863,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 new file mode 100644 index 00000000..843ec5b7 --- /dev/null +++ b/contracts/ip_registry/src/lifecycle_tests.rs @@ -0,0 +1,332 @@ +//! 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 _, Ledger}; +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)); +} + +// ── #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)); +} + +// ── #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)); +} + +// ── #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 e40c1c01..fbd9d492 100644 --- a/contracts/ip_registry/src/metadata.rs +++ b/contracts/ip_registry/src/metadata.rs @@ -46,7 +46,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() { @@ -73,7 +73,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. @@ -100,7 +100,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() @@ -111,11 +111,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); @@ -124,7 +124,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/test.rs b/contracts/ip_registry/src/test.rs index b4fb580b..af4aa7c2 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); @@ -2945,7 +2946,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); @@ -2953,19 +2954,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/contracts/ip_registry/src/validation.rs b/contracts/ip_registry/src/validation.rs index d9975f2e..b99a53d6 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 @@ -256,6 +297,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); @@ -276,6 +319,8 @@ mod tests { notary_signature: None, expiry_timestamp: 0, grace_period_seconds: 0, + unlock_time: 0, + privacy_level: 0, }; require_not_revoked(&env, &record); } @@ -295,6 +340,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); @@ -317,6 +364,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..b7415022 100644 --- a/docs/commitment-scheme.md +++ b/docs/commitment-scheme.md @@ -171,6 +171,38 @@ 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`. +- **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. + +### 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. diff --git a/docs/soroban-resource-limits.md b/docs/soroban-resource-limits.md index c83fa56c..15b52fa9 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`)