Skip to content

Commit a8a88b0

Browse files
Merge pull request #938 from Zalgia-Saga/fix/ip-registry-807-810
Fix/ip registry 807 810
2 parents 7082dda + 2e96f40 commit a8a88b0

5 files changed

Lines changed: 265 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ jobs:
3333
restore-keys: |
3434
${{ runner.os }}-cargo-
3535
36+
- name: Check for stale merge-conflict FIXMEs
37+
# benchmarks.rs and invariant_tests.rs are intentionally still disabled
38+
# (tracked separately) and carry accurate FIXMEs; everything else must
39+
# be clean, so a re-enabled module never ships with a stale FIXME again.
40+
run: |
41+
if grep -rn "FIXME.*merge conflict" --include='*.rs' . \
42+
| grep -v 'benchmarks\.rs\|invariant_tests\.rs'; then
43+
echo "Found FIXME comments referencing unresolved merge conflicts. Resolve or remove them." >&2
44+
exit 1
45+
fi
46+
3647
- name: Build
3748
run: cargo build --workspace --verbose
3849

contracts/ip_registry/src/lib.rs

Lines changed: 109 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ use types::*;
1616

1717
mod zk_commitment;
1818

19-
// FIXME: test.rs has compilation errors from merge conflict - re-enable after fix
20-
// FIXME: test.rs has pre-existing compilation errors from a merge conflict - fix before enabling
2119
#[cfg(test)]
2220
mod test;
2321

@@ -1866,8 +1864,24 @@ impl IpRegistry {
18661864
) -> bool {
18671865
let record = require_ip_exists(&env, ip_id);
18681866

1869-
// Reject if expired
1870-
// Expiry check removed - field not in types
1867+
// Emit EXPIRY_TOPIC exactly once per expiry transition so off-chain
1868+
// indexers can cheaply detect an IP crossing into its grace period.
1869+
if record.expiry_timestamp != 0 && env.ledger().timestamp() >= record.expiry_timestamp {
1870+
let already_notified: bool = env
1871+
.storage()
1872+
.persistent()
1873+
.get(&DataKey::ExpiryNotified(ip_id))
1874+
.unwrap_or(false);
1875+
if !already_notified {
1876+
env.events().publish(
1877+
(EXPIRY_TOPIC, ip_id),
1878+
(record.owner.clone(), record.expiry_timestamp),
1879+
);
1880+
env.storage()
1881+
.persistent()
1882+
.set(&DataKey::ExpiryNotified(ip_id), &true);
1883+
}
1884+
}
18711885

18721886
// Concatenate secret || blinding_factor into Bytes, then SHA256
18731887
let mut preimage = soroban_sdk::Bytes::new(&env);
@@ -2236,9 +2250,12 @@ impl IpRegistry {
22362250
.get(&DataKey::SuggestedPrice(ip_id))
22372251
}
22382252

2239-
/// Add a co-owner to an IP. Owner-only.
2253+
/// Add a co-owner to an IP with an explicit ownership percentage. Owner-only.
22402254
/// Co-owners can verify commitments but cannot transfer or revoke the IP.
2241-
pub fn add_co_owner(env: Env, ip_id: u64, co_owner: Address) {
2255+
///
2256+
/// `percentage` is deducted from the current owner's remaining share, so the
2257+
/// full cap table (owner + co-owners) always sums to exactly 100.
2258+
pub fn add_co_owner(env: Env, ip_id: u64, co_owner: Address, percentage: u32) {
22422259
let mut record = require_ip_exists(&env, ip_id);
22432260
record.owner.require_auth();
22442261

@@ -2249,19 +2266,63 @@ impl IpRegistry {
22492266
}
22502267
}
22512268

2269+
let mut shares: Vec<OwnershipShare> = env
2270+
.storage()
2271+
.persistent()
2272+
.get(&DataKey::OwnershipShares(ip_id))
2273+
.unwrap_or_else(|| {
2274+
let mut v = Vec::new(&env);
2275+
v.push_back(OwnershipShare {
2276+
address: record.owner.clone(),
2277+
percentage: 100,
2278+
});
2279+
v
2280+
});
2281+
2282+
let owner_idx = shares
2283+
.iter()
2284+
.position(|s| s.address == record.owner)
2285+
.unwrap_or_else(|| {
2286+
env.panic_with_error(Error::from_contract_error(
2287+
ContractError::InvalidShareTotal as u32,
2288+
))
2289+
}) as u32;
2290+
let mut owner_share = shares.get(owner_idx).unwrap();
2291+
if percentage == 0 || percentage > owner_share.percentage {
2292+
env.panic_with_error(Error::from_contract_error(
2293+
ContractError::InvalidOwnershipPercentage as u32,
2294+
));
2295+
}
2296+
owner_share.percentage -= percentage;
2297+
shares.set(owner_idx, owner_share);
2298+
shares.push_back(OwnershipShare {
2299+
address: co_owner.clone(),
2300+
percentage,
2301+
});
2302+
require_valid_share_total(&env, &shares);
2303+
22522304
record.co_owners.push_back(co_owner.clone());
22532305
env.storage()
22542306
.persistent()
22552307
.set(&DataKey::IpRecord(ip_id), &record);
22562308
env.storage()
22572309
.persistent()
22582310
.extend_ttl(&DataKey::IpRecord(ip_id), LEDGER_BUMP, LEDGER_BUMP);
2311+
env.storage()
2312+
.persistent()
2313+
.set(&DataKey::OwnershipShares(ip_id), &shares);
2314+
env.storage().persistent().extend_ttl(
2315+
&DataKey::OwnershipShares(ip_id),
2316+
LEDGER_BUMP,
2317+
LEDGER_BUMP,
2318+
);
22592319

22602320
env.events()
22612321
.publish((symbol_short!("co_add"), record.owner), (ip_id, co_owner));
22622322
}
22632323

22642324
/// Remove a co-owner from an IP. Owner-only.
2325+
/// The removed co-owner's percentage is returned to the primary owner's share.
22652326
pub fn remove_co_owner(env: Env, ip_id: u64, co_owner: Address) {
22662327
let mut record = require_ip_exists(&env, ip_id);
22672328
record.owner.require_auth();
@@ -2278,11 +2339,47 @@ impl IpRegistry {
22782339
LEDGER_BUMP,
22792340
);
22802341

2342+
if let Some(mut shares) = env
2343+
.storage()
2344+
.persistent()
2345+
.get::<DataKey, Vec<OwnershipShare>>(&DataKey::OwnershipShares(ip_id))
2346+
{
2347+
if let Some(share_idx) = shares.iter().position(|s| s.address == co_owner) {
2348+
let removed = shares.get(share_idx as u32).unwrap();
2349+
shares.remove(share_idx as u32);
2350+
if let Some(owner_idx) = shares.iter().position(|s| s.address == record.owner)
2351+
{
2352+
let mut owner_share = shares.get(owner_idx as u32).unwrap();
2353+
owner_share.percentage += removed.percentage;
2354+
shares.set(owner_idx as u32, owner_share);
2355+
}
2356+
require_valid_share_total(&env, &shares);
2357+
env.storage()
2358+
.persistent()
2359+
.set(&DataKey::OwnershipShares(ip_id), &shares);
2360+
env.storage().persistent().extend_ttl(
2361+
&DataKey::OwnershipShares(ip_id),
2362+
LEDGER_BUMP,
2363+
LEDGER_BUMP,
2364+
);
2365+
}
2366+
}
2367+
22812368
env.events()
22822369
.publish((symbol_short!("co_rem"), record.owner), (ip_id, co_owner));
22832370
}
22842371
}
22852372

2373+
/// Get the current ownership cap table (owner + co-owners) for an IP.
2374+
/// Returns an empty vector if no co-owners have ever been added.
2375+
pub fn get_ownership_shares(env: Env, ip_id: u64) -> Vec<OwnershipShare> {
2376+
require_ip_exists(&env, ip_id);
2377+
env.storage()
2378+
.persistent()
2379+
.get(&DataKey::OwnershipShares(ip_id))
2380+
.unwrap_or_else(|| Vec::new(&env))
2381+
}
2382+
22862383
/// Create a new version of an existing IP commitment.
22872384
///
22882385
/// This function allows an IP owner to create a new version of their IP
@@ -5515,6 +5612,9 @@ impl IpRegistry {
55155612
env.storage()
55165613
.persistent()
55175614
.extend_ttl(&DataKey::IpRecord(ip_id), LEDGER_BUMP, LEDGER_BUMP);
5615+
env.storage()
5616+
.persistent()
5617+
.remove(&DataKey::ExpiryNotified(ip_id));
55185618
}
55195619

55205620
/// Renew an IP commitment's expiry. Owner-only.
@@ -5537,6 +5637,9 @@ impl IpRegistry {
55375637
env.storage()
55385638
.persistent()
55395639
.extend_ttl(&DataKey::IpRecord(ip_id), LEDGER_BUMP, LEDGER_BUMP);
5640+
env.storage()
5641+
.persistent()
5642+
.remove(&DataKey::ExpiryNotified(ip_id));
55405643

55415644
env.events().publish(
55425645
(symbol_short!("ip_renew"), record.owner),

contracts/ip_registry/src/test.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ mod tests {
197197
fn set_ip_expiry(env: Env, ip_id: u64, expiry_timestamp: u64, grace_period_seconds: u64);
198198
fn renew_ip_commitment(env: Env, ip_id: u64, new_expiry: u64) -> bool;
199199
fn cleanup_expired_ips(env: Env, ip_ids: Vec<u64>);
200+
// Issue #809
201+
fn add_co_owner(env: Env, ip_id: u64, co_owner: Address, percentage: u32);
202+
fn remove_co_owner(env: Env, ip_id: u64, co_owner: Address);
203+
fn get_ownership_shares(env: Env, ip_id: u64) -> Vec<crate::OwnershipShare>;
200204
}
201205

202206
#[test]
@@ -2760,6 +2764,92 @@ mod expiry_tests {
27602764
let events = env.events().all();
27612765
assert!(events.events().len() >= 1, "ip_clean event must be emitted");
27622766
}
2767+
2768+
#[test]
2769+
fn test_verify_commitment_emits_expiry_event_exactly_once() {
2770+
let (env, client, _owner, ip_id) = setup();
2771+
let now = env.ledger().timestamp();
2772+
client.set_ip_expiry(&ip_id, &(now + 100), &500);
2773+
2774+
// Advance past expiry but still within the grace period.
2775+
env.ledger().with_mut(|l| l.timestamp = now + 150);
2776+
2777+
let secret = BytesN::from_array(&env, &[1u8; 32]);
2778+
let blinding = BytesN::from_array(&env, &[2u8; 32]);
2779+
2780+
// No events emitted yet (set_ip_expiry does not publish).
2781+
assert_eq!(env.events().all().events().len(), 0);
2782+
2783+
// Calling verify_commitment repeatedly must only fire the expiry
2784+
// event once for this expiry transition.
2785+
client.verify_commitment(&ip_id, &secret, &blinding);
2786+
assert_eq!(env.events().all().events().len(), 1);
2787+
2788+
client.verify_commitment(&ip_id, &secret, &blinding);
2789+
client.verify_commitment(&ip_id, &secret, &blinding);
2790+
assert_eq!(
2791+
env.events().all().events().len(),
2792+
1,
2793+
"EXPIRY_TOPIC must fire exactly once per expiry transition"
2794+
);
2795+
}
2796+
}
2797+
2798+
// ── Issue #809: OwnershipShare Percentage Sum Invariant ──────────────────────
2799+
2800+
#[cfg(test)]
2801+
mod ownership_share_tests {
2802+
use super::tests::IpRegistryClient;
2803+
use soroban_sdk::{testutils::Address as _, Address, BytesN, Env};
2804+
2805+
fn setup() -> (Env, IpRegistryClient<'static>, Address, u64) {
2806+
let env = Env::default();
2807+
env.mock_all_auths();
2808+
let contract_id = env.register(crate::IpRegistry, ());
2809+
let client = IpRegistryClient::new(&env, &contract_id);
2810+
let owner = Address::generate(&env);
2811+
let hash = BytesN::from_array(&env, &[0x55u8; 32]);
2812+
let ip_id = client.commit_ip(&owner, &hash, &0u32);
2813+
(env, client, owner, ip_id)
2814+
}
2815+
2816+
#[test]
2817+
fn test_add_co_owner_splits_shares_to_100() {
2818+
let (env, client, owner, ip_id) = setup();
2819+
let co_owner = Address::generate(&env);
2820+
client.add_co_owner(&ip_id, &co_owner, &40);
2821+
2822+
let shares = client.get_ownership_shares(&ip_id);
2823+
assert_eq!(shares.len(), 2);
2824+
let total: u32 = shares.iter().map(|s| s.percentage).sum();
2825+
assert_eq!(total, 100);
2826+
assert_eq!(shares.get(0).unwrap().address, owner);
2827+
assert_eq!(shares.get(0).unwrap().percentage, 60);
2828+
assert_eq!(shares.get(1).unwrap().address, co_owner);
2829+
assert_eq!(shares.get(1).unwrap().percentage, 40);
2830+
}
2831+
2832+
#[test]
2833+
#[should_panic]
2834+
fn test_add_co_owner_rejects_percentage_exceeding_owner_share() {
2835+
let (env, client, _owner, ip_id) = setup();
2836+
let co_owner = Address::generate(&env);
2837+
// Owner only holds 100; requesting 101 must panic (sum would exceed 100).
2838+
client.add_co_owner(&ip_id, &co_owner, &101);
2839+
}
2840+
2841+
#[test]
2842+
fn test_remove_co_owner_returns_share_to_owner() {
2843+
let (env, client, owner, ip_id) = setup();
2844+
let co_owner = Address::generate(&env);
2845+
client.add_co_owner(&ip_id, &co_owner, &30);
2846+
client.remove_co_owner(&ip_id, &co_owner);
2847+
2848+
let shares = client.get_ownership_shares(&ip_id);
2849+
assert_eq!(shares.len(), 1);
2850+
assert_eq!(shares.get(0).unwrap().address, owner);
2851+
assert_eq!(shares.get(0).unwrap().percentage, 100);
2852+
}
27632853
}
27642854

27652855
// ── #464: get_blinded_owner_batch tests ──────────────────────────────────────

contracts/ip_registry/src/types.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub const LEDGER_BUMP: u32 = 6_307_200;
1212
pub const REVOKE_TOPIC: Symbol = soroban_sdk::symbol_short!("revoke");
1313
pub const TRANSFER_TOPIC: Symbol = soroban_sdk::symbol_short!("ip_xfer");
1414
pub const BATCH_VERIFY_TOPIC: Symbol = soroban_sdk::symbol_short!("batch_vfy");
15+
pub const EXPIRY_TOPIC: Symbol = soroban_sdk::symbol_short!("ip_expiry");
1516

1617
// ── Access Control ────────────────────────────────────────────────────────────
1718

contracts/ip_registry/src/validation.rs

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
//! This module provides reusable validation functions to reduce code duplication
44
//! and ensure consistent error handling across the contract.
55
6-
use crate::{ContractError, DataKey, IpRecord};
7-
use soroban_sdk::{symbol_short, Address, BytesN, Env, Error};
6+
use crate::{ContractError, DataKey, IpRecord, OwnershipShare};
7+
use soroban_sdk::{symbol_short, Address, BytesN, Env, Error, Vec};
88

99
/// Retrieves an IP record by ID, panicking if not found.
1010
///
@@ -182,6 +182,25 @@ pub fn calculate_commitment_strength(secret_length: u32, pow_difficulty: u32) ->
182182
}
183183
}
184184

185+
/// Validates that a set of `OwnershipShare` percentages sums to exactly 100.
186+
///
187+
/// # Arguments
188+
///
189+
/// * `env` - The Soroban environment
190+
/// * `shares` - The full cap table (owner + co-owners) for an IP
191+
///
192+
/// # Panics
193+
///
194+
/// Panics with `InvalidShareTotal` if the percentages do not sum to exactly 100.
195+
pub fn require_valid_share_total(env: &Env, shares: &Vec<OwnershipShare>) {
196+
let total: u32 = shares.iter().map(|s| s.percentage).sum();
197+
if total != 100 {
198+
env.panic_with_error(Error::from_contract_error(
199+
ContractError::InvalidShareTotal as u32,
200+
));
201+
}
202+
}
203+
185204
#[cfg(test)]
186205
mod tests {
187206
use super::*;
@@ -306,4 +325,37 @@ mod tests {
306325
};
307326
require_owner(&env, &not_owner, &record);
308327
}
328+
329+
fn shares_summing_to(env: &Env, total: u32) -> soroban_sdk::Vec<OwnershipShare> {
330+
let mut shares = soroban_sdk::Vec::new(env);
331+
shares.push_back(OwnershipShare {
332+
address: Address::generate(env),
333+
percentage: total,
334+
});
335+
shares
336+
}
337+
338+
#[test]
339+
#[should_panic]
340+
fn test_require_valid_share_total_panics_for_99() {
341+
let env = Env::default();
342+
let shares = shares_summing_to(&env, 99);
343+
require_valid_share_total(&env, &shares);
344+
}
345+
346+
#[test]
347+
fn test_require_valid_share_total_succeeds_for_100() {
348+
let env = Env::default();
349+
let shares = shares_summing_to(&env, 100);
350+
// Should not panic
351+
require_valid_share_total(&env, &shares);
352+
}
353+
354+
#[test]
355+
#[should_panic]
356+
fn test_require_valid_share_total_panics_for_101() {
357+
let env = Env::default();
358+
let shares = shares_summing_to(&env, 101);
359+
require_valid_share_total(&env, &shares);
360+
}
309361
}

0 commit comments

Comments
 (0)