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`)