diff --git a/Cargo.lock b/Cargo.lock index 3e225a9..8d43919 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6,6 +6,8 @@ version = 4 name = "access-lease" version = "0.1.0" dependencies = [ + "common", + "proptest", "soroban-sdk", ] @@ -325,6 +327,13 @@ dependencies = [ "windows-link", ] +[[package]] +name = "common" +version = "0.1.0" +dependencies = [ + "soroban-sdk", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1901,6 +1910,8 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" name = "subscription" version = "0.1.0" dependencies = [ + "common", + "proptest", "soroban-sdk", ] @@ -2043,6 +2054,7 @@ checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" name = "vault-registry" version = "0.1.0" dependencies = [ + "common", "proptest", "soroban-sdk", ] diff --git a/Cargo.toml b/Cargo.toml index 6954ef1..d9001f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "contracts/vault-registry", "contracts/access-lease", "contracts/subscription", + "contracts/common", ] [workspace.package] diff --git a/contracts/access-lease/Cargo.toml b/contracts/access-lease/Cargo.toml index 1a14a4a..d539464 100644 --- a/contracts/access-lease/Cargo.toml +++ b/contracts/access-lease/Cargo.toml @@ -15,4 +15,5 @@ soroban-sdk = { workspace = true } common = { path = "../common" } [dev-dependencies] -soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.11.0" diff --git a/contracts/access-lease/src/expiry_props.rs b/contracts/access-lease/src/expiry_props.rs new file mode 100644 index 0000000..2e6c703 --- /dev/null +++ b/contracts/access-lease/src/expiry_props.rs @@ -0,0 +1,173 @@ +//! Property tests for lease expiry arithmetic (`grant_lease`, `extend_lease`, +//! `is_valid`). Each property is checked against a plain-integer model of the +//! documented rules instead of hand-picked examples: +//! +//! - grant: `expires_at = now + duration`, or `InvalidDuration` on overflow. +//! - extend: `expires_at = max(expires_at, now) + extra`, or `InvalidDuration` +//! on overflow, leaving the stored lease untouched. +//! - valid: a lease is valid while `expires_at > ledger.sequence()`. +use super::*; +use proptest::prelude::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, String, +}; + +/// Ledgers entries stay live for in these tests, so advancing time never +/// archives the lease, plan or instance entry under test. +const TEST_ENTRY_TTL: u32 = 20_000_000; +/// Largest start ledger used; keeps `now + TEST_ENTRY_TTL` inside `u32`. +const MAX_START: u32 = u32::MAX - 2 * TEST_ENTRY_TTL; +/// Largest amount of time advanced in one step. +const MAX_STEP: u32 = 10_000_000; + +fn setup<'a>(start: u32) -> (Env, AccessLeaseClient<'a>, String, Address) { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| { + l.sequence_number = start; + l.min_persistent_entry_ttl = TEST_ENTRY_TTL; + l.min_temp_entry_ttl = TEST_ENTRY_TTL; + l.max_entry_ttl = 2 * TEST_ENTRY_TTL; + }); + let id = env.register(AccessLease, ()); + let client = AccessLeaseClient::new(&env, &id); + client.init(&Address::generate(&env)); + let rid = String::from_str(&env, "res"); + let buyer = Address::generate(&env); + (env, client, rid, buyer) +} + +fn set_ledger(env: &Env, seq: u32) { + env.ledger().set_sequence_number(seq); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// `grant_lease` sets `expires_at = now + duration` exactly, or rejects an + /// overflowing duration without storing anything. + #[test] + fn grant_sets_expiry_or_rejects_overflow(now in 0..=MAX_START, duration in 1..=u32::MAX) { + let (_env, client, rid, buyer) = setup(now); + match now.checked_add(duration) { + Some(expected) => { + let lease = client.grant_lease(&rid, &buyer, &duration); + prop_assert_eq!(lease.expires_at, expected); + prop_assert_eq!(lease.granted_at, now); + prop_assert_eq!(lease.duration_ledgers, duration); + prop_assert!(client.is_valid(&rid, &buyer)); + } + None => { + prop_assert_eq!( + client.try_grant_lease(&rid, &buyer, &duration), + Err(Ok(Error::InvalidDuration)) + ); + prop_assert!(client.try_get_lease(&rid, &buyer).is_err()); + prop_assert!(!client.is_valid(&rid, &buyer)); + } + } + } + + /// A lease is valid on every ledger before `expires_at` and invalid from + /// `expires_at` onwards. + #[test] + fn lease_valid_strictly_before_expiry(now in 0..=MAX_START, duration in 1..=MAX_STEP) { + let (env, client, rid, buyer) = setup(now); + let lease = client.grant_lease(&rid, &buyer, &duration); + + set_ledger(&env, lease.expires_at - 1); + prop_assert!(client.is_valid(&rid, &buyer)); + set_ledger(&env, lease.expires_at); + prop_assert!(!client.is_valid(&rid, &buyer)); + set_ledger(&env, lease.expires_at.saturating_add(1)); + prop_assert!(!client.is_valid(&rid, &buyer)); + } + + /// `extend_lease` adds to `max(expires_at, now)`: an active lease keeps its + /// remaining time, an expired one restarts from now. The result is always + /// later than both the old expiry and now. Overflow is rejected and leaves + /// the stored lease unchanged. + #[test] + fn extend_adds_to_later_of_expiry_and_now( + now in 0..=MAX_START, + duration in 1..=MAX_STEP, + elapsed in 0..=MAX_STEP, + extra in 1..=u32::MAX, + ) { + let (env, client, rid, buyer) = setup(now); + let lease = client.grant_lease(&rid, &buyer, &duration); + let t = now + elapsed; + set_ledger(&env, t); + + match lease.expires_at.max(t).checked_add(extra) { + Some(expected) => { + let extended = client.extend_lease(&rid, &buyer, &extra); + prop_assert_eq!(extended.expires_at, expected); + prop_assert!(extended.expires_at > lease.expires_at); + prop_assert!(extended.expires_at > t); + prop_assert!(client.is_valid(&rid, &buyer)); + // Extending never rewrites when the lease was granted. + prop_assert_eq!(extended.granted_at, lease.granted_at); + } + None => { + prop_assert_eq!( + client.try_extend_lease(&rid, &buyer, &extra), + Err(Ok(Error::InvalidDuration)) + ); + prop_assert_eq!(client.get_lease(&rid, &buyer), lease); + } + } + } + + /// Repeated extensions of an active lease accumulate exactly. + #[test] + fn consecutive_extensions_accumulate( + now in 0..=MAX_START, + duration in 1..=MAX_STEP, + extras in proptest::collection::vec(1u32..=1_000_000, 1..6), + ) { + let (_env, client, rid, buyer) = setup(now); + let mut expected = client.grant_lease(&rid, &buyer, &duration).expires_at; + for extra in extras { + expected += extra; + prop_assert_eq!(client.extend_lease(&rid, &buyer, &extra).expires_at, expected); + } + } + + /// Re-granting is refused while the lease is active and, once it has + /// expired, starts a fresh lease from the current ledger. + #[test] + fn regrant_only_after_expiry_and_restarts_from_now( + now in 0..=MAX_START, + first in 1..=MAX_STEP, + elapsed in 0..=MAX_STEP, + second in 1..=MAX_STEP, + ) { + let (env, client, rid, buyer) = setup(now); + let lease = client.grant_lease(&rid, &buyer, &first); + let t = now + elapsed; + set_ledger(&env, t); + + if lease.expires_at > t { + prop_assert_eq!( + client.try_grant_lease(&rid, &buyer, &second), + Err(Ok(Error::AlreadyActive)) + ); + } else { + let regranted = client.grant_lease(&rid, &buyer, &second); + prop_assert_eq!(regranted.granted_at, t); + prop_assert_eq!(regranted.expires_at, t + second); + } + } + + /// Zero-length grants and extensions are always rejected. + #[test] + fn zero_duration_always_rejected(now in 0..=MAX_START, duration in 1..=MAX_STEP) { + let (_env, client, rid, buyer) = setup(now); + prop_assert_eq!(client.try_grant_lease(&rid, &buyer, &0), Err(Ok(Error::InvalidDuration))); + let lease = client.grant_lease(&rid, &buyer, &duration); + prop_assert_eq!(client.try_extend_lease(&rid, &buyer, &0), Err(Ok(Error::InvalidDuration))); + prop_assert_eq!(client.get_lease(&rid, &buyer), lease); + } +} diff --git a/contracts/access-lease/src/lib.rs b/contracts/access-lease/src/lib.rs index f7a57b3..b848d2a 100644 --- a/contracts/access-lease/src/lib.rs +++ b/contracts/access-lease/src/lib.rs @@ -5,7 +5,6 @@ //! Any third party can verify a buyer's access via `is_valid` without //! trusting the SynapsVault backend — the Stellar ledger is the source of truth. -use common::{BUMP, BUMP_THRESH}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, BytesN, ContractExecutable, Env, String, @@ -149,13 +148,17 @@ impl AccessLease { } pub fn get_version(env: Env) -> String { - let stored: Option = env.storage().instance().get(&DataKey::Version); - common::get_version(&env, stored.as_ref()) + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, VERSION)) } pub fn is_compatible(env: Env) -> bool { - let stored: Option = env.storage().instance().get(&DataKey::Version); - common::is_compatible(&env, stored.as_ref()) + match env.storage().instance().get::<_, String>(&DataKey::Version) { + Some(stored) => stored == String::from_str(&env, VERSION), + None => false, + } } pub fn grant_lease( @@ -165,34 +168,6 @@ impl AccessLease { duration_ledgers: u32, ) -> Result { Self::require_admin(&env)?; - if resource_id.len() == 0 || resource_id.len() > MAX_RESOURCE_ID_LEN { - return Err(Error::InvalidResourceId); - } - if duration_ledgers == 0 { - return Err(Error::InvalidDuration); - } - let key = DataKey::Lease(resource_id.clone(), buyer.clone()); - let now = env.ledger().sequence(); - - if let Some(existing) = env.storage().persistent().get::<_, Lease>(&key) { - if existing.expires_at > now { - return Err(Error::AlreadyActive); - } - } - - let expires_at = now.checked_add(duration_ledgers).ok_or(Error::InvalidDuration)?; - let lease = Lease { - resource_id: resource_id.clone(), - buyer: buyer.clone(), - granted_at: now, - expires_at, - duration_ledgers, - }; - - env.storage().persistent().set(&key, &lease); - env.storage().persistent().extend_ttl(&key, BUMP_THRESH, BUMP); - GrantEvent { resource_id, buyer, expires_at }.publish(&env); - Ok(lease) Self::grant_lease_internal(&env, resource_id, buyer, duration_ledgers) } @@ -219,15 +194,28 @@ impl AccessLease { return Err(Error::InvalidDuration); } let key = DataKey::Lease(resource_id.clone(), buyer.clone()); - let mut lease: Lease = env.storage().persistent().get(&key).ok_or(Error::LeaseNotFound)?; - + let mut lease: Lease = env + .storage() + .persistent() + .get(&key) + .ok_or(Error::LeaseNotFound)?; + let now = env.ledger().sequence(); let base = lease.expires_at.max(now); - lease.expires_at = base.checked_add(extra_ledgers).ok_or(Error::InvalidDuration)?; - + lease.expires_at = base + .checked_add(extra_ledgers) + .ok_or(Error::InvalidDuration)?; + env.storage().persistent().set(&key, &lease); - env.storage().persistent().extend_ttl(&key, BUMP_THRESH, BUMP); - ExtendEvent { resource_id, buyer, expires_at: lease.expires_at }.publish(&env); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESH, BUMP); + ExtendEvent { + resource_id, + buyer, + expires_at: lease.expires_at, + } + .publish(&env); Ok(lease) } @@ -306,7 +294,7 @@ impl AccessLease { buyer: Address, duration_ledgers: u32, ) -> Result { - if resource_id.len() == 0 || resource_id.len() > MAX_RESOURCE_ID_LEN { + if resource_id.is_empty() || resource_id.len() > MAX_RESOURCE_ID_LEN { return Err(Error::InvalidResourceId); } if duration_ledgers == 0 { @@ -314,14 +302,16 @@ impl AccessLease { } let key = DataKey::Lease(resource_id.clone(), buyer.clone()); let now = env.ledger().sequence(); - + if let Some(existing) = env.storage().persistent().get::<_, Lease>(&key) { if existing.expires_at > now { return Err(Error::AlreadyActive); } } - - let expires_at = now.checked_add(duration_ledgers).ok_or(Error::InvalidDuration)?; + + let expires_at = now + .checked_add(duration_ledgers) + .ok_or(Error::InvalidDuration)?; let lease = Lease { resource_id: resource_id.clone(), buyer: buyer.clone(), @@ -329,10 +319,17 @@ impl AccessLease { expires_at, duration_ledgers, }; - + env.storage().persistent().set(&key, &lease); - env.storage().persistent().extend_ttl(&key, BUMP_THRESH, BUMP); - GrantEvent { resource_id, buyer, expires_at }.publish(env); + env.storage() + .persistent() + .extend_ttl(&key, BUMP_THRESH, BUMP); + GrantEvent { + resource_id, + buyer, + expires_at, + } + .publish(env); Ok(lease) } } @@ -346,6 +343,15 @@ mod tests { vec, Address, BytesN, Env, IntoVal, String, }; + /// Real WASM for success-path `upgrade` tests: the host rejects a hash + /// that was never uploaded. Reuses the vault-registry upgrade fixture. + const UPGRADE_TARGET_WASM: &[u8] = + include_bytes!("../../vault-registry/fixtures/vault_registry_upgrade_target.wasm"); + + fn uploaded_wasm_hash(env: &Env) -> BytesN<32> { + env.deployer().upload_contract_wasm(UPGRADE_TARGET_WASM) + } + fn setup<'a>() -> (Env, AccessLeaseClient<'a>, Address) { let env = Env::default(); env.mock_all_auths(); @@ -464,7 +470,7 @@ mod tests { }]); assert!(client.try_grant_lease(&rid, &buyer, &10u32).is_err()); } - + #[test] fn set_admin_by_non_admin_is_rejected() { let (env, client, _admin) = setup(); @@ -485,7 +491,7 @@ mod tests { #[test] fn upgrade_success() { let (env, client, _admin) = setup(); - let hash = BytesN::from_array(&env, &[1; 32]); + let hash = uploaded_wasm_hash(&env); client.upgrade(&hash); // Auth checks passed successfully } @@ -647,80 +653,57 @@ mod tests { let buyer = Address::generate(&env); let rid = String::from_str(&env, "r"); let new_admin = Address::generate(&env); - let hash = BytesN::from_array(&env, &[1; 32]); - let lease = client.grant_lease(&rid, &buyer, &10u32); - let extended = client.extend_lease(&rid, &buyer, &5u32); - client.revoke_lease(&rid, &buyer); - client.set_admin(&new_admin); - client.upgrade(&hash); - - let events = env.events().all(); - let mut idx = events.len() - 6; + // `env.events().all()` only holds the events of the most recent + // invocation, so each call's event is checked straight after it. + let assert_last_event = |topics: soroban_sdk::Vec, + data: soroban_sdk::Val| { + assert_eq!( + env.events().all().filter_by_contract(&client.address), + vec![&env, (client.address.clone(), topics, data)] + ); + }; // 1. init (from setup) - assert_eq!( - events.get_unchecked(idx), - ( - client.address.clone(), - (symbol_short!("init"),).into_val(&env), - admin.into_val(&env), - ) + assert_last_event( + (symbol_short!("init"),).into_val(&env), + admin.into_val(&env), ); - idx += 1; // 2. grant - assert_eq!( - events.get_unchecked(idx), - ( - client.address.clone(), - (symbol_short!("grant"), rid.clone(), buyer.clone()).into_val(&env), - lease.expires_at.into_val(&env), - ) + let lease = client.grant_lease(&rid, &buyer, &10u32); + assert_last_event( + (symbol_short!("grant"), rid.clone(), buyer.clone()).into_val(&env), + lease.expires_at.into_val(&env), ); - idx += 1; // 3. extend - assert_eq!( - events.get_unchecked(idx), - ( - client.address.clone(), - (symbol_short!("extend"), rid.clone(), buyer.clone()).into_val(&env), - extended.expires_at.into_val(&env), - ) + let extended = client.extend_lease(&rid, &buyer, &5u32); + assert_last_event( + (symbol_short!("extend"), rid.clone(), buyer.clone()).into_val(&env), + extended.expires_at.into_val(&env), ); - idx += 1; // 4. revoke - assert_eq!( - events.get_unchecked(idx), - ( - client.address.clone(), - (symbol_short!("revoke"), rid.clone(), buyer.clone()).into_val(&env), - ().into_val(&env), - ) + client.revoke_lease(&rid, &buyer); + assert_last_event( + (symbol_short!("revoke"), rid.clone(), buyer.clone()).into_val(&env), + ().into_val(&env), ); - idx += 1; - + // 5. setadmin - assert_eq!( - events.get_unchecked(idx), - ( - client.address.clone(), - (symbol_short!("setadmin"),).into_val(&env), - new_admin.into_val(&env), - ) + client.set_admin(&new_admin); + assert_last_event( + (symbol_short!("setadmin"),).into_val(&env), + new_admin.into_val(&env), ); - idx += 1; - - // 6. upgrade - assert_eq!( - events.get_unchecked(idx), - ( - client.address.clone(), - (symbol_short!("upgrade"),).into_val(&env), - hash.into_val(&env), - ) + + // 6. upgrade (uploading the WASM is itself a host call, so do it last) + let hash = uploaded_wasm_hash(&env); + client.upgrade(&hash); + assert_last_event( + (symbol_short!("upgrade"),).into_val(&env), + hash.into_val(&env), ); } @@ -839,4 +822,6 @@ mod tests { let client = AccessLeaseClient::new(&env, &env.register(AccessLease, ())); assert_eq!(client.try_migrate(), Err(Ok(Error::NotInitialised))); } -} \ No newline at end of file +} +#[cfg(test)] +mod expiry_props; diff --git a/contracts/common/Cargo.toml b/contracts/common/Cargo.toml index 1bea44e..68225c9 100644 --- a/contracts/common/Cargo.toml +++ b/contracts/common/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "common" version = "0.1.0" -edition = "2021" +edition.workspace = true +rust-version.workspace = true +publish = false +license.workspace = true +repository.workspace = true [lib] name = "common" @@ -9,4 +13,4 @@ path = "src/lib.rs" crate-type = ["lib"] [dependencies] -soroban-sdk = "20.0.0" \ No newline at end of file +soroban-sdk = { workspace = true } diff --git a/contracts/common/src/lib.rs b/contracts/common/src/lib.rs index 4a3c863..699fb66 100644 --- a/contracts/common/src/lib.rs +++ b/contracts/common/src/lib.rs @@ -44,4 +44,4 @@ pub fn get_version(_env: &Env) -> u32 { /// the canonical [`VERSION`]. pub fn is_compatible(version: u32) -> bool { version > 0 && version <= VERSION -} \ No newline at end of file +} diff --git a/contracts/subscription/Cargo.toml b/contracts/subscription/Cargo.toml index 29616b6..105c224 100644 --- a/contracts/subscription/Cargo.toml +++ b/contracts/subscription/Cargo.toml @@ -15,4 +15,5 @@ soroban-sdk = { workspace = true } common = { path = "../common" } [dev-dependencies] -soroban-sdk = { workspace = true, features = ["testutils"] } \ No newline at end of file +soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = "1.11.0" diff --git a/contracts/subscription/src/expiry_props.rs b/contracts/subscription/src/expiry_props.rs new file mode 100644 index 0000000..ab859f3 --- /dev/null +++ b/contracts/subscription/src/expiry_props.rs @@ -0,0 +1,162 @@ +//! Property tests for subscription expiry arithmetic (`subscribe`, `renew`, +//! `cancel`, `is_active`). Each property is checked against a plain-integer +//! model of the documented rules: +//! +//! - subscribe: `current_period_end = base + CYCLE`, where `base` is the +//! remaining paid period of a cancelled-but-active subscription, otherwise +//! `now`; `Overflow` if that does not fit in `u32`. +//! - renew: `current_period_end = max(current_period_end, now) + CYCLE`, +//! or `Overflow`, leaving the stored subscription untouched. +//! - active: a subscription is active while `current_period_end > now`, +//! regardless of `cancelled`. +use super::*; +use proptest::prelude::*; +use soroban_sdk::{ + testutils::{Address as _, Ledger as _}, + Address, Env, String, +}; + +/// Ledgers entries stay live for in these tests, so advancing time past a full +/// `CYCLE` never archives the plan, subscription or instance entry. +const TEST_ENTRY_TTL: u32 = 20_000_000; +/// Largest start ledger used; keeps `now + TEST_ENTRY_TTL` inside `u32`. +const MAX_START: u32 = u32::MAX - 2 * TEST_ENTRY_TTL; +/// Largest amount of time advanced in one step (just over three cycles). +const MAX_STEP: u32 = 3 * CYCLE + 1; + +struct Ctx<'a> { + env: Env, + client: SubscriptionManagerClient<'a>, + plan: String, + sub: Address, + payref: String, +} + +fn setup<'a>(start: u32) -> Ctx<'a> { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().with_mut(|l| { + l.sequence_number = start; + l.min_persistent_entry_ttl = TEST_ENTRY_TTL; + l.min_temp_entry_ttl = TEST_ENTRY_TTL; + l.max_entry_ttl = 2 * TEST_ENTRY_TTL; + }); + let id = env.register(SubscriptionManager, ()); + let client = SubscriptionManagerClient::new(&env, &id); + client.init(&Address::generate(&env)); + let plan = String::from_str(&env, "plan"); + client.create_plan(&Address::generate(&env), &plan, &1_000_000i128); + let sub = Address::generate(&env); + let payref = String::from_str(&env, "ref"); + Ctx { + env, + client, + plan, + sub, + payref, + } +} + +fn set_ledger(env: &Env, seq: u32) { + env.ledger().set_sequence_number(seq); +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(64))] + + /// A first subscription starts now and ends exactly one `CYCLE` later. + /// + /// The `Overflow` branch (`now + CYCLE > u32::MAX`) is not exercised: it + /// needs a ledger within one cycle of `u32::MAX`, where the host's own + /// storage-TTL arithmetic fails before the contract's check is reached. + #[test] + fn subscribe_ends_one_cycle_after_now(now in 0..=MAX_START) { + let c = setup(now); + let s = c.client.subscribe(&c.plan, &c.sub, &c.payref); + prop_assert_eq!(s.current_period_end, now + CYCLE); + prop_assert_eq!(s.started_at, now); + prop_assert!(!s.cancelled); + prop_assert_eq!(s.total_renewals, 0); + } + + /// Active strictly before `current_period_end`, inactive from it onwards. + #[test] + fn active_strictly_before_period_end(now in 0..=MAX_START) { + let c = setup(now); + let s = c.client.subscribe(&c.plan, &c.sub, &c.payref); + set_ledger(&c.env, s.current_period_end - 1); + prop_assert!(c.client.is_active(&c.plan, &c.sub)); + set_ledger(&c.env, s.current_period_end); + prop_assert!(!c.client.is_active(&c.plan, &c.sub)); + } + + /// `renew` extends from the later of the current period end and now: an + /// early renewal keeps the unused time, a late one restarts from now. The + /// new end is later than both and the renewal counter goes up by one. + #[test] + fn renew_extends_from_later_of_period_end_and_now(now in 0..=MAX_START, elapsed in 0..=MAX_STEP) { + let c = setup(now); + let s = c.client.subscribe(&c.plan, &c.sub, &c.payref); + let t = now + elapsed; + set_ledger(&c.env, t); + + let expected = s.current_period_end.max(t) + CYCLE; + let renewed = c.client.renew(&c.plan, &c.sub, &c.payref); + prop_assert_eq!(renewed.current_period_end, expected); + prop_assert!(renewed.current_period_end > s.current_period_end); + prop_assert!(renewed.current_period_end > t); + prop_assert_eq!(renewed.total_renewals, s.total_renewals + 1); + prop_assert_eq!(renewed.started_at, s.started_at); + prop_assert!(c.client.is_active(&c.plan, &c.sub)); + } + + /// Renewing `k` times without letting the subscription lapse adds exactly + /// `k` cycles. + #[test] + fn early_renewals_accumulate_whole_cycles(now in 0..=MAX_START, k in 1u32..6) { + let c = setup(now); + c.client.subscribe(&c.plan, &c.sub, &c.payref); + let mut last = None; + for _ in 0..k { + last = Some(c.client.renew(&c.plan, &c.sub, &c.payref)); + } + let last = last.unwrap(); + prop_assert_eq!(last.current_period_end, now + (k + 1) * CYCLE); + prop_assert_eq!(last.total_renewals, k); + } + + /// Cancelling keeps access until the period end. Re-subscribing while the + /// paid period is still running carries the remaining time over; after + /// it lapses, the new period starts from now. + #[test] + fn resubscribe_after_cancel_carries_over_remaining_time(now in 0..=MAX_START, elapsed in 0..=MAX_STEP) { + let c = setup(now); + let s = c.client.subscribe(&c.plan, &c.sub, &c.payref); + c.client.cancel(&c.plan, &c.sub); + let t = now + elapsed; + set_ledger(&c.env, t); + + prop_assert_eq!(c.client.is_active(&c.plan, &c.sub), s.current_period_end > t); + prop_assert_eq!(c.client.try_renew(&c.plan, &c.sub, &c.payref), Err(Ok(Error::Cancelled))); + + let again = c.client.subscribe(&c.plan, &c.sub, &c.payref); + let base = if s.current_period_end > t { s.current_period_end } else { t }; + prop_assert_eq!(again.current_period_end, base + CYCLE); + prop_assert!(!again.cancelled); + prop_assert_eq!(again.started_at, s.started_at); + } + + /// Subscribing again while an uncancelled subscription is active is + /// refused and leaves it unchanged. + #[test] + fn duplicate_subscribe_while_active_rejected(now in 0..=MAX_START, elapsed in 0..CYCLE) { + let c = setup(now); + let s = c.client.subscribe(&c.plan, &c.sub, &c.payref); + set_ledger(&c.env, now + elapsed); + prop_assert_eq!( + c.client.try_subscribe(&c.plan, &c.sub, &c.payref), + Err(Ok(Error::AlreadySubbed)) + ); + prop_assert_eq!(c.client.get_subscription(&c.plan, &c.sub), s); + } +} diff --git a/contracts/subscription/src/lib.rs b/contracts/subscription/src/lib.rs index 97c0401..9354eb7 100644 --- a/contracts/subscription/src/lib.rs +++ b/contracts/subscription/src/lib.rs @@ -5,12 +5,14 @@ //! Publishers create plans; the backend subscribes buyers after payment confirmation //! and renews each billing cycle. Subscribers can self-cancel with access until period end. -use common::{BUMP, BUMP_THRESH, DAY}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, BytesN, ContractExecutable, Env, String, }; +/// Ledgers per day (~5 s per ledger). +const DAY: u32 = 17_280; + /// Length of one billing cycle in ledgers (30 days). pub const CYCLE: u32 = 30 * DAY; @@ -105,7 +107,7 @@ pub struct PlanActiveEvent { pub active: bool, } -#[contractevent(topics = ["subscribe"])] +#[contractevent(topics = ["subscribe"], data_format = "vec")] #[derive(Clone, Debug, Eq, PartialEq)] pub struct SubscribedEvent { #[topic] @@ -117,7 +119,7 @@ pub struct SubscribedEvent { pub payment_reference: String, } -#[contractevent(topics = ["renew"])] +#[contractevent(topics = ["renew"], data_format = "vec")] #[derive(Clone, Debug, Eq, PartialEq)] pub struct RenewedEvent { #[topic] @@ -181,11 +183,17 @@ impl SubscriptionManager { } pub fn get_version(env: Env) -> String { - common::get_version(&env, &DataKey::Version, VERSION) + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, VERSION)) } pub fn is_compatible(env: Env) -> bool { - common::is_compatible(&env, &DataKey::Version, VERSION) + match env.storage().instance().get::<_, String>(&DataKey::Version) { + Some(stored) => stored == String::from_str(&env, VERSION), + None => false, + } } pub fn create_plan( @@ -195,7 +203,7 @@ impl SubscriptionManager { price_per_cycle: i128, ) -> Result { publisher.require_auth(); - if plan_id.len() == 0 || plan_id.len() > MAX_PLAN_ID_LEN { + if plan_id.is_empty() || plan_id.len() > MAX_PLAN_ID_LEN { return Err(Error::InvalidPlanId); } if price_per_cycle <= 0 { @@ -247,14 +255,14 @@ impl SubscriptionManager { env.storage() .persistent() .extend_ttl(&key, BUMP_THRESH, BUMP); - + plan.publisher.require_auth(); plan.active = active; env.storage().persistent().set(&key, &plan); env.storage() .persistent() .extend_ttl(&key, BUMP_THRESH, BUMP); - + PlanActiveEvent { plan_id, active }.publish(&env); Ok(()) } @@ -290,7 +298,7 @@ impl SubscriptionManager { if let Some(s) = env.storage().persistent().get::<_, Subscription>(&key) { started_at = s.started_at; total_renewals = s.total_renewals; - + if s.current_period_end > now { if !s.cancelled { return Err(Error::AlreadySubbed); @@ -307,12 +315,12 @@ impl SubscriptionManager { cancelled: false, total_renewals, }; - + env.storage().persistent().set(&key, &sub); env.storage() .persistent() .extend_ttl(&key, BUMP_THRESH, BUMP); - + SubscribedEvent { plan_id, subscriber, @@ -321,18 +329,18 @@ impl SubscriptionManager { payment_reference, } .publish(&env); - + Ok(sub) } pub fn renew( - env: Env, - plan_id: String, - subscriber: Address, - payment_reference: String + env: Env, + plan_id: String, + subscriber: Address, + payment_reference: String, ) -> Result { Self::require_admin(&env)?; - + let plan_key = DataKey::Plan(plan_id.clone()); let plan: Plan = env .storage() @@ -342,7 +350,7 @@ impl SubscriptionManager { env.storage() .persistent() .extend_ttl(&plan_key, BUMP_THRESH, BUMP); - + let key = DataKey::Sub(plan_id.clone(), subscriber.clone()); let mut sub: Subscription = env .storage() @@ -365,7 +373,7 @@ impl SubscriptionManager { env.storage() .persistent() .extend_ttl(&key, BUMP_THRESH, BUMP); - + RenewedEvent { plan_id, subscriber, @@ -374,7 +382,7 @@ impl SubscriptionManager { payment_reference, } .publish(&env); - + Ok(sub) } @@ -455,8 +463,14 @@ impl SubscriptionManager { impl SubscriptionManager { fn require_admin(env: &Env) -> Result<(), Error> { - // We catch the internal error here, but the contract entrypoint itself traps instead of returning Error::NotAdmin natively due to require_auth on the extracted wallet. - common::require_admin(env, &DataKey::Admin).map_err(|_| Error::NotInitialised) + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialised)?; + common::require_admin(env, &admin); + env.storage().instance().extend_ttl(BUMP_THRESH, BUMP); + Ok(()) } } @@ -477,6 +491,15 @@ mod tests { plan_id: String, } + /// Real WASM for success-path `upgrade` tests: the host rejects a hash + /// that was never uploaded. Reuses the vault-registry upgrade fixture. + const UPGRADE_TARGET_WASM: &[u8] = + include_bytes!("../../vault-registry/fixtures/vault_registry_upgrade_target.wasm"); + + fn uploaded_wasm_hash(env: &Env) -> BytesN<32> { + env.deployer().upload_contract_wasm(UPGRADE_TARGET_WASM) + } + fn setup<'a>() -> Ctx<'a> { let env = Env::default(); env.mock_all_auths(); @@ -548,16 +571,13 @@ mod tests { #[test] fn set_admin_to_same_admin_fails() { let c = setup(); - assert_eq!( - c.client.try_set_admin(&c.admin), - Err(Ok(Error::SameAdmin)) - ); + assert_eq!(c.client.try_set_admin(&c.admin), Err(Ok(Error::SameAdmin))); } #[test] fn upgrade_success() { let c = setup(); - let hash = BytesN::from_array(&c.env, &[1; 32]); + let hash = uploaded_wasm_hash(&c.env); c.client.upgrade(&hash); // Validating the call succeeded through auth checks } @@ -597,92 +617,59 @@ mod tests { let payment_ref = String::from_str(&c.env, "t1"); let payment_ref2 = String::from_str(&c.env, "t2"); + // `env.events().all()` only holds the events of the most recent + // invocation, so each call's event is checked straight after it. + let assert_last_event = |topics: soroban_sdk::Vec, + data: soroban_sdk::Val| { + assert_eq!( + c.env.events().all().filter_by_contract(&c.client.address), + vec![&c.env, (c.client.address.clone(), topics, data)] + ); + }; + // 1. set_admin c.client.set_admin(&new_admin); - + assert_last_event( + (symbol_short!("setadmin"),).into_val(&c.env), + new_admin.into_val(&c.env), + ); + // 2. create_plan c.client.create_plan(&c.publisher, &plan_id2, &1000); + assert_last_event( + (symbol_short!("plan"), plan_id2.clone(), c.publisher.clone()).into_val(&c.env), + 1000i128.into_val(&c.env), + ); // 3. subscribe let s = c.client.subscribe(&plan_id2, &sub, &payment_ref); + assert_last_event( + (symbol_short!("subscribe"), plan_id2.clone(), sub.clone()).into_val(&c.env), + (s.current_period_end, 1000i128, payment_ref.clone()).into_val(&c.env), + ); // 4. renew advance(&c.env, 10); let r = c.client.renew(&plan_id2, &sub, &payment_ref2); + assert_last_event( + (symbol_short!("renew"), plan_id2.clone(), sub.clone()).into_val(&c.env), + (r.current_period_end, 1000i128, payment_ref2.clone()).into_val(&c.env), + ); // 5. cancel c.client.cancel(&plan_id2, &sub); + assert_last_event( + (symbol_short!("cancel"), plan_id2.clone(), sub.clone()).into_val(&c.env), + ().into_val(&c.env), + ); // 6. set_plan_active c.client.set_plan_active(&plan_id2, &false); - - // Validate the structure of every event by pulling them down - let events = c.env.events().all(); - let mut idx = events.len() - 6; - - // set_admin - assert_eq!( - events.get_unchecked(idx), - ( - c.client.address.clone(), - (symbol_short!("setadmin"),).into_val(&c.env), - new_admin.into_val(&c.env), - ) - ); - idx += 1; - - // create_plan - assert_eq!( - events.get_unchecked(idx), - ( - c.client.address.clone(), - (symbol_short!("plan"), plan_id2.clone(), c.publisher.clone()).into_val(&c.env), - 1000i128.into_val(&c.env), - ) - ); - idx += 1; - - // subscribe - assert_eq!( - events.get_unchecked(idx), - ( - c.client.address.clone(), - (symbol_short!("subscribe"), plan_id2.clone(), sub.clone()).into_val(&c.env), - (s.current_period_end, 1000i128, payment_ref.clone()).into_val(&c.env), - ) - ); - idx += 1; - - // renew - assert_eq!( - events.get_unchecked(idx), - ( - c.client.address.clone(), - (symbol_short!("renew"), plan_id2.clone(), sub.clone()).into_val(&c.env), - (r.current_period_end, 1000i128, payment_ref2.clone()).into_val(&c.env), - ) - ); - idx += 1; - - // cancel - assert_eq!( - events.get_unchecked(idx), - ( - c.client.address.clone(), - (symbol_short!("cancel"), plan_id2.clone(), sub.clone()).into_val(&c.env), - ().into_val(&c.env), - ) - ); - idx += 1; - - // set_plan_active - assert_eq!( - events.get_unchecked(idx), - ( - c.client.address.clone(), - (symbol_short!("setactive"), plan_id2.clone()).into_val(&c.env), - false.into_val(&c.env), - ) + assert_last_event( + (symbol_short!("setactive"), plan_id2.clone()).into_val(&c.env), + false.into_val(&c.env), ); } -} \ No newline at end of file +} +#[cfg(test)] +mod expiry_props; diff --git a/contracts/vault-registry/fixtures/vault_registry_upgrade_target.wasm b/contracts/vault-registry/fixtures/vault_registry_upgrade_target.wasm index a39f847..3139fad 100755 Binary files a/contracts/vault-registry/fixtures/vault_registry_upgrade_target.wasm and b/contracts/vault-registry/fixtures/vault_registry_upgrade_target.wasm differ diff --git a/contracts/vault-registry/src/lib.rs b/contracts/vault-registry/src/lib.rs index 9de4abc..f89e680 100644 --- a/contracts/vault-registry/src/lib.rs +++ b/contracts/vault-registry/src/lib.rs @@ -10,7 +10,6 @@ //! Only the recorded creator can mutate a resource (enforced via //! `require_auth`). Ownership can be transferred. -use common::{BUMP, BUMP_THRESH}; use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, Address, BytesN, ContractExecutable, Env, IntoVal, String, Val, Vec, @@ -43,10 +42,6 @@ pub const SUBSCRIPTION_TTL_DAYS: u32 = 365; /// Retention window applied to this contract's persistent entries. const BUMP_AMOUNT: u32 = REGISTRY_TTL_DAYS * DAY_IN_LEDGERS; const LIFETIME_THRESHOLD: u32 = BUMP_AMOUNT - DAY_IN_LEDGERS; -// ~5s ledgers → 17,280 per day. Persistent entries are bumped ~30 days on each -// write so an actively-managed resource is never archived out from under us. -const BUMP_AMOUNT: u32 = BUMP; -const LIFETIME_THRESHOLD: u32 = BUMP_THRESH; /// Maximum byte length of a metadata pointer (IPFS URI, content hash, compact /// JSON anchor). Enforced on `register` and `update_metadata`; longer values /// are rejected with [`Error::MetadataTooLong`]. @@ -459,7 +454,11 @@ impl VaultRegistry { Self::validate_metadata_pointer(&metadata)?; resource.metadata = metadata.clone(); Self::save(&env, &resource); - UpdateMetadataEvent { id, new_metadata: metadata }.publish(&env); + UpdateMetadataEvent { + id, + new_metadata: metadata, + } + .publish(&env); Ok(()) } @@ -483,7 +482,11 @@ impl VaultRegistry { resource.creator.require_auth(); resource.tags = normalised_tags.clone(); Self::save(&env, &resource); - SetTagsEvent { id, tags: normalised_tags }.publish(&env); + SetTagsEvent { + id, + tags: normalised_tags, + } + .publish(&env); Ok(()) } @@ -547,11 +550,7 @@ impl VaultRegistry { env.storage().persistent().remove(&pending_key); resource.creator = new_creator.clone(); Self::save(&env, &resource); - TransferEvent { - id, - new_creator, - } - .publish(&env); + TransferEvent { id, new_creator }.publish(&env); Ok(()) } @@ -715,9 +714,7 @@ impl VaultRegistry { let page_size = limit.min(MAX_PAGE_SIZE); // Bound the scan window so the loop is always bounded by a constant, // regardless of the value of Count (fixes #69). - let scan_cap: u32 = page_size - .saturating_mul(2) - .saturating_add(MAX_PAGE_SIZE); + let scan_cap: u32 = page_size.saturating_mul(2).saturating_add(MAX_PAGE_SIZE); let scan_end: u32 = start.saturating_add(scan_cap); let mut result: Vec = Vec::new(&env); let mut i = start; @@ -848,14 +845,17 @@ impl VaultRegistry { /// first deployed. Falls back to the compile-time `VERSION` constant if no /// version has been stored yet. pub fn get_version(env: Env) -> String { - common::get_version(&env) + env.storage() + .instance() + .get(&DataKey::Version) + .unwrap_or_else(|| String::from_str(&env, VERSION)) } /// Whether `version` is compatible with this contract. A version is /// considered compatible when its major/minor components match the /// compile-time `VERSION`. pub fn is_compatible(version: String) -> bool { - common::is_compatible(&version) + Self::compatibility_check(&version) } } @@ -881,7 +881,7 @@ impl VaultRegistry { } fn validate_metadata_pointer(metadata: &String) -> Result<(), Error> { - if metadata.len() == 0 { + if metadata.is_empty() { return Err(Error::MetadataEmpty); } if metadata.len() > MAX_METADATA_POINTER_LEN { @@ -919,7 +919,7 @@ impl VaultRegistry { *b = b.to_ascii_lowercase(); } } - let lower = String::from_bytes(env, &soroban_sdk::Bytes::from_slice(env, &buf[..len as usize])); + let lower = String::from_bytes(env, &buf[..len as usize]); // Reject duplicate tags (after normalisation). for existing in normalised.iter() { @@ -993,7 +993,9 @@ impl VaultRegistry { /// - empty strings, /// - strings longer than the 32-byte buffer, /// - non-numeric / malformed strings (e.g. `"abc"`, `"1."`, `"0..1"`), - /// - strings with fewer than two dots (no patch component, e.g. `"0.1"`). + /// - strings with fewer than two dots (no patch component, e.g. `"0.1"`), + /// - an empty, non-numeric or extra component after the minor version + /// (e.g. `"0.1."`, `"0.1.x"`, `"0.1.2.3"`). fn compatibility_check(version: &String) -> bool { const BUF_LEN: usize = 32; let len = version.len() as usize; @@ -1037,6 +1039,12 @@ impl VaultRegistry { // comparison always fails against any well-formed version. return &version[..0]; } + // The patch component must be non-empty and purely numeric, which also + // rules out a fourth component ("0.1.2.3"). + let patch = &version[second_dot + 1..]; + if patch.is_empty() || !patch.iter().all(|b| b.is_ascii_digit()) { + return &version[..0]; + } // Reject empty components (e.g. leading/trailing/consecutive dots). let prefix = &version[..second_dot]; if prefix.is_empty() { diff --git a/contracts/vault-registry/src/test.rs b/contracts/vault-registry/src/test.rs index 9f23de0..8a81923 100644 --- a/contracts/vault-registry/src/test.rs +++ b/contracts/vault-registry/src/test.rs @@ -2,6 +2,8 @@ extern crate std; +use std::string::ToString; + use super::*; use proptest::prelude::*; use soroban_sdk::{ @@ -183,7 +185,8 @@ fn ownership_can_transfer() { ); let new_owner = Address::generate(&env); - client.transfer_ownership(&id, &new_owner); + client.propose_transfer(&id, &new_owner); + client.accept_transfer(&id, &new_owner); assert_eq!(client.get(&id).creator, new_owner); } @@ -263,7 +266,8 @@ fn transfer_ownership_keeps_count_and_order() { let new_owner = Address::generate(&env); let id = String::from_str(&env, "a"); - client.transfer_ownership(&id, &new_owner); + client.propose_transfer(&id, &new_owner); + client.accept_transfer(&id, &new_owner); assert_eq!(client.count(), 2); let list = client.list(&0u32, &10u32, &false, &None); @@ -330,7 +334,8 @@ fn get_owner_after_transfer() { ); let new_owner = Address::generate(&env); - client.transfer_ownership(&id, &new_owner); + client.propose_transfer(&id, &new_owner); + client.accept_transfer(&id, &new_owner); assert_eq!(client.get_owner(&id), new_owner); } @@ -577,7 +582,8 @@ fn transfer_ownership_reextends_resource_ttl() { .set_sequence_number(env.ledger().sequence() + DAY_IN_LEDGERS); let new_owner = Address::generate(&env); - client.transfer_ownership(&id, &new_owner); + client.propose_transfer(&id, &new_owner); + client.accept_transfer(&id, &new_owner); assert_eq!( resource_storage_ttl(&env, &client.address, &id), TTL_BUMP_AMOUNT @@ -594,7 +600,7 @@ fn list_limit_capped_at_20() { register_n(&env, &creator, &client, &ids); // Requesting 25 items should be silently capped to 20. - let page = client.list(&0u32, &25u32); + let page = client.list(&0u32, &25u32, &false, &None); assert_eq!(page.len(), 20); } @@ -758,7 +764,9 @@ fn entrypoint_behavior_regression_after_optimization() { client.set_listed(&id, &false); assert!(!client.get(&id).listed); - client.transfer_ownership(&id, &Address::generate(&env)); + let new_owner = Address::generate(&env); + client.propose_transfer(&id, &new_owner); + client.accept_transfer(&id, &new_owner); assert_eq!(client.count(), 1); } @@ -780,7 +788,7 @@ fn footprint_regression_register_and_read() { assert_eq!(r.metadata, metadata); assert!(r.listed); - let page = client.list(&0u32, &10u32); + let page = client.list(&0u32, &10u32, &false, &None); assert_eq!(page.len(), 1); assert_eq!(page.get(0).unwrap().id, id); } @@ -869,8 +877,11 @@ fn mutations_require_creator_auth() { assert_eq!(env.auths()[0].0, creator); let new_owner = Address::generate(&env); - client.transfer_ownership(&id, &new_owner); + client.propose_transfer(&id, &new_owner); assert_eq!(env.auths()[0].0, creator); + // Accepting is authorised by the incoming owner (two-step transfer). + client.accept_transfer(&id, &new_owner); + assert_eq!(env.auths()[0].0, new_owner); // After transfer only the new owner is asked to authorize. client.set_listed(&id, &false); @@ -944,7 +955,7 @@ fn gas_register_and_list_budget() { register_n(&env, &creator, &client, &ids); env.cost_estimate().budget().reset_default(); - let page = client.list(&0u32, &MAX_PAGE_SIZE); + let page = client.list(&0u32, &MAX_PAGE_SIZE, &false, &None); let list_cpu = env.cost_estimate().budget().cpu_instruction_cost(); let list_mem = env.cost_estimate().budget().memory_bytes_cost(); std::println!("list(20): cpu={list_cpu} mem={list_mem}"); @@ -1001,8 +1012,8 @@ proptest! { id_str in r"[a-zA-Z0-9_-]{1,32}", price in 1..1000000000000i128, price_2 in 1..1000000000000i128, - meta_str in r"[a-zA-Z0-9:/\\._-]{0,512}", - meta_str_2 in r"[a-zA-Z0-9:/\\._-]{0,512}", + meta_str in r"[a-zA-Z0-9:/\\._-]{1,512}", + meta_str_2 in r"[a-zA-Z0-9:/\\._-]{1,512}", listed in any::(), ) { let env = Env::default(); @@ -1065,12 +1076,18 @@ fn update_metadata_event_includes_new_pointer() { client.update_metadata(&id, &new_meta); - let all = env.events().all().filter_by_contract(&client.address); - assert_eq!(all.len(), 1); // data value must be the new metadata string, not unit / empty - let (_contract, _topics, data) = all.get(0).unwrap(); - let emitted: String = data.into_val(&env); - assert_eq!(emitted, new_meta); + assert_eq!( + env.events().all().filter_by_contract(&client.address), + soroban_sdk::vec![ + &env, + ( + client.address.clone(), + (symbol_short!("updmeta"), id.clone()).into_val(&env), + new_meta.into_val(&env), + ), + ] + ); } // ── #81: set_listed must be a no-op when value is unchanged ────────────────── @@ -1097,11 +1114,18 @@ fn set_listed_no_op_when_value_unchanged() { // No events emitted. let events = env.events().all().filter_by_contract(&client.address); - assert_eq!(events.len(), 0, "expected no event on no-op set_listed"); + assert_eq!( + events.events().len(), + 0, + "expected no event on no-op set_listed" + ); // TTL must not have been bumped (no storage write happened). let ttl_after = resource_storage_ttl(&env, &client.address, &id); - assert_eq!(ttl_before, ttl_after, "TTL must not change on no-op set_listed"); + assert_eq!( + ttl_before, ttl_after, + "TTL must not change on no-op set_listed" + ); } /// Calling `set_listed` with a different value must still emit the event. @@ -1120,11 +1144,19 @@ fn set_listed_emits_event_when_value_changes() { client.set_listed(&id, &false); - let events = env.events().all().filter_by_contract(&client.address); - assert_eq!(events.len(), 1, "expected one event when listed changes"); - let (_contract, _topics, data) = events.get(0).unwrap(); - let emitted: bool = data.into_val(&env); - assert!(!emitted); + assert_eq!( + env.events().all().filter_by_contract(&client.address), + soroban_sdk::vec![ + &env, + ( + client.address.clone(), + // Unlisting goes through the canonical delist path. + (symbol_short!("delist"), id.clone()).into_val(&env), + creator.into_val(&env), + ), + ], + "expected one delist event when listed changes to false" + ); } // ── #83: is_compatible must reject malformed / boundary version strings ─────── @@ -1148,7 +1180,9 @@ fn is_compatible_rejects_version_without_patch() { #[test] fn is_compatible_rejects_malformed_versions() { let (env, _creator, client) = setup(); - let bad = ["", "abc", ".", "..", "1", "1.", ".0.1", "0..1", "0.1.", "0.1.2.3"]; + let bad = [ + "", "abc", ".", "..", "1", "1.", ".0.1", "0..1", "0.1.", "0.1.2.3", + ]; for v in &bad { assert!( !client.is_compatible(&String::from_str(&env, v)), @@ -1174,7 +1208,7 @@ fn is_compatible_accepts_different_patch() { ); // A higher patch on the same major.minor is also compatible. let _ = compatible_alt; // used conditionally below - // Build a version string with the same major.minor as VERSION but patch=99. + // Build a version string with the same major.minor as VERSION but patch=99. let version_bytes = VERSION.as_bytes(); let mut dots = 0usize; let mut second_dot = version_bytes.len(); @@ -1201,7 +1235,7 @@ fn is_compatible_accepts_different_patch() { fn is_compatible_length_boundary() { let (env, _creator, client) = setup(); // 33-character version — must be rejected by the len > BUF_LEN guard. - let long = "1".repeat(11) + "." + &"2".repeat(11) + "." + &"3".repeat(8); // 33 chars + let long = "1".repeat(11) + "." + &"2".repeat(11) + "." + &"3".repeat(9); // 33 chars assert_eq!(long.len(), 33); assert!( !client.is_compatible(&String::from_str(&env, &long)),