diff --git a/TODO.md b/TODO.md index a4c71af9..e826cccc 100644 --- a/TODO.md +++ b/TODO.md @@ -1,46 +1,19 @@ -Property-Based Invariant Tests - feature/blackboxai/property-tests - -Status: 0/6 Complete - -## Breakdown from Approved Plan - -### 1. Enhance proptest_helpers.rs [x] -- [x] Complete TestOperation enum/strategies (register/report/deposit/claim/blacklist/multisig/pause) -- [x] arb_valid_sequence generator (invariant-preserving sequences) -- [x] Validate: cargo test proptest_helpers - -### 2. Hardened src/test.rs Properties [x] -- [x] check_invariants_enhanced oracle + 7 properties (period/pagination/blacklist/concentration/multisig/pause/random) - - -- [ ] check_invariants oracle: payout conservation/blacklist/concentration/pause/multisig/pagination -- [ ] prop_period_ordering (strictly increasing) -- [ ] prop_blacklist_enforcement (claims=0) -- [x] prop_concentration_limits (enforce blocks) -- [ ] prop_pagination_stability (deterministic register→paginate) -- [ ] prop_multisig_threshold (below threshold fails) -- [x] prop_pause_safety (mutations panic post-pause) -- [ ] prop_random_operations (full sequences, seeds/shrinking) -- [ ] Validate: cargo test prop_ - -### 3. src/lib.rs Helpers (views only) [ ] -- [ ] total_claimed_for_holder(issuer, holder) → oracle -- [ ] NO mutations - -### 4. docs/property-based-invariant-tests.md [ ] -- [ ] Update pass rates + minimal seeds - -### 5. Validation [ ] -- [ ] cargo test --lib (100%) -- [ ] cargo clippy --fix -- [ ] cargo test prop_ -- --cases 1000 (stress) -- [ ] Repro: RUST_LOG=proptest=trace cargo test prop_period_ordering --exact 1 - -### 6. Git + PR [ ] -- [ ] git checkout -b blackboxai/property-tests -- [ ] Commit changes -- [ ] gh pr create - -## Next Step -**Step 1: Enhance proptest_helpers.rs → cargo test proptest_helpers** +# TODO + +## feat/multisig-administered-offerings (#453) + +1. Inspect `src/lib.rs` around existing offering registration (`register_offering`) and multisig-related storage/API. +2. Implement new entrypoint `register_offering_multisig` in `src/lib.rs`. + - Inputs: same as `register_offering` plus `multisig: Address`. + - Auth: require_auth resolution expectation documented against multisig. + - Fail-fast: cross-contract call `multisig.get_threshold()`; fail cleanly if call fails or method missing. + - Persist: store canonical issuer principal as `issuer` after validation. + - Events/indexer mapping: ensure `off_reg`/offering register event includes `is_multisig_admin=true` (or equivalent documented mapping). +3. Add any required storage keys/flags in `src/lib.rs` to mark multisig-administered offerings. +4. Add tests to `src/test_multisig_gas.rs` (or new test file if needed): + - success: multisig implements `get_threshold()`; registration succeeds. + - failure: multisig does not implement `get_threshold()`; registration fails cleanly. +5. Validate edge cases: duplicate offering idempotency; ensure no partial writes on failed multisig validation. +6. Run `cargo test --all` and `cargo clippy --all-targets --all-features -- -D warnings`. +7. Update documentation/comments where needed for indexers and security notes. diff --git a/TODO_blacklist_batch_prop.md b/TODO_blacklist_batch_prop.md new file mode 100644 index 00000000..e376eff2 --- /dev/null +++ b/TODO_blacklist_batch_prop.md @@ -0,0 +1,13 @@ +TODO: #477 proptest harness for blacklist_add_many/remove_many order-independence + +1. Update src/test_blacklist_batch_prop.rs: + - Scope event collection to only events emitted by blacklist_add_many/remove_many (use before/after event indices). + - Enforce empty-input no-op semantics: if addrs empty => no bl_add events; if rem_addrs empty => no bl_rem events. + - Strengthen duplicate/overlap coverage in vectors. + - Assert final blacklist state equality as deduped set (sorted unique addresses). + - Assert emitted blacklist events equality as order-independent multiset. +2. Run cargo test --all. +3. Ensure all lint/format is clean. +4. Commit as: test: add proptest for blacklist batch order-independence. +5. Push branch and open PR. + diff --git a/src/test_blacklist_batch_prop.rs b/src/test_blacklist_batch_prop.rs new file mode 100644 index 00000000..0d64289a --- /dev/null +++ b/src/test_blacklist_batch_prop.rs @@ -0,0 +1,193 @@ +#![cfg(test)] + +use crate::{RevoraRevenueShare, RevoraRevenueShareClient}; +use proptest::prelude::*; +use soroban_sdk::{ + symbol_short, + Address, Env, Vec, +}; + + +fn make_client(env: &Env) -> RevoraRevenueShareClient<'_> { + let id = env.register_contract(None, RevoraRevenueShare); + RevoraRevenueShareClient::new(env, &id) +} + +fn setup_offering(env: &Env, issuer: &Address, token: &Address, ns: &soroban_sdk::Symbol) { + let client = make_client(env); + client.initialize(issuer, &None::
, &None::); + client.register_offering(issuer, ns, token, &1000u32, token, &0_i128); +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +enum BlEventKind { + Add, + Rem, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct NormalizedBlEvent { + kind: BlEventKind, + caller: Address, + investor: Address, +} + +fn collect_blacklist_events(env: &Env) -> Vec { + // Event filtering: only consider bl_add/bl_rem. + // We normalize away ordering and compare as a sorted multiset. + let mut out: Vec = Vec::new(&env); + + // Soroban SDK testutils: env.events().all() gives all published events + // as typed tuples we can match on. + for i in 0..env.events().all().len() { + // Each event is internally a tuple like: (topics..., data...) + // We rely on the topic symbol being present in the first position. + // Since this is a test harness and we only need deterministic comparisons, + // we use the Events decoding to get (symbol, data) shape. + let ev = env.events().all().get(i).unwrap(); + + // ev is (topic tuple, data tuple) under testutils. + // We only need the event symbol and (caller, investor). + // The event topic published by the contract is: + // (EVENT_BL_ADD, issuer, namespace, token) + // (EVENT_BL_REM, issuer, namespace, token) + // with data (caller, investor) + let topics = ev.topics(); + if topics.is_empty() { + continue; + } + // topics[0] is the event symbol. + let sym: soroban_sdk::Symbol = topics.get(0).unwrap(); + + if sym == symbol_short!("bl_add") { + let data = ev.data(); + let caller: Address = data.get(0).unwrap(); + let investor: Address = data.get(1).unwrap(); + out.push_back(NormalizedBlEvent { kind: BlEventKind::Add, caller, investor }); + } else if sym == symbol_short!("bl_rem") { + let data = ev.data(); + let caller: Address = data.get(0).unwrap(); + let investor: Address = data.get(1).unwrap(); + out.push_back(NormalizedBlEvent { kind: BlEventKind::Rem, caller, investor }); + } + } + + out +} + +fn sort_std_normalized(mut events: std::vec::Vec) -> std::vec::Vec { + events.sort(); + events +} + + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + ..ProptestConfig::default() + })] + + #[test] + fn blacklist_batch_add_remove_order_independent( + addrs in proptest::collection::vec(any::(), 0..50), + rem_addrs in proptest::collection::vec(any::(), 0..50), + shuffle_seed in any::(), + ) { + let env = Env::default(); + env.mock_all_auths(); + + let issuer = Address::generate(&env); + let token = Address::generate(&env); + let ns = symbol_short!("def"); + + setup_offering(&env, &issuer, &token, &ns); + let client = make_client(&env); + + // Prebuild a stable pool of 256 addresses. + // Duplicates/overlaps are intentionally produced because u8 values are mapped by index. + let mut pool: std::vec::Vec
= std::vec::Vec::with_capacity(256); + + for i in 0u16..256u16 { + pool.push(Address::generate(&env)); + } + let mut to_addr_vec = |bytes: &std::vec::Vec| -> Vec
{ + let mut out: Vec
= Vec::new(&env); + for &b in bytes.iter() { + out.push_back(pool[b as usize].clone()); + } + out + }; + + let add_vec_a: Vec
= to_addr_vec(&addrs); + let rem_vec_a: Vec
= to_addr_vec(&rem_addrs); + + // Execution A: original order. + let mut env_a = env.clone(); + env_a.mock_all_auths(); + setup_offering(&env_a, &issuer, &token, &ns); + let client_a = make_client(&env_a); + client_a.blacklist_add_many(&issuer, &issuer, &ns, &token, &add_vec_a); + client_a.blacklist_remove_many(&issuer, &issuer, &ns, &token, &rem_vec_a); + let final_state_a = client_a.get_blacklist(&issuer, &ns, &token); + let events_a = collect_blacklist_events(&env_a); + + + // Execution B: shuffled order, but identical multiset of input addresses. + let mut rng = proptest::test_runner::TestRng::deterministic(shuffle_seed); + let mut add_vec_b: Vec
= add_vec_a.clone(); + let mut rem_vec_b: Vec
= rem_vec_a.clone(); + + // Convert Soroban Vec to std for shuffling, then back. + let mut add_std: std::vec::Vec
= Vec::into_iter(add_vec_b).collect(); + let mut rem_std: std::vec::Vec
= Vec::into_iter(rem_vec_b).collect(); + + add_std.shuffle(&mut rng); + rem_std.shuffle(&mut rng); + + let add_vec_b2: Vec
= { + let mut out: Vec
= Vec::new(&env); + for a in add_std { out.push_back(a); } + out + }; + let rem_vec_b2: Vec
= { + let mut out: Vec
= Vec::new(&env); + for a in rem_std { out.push_back(a); } + out + }; + + let mut env_b = env.clone(); + env_b.mock_all_auths(); + setup_offering(&env_b, &issuer, &token, &ns); + let client_b = make_client(&env_b); + client_b.blacklist_add_many(&issuer, &issuer, &ns, &token, &add_vec_b2); + client_b.blacklist_remove_many(&issuer, &issuer, &ns, &token, &rem_vec_b2); + let final_state_b = client_b.get_blacklist(&issuer, &ns, &token); + let events_b = collect_blacklist_events(&env_b); + + // Assert final blacklist state identical. + // Contract uses deterministic insertion-order, but because batch dedups based on first + // occurrence, shuffling should still converge to same *set*; however order may differ. + // The requirement says identical final state, so compare as set by sorting. + let mut a_state_std: std::vec::Vec
= Vec::into_iter(final_state_a).collect(); + let mut b_state_std: std::vec::Vec
= Vec::into_iter(final_state_b).collect(); + a_state_std.sort(); + b_state_std.sort(); + prop_assert_eq!(a_state_std, b_state_std); + + // Assert events form an order-equivalent multiset. + let mut a_events_std: std::vec::Vec = Vec::into_iter(events_a).collect(); + let mut b_events_std: std::vec::Vec = Vec::into_iter(events_b).collect(); + a_events_std.sort(); + b_events_std.sort(); + prop_assert_eq!(a_events_std, b_events_std); + + // Empty input no-op with no events. + if addrs.is_empty() && rem_addrs.is_empty() { + // After setup only, no blacklist ops were called with empty vectors (contract returns Ok()). + // However other setup events exist; we only care bl_* events. + prop_assert!(a_events_std.is_empty()); + prop_assert!(b_events_std.is_empty()); + } + } +} +