From 25eea79f5254ae57d3e481ed4aba064e77c2acd4 Mon Sep 17 00:00:00 2001 From: adenueltech Date: Sat, 17 Oct 2026 22:38:06 +0100 Subject: [PATCH] test: add property tests for expiry arithmetic across grant, extend, renew and subscribe (#108) Property tests (proptest, checked against an integer model): - access-lease: grant sets expires_at = now + duration or rejects overflow; validity holds strictly before expiry; extend adds to max(expiry, now) or rejects overflow leaving the lease unchanged; extensions accumulate; re-grant only after expiry and restarts from now; zero rejected. - subscription: subscribe ends one CYCLE after now; active strictly before period end; renew extends from max(period end, now); early renewals add whole cycles; re-subscribing after cancel carries over remaining time; duplicate subscribe rejected while active. main has failed CI since Oct 16, so these could not run. Minimal fixes to get the workspace compiling and CI green: - access-lease: remove a duplicated grant_lease body left by a merge. - Drop common BUMP/BUMP_THRESH/DAY imports that clashed with each crate's own ledger-based constants (common's DAY is in seconds). - Restore per-contract get_version/is_compatible; the common helpers are u32-based and did not match any caller. Fix subscription require_admin. - common: use the workspace soroban-sdk (was 20.0.0) and add it to the workspace members / Cargo.lock. - vault-registry: fix String::from_bytes call; validate the patch component in is_compatible so "0.1." and "0.1.2.3" are rejected (#83 test). - subscription: subscribe/renew events use data_format = "vec" to match the documented (current_period_end, price, payment_reference) tuple. - Tests: port event assertions to the soroban-sdk 29 ContractEvents API, use propose/accept_transfer instead of removed transfer_ownership, pass the new list() filter args, upload real WASM for success-path upgrade tests, fix a 32-vs-33 length test and an empty-metadata generator. - Regenerate the vault-registry upgrade fixture (make fixture). - clippy (len_zero) and cargo fmt. Closes #108 Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 12 ++ Cargo.toml | 1 + contracts/access-lease/Cargo.toml | 3 +- contracts/access-lease/src/expiry_props.rs | 173 +++++++++++++++ contracts/access-lease/src/lib.rs | 201 ++++++++---------- contracts/common/Cargo.toml | 8 +- contracts/common/src/lib.rs | 2 +- contracts/subscription/Cargo.toml | 3 +- contracts/subscription/src/expiry_props.rs | 162 ++++++++++++++ contracts/subscription/src/lib.rs | 181 ++++++++-------- .../vault_registry_upgrade_target.wasm | Bin 14533 -> 27141 bytes contracts/vault-registry/src/lib.rs | 48 +++-- contracts/vault-registry/src/test.rs | 86 +++++--- 13 files changed, 624 insertions(+), 256 deletions(-) create mode 100644 contracts/access-lease/src/expiry_props.rs create mode 100644 contracts/subscription/src/expiry_props.rs 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 a39f847da94698f35b6586e4c7c28b378513b882..3139fadb3aa0f40e30c2505fea8ac686198aa558 100755 GIT binary patch literal 27141 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 X^qLxuJ!nRAqV2)Bx0{Z?E6(}9)DW%g delta 4949 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 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)),