diff --git a/contracts/ip_registry/src/lib.rs b/contracts/ip_registry/src/lib.rs index 6350aa5..4316992 100644 --- a/contracts/ip_registry/src/lib.rs +++ b/contracts/ip_registry/src/lib.rs @@ -97,6 +97,9 @@ pub const NOTARY_PUBLIC_KEY: &[u8] = b"notary_public_key_placeholder"; /// Issue #437: Number of storage shards for commitment distribution. pub const NUM_SHARDS: u32 = 16; +/// Issue #459: Maximum category hierarchy depth (e.g., "a/b/c" = 3 levels). +pub const MAX_CATEGORY_DEPTH: u32 = 5; + // ── Storage Keys ──────────────────────────────────────────────────────────── @@ -113,6 +116,8 @@ pub enum DataKey { PartialDisclosure(u64), // stores partial_hash for a given ip_id after reveal IpLicenses(u64), // stores license entries for a given ip_id CategoryIps(BytesN<32>), // maps category hash -> Vec of IP IDs + OwnerCategories(Address), // maps owner -> Vec> of category hashes they use + IpCategories(u64), // maps ip_id -> Vec> of assigned category hashes PowDifficulty, // stores the current PoW difficulty (leading zero bits required) IpVersions(u64), // stores Vec of all version IDs for a given IP SuggestedPrice(u64), // stores suggested price for an IP @@ -1454,6 +1459,166 @@ impl IpRegistry { .unwrap_or(Vec::new(&env)) } + // ── Category Methods (Issue #459) ──────────────────────────────────────── + + /// Validates a category path string and returns its SHA-256 hash. + /// + /// The path must be UTF-8 segments separated by `/`, with max depth of 5 levels. + /// Path traversal (`..`) and empty segments are rejected. + /// + /// Callers should use this off-chain to compute the `category_hash` to pass + /// to `assign_ip_to_category`. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment + /// * `path` - The category path as UTF-8 bytes (e.g., `b"Software/Cryptography/ZK-Proofs"`) + /// + /// # Returns + /// + /// `BytesN<32>` — `sha256(path)` to use as the category hash. + /// + /// # Panics + /// + /// * `CategoryDepthExceeded` (30) — path has more than 5 levels + /// * `CategoryPathTraversal` (31) — path contains `..` or empty segments + pub fn validate_category_path(env: Env, path: Bytes) -> BytesN<32> { + validate_category_path(&env, &path) + } + + /// Assigns an IP record to a category. Only the IP owner can assign. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment + /// * `ip_id` - The IP record ID to categorize + /// * `category_hash` - 32-byte SHA-256 hash of the category path (see `validate_category_path`) + /// + /// # Panics + /// + /// * `IpNotFound` (1) — no record for `ip_id` + /// * `IpAlreadyRevoked` (4) — the IP has been revoked + /// * `Unauthorized` (6) — caller is not the IP owner + /// * `InvalidCategoryHash` (29) — `category_hash` is all zeros + /// + /// # Event + /// + /// Emits `(symbol_short!("ip_cat"), owner)` → `(ip_id, category_hash)`. + pub fn assign_ip_to_category(env: Env, ip_id: u64, category_hash: BytesN<32>) { + require_valid_category_hash(&env, &category_hash); + + let record = require_ip_exists(&env, ip_id); + require_not_revoked(&env, &record); + record.owner.require_auth(); + + // Add to global category index + let mut cat_ips: Vec = env + .storage() + .persistent() + .get(&DataKey::CategoryIps(category_hash.clone())) + .unwrap_or(Vec::new(&env)); + if !cat_ips.iter().any(|x| x == ip_id) { + cat_ips.push_back(ip_id); + } + env.storage() + .persistent() + .set(&DataKey::CategoryIps(category_hash.clone()), &cat_ips); + env.storage().persistent().extend_ttl( + &DataKey::CategoryIps(category_hash.clone()), + LEDGER_BUMP, + LEDGER_BUMP, + ); + + // Track category usage per owner + let mut owner_cats: Vec> = env + .storage() + .persistent() + .get(&DataKey::OwnerCategories(record.owner.clone())) + .unwrap_or(Vec::new(&env)); + if !owner_cats.iter().any(|c| c == category_hash) { + owner_cats.push_back(category_hash.clone()); + } + env.storage() + .persistent() + .set(&DataKey::OwnerCategories(record.owner.clone()), &owner_cats); + env.storage().persistent().extend_ttl( + &DataKey::OwnerCategories(record.owner.clone()), + LEDGER_BUMP, + LEDGER_BUMP, + ); + + // Track categories per IP (for cleanup on transfer/revoke) + let mut ip_cats: Vec> = env + .storage() + .persistent() + .get(&DataKey::IpCategories(ip_id)) + .unwrap_or(Vec::new(&env)); + if !ip_cats.iter().any(|c| c == category_hash) { + ip_cats.push_back(category_hash.clone()); + } + env.storage() + .persistent() + .set(&DataKey::IpCategories(ip_id), &ip_cats); + env.storage() + .persistent() + .extend_ttl(&DataKey::IpCategories(ip_id), LEDGER_BUMP, LEDGER_BUMP); + + env.events().publish( + (symbol_short!("ip_cat"), record.owner.clone()), + (ip_id, category_hash), + ); + } + + /// Returns all IP IDs within a category that belong to the given owner. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment + /// * `owner` - The owner to filter by + /// * `category_hash` - The 32-byte category hash + /// + /// # Returns + /// + /// `Vec` of IP IDs owned by `owner` in this category, or empty if none. + pub fn list_ip_by_category(env: Env, owner: Address, category_hash: BytesN<32>) -> Vec { + let all_ips: Vec = env + .storage() + .persistent() + .get(&DataKey::CategoryIps(category_hash)) + .unwrap_or(Vec::new(&env)); + + let mut result: Vec = Vec::new(&env); + for ip_id in all_ips.iter() { + if let Some(record) = env + .storage() + .persistent() + .get::(&DataKey::IpRecord(ip_id)) + { + if record.owner == owner { + result.push_back(ip_id); + } + } + } + result + } + + /// Returns all category hashes used by the given owner. + /// + /// # Arguments + /// + /// * `env` - The Soroban environment + /// * `owner` - The address to list categories for + /// + /// # Returns + /// + /// `Vec>` — all category hashes this owner has ever assigned IPs to. + pub fn list_owner_categories(env: Env, owner: Address) -> Vec> { + env.storage() + .persistent() + .get(&DataKey::OwnerCategories(owner)) + .unwrap_or(Vec::new(&env)) + } + /// Returns the current PoW difficulty (number of leading zero bits required in commitment_hash). /// Defaults to 4 if not explicitly set. pub fn get_pow_difficulty(env: Env) -> u32 { @@ -3247,893 +3412,6 @@ impl IpRegistry { .unwrap_or_else(|| panic_with_error!(&env, ContractError::DisputeNotFound)) } -<<<<<<< HEAD - // ── Issue #447: IP Commitment Staking ───────────────────────────────────── - - /// Stake XLM (represented as an i128 amount) against an IP commitment. - /// Only the IP owner may stake. One active stake per IP. - pub fn stake_commitment(env: Env, ip_id: u64, amount: i128) { - let record = require_ip_exists(&env, ip_id); - record.owner.require_auth(); - - if env.storage().persistent().has(&DataKey::IpStake(ip_id)) { - panic_with_error!(&env, ContractError::AlreadyStaked); - } - - let stake = StakeRecord { - ip_id, - owner: record.owner.clone(), - amount, - slashed: false, - }; - env.storage().persistent().set(&DataKey::IpStake(ip_id), &stake); - env.storage().persistent().extend_ttl(&DataKey::IpStake(ip_id), LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish((symbol_short!("staked"), record.owner), (ip_id, amount)); - } - - /// Stake XLM against multiple IP commitments in one call. - /// Each `ip_ids[i]` is staked with `amounts[i]` and requires the owner - /// of that IP to authorize the call. - pub fn batch_stake_commitments(env: Env, ip_ids: Vec, amounts: Vec) { - if ip_ids.len() != amounts.len() { - env.panic_with_error(Error::from_contract_error( - ContractError::BatchSizeMismatch as u32, - )); - } - - for i in 0..ip_ids.len() { - let ip_id = ip_ids.get(i).unwrap(); - let amount = amounts.get(i).unwrap(); - let record = require_ip_exists(&env, *ip_id); - record.owner.require_auth(); - - if env.storage().persistent().has(&DataKey::IpStake(*ip_id)) { - panic_with_error!(&env, ContractError::AlreadyStaked); - } - - let stake = StakeRecord { - ip_id: *ip_id, - owner: record.owner.clone(), - amount: *amount, - slashed: false, - }; - env.storage().persistent().set(&DataKey::IpStake(*ip_id), &stake); - env.storage().persistent().extend_ttl(&DataKey::IpStake(*ip_id), LEDGER_BUMP, LEDGER_BUMP); - env.events().publish((symbol_short!("staked"), record.owner), (*ip_id, *amount)); - } - } - - /// Slash the stake for an IP (admin-only). Marks the stake as slashed and - /// decrements the owner's reputation score. - pub fn slash_stake(env: Env, ip_id: u64) { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::Unauthorized)); - admin.require_auth(); - - let mut stake: StakeRecord = env - .storage() - .persistent() - .get(&DataKey::IpStake(ip_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::StakeNotFound)); - - if stake.slashed { - panic_with_error!(&env, ContractError::StakeAlreadySlashed); - } - - stake.slashed = true; - env.storage().persistent().set(&DataKey::IpStake(ip_id), &stake); - env.storage().persistent().extend_ttl(&DataKey::IpStake(ip_id), LEDGER_BUMP, LEDGER_BUMP); - - // Penalise reputation - let mut rep: ReputationRecord = env - .storage() - .persistent() - .get(&DataKey::OwnerReputation(stake.owner.clone())) - .unwrap_or(ReputationRecord { - owner: stake.owner.clone(), - score: 0, - commitments: 0, - disputes_lost: 0, - }); - rep.score = rep.score.saturating_sub(10); - rep.disputes_lost += 1; - env.storage().persistent().set(&DataKey::OwnerReputation(stake.owner.clone()), &rep); - env.storage().persistent().extend_ttl(&DataKey::OwnerReputation(stake.owner.clone()), LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish((symbol_short!("slashed"), stake.owner), ip_id); - } - - /// Unstake: remove an active (non-slashed) stake. Owner-only. - pub fn unstake(env: Env, ip_id: u64) { - let stake: StakeRecord = env - .storage() - .persistent() - .get(&DataKey::IpStake(ip_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::StakeNotFound)); - - stake.owner.require_auth(); - - if stake.slashed { - panic_with_error!(&env, ContractError::StakeAlreadySlashed); - } - - env.storage().persistent().remove(&DataKey::IpStake(ip_id)); - env.events().publish((symbol_short!("unstaked"), stake.owner), ip_id); - } - - /// Get the stake record for an IP. - pub fn get_stake(env: Env, ip_id: u64) -> Option { - env.storage().persistent().get(&DataKey::IpStake(ip_id)) - } - - // ── Issue #448: IP Commitment Reputation System ─────────────────────────── - - /// Get the reputation record for an owner. Returns a default record if none exists. - pub fn get_reputation(env: Env, owner: Address) -> ReputationRecord { - env.storage() - .persistent() - .get(&DataKey::OwnerReputation(owner.clone())) - .unwrap_or(ReputationRecord { - owner, - score: 0, - commitments: 0, - disputes_lost: 0, - }) - } - - /// Increment the commitment count and score for an owner (called internally on commit). - /// Also callable by admin to manually adjust reputation. - pub fn update_reputation(env: Env, owner: Address, score_delta: i64) { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::Unauthorized)); - admin.require_auth(); - - let mut rep: ReputationRecord = env - .storage() - .persistent() - .get(&DataKey::OwnerReputation(owner.clone())) - .unwrap_or(ReputationRecord { - owner: owner.clone(), - score: 0, - commitments: 0, - disputes_lost: 0, - }); - rep.score = rep.score.saturating_add(score_delta); - env.storage().persistent().set(&DataKey::OwnerReputation(owner.clone()), &rep); - env.storage().persistent().extend_ttl(&DataKey::OwnerReputation(owner), LEDGER_BUMP, LEDGER_BUMP); - } - - /// Adjust reputation for multiple IP commitments in one call. - /// Each `ip_ids[i]` is used to lookup the IP owner, then the corresponding - /// `score_deltas[i]` is applied to that owner's reputation. - pub fn batch_update_reputation(env: Env, ip_ids: Vec, score_deltas: Vec) { - if ip_ids.len() != score_deltas.len() { - env.panic_with_error(Error::from_contract_error( - ContractError::BatchSizeMismatch as u32, - )); - } - - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::Unauthorized)); - admin.require_auth(); - - for i in 0..ip_ids.len() { - let ip_id = ip_ids.get(i).unwrap(); - let score_delta = score_deltas.get(i).unwrap(); - let record = require_ip_exists(&env, *ip_id); - let owner = record.owner.clone(); - - let mut rep: ReputationRecord = env - .storage() - .persistent() - .get(&DataKey::OwnerReputation(owner.clone())) - .unwrap_or(ReputationRecord { - owner: owner.clone(), - score: 0, - commitments: 0, - disputes_lost: 0, - }); - rep.score = rep.score.saturating_add(*score_delta); - env.storage().persistent().set(&DataKey::OwnerReputation(owner.clone()), &rep); - env.storage().persistent().extend_ttl(&DataKey::OwnerReputation(owner), LEDGER_BUMP, LEDGER_BUMP); - } - } - - // ── Issue #449: IP Commitment Dispute Arbitration ───────────────────────── - - /// Nominate an address as an arbitrator (admin-only). - pub fn nominate_arbitrator(env: Env, arbitrator: Address) { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::Unauthorized)); - admin.require_auth(); - - let mut pool: Vec
= env - .storage() - .persistent() - .get(&DataKey::ArbitratorPool) - .unwrap_or(Vec::new(&env)); - - // Idempotent: skip if already in pool - for a in pool.iter() { - if a == arbitrator { - return; - } - } - pool.push_back(arbitrator.clone()); - env.storage().persistent().set(&DataKey::ArbitratorPool, &pool); - env.storage().persistent().extend_ttl(&DataKey::ArbitratorPool, LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish((symbol_short!("arb_nom"), admin), arbitrator); - } - - /// Open an arbitration case for an existing dispute. Admin-only. - /// Returns the new arbitration_id. - pub fn open_arbitration(env: Env, dispute_id: u64) -> u64 { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::Unauthorized)); - admin.require_auth(); - - // Ensure dispute exists - let _dispute: DisputeRecord = env - .storage() - .persistent() - .get(&DataKey::IpDisputes(dispute_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::DisputeNotFound)); - - let arb_id: u64 = env - .storage() - .persistent() - .get(&DataKey::NextArbitrationId) - .unwrap_or(1); - - let pool: Vec
= env - .storage() - .persistent() - .get(&DataKey::ArbitratorPool) - .unwrap_or(Vec::new(&env)); - - let case = ArbitrationRecord { - arbitration_id: arb_id, - dispute_id, - arbitrators: pool, - votes_owner: 0, - votes_challenger: 0, - finalized: false, - winner: None, - }; - - env.storage().persistent().set(&DataKey::ArbitrationCase(arb_id), &case); - env.storage().persistent().extend_ttl(&DataKey::ArbitrationCase(arb_id), LEDGER_BUMP, LEDGER_BUMP); - env.storage().persistent().set(&DataKey::NextArbitrationId, &(arb_id + 1)); - env.storage().persistent().extend_ttl(&DataKey::NextArbitrationId, LEDGER_BUMP, LEDGER_BUMP); - - arb_id - } - - /// Cast a vote on an arbitration case. `vote_for_owner = true` votes for the - /// IP owner; `false` votes for the challenger. Caller must be a nominated arbitrator. - pub fn vote_on_dispute(env: Env, arbitration_id: u64, voter: Address, vote_for_owner: bool) { - voter.require_auth(); - - let mut case: ArbitrationRecord = env - .storage() - .persistent() - .get(&DataKey::ArbitrationCase(arbitration_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::ArbitrationNotFound)); - - if case.finalized { - panic_with_error!(&env, ContractError::ArbitrationAlreadyFinalized); - } - - // Verify voter is in the arbitrator pool - let mut is_arbitrator = false; - for a in case.arbitrators.iter() { - if a == voter { - is_arbitrator = true; - break; - } - } - if !is_arbitrator { - panic_with_error!(&env, ContractError::NotAnArbitrator); - } - - if vote_for_owner { - case.votes_owner += 1; - } else { - case.votes_challenger += 1; - } - - env.storage().persistent().set(&DataKey::ArbitrationCase(arbitration_id), &case); - env.storage().persistent().extend_ttl(&DataKey::ArbitrationCase(arbitration_id), LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish((symbol_short!("arb_vote"), voter), (arbitration_id, vote_for_owner)); - } - - /// Finalize an arbitration case. Admin-only. Determines winner by majority vote - /// and resolves the underlying dispute. - pub fn finalize_arbitration(env: Env, arbitration_id: u64) { - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::Unauthorized)); - admin.require_auth(); - - let mut case: ArbitrationRecord = env - .storage() - .persistent() - .get(&DataKey::ArbitrationCase(arbitration_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::ArbitrationNotFound)); - - if case.finalized { - panic_with_error!(&env, ContractError::ArbitrationAlreadyFinalized); - } - - let mut dispute: DisputeRecord = env - .storage() - .persistent() - .get(&DataKey::IpDisputes(case.dispute_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::DisputeNotFound)); - - let ip_record = require_ip_exists(&env, dispute.ip_id); - - // Majority vote determines winner; ties go to the IP owner - let winner = if case.votes_challenger > case.votes_owner { - dispute.challenger.clone() - } else { - ip_record.owner.clone() - }; - - case.finalized = true; - case.winner = Some(winner.clone()); - dispute.resolved = true; - dispute.winner = Some(winner.clone()); - - env.storage().persistent().set(&DataKey::ArbitrationCase(arbitration_id), &case); - env.storage().persistent().extend_ttl(&DataKey::ArbitrationCase(arbitration_id), LEDGER_BUMP, LEDGER_BUMP); - env.storage().persistent().set(&DataKey::IpDisputes(case.dispute_id), &dispute); - env.storage().persistent().extend_ttl(&DataKey::IpDisputes(case.dispute_id), LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish((symbol_short!("arb_fin"), winner.clone()), arbitration_id); - } - - /// Get an arbitration case by ID. - pub fn get_arbitration(env: Env, arbitration_id: u64) -> ArbitrationRecord { - env.storage() - .persistent() - .get(&DataKey::ArbitrationCase(arbitration_id)) - .unwrap_or_else(|| panic_with_error!(&env, ContractError::ArbitrationNotFound)) - } - - // ── Issue: IP Commitment Renewal ─────────────────────────────────────────── - - /// Renew an expiring IP commitment by extending its on-chain TTL. - /// - /// Bumps the storage TTL of the IP record back to `LEDGER_BUMP` ledgers - /// without creating a new commitment or changing the commitment hash. - /// A renewal counter is incremented on each call. - /// - /// # Panics - /// - /// Panics if the IP does not exist, the caller is not the owner, or the IP - /// is revoked. - pub fn renew_ip(env: Env, ip_id: u64) { - let record = require_ip_exists(&env, ip_id); - record.owner.require_auth(); - require_not_revoked(&env, &record); - - env.storage() - .persistent() - .extend_ttl(&DataKey::IpRecord(ip_id), LEDGER_BUMP, LEDGER_BUMP); - - let count: u32 = env - .storage() - .persistent() - .get(&DataKey::RenewalCount(ip_id)) - .unwrap_or(0u32); - let new_count = count + 1; - env.storage() - .persistent() - .set(&DataKey::RenewalCount(ip_id), &new_count); - env.storage() - .persistent() - .extend_ttl(&DataKey::RenewalCount(ip_id), LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish( - (symbol_short!("renewed"), record.owner), - (ip_id, new_count), - ); - } - - /// Get the number of times an IP commitment has been renewed. - pub fn get_renewal_count(env: Env, ip_id: u64) -> u32 { - require_ip_exists(&env, ip_id); - env.storage() - .persistent() - .get(&DataKey::RenewalCount(ip_id)) - .unwrap_or(0u32) - } - - // ── Issue: Delegation Chains ─────────────────────────────────────────────────────────────── - - pub fn delegate_commitment_authority( - env: Env, - root_owner: Address, - delegator: Address, - delegate_address: Address, - ) { - delegator.require_auth(); - - let new_depth: u32 = if delegator == root_owner { - 0 - } else { - let stored: Option = env - .storage() - .persistent() - .get(&DataKey::DelegateDepth(delegator.clone())); - match stored { - Some(d) => d + 1, - None => panic_with_error!(&env, ContractError::Unauthorized), - } - }; - - if new_depth >= MAX_DELEGATION_DEPTH { - panic_with_error!(&env, ContractError::Unauthorized); - } - - let key = DataKey::Delegates(root_owner.clone()); - let mut delegates: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(&env)); - - for i in 0..delegates.len() { - if delegates.get(i).unwrap().delegate == delegate_address { - return; - } - } - - delegates.push_back(DelegationRecord { - delegate: delegate_address.clone(), - depth: new_depth, - }); - env.storage().persistent().set(&key, &delegates); - env.storage() - .persistent() - .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); - - env.storage() - .persistent() - .set(&DataKey::DelegateDepth(delegate_address.clone()), &new_depth); - env.storage().persistent().extend_ttl( - &DataKey::DelegateDepth(delegate_address.clone()), - LEDGER_BUMP, - LEDGER_BUMP, - ); - - env.events().publish( - (symbol_short!("delegated"), root_owner), - (delegate_address, new_depth), - ); - } - - pub fn revoke_delegation(env: Env, owner: Address, delegate_address: Address) { - owner.require_auth(); - - let key = DataKey::Delegates(owner.clone()); - let delegates: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(&env)); - - let mut updated = Vec::new(&env); - for i in 0..delegates.len() { - let rec = delegates.get(i).unwrap(); - if rec.delegate != delegate_address { - updated.push_back(rec); - } - } - - env.storage().persistent().set(&key, &updated); - env.storage() - .persistent() - .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); - - env.storage() - .persistent() - .remove(&DataKey::DelegateDepth(delegate_address.clone())); - - env.events().publish( - (symbol_short!("revoke"), owner), - delegate_address, - ); - } - - pub fn is_delegate(env: Env, owner: Address, delegate_address: Address) -> bool { - Self::is_delegate_in_chain(&env, &owner, &delegate_address, 0) - } - - pub fn commit_ip_delegated( - env: Env, - owner: Address, - commitment_hash: BytesN<32>, - pow_difficulty: u32, - ) -> u64 { - owner.require_auth(); - - let key = DataKey::Delegates(owner.clone()); - let delegates: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(&env)); - if delegates.is_empty() { - panic_with_error!(&env, ContractError::Unauthorized); - } - - require_non_zero_commitment(&env, &commitment_hash); - require_unique_commitment(&env, &commitment_hash); - require_pow(&env, &commitment_hash, pow_difficulty); - - let id: u64 = env - .storage() - .persistent() - .get(&DataKey::NextId) - .unwrap_or(1); - - let record = IpRecord { - ip_id: id, - owner: owner.clone(), - commitment_hash: commitment_hash.clone(), - timestamp: env.ledger().timestamp(), - revoked: false, - co_owners: Vec::new(&env), - parent_ip_id: None, - notary_signature: None, - }; - - env.storage() - .persistent() - .set(&DataKey::IpRecord(id), &record); - env.storage() - .persistent() - .extend_ttl(&DataKey::IpRecord(id), LEDGER_BUMP, LEDGER_BUMP); - - let mut ids: Vec = env - .storage() - .persistent() - .get(&DataKey::OwnerIps(owner.clone())) - .unwrap_or(Vec::new(&env)); - ids.push_back(id); - env.storage() - .persistent() - .set(&DataKey::OwnerIps(owner.clone()), &ids); - env.storage().persistent().extend_ttl( - &DataKey::OwnerIps(owner.clone()), - LEDGER_BUMP, - LEDGER_BUMP, - ); - - env.storage() - .persistent() - .set(&DataKey::CommitmentOwner(commitment_hash.clone()), &owner); - env.storage().persistent().extend_ttl( - &DataKey::CommitmentOwner(commitment_hash.clone()), - LEDGER_BUMP, - LEDGER_BUMP, - ); - - env.storage().persistent().set(&DataKey::NextId, &(id + 1)); - env.storage() - .persistent() - .extend_ttl(&DataKey::NextId, LEDGER_BUMP, LEDGER_BUMP); - - env.events().publish( - (symbol_short!("ip_commit"), owner.clone()), - (id, record.timestamp), - ); - - Self::update_commitment_checksum(&env); - - id - } - - fn is_delegate_in_chain( - env: &Env, - root: &Address, - candidate: &Address, - depth: u32, - ) -> bool { - if depth >= MAX_DELEGATION_DEPTH { - return false; - } - let key = DataKey::Delegates(root.clone()); - let delegates: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(env)); - - for i in 0..delegates.len() { - let rec = delegates.get(i).unwrap(); - if &rec.delegate == candidate { - return true; - } - if Self::is_delegate_in_chain(env, &rec.delegate, candidate, depth + 1) { - return true; - } - } - false - } - - // ── Issue #458: Batch Verification with ZK Proofs ───────────────────────── - - /// Verify multiple IP commitments in a single call. - /// - /// For each request, recomputes `sha256(secret || blinding_factor)` and - /// checks it against the stored commitment hash — the same ZK-style proof - /// used by `verify_commitment`. Returns one `VerifyResult` per request in - /// the same order as the input. - /// - /// # Arguments - /// - /// * `requests` - Vec of `VerifyRequest` (ip_id, secret, blinding_factor) - /// - /// # Returns - /// - /// `Vec` — one entry per request with `valid: true/false`. - /// - /// # Panics - /// - /// Panics with `IpNotFound` if any `ip_id` does not exist. - pub fn batch_verify_commitments(env: Env, requests: Vec) -> Vec { - let mut results = Vec::new(&env); - for req in requests.iter() { - let record = require_ip_exists(&env, req.ip_id); - let mut preimage = Bytes::new(&env); - preimage.append(&req.secret.into()); - preimage.append(&req.blinding_factor.into()); - let computed: BytesN<32> = env.crypto().sha256(&preimage).into(); - results.push_back(VerifyResult { - ip_id: req.ip_id, - valid: record.commitment_hash == computed, - }); - } - results - } - - // ── Issue #459: Hierarchical Storage ───────────────────────────────────── - - /// Assign an IP to a category within the owner's hierarchy. - /// - /// Stores the IP under `owner → category_hash → ip_ids` for fast - /// category-scoped queries. Only the IP owner may call this. - /// - /// # Panics - /// - /// Panics with `IpNotFound` if the IP does not exist, or auth error if - /// the caller is not the owner. - pub fn assign_ip_to_category(env: Env, ip_id: u64, category_hash: BytesN<32>) { - let record = require_ip_exists(&env, ip_id); - record.owner.require_auth(); - - let owner = record.owner.clone(); - let node_key = DataKey::HierarchyNode(owner.clone(), category_hash.clone()); - - let mut ids: Vec = env - .storage() - .persistent() - .get(&node_key) - .unwrap_or(Vec::new(&env)); - // Avoid duplicates - for existing in ids.iter() { - if existing == ip_id { - return; - } - } - ids.push_back(ip_id); - env.storage().persistent().set(&node_key, &ids); - env.storage() - .persistent() - .extend_ttl(&node_key, LEDGER_BUMP, LEDGER_BUMP); - - // Track which categories this owner has - let cat_key = DataKey::OwnerCategories(owner.clone()); - let mut cats: Vec> = env - .storage() - .persistent() - .get(&cat_key) - .unwrap_or(Vec::new(&env)); - let mut found = false; - for c in cats.iter() { - if c == category_hash { - found = true; - break; - } - } - if !found { - cats.push_back(category_hash.clone()); - env.storage().persistent().set(&cat_key, &cats); - env.storage() - .persistent() - .extend_ttl(&cat_key, LEDGER_BUMP, LEDGER_BUMP); - } - - env.events().publish( - (symbol_short!("ip_cat"), owner), - (ip_id, category_hash), - ); - } - - /// List all IP IDs for an owner within a specific category. - /// - /// Returns an empty vector if the owner has no IPs in that category. - pub fn list_ip_by_category(env: Env, owner: Address, category_hash: BytesN<32>) -> Vec { - env.storage() - .persistent() - .get(&DataKey::HierarchyNode(owner, category_hash)) - .unwrap_or(Vec::new(&env)) - } - - /// List all category hashes registered for an owner. - /// - /// Returns an empty vector if the owner has no categories. - pub fn list_owner_categories(env: Env, owner: Address) -> Vec> { - env.storage() - .persistent() - .get(&DataKey::OwnerCategories(owner)) - .unwrap_or(Vec::new(&env)) - } - - // ── Issue #460: Batch Delegation ────────────────────────────────────────── - - /// Delegate multiple addresses to a root owner's commitment authority in one call. - /// - /// Equivalent to calling `delegate_commitment_authority` for each address in - /// `delegates`. Requires auth from `delegator`. The delegator must be the - /// `root_owner` or an existing registered delegate to sub-delegate. - /// Duplicate addresses are silently skipped. - /// - /// # Panics - /// - /// Panics with `Unauthorized` if the delegator is not authorized or if - /// the delegation depth limit would be exceeded. - pub fn batch_delegate_commitment( - env: Env, - root_owner: Address, - delegator: Address, - delegates: Vec
, - ) { - delegator.require_auth(); - - let new_depth: u32 = if delegator == root_owner { - 0 - } else { - let stored: Option = env - .storage() - .persistent() - .get(&DataKey::DelegateDepth(delegator.clone())); - match stored { - Some(d) => d + 1, - None => panic_with_error!(&env, ContractError::Unauthorized), - } - }; - - if new_depth >= MAX_DELEGATION_DEPTH { - panic_with_error!(&env, ContractError::Unauthorized); - } - - let key = DataKey::Delegates(root_owner.clone()); - let mut stored_delegates: Vec = env - .storage() - .persistent() - .get(&key) - .unwrap_or(Vec::new(&env)); - - for delegate_address in delegates.iter() { - let mut already_exists = false; - for i in 0..stored_delegates.len() { - if stored_delegates.get(i).unwrap().delegate == delegate_address { - already_exists = true; - break; - } - } - if already_exists { - continue; - } - - stored_delegates.push_back(DelegationRecord { - delegate: delegate_address.clone(), - depth: new_depth, - }); - - env.storage() - .persistent() - .set(&DataKey::DelegateDepth(delegate_address.clone()), &new_depth); - env.storage().persistent().extend_ttl( - &DataKey::DelegateDepth(delegate_address.clone()), - LEDGER_BUMP, - LEDGER_BUMP, - ); - - env.events().publish( - (symbol_short!("delegated"), root_owner.clone()), - (delegate_address, new_depth), - ); - } - - env.storage().persistent().set(&key, &stored_delegates); - env.storage() - .persistent() - .extend_ttl(&key, LEDGER_BUMP, LEDGER_BUMP); - } - - // ── Issue #461: Batch Renewal ───────────────────────────────────────────── - - /// Renew multiple IP commitments in a single transaction. - /// - /// Requires auth from `owner`. Each IP must exist, belong to `owner`, and - /// not be revoked. Extends storage TTL for every IP and increments each - /// IP's renewal counter. - /// - /// # Panics - /// - /// Panics with `IpNotFound` if any ID does not exist, `Unauthorized` if - /// any IP is not owned by `owner`, or `IpAlreadyRevoked` if any IP is revoked. - pub fn batch_renew_ip(env: Env, owner: Address, ip_ids: Vec) { - owner.require_auth(); - - for ip_id in ip_ids.iter() { - let record = require_ip_exists(&env, ip_id); - - if record.owner != owner { - panic_with_error!(&env, ContractError::Unauthorized); - } - - require_not_revoked(&env, &record); - - env.storage() - .persistent() - .extend_ttl(&DataKey::IpRecord(ip_id), LEDGER_BUMP, LEDGER_BUMP); - - let count: u32 = env - .storage() - .persistent() - .get(&DataKey::RenewalCount(ip_id)) - .unwrap_or(0u32); - let new_count = count + 1; - env.storage() - .persistent() - .set(&DataKey::RenewalCount(ip_id), &new_count); - env.storage().persistent().extend_ttl( - &DataKey::RenewalCount(ip_id), - LEDGER_BUMP, - LEDGER_BUMP, - ); - - env.events().publish( - (symbol_short!("renewed"), owner.clone()), - (ip_id, new_count), - ); -======= // ── IP Expiry & Grace Period ─────────────────────────────────────────────── /// Set expiry and grace period for an IP. Owner-only. @@ -4193,7 +3471,6 @@ impl IpRegistry { ); } } ->>>>>>> 6af3b64dbbf925a3937a2822b5ba7f5180df96ee } } } diff --git a/contracts/ip_registry/src/test.rs b/contracts/ip_registry/src/test.rs index cfe2c49..f033116 100644 --- a/contracts/ip_registry/src/test.rs +++ b/contracts/ip_registry/src/test.rs @@ -4,7 +4,7 @@ mod tests { use soroban_sdk::contractclient; use soroban_sdk::testutils::Address as TestAddress; use soroban_sdk::testutils::Events; - use soroban_sdk::{symbol_short, Address, BytesN, Env, IntoVal, TryFromVal, Vec}; + use soroban_sdk::{symbol_short, Address, Bytes, BytesN, Env, IntoVal, TryFromVal, Vec}; use crate::types::REVOKE_TOPIC; use crate::types::TRANSFER_TOPIC; @@ -98,6 +98,11 @@ mod tests { fn revoke_ip_access(env: Env, ip_id: u64, grantee: Address); fn get_ip_access_grants(env: Env, ip_id: u64) -> Vec; fn check_ip_access(env: Env, ip_id: u64, grantee: Address, required_level: u32) -> bool; + // Issue #459: Category methods + fn validate_category_path(env: Env, path: Bytes) -> BytesN<32>; + fn assign_ip_to_category(env: Env, ip_id: u64, category_hash: BytesN<32>); + fn list_ip_by_category(env: Env, owner: Address, category_hash: BytesN<32>) -> Vec; + fn list_owner_categories(env: Env, owner: Address) -> Vec>; } #[test] @@ -2317,6 +2322,269 @@ mod tests { assert_eq!(anon_ids.get(1).unwrap(), 3); assert_eq!(id4, 4); } + + // ── Category Tests (Issue #459) ────────────────────────────────────────── + + /// Basic assign and list within a category. + #[test] + fn test_assign_ip_to_category_basic() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[1u8; 32]), &0u32); + let cat = BytesN::from_array(&env, &[2u8; 32]); + + client.assign_ip_to_category(&ip_id, &cat); + + let ips = client.list_ip_by_category(&owner, &cat); + assert_eq!(ips.len(), 1); + assert_eq!(ips.get(0).unwrap(), ip_id); + } + + /// Access control: `assign_ip_to_category` calls `record.owner.require_auth()`, + /// which the Soroban host enforces at the protocol level. In test environments + /// with `mock_all_auths()` the check is bypassed, so the effective security + /// guarantee is the `require_auth()` call in the contract — not this test. + + /// Assigning the same category twice is idempotent. + #[test] + fn test_assign_ip_to_category_idempotent() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[1u8; 32]), &0u32); + let cat = BytesN::from_array(&env, &[2u8; 32]); + + client.assign_ip_to_category(&ip_id, &cat); + client.assign_ip_to_category(&ip_id, &cat); // second time — no-op + + let ips = client.list_ip_by_category(&owner, &cat); + assert_eq!(ips.len(), 1); + } + + /// Multiple IPs can be assigned to the same category. + #[test] + fn test_multiple_ips_same_category() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let id1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[1u8; 32]), &0u32); + let id2 = client.commit_ip(&owner, &BytesN::from_array(&env, &[2u8; 32]), &0u32); + let id3 = client.commit_ip(&owner, &BytesN::from_array(&env, &[3u8; 32]), &0u32); + let cat = BytesN::from_array(&env, &[0xCu8; 32]); + + client.assign_ip_to_category(&id1, &cat); + client.assign_ip_to_category(&id2, &cat); + client.assign_ip_to_category(&id3, &cat); + + let ips = client.list_ip_by_category(&owner, &cat); + assert_eq!(ips.len(), 3); + + let mut ids: Vec = Vec::new(&env); + ids.push_back(id1); + ids.push_back(id2); + ids.push_back(id3); + for id in ids.iter() { + assert!(ips.iter().any(|x| x == id)); + } + } + + /// list_ip_by_category returns empty for unknown category. + #[test] + fn test_list_ip_by_category_empty() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let cat = BytesN::from_array(&env, &[0x99u8; 32]); + + let ips = client.list_ip_by_category(&owner, &cat); + assert_eq!(ips.len(), 0); + } + + /// list_owner_categories returns all categories used by the owner. + #[test] + fn test_list_owner_categories() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let cat1 = BytesN::from_array(&env, &[0x10u8; 32]); + let cat2 = BytesN::from_array(&env, &[0x20u8; 32]); + let cat3 = BytesN::from_array(&env, &[0x30u8; 32]); + + let ip1 = client.commit_ip(&owner, &BytesN::from_array(&env, &[1u8; 32]), &0u32); + let ip2 = client.commit_ip(&owner, &BytesN::from_array(&env, &[2u8; 32]), &0u32); + + client.assign_ip_to_category(&ip1, &cat1); + client.assign_ip_to_category(&ip1, &cat2); + client.assign_ip_to_category(&ip2, &cat3); + + let cats = client.list_owner_categories(&owner); + assert_eq!(cats.len(), 3); + assert!(cats.iter().any(|c| c == cat1)); + assert!(cats.iter().any(|c| c == cat2)); + assert!(cats.iter().any(|c| c == cat3)); + } + + /// list_owner_categories returns empty for unknown owner. + #[test] + fn test_list_owner_categories_empty() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let stranger =
::generate(&env); + let cats = client.list_owner_categories(&stranger); + assert_eq!(cats.len(), 0); + } + + /// Category hierarchy depth: 5 levels is allowed. + #[test] + fn test_validate_category_path_max_depth_allowed() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + // 5 levels: a/b/c/d/e + let path = Bytes::from_array(&env, &[b'a', b'/', b'b', b'/', b'c', b'/', b'd', b'/', b'e']); + let hash = client.validate_category_path(&path); + // Should not panic; hash should be non-zero + assert_ne!(hash, BytesN::from_array(&env, &[0u8; 32])); + } + + /// Category hierarchy depth: 6 levels panics. + #[test] + #[should_panic(expected = "CategoryDepthExceeded")] + fn test_validate_category_path_depth_exceeded() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + // 6 levels: a/b/c/d/e/f + let path = Bytes::from_array( + &env, + &[b'a', b'/', b'b', b'/', b'c', b'/', b'd', b'/', b'e', b'/', b'f'], + ); + client.validate_category_path(&path); + } + + /// Path traversal ".." is rejected. + #[test] + #[should_panic(expected = "CategoryPathTraversal")] + fn test_validate_category_path_traversal_dotdot() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let path = Bytes::from_array(&env, &[b'a', b'/', b'.', b'.']); + client.validate_category_path(&path); + } + + /// Leading slash is rejected. + #[test] + #[should_panic(expected = "CategoryPathTraversal")] + fn test_validate_category_path_leading_slash() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let path = Bytes::from_array(&env, &[b'/', b'a', b'/', b'b']); + client.validate_category_path(&path); + } + + /// Trailing slash is rejected. + #[test] + #[should_panic(expected = "CategoryPathTraversal")] + fn test_validate_category_path_trailing_slash() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let path = Bytes::from_array(&env, &[b'a', b'/', b'b', b'/']); + client.validate_category_path(&path); + } + + /// Double slash is rejected. + #[test] + #[should_panic(expected = "CategoryPathTraversal")] + fn test_validate_category_path_double_slash() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let path = Bytes::from_array(&env, &[b'a', b'/', b'/', b'b']); + client.validate_category_path(&path); + } + + /// Empty path is rejected. + #[test] + #[should_panic(expected = "CategoryPathTraversal")] + fn test_validate_category_path_empty() { + let env = Env::default(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let path = Bytes::new(&env); + client.validate_category_path(&path); + } + + /// Zero category hash is rejected on assign. + #[test] + #[should_panic(expected = "InvalidCategoryHash")] + fn test_assign_ip_to_category_zero_hash() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner =
::generate(&env); + let ip_id = client.commit_ip(&owner, &BytesN::from_array(&env, &[1u8; 32]), &0u32); + let zero_hash = BytesN::from_array(&env, &[0u8; 32]); + + client.assign_ip_to_category(&ip_id, &zero_hash); + } + + /// Category queries filter by owner correctly. + #[test] + fn test_list_ip_by_category_filters_by_owner() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(crate::IpRegistry, ()); + let client = IpRegistryClient::new(&env, &contract_id); + + let owner1 =
::generate(&env); + let owner2 =
::generate(&env); + let cat = BytesN::from_array(&env, &[0xCu8; 32]); + + let ip1 = client.commit_ip(&owner1, &BytesN::from_array(&env, &[1u8; 32]), &0u32); + let ip2 = client.commit_ip(&owner2, &BytesN::from_array(&env, &[2u8; 32]), &0u32); + + client.assign_ip_to_category(&ip1, &cat); + client.assign_ip_to_category(&ip2, &cat); + + let owner1_ips = client.list_ip_by_category(&owner1, &cat); + assert_eq!(owner1_ips.len(), 1); + assert_eq!(owner1_ips.get(0).unwrap(), ip1); + + let owner2_ips = client.list_ip_by_category(&owner2, &cat); + assert_eq!(owner2_ips.len(), 1); + assert_eq!(owner2_ips.get(0).unwrap(), ip2); + } } // ── Expiry & Grace Period Tests ─────────────────────────────────────────────── diff --git a/contracts/ip_registry/src/types.rs b/contracts/ip_registry/src/types.rs index d98b838..59b2cab 100644 --- a/contracts/ip_registry/src/types.rs +++ b/contracts/ip_registry/src/types.rs @@ -38,6 +38,8 @@ pub enum DataKey { CommitmentOwner(BytesN<32>), // tracks which owner already holds a commitment hash Admin, CategoryIps(BytesN<32>), // maps category hash -> Vec of IP IDs + OwnerCategories(Address), // maps owner -> Vec> of category hashes they use + IpCategories(u64), // maps ip_id -> Vec> of assigned category hashes 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 diff --git a/contracts/ip_registry/src/validation.rs b/contracts/ip_registry/src/validation.rs index 79b849d..169987d 100644 --- a/contracts/ip_registry/src/validation.rs +++ b/contracts/ip_registry/src/validation.rs @@ -4,7 +4,7 @@ //! and ensure consistent error handling across the contract. use crate::{ContractError, DataKey, IpRecord}; -use soroban_sdk::{symbol_short, Address, BytesN, Env, Error}; +use soroban_sdk::{symbol_short, Address, Bytes, BytesN, Env, Error}; /// Retrieves an IP record by ID, panicking if not found. /// @@ -169,6 +169,96 @@ pub fn require_pow(env: &Env, commitment_hash: &BytesN<32>, difficulty: u32) { } } +/// Validates that a category hash is non-zero. +/// +/// # Panics +/// +/// Panics with `InvalidCategoryHash` if the hash is all zeros. +pub fn require_valid_category_hash(env: &Env, category_hash: &BytesN<32>) { + if category_hash == &BytesN::from_array(env, &[0u8; 32]) { + env.panic_with_error(Error::from_contract_error( + ContractError::InvalidCategoryHash as u32, + )); + } +} + +/// Validates a UTF-8 category path string for max depth and path traversal. +/// +/// Rules: +/// - Max depth is `MAX_CATEGORY_DEPTH` segments separated by `/`. +/// - No empty segments (e.g., `//`, leading/trailing `/`). +/// - No `..` path traversal segments. +/// - Path length must be between 1 and 512 bytes. +/// +/// # Returns +/// +/// `sha256(path)` to use as the category_hash for storage. +/// +/// # Panics +/// +/// Panics with `CategoryDepthExceeded` or `CategoryPathTraversal` on validation failure. +pub fn validate_category_path(env: &Env, path: &Bytes) -> BytesN<32> { + let len = path.len(); + if len == 0 || len > 512 { + env.panic_with_error(Error::from_contract_error( + ContractError::CategoryPathTraversal as u32, + )); + } + + let mut depth: u32 = 0; + let mut seg_start: u32 = 0; + + for i in 0..len { + if path.get(i).unwrap() == b'/' { + // Empty segment (leading slash, double slash) + if i == seg_start { + env.panic_with_error(Error::from_contract_error( + ContractError::CategoryPathTraversal as u32, + )); + } + // Check for ".." segment + let seg_len = i - seg_start; + if seg_len == 2 + && path.get(seg_start).unwrap() == b'.' + && path.get(seg_start + 1).unwrap() == b'.' + { + env.panic_with_error(Error::from_contract_error( + ContractError::CategoryPathTraversal as u32, + )); + } + depth += 1; + seg_start = i + 1; + } + } + + // Validate last segment + if seg_start >= len { + // Trailing slash + env.panic_with_error(Error::from_contract_error( + ContractError::CategoryPathTraversal as u32, + )); + } + let last_seg_len = len - seg_start; + if last_seg_len == 2 + && path.get(seg_start).unwrap() == b'.' + && path.get(seg_start + 1).unwrap() == b'.' + { + env.panic_with_error(Error::from_contract_error( + ContractError::CategoryPathTraversal as u32, + )); + } + + // depth = number of separators, so total segments = depth + 1 + let total_segments = depth + 1; + if total_segments > crate::MAX_CATEGORY_DEPTH { + env.panic_with_error(Error::from_contract_error( + ContractError::CategoryDepthExceeded as u32, + )); + } + + env.crypto().sha256(path).into() +} + /// Calculate commitment strength (0-100 scale) based on secret length and PoW difficulty. /// Strength = min(100, (secret_length * 2) + (pow_difficulty * 3)) #[allow(dead_code)] diff --git a/docs/api-reference.md b/docs/api-reference.md index f6dc811..c54e7d3 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -600,6 +600,9 @@ if registry.is_ip_owner(&ip_id, &address) { | `CommitmentAlreadyRegistered` | 3 | Commitment hash already registered | | `IpAlreadyRevoked` | 4 | IP is already revoked | | `UnauthorizedUpgrade` | 5 | Caller is not admin (upgrade only) | +| `InvalidCategoryHash` | 29 | Category hash is all zeros or malformed | +| `CategoryDepthExceeded` | 30 | Category path exceeds 5 levels | +| `CategoryPathTraversal` | 31 | Category path contains traversal or empty segments | --- @@ -614,6 +617,15 @@ Emitted when a new IP is committed. --- +### `ip_cat` + +Emitted when an IP is assigned to a category (Issue #459). + +**Topics:** `(symbol_short!("ip_cat"), owner: Address)` +**Data:** `(ip_id: u64, category_hash: BytesN<32>)` + +--- + ## Storage Keys | Key | Type | Description | @@ -623,6 +635,9 @@ Emitted when a new IP is committed. | `NextId` | Persistent | Next available IP ID (monotonic counter) | | `CommitmentOwner(BytesN<32>)` | Persistent | Maps commitment hash → owner (duplicate detection) | | `Admin` | Persistent | Admin address for upgrades | +| `CategoryIps(BytesN<32>)` | Persistent | Maps category hash → Vec of IP IDs | +| `OwnerCategories(Address)` | Persistent | Maps owner → Vec of category hashes they use | +| `IpCategories(u64)` | Persistent | Maps IP ID → Vec of assigned category hashes | ---