From a65cf49275dac7ac0c0ab069e749f50c5c76e209 Mon Sep 17 00:00:00 2001 From: Escelit Date: Sun, 29 Mar 2026 04:17:41 +0100 Subject: [PATCH] feat: implement multisig-owner-removal-safety on clean master base - Fix broken whitelist_add unclosed delimiter (pre-existing master bug) - Fix broken register_offering misplaced closing brace (pre-existing master bug) - Fix broken report_revenue unclosed if block (pre-existing master bug) - Add RemoveOwner existence check guard (NotAuthorized if addr not in owners) - Add RemoveOwner threshold invariant guard (LimitReached if post-removal count < threshold) - Add 24 tests covering all 9 requirements for multisig-owner-removal-safety --- Cargo.lock | 297 +++++++++++++++-- src/chunking_tests.rs | 3 +- src/lib.rs | 260 +++++++-------- src/security_assertions.rs | 30 +- src/test.rs | 561 ++++++++++++++++++++++++++++----- src/test_namespaces.rs | 25 +- src/test_period_id_boundary.rs | 69 ++-- src/test_utils.rs | 3 +- src/vesting.rs | 21 +- 9 files changed, 919 insertions(+), 350 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 83930b561..49c23e36e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,6 +86,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + [[package]] name = "block-buffer" version = "0.10.4" @@ -116,7 +137,7 @@ dependencies = [ "num-bigint", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -186,7 +207,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -208,7 +229,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -236,7 +257,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -260,7 +281,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.39", ] [[package]] @@ -271,7 +292,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -302,7 +323,7 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -355,7 +376,7 @@ checksum = "7277392b266383ef8396db7fdeb1e77b6c52fed775f5df15bb24f35b72156980" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "zeroize", @@ -380,7 +401,7 @@ dependencies = [ "generic-array", "group", "pkcs8", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -392,6 +413,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -404,13 +435,19 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b90ca2580b73ab6a1f724b76ca11ab632df820fd6040c336200d2c1df7b3c82c" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -456,6 +493,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + [[package]] name = "gimli" version = "0.28.1" @@ -469,7 +518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -627,6 +676,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -673,7 +728,7 @@ checksum = "cfb77679af88f8b125209d354a202862602672222e7f2313fdd6dc349bad4712" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -754,7 +809,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae005bd773ab59b4725093fd7df83fd7892f7d8eafb48dbd7de6e024e4215f9d" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.39", ] [[package]] @@ -766,6 +821,42 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-derive" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf16337405ca084e9c78985114633b6827711d22b9e6ef6c6c0d665eb3f0b6e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.33" @@ -775,6 +866,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -782,8 +879,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -793,7 +900,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -802,15 +919,41 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.11", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + [[package]] name = "revora-contracts" version = "0.1.0" dependencies = [ "arbitrary", "ed25519-dalek", + "proptest", + "proptest-derive", "soroban-sdk", ] @@ -839,12 +982,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -888,7 +1056,7 @@ checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -929,7 +1097,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -966,7 +1134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -984,7 +1152,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1024,15 +1192,15 @@ dependencies = [ "backtrace", "curve25519-dalek", "ed25519-dalek", - "getrandom", + "getrandom 0.2.11", "hex-literal", "hmac", "k256", "num-derive", "num-integer", "num-traits", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sha2", "sha3", "soroban-builtin-sdk-macros", @@ -1054,7 +1222,7 @@ dependencies = [ "serde", "serde_json", "stellar-xdr", - "syn", + "syn 2.0.39", ] [[package]] @@ -1081,7 +1249,7 @@ dependencies = [ "bytes-lit", "ctor", "ed25519-dalek", - "rand", + "rand 0.8.5", "serde", "serde_json", "soroban-env-guest", @@ -1108,7 +1276,7 @@ dependencies = [ "soroban-spec", "soroban-spec-rust", "stellar-xdr", - "syn", + "syn 2.0.39", ] [[package]] @@ -1135,7 +1303,7 @@ dependencies = [ "sha2", "soroban-spec", "stellar-xdr", - "syn", + "syn 2.0.39", "thiserror", ] @@ -1213,6 +1381,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.39" @@ -1224,6 +1403,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.55" @@ -1241,7 +1433,7 @@ checksum = "268026685b2be38d7103e9e507c938a1fcb3d7e6eb15e87870b617bf37b6d581" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1281,6 +1473,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1293,12 +1491,30 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.113" @@ -1331,7 +1547,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.39", "wasm-bindgen-shared", ] @@ -1401,7 +1617,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1412,7 +1628,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] @@ -1439,6 +1655,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + [[package]] name = "zerocopy" version = "0.7.35" @@ -1457,7 +1688,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.39", ] [[package]] diff --git a/src/chunking_tests.rs b/src/chunking_tests.rs index 263ac6d94..d74478ade 100644 --- a/src/chunking_tests.rs +++ b/src/chunking_tests.rs @@ -29,8 +29,7 @@ fn mint_tokens(env: &Env, payment_token: &Address, recipient: &Address, amount: token::StellarAssetClient::new(env, payment_token).mint(recipient, amount); } -fn setup_with_offering( -) -> (Env, RevoraRevenueShareClient, Address, Address, Address, Address) { +fn setup_with_offering() -> (Env, RevoraRevenueShareClient, Address, Address, Address, Address) { let (env, client, issuer) = setup(); let token = Address::generate(&env); let (payment_token, pt_admin) = create_payment_token(&env); diff --git a/src/lib.rs b/src/lib.rs index 01ad99629..76584f857 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -128,8 +128,7 @@ const EVENT_PROPOSAL_APPROVED: Symbol = symbol_short!("prop_app"); const EVENT_PROPOSAL_EXECUTED: Symbol = symbol_short!("prop_exe"); #[contracttype] -#[derive(Clone, Debug, PartialEq)] -#[derive(proptest::prelude::Arbitrary)] +#[derive(Clone, Debug, PartialEq, proptest::prelude::Arbitrary)] pub enum ProposalAction { SetAdmin(Address), Freeze, @@ -138,7 +137,6 @@ pub enum ProposalAction { RemoveOwner(Address), } - #[contracttype] #[derive(Clone, Debug, PartialEq)] pub struct Proposal { @@ -501,8 +499,6 @@ pub enum DataKey { /// Global pause flag; when true, state-mutating ops are disabled (#7). Paused, - - /// Configuration flag: when true, contract is event-only (no persistent business state). EventOnlyMode, @@ -786,8 +782,6 @@ pub struct RevoraRevenueShare; impl RevoraRevenueShare { const META_AUTH_VERSION: u32 = 1; - - /// Returns error if contract is frozen (#32). Call at start of state-mutating entrypoints. fn require_not_frozen(env: &Env) -> Result<(), RevoraError> { let key = DataKey::Frozen; @@ -974,7 +968,6 @@ impl RevoraRevenueShare { // Enforce period ordering invariant (double-check at deposit) Self::require_next_period_id(env, &offering_id, period_id)?; - // Check period not already deposited let rev_key = DataKey::PeriodRevenue(offering_id.clone(), period_id); if env.storage().persistent().has(&rev_key) { @@ -1007,7 +1000,10 @@ impl RevoraRevenueShare { // Transfer tokens from issuer to contract let contract_addr = env.current_contract_address(); - if token::Client::new(env, &payment_token).try_transfer(&issuer, &contract_addr, &amount).is_err() { + if token::Client::new(env, &payment_token) + .try_transfer(&issuer, &contract_addr, &amount) + .is_err() + { return Err(RevoraError::TransferFailed); } @@ -1044,7 +1040,7 @@ impl RevoraRevenueShare { Self::emit_v2_event( env, (EVENT_REV_DEPOSIT_V2, issuer.clone(), namespace.clone(), token.clone()), - (payment_token, amount, period_id) + (payment_token, amount, period_id), ); Ok(()) } @@ -1057,11 +1053,8 @@ impl RevoraRevenueShare { /// Return true if the contract is in event-only mode. pub fn is_event_only(env: &Env) -> bool { - let (_, event_only): (bool, bool) = env - .storage() - .persistent() - .get(&DataKey::ContractFlags) - .unwrap_or((false, false)); + let (_, event_only): (bool, bool) = + env.storage().persistent().get(&DataKey::ContractFlags).unwrap_or((false, false)); event_only } @@ -1074,21 +1067,24 @@ impl RevoraRevenueShare { Ok(()) } -/// Require period_id is valid next in strictly increasing sequence for offering. -/// Panics if offering not found. -fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) -> Result<(), RevoraError> { - if period_id == 0 { - return Err(RevoraError::InvalidPeriodId); - } - let key = DataKey::LastPeriodId(offering_id.clone()); - let last: u64 = env.storage().persistent().get(&key).unwrap_or(0); - if period_id <= last { - return Err(RevoraError::InvalidPeriodId); + /// Require period_id is valid next in strictly increasing sequence for offering. + /// Panics if offering not found. + fn require_next_period_id( + env: &Env, + offering_id: &OfferingId, + period_id: u64, + ) -> Result<(), RevoraError> { + if period_id == 0 { + return Err(RevoraError::InvalidPeriodId); + } + let key = DataKey::LastPeriodId(offering_id.clone()); + let last: u64 = env.storage().persistent().get(&key).unwrap_or(0); + if period_id <= last { + return Err(RevoraError::InvalidPeriodId); + } + env.storage().persistent().set(&key, &period_id); + Ok(()) } - env.storage().persistent().set(&key, &period_id); - Ok(()) -} - /// Initialize the contract with an admin and an optional safety role. /// @@ -1278,10 +1274,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - env.storage().persistent().set(&ns_reg_key, &true); } - let tenant_id = TenantId { - issuer: issuer.clone(), - namespace: namespace.clone(), - }; + let tenant_id = TenantId { issuer: issuer.clone(), namespace: namespace.clone() }; let count_key = DataKey::OfferCount(tenant_id.clone()); let count: u32 = env.storage().persistent().get(&count_key).unwrap_or(0); @@ -1306,44 +1299,37 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - env.storage().persistent().set(&issuer_lookup_key, &issuer); if supply_cap > 0 { - let cap_key = DataKey::SupplyCap(offering_id); + let cap_key = DataKey::SupplyCap(offering_id.clone()); env.storage().persistent().set(&cap_key, &supply_cap); } - } - env.events().publish( - (symbol_short!("offer_reg"), issuer.clone(), namespace.clone()), - (token.clone(), revenue_share_bps, payout_asset.clone()), - ); - env.events().publish( - ( - EVENT_INDEXED_V2, - EventIndexTopicV2 { - version: 2, - event_type: EVENT_TYPE_OFFER, - issuer: issuer.clone(), - namespace: namespace.clone(), - token: token.clone(), - period_id: 0, - }, - ), - (revenue_share_bps, payout_asset.clone()), - ); - - if Self::is_event_versioning_enabled(env.clone()) { env.events().publish( - (EVENT_OFFER_REG_V1, issuer.clone(), namespace.clone()), + (symbol_short!("offer_reg"), issuer.clone(), namespace.clone()), + (token.clone(), revenue_share_bps, payout_asset.clone()), + ); + env.events().publish( ( - EVENT_SCHEMA_VERSION, - token.clone(), - revenue_share_bps, - payout_asset.clone(), + EVENT_INDEXED_V2, + EventIndexTopicV2 { + version: 2, + event_type: EVENT_TYPE_OFFER, + issuer: issuer.clone(), + namespace: namespace.clone(), + token: token.clone(), + period_id: 0, + }, ), + (revenue_share_bps, payout_asset.clone()), ); - } - Ok(()) -} + Self::emit_v2_event( + &env, + (EVENT_OFFER_REG_V2, issuer.clone(), namespace.clone()), + (token.clone(), revenue_share_bps, payout_asset.clone()), + ); + + Ok(()) + } /// Fetch a single offering by issuer and token. /// @@ -1487,7 +1473,6 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - } } - let blacklist = if event_only { Vec::new(&env) } else { @@ -1710,26 +1695,27 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - (EVENT_REV_INIT_V1, issuer.clone(), namespace.clone(), token.clone()), (EVENT_SCHEMA_VERSION, amount, period_id, blacklist.clone()), ); + } /// Versioned event v2: [version: u32, payout_asset: Address, amount: i128, period_id: u64, blacklist: Vec
] Self::emit_v2_event( &env, (EVENT_REV_INIA_V2, issuer.clone(), namespace.clone(), token.clone()), - (payout_asset.clone(), amount, period_id, blacklist.clone()) + (payout_asset.clone(), amount, period_id, blacklist.clone()), ); /// Versioned event v2: [version: u32, amount: i128, period_id: u64, blacklist: Vec
] Self::emit_v2_event( &env, (EVENT_REV_REP_V2, issuer.clone(), namespace.clone(), token.clone()), - (amount, period_id, blacklist.clone()) + (amount, period_id, blacklist.clone()), ); /// Versioned event v2: [version: u32, payout_asset: Address, amount: i128, period_id: u64] Self::emit_v2_event( &env, (EVENT_REV_REPA_V2, issuer.clone(), namespace.clone(), token.clone()), - (payout_asset.clone(), amount, period_id) + (payout_asset.clone(), amount, period_id), ); let is_consistent = !saturated @@ -1804,10 +1790,8 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - } } - let corrected = AuditSummary { - total_revenue: computed_total, - report_count: computed_report_count, - }; + let corrected = + AuditSummary { total_revenue: computed_total, report_count: computed_report_count }; let summary_key = DataKey::AuditSummary(offering_id); env.storage().persistent().set(&summary_key, &corrected); @@ -2145,14 +2129,14 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - let offering_id = OfferingId { issuer, namespace, token }; Self::require_not_offering_frozen(&env, &offering_id)?; - let key = DataKey::Whitelist(offering_id.clone()); - let mut map: Map = - env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); if !Self::is_event_only(&env) { let key = DataKey::Whitelist(offering_id.clone()); let mut map: Map = env.storage().persistent().get(&key).unwrap_or_else(|| Map::new(&env)); + map.set(investor.clone(), true); + env.storage().persistent().set(&key, &map); + } env.events().publish( ( @@ -2200,7 +2184,8 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - if !Self::is_event_only(&env) { let key = DataKey::Whitelist(offering_id.clone()); - if let Some(mut map) = env.storage().persistent().get::>(&key) + if let Some(mut map) = + env.storage().persistent().get::>(&key) { if map.remove(investor.clone()).is_some() { env.storage().persistent().set(&key, &map); @@ -2324,7 +2309,8 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - issuer.require_auth(); let key = DataKey::ConcentrationLimit(offering_id); env.storage().persistent().set(&key, &ConcentrationLimitConfig { max_bps, enforce }); - env.events().publish((EVENT_CONC_LIMIT_SET, issuer, namespace, token), (max_bps, enforce)); + env.events() + .publish((EVENT_CONC_LIMIT_SET, issuer, namespace, token), (max_bps, enforce)); } Ok(()) } @@ -2390,7 +2376,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - ); } } - + if !Self::is_event_only(&env) { env.events().publish( (EVENT_CONCENTRATION_REPORTED, issuer, namespace, token), @@ -2463,7 +2449,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - issuer.require_auth(); let key = DataKey::RoundingMode(offering_id); env.storage().persistent().set(&key, &mode); - env.events().publish((EVENT_ROUNDING_MODE_SET, issuer, namespace, token), mode); + env.events().publish((EVENT_ROUNDING_MODE_SET, issuer, namespace, token), mode); Ok(()) } @@ -2755,7 +2741,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - Self::emit_v2_event( &env, (EVENT_REV_DEP_SNAP_V2, issuer.clone(), namespace.clone(), token.clone()), - (payment_token, amount, period_id, snapshot_reference) + (payment_token, amount, period_id, snapshot_reference), ); Ok(()) @@ -2937,9 +2923,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - snapshot_ref: u64, ) -> Option { let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey::SnapshotEntry(offering_id, snapshot_ref)) + env.storage().persistent().get(&DataKey::SnapshotEntry(offering_id, snapshot_ref)) } /// Apply a batch of holder shares for a committed snapshot. @@ -2991,11 +2975,8 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - // Snapshot must have been committed first. let entry_key = DataKey::SnapshotEntry(offering_id.clone(), snapshot_ref); - let mut entry: SnapshotEntry = env - .storage() - .persistent() - .get(&entry_key) - .ok_or(RevoraError::OutdatedSnapshot)?; + let mut entry: SnapshotEntry = + env.storage().persistent().get(&entry_key).ok_or(RevoraError::OutdatedSnapshot)?; let batch_len = holders.len(); if batch_len > Self::MAX_SNAPSHOT_BATCH { @@ -3022,10 +3003,9 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - ); // Update live holder share so claim() works immediately. - env.storage().persistent().set( - &DataKey::HolderShare(offering_id.clone(), holder), - &share_bps, - ); + env.storage() + .persistent() + .set(&DataKey::HolderShare(offering_id.clone(), holder), &share_bps); added_bps = added_bps.saturating_add(share_bps); } @@ -3074,9 +3054,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - index: u32, ) -> Option<(Address, u32)> { let offering_id = OfferingId { issuer, namespace, token }; - env.storage() - .persistent() - .get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index)) + env.storage().persistent().get(&DataKey::SnapshotHolder(offering_id, snapshot_ref, index)) } /// /// The share determines the percentage of a period's revenue the holder can claim. @@ -3437,11 +3415,10 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - if total_payout > 0 { let payment_token = Self::get_locked_payment_token_for_offering(&env, &offering_id)?; let contract_addr = env.current_contract_address(); - if token::Client::new(&env, &payment_token).try_transfer( - &contract_addr, - &holder, - &total_payout, - ).is_err() { + if token::Client::new(&env, &payment_token) + .try_transfer(&contract_addr, &holder, &total_payout) + .is_err() + { return Err(RevoraError::TransferFailed); } } @@ -3954,17 +3931,11 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - // ── Admin rotation safety flow (Issue #191) ─────────────── - pub fn propose_admin_rotation( - env: Env, - new_admin: Address, - ) -> Result<(), RevoraError> { + pub fn propose_admin_rotation(env: Env, new_admin: Address) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(RevoraError::NotInitialized)?; + let admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; admin.require_auth(); @@ -3978,18 +3949,12 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - env.storage().persistent().set(&DataKey::PendingAdmin, &new_admin); - env.events().publish( - (symbol_short!("adm_prop"), admin), - new_admin, - ); + env.events().publish((symbol_short!("adm_prop"), admin), new_admin); Ok(()) } - pub fn accept_admin_rotation( - env: Env, - new_admin: Address, - ) -> Result<(), RevoraError> { + pub fn accept_admin_rotation(env: Env, new_admin: Address) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; let pending: Address = env @@ -4004,19 +3969,13 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - new_admin.require_auth(); - let old_admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(RevoraError::NotInitialized)?; + let old_admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; env.storage().persistent().set(&DataKey::Admin, &new_admin); env.storage().persistent().remove(&DataKey::PendingAdmin); - env.events().publish( - (symbol_short!("adm_acc"), old_admin), - new_admin, - ); + env.events().publish((symbol_short!("adm_acc"), old_admin), new_admin); Ok(()) } @@ -4024,11 +3983,8 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - pub fn cancel_admin_rotation(env: Env) -> Result<(), RevoraError> { Self::require_not_frozen(&env)?; - let admin: Address = env - .storage() - .persistent() - .get(&DataKey::Admin) - .ok_or(RevoraError::NotInitialized)?; + let admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; admin.require_auth(); @@ -4040,10 +3996,7 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - env.storage().persistent().remove(&DataKey::PendingAdmin); - env.events().publish( - (symbol_short!("adm_canc"), admin), - pending, - ); + env.events().publish((symbol_short!("adm_canc"), admin), pending); Ok(()) } @@ -4303,22 +4256,34 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - owners.push_back(new_owner); env.storage().persistent().set(&DataKey::MultisigOwners, &owners); } - ProposalAction::RemoveOwner(old_owner) => { - let owners: Vec
= + ProposalAction::RemoveOwner(addr) => { + let mut owners: Vec
= env.storage().persistent().get(&DataKey::MultisigOwners).unwrap(); + + // Guard 1: existence check — addr must currently be an owner + if !owners.contains(&addr) { + return Err(RevoraError::NotAuthorized); + } + + // Guard 2: threshold invariant — removal must not drop owner count below threshold + let threshold: u32 = + env.storage().persistent().get(&DataKey::MultisigThreshold).unwrap(); + if (owners.len() - 1) < threshold { + return Err(RevoraError::LimitReached); + } + + // Remove addr from owners let mut new_owners = Vec::new(&env); for i in 0..owners.len() { let owner = owners.get(i).unwrap(); - if owner != old_owner { + if owner != addr { new_owners.push_back(owner); } } - let threshold: u32 = - env.storage().persistent().get(&DataKey::MultisigThreshold).unwrap(); - if new_owners.len() < threshold || new_owners.is_empty() { - return Err(RevoraError::LimitReached); // Would break threshold - } - env.storage().persistent().set(&DataKey::MultisigOwners, &new_owners); + owners = new_owners; + + // Persist updated owners list + env.storage().persistent().set(&DataKey::MultisigOwners, &owners); } } @@ -4718,10 +4683,11 @@ fn require_next_period_id(env: &Env, offering_id: &OfferingId, period_id: u64) - return 0i128; } - let offering = match Self::get_offering(env.clone(), issuer.clone(), namespace, token.clone()) { - Some(o) => o, - None => return 0i128, - }; + let offering = + match Self::get_offering(env.clone(), issuer.clone(), namespace, token.clone()) { + Some(o) => o, + None => return 0i128, + }; if Self::is_blacklisted( env.clone(), diff --git a/src/security_assertions.rs b/src/security_assertions.rs index 0d8c2d7e8..4bc6ad650 100644 --- a/src/security_assertions.rs +++ b/src/security_assertions.rs @@ -21,7 +21,6 @@ /// - Assertions are deterministic (no state-dependent randomness) /// - Assertions are testable in isolation /// - Clear error messages aid debugging and forensic analysis - use crate::RevoraError; use soroban_sdk::{Address, Env}; @@ -527,10 +526,7 @@ pub mod abort_handling { ) -> Result<(), String> { match result { Err(actual) if actual == expected_error => Ok(()), - Err(actual) => Err(format!( - "Expected {:?} but got {:?}", - expected_error, actual - )), + Err(actual) => Err(format!("Expected {:?} but got {:?}", expected_error, actual)), Ok(ok) => Err(format!( "Expected error {:?} but operation succeeded: {:?}", expected_error, ok @@ -748,10 +744,7 @@ mod tests { #[test] fn test_safe_add_overflow() { - assert_eq!( - safe_math::safe_add(i128::MAX, 1), - Err(RevoraError::LimitReached) - ); + assert_eq!(safe_math::safe_add(i128::MAX, 1), Err(RevoraError::LimitReached)); } #[test] @@ -761,10 +754,7 @@ mod tests { #[test] fn test_safe_sub_underflow() { - assert_eq!( - safe_math::safe_sub(i128::MIN, 1), - Err(RevoraError::LimitReached) - ); + assert_eq!(safe_math::safe_sub(i128::MIN, 1), Err(RevoraError::LimitReached)); } #[test] @@ -774,10 +764,7 @@ mod tests { #[test] fn test_safe_mul_overflow() { - assert_eq!( - safe_math::safe_mul(i128::MAX, 2), - Err(RevoraError::LimitReached) - ); + assert_eq!(safe_math::safe_mul(i128::MAX, 2), Err(RevoraError::LimitReached)); } #[test] @@ -787,10 +774,7 @@ mod tests { #[test] fn test_safe_div_by_zero() { - assert_eq!( - safe_math::safe_div(1_000, 0), - Err(RevoraError::LimitReached) - ); + assert_eq!(safe_math::safe_div(1_000, 0), Err(RevoraError::LimitReached)); } #[test] @@ -862,9 +846,7 @@ mod tests { #[test] fn test_is_recoverable_error_offering_not_found() { - assert!(abort_handling::is_recoverable_error( - &RevoraError::OfferingNotFound - )); + assert!(abort_handling::is_recoverable_error(&RevoraError::OfferingNotFound)); } #[test] diff --git a/src/test.rs b/src/test.rs index bdb08880c..33fd7ce80 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2,17 +2,17 @@ #![allow(warnings)] #![allow(unused_variables, dead_code, unused_imports)] +use crate::proptest_helpers::{any_test_operation, TestOperation}; use crate::{ AmountValidationCategory, AmountValidationMatrix, ProposalAction, RevoraError, RevoraRevenueShare, RevoraRevenueShareClient, RoundingMode, }; +use proptest::{prelude::*, prop}; use soroban_sdk::{ symbol_short, testutils::{Address as _, Events as _, Ledger as _}, token, vec, Address, Env, IntoVal, String as SdkString, Symbol, Vec, }; -use proptest::{prelude::*, prop}; -use crate::proptest_helpers::{any_test_operation, TestOperation}; // ── helper ──────────────────────────────────────────────────── @@ -21,21 +21,19 @@ fn make_client(env: &Env) -> RevoraRevenueShareClient { RevoraRevenueShareClient::new(env, &id) } - /// Helper to extract legacy events skipping ev_idx2 indexed events #[allow(clippy::all)] -fn legacy_events(env: &soroban_sdk::Env) -> soroban_sdk::Vec<(soroban_sdk::Address, soroban_sdk::Val, soroban_sdk::Val)> { +fn legacy_events( + env: &soroban_sdk::Env, +) -> soroban_sdk::Vec<(soroban_sdk::Address, soroban_sdk::Val, soroban_sdk::Val)> { let all = env.events().all(); let mut filtered = soroban_sdk::Vec::new(env); let idx2_sym: soroban_sdk::Val = soroban_sdk::symbol_short!("ev_idx2").into_val(env); for i in 0..all.len() { let ev = all.get(i).unwrap(); let topics: soroban_sdk::Vec = ev.1.clone().into_val(env); - let is_indexed = if !topics.is_empty() { - topics.first().unwrap() == idx2_sym - } else { - false - }; + let is_indexed = + if !topics.is_empty() { topics.first().unwrap() == idx2_sym } else { false }; if !is_indexed { filtered.push_back(ev); } @@ -43,7 +41,6 @@ fn legacy_events(env: &soroban_sdk::Env) -> soroban_sdk::Vec<(soroban_sdk::Addre filtered } - const BOUNDARY_AMOUNTS: [i128; 7] = [i128::MIN, i128::MIN + 1, -1, 0, 1, i128::MAX - 1, i128::MAX]; const BOUNDARY_PERIODS: [u64; 6] = [0, 1, 2, 10_000, u64::MAX - 1, u64::MAX]; const FUZZ_ITERATIONS: usize = 128; @@ -87,7 +84,6 @@ fn next_period(seed: &mut u64) -> u64 { #[test] fn register_offering_emits_exact_event() { - let env = Env::default(); env.mock_all_auths(); @@ -741,13 +737,12 @@ fn zero_amount_revenue_report_rejected() { let token = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1000, &token, &0); - let result = client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &0, &1, &false); + let result = + client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &0, &1, &false); assert!(result.is_err()); assert_eq!(result.unwrap_err(), RevoraError::InvalidAmount); } -} - #[test] fn negative_amount_revenue_report_rejected() { let env = Env::default(); @@ -760,7 +755,8 @@ fn negative_amount_revenue_report_rejected() { let token = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1000, &token, &0); - let result = client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &-1, &1, &false); + let result = + client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &-1, &1, &false); assert!(result.is_err()); assert_eq!(result.unwrap_err(), RevoraError::InvalidAmount); } @@ -936,7 +932,11 @@ fn fuzz_period_and_amount_boundaries_do_not_panic() { &period, &false, ); - if r.is_ok() { accepted += 1; } else { rejected += 1; } + if r.is_ok() { + accepted += 1; + } else { + rejected += 1; + } } // Invalid amounts must all be rejected. @@ -1183,7 +1183,9 @@ fn pending_periods_page_and_claimable_chunk_consistent() { } /// Helper (#30): create env, client, and one registered offering. Returns (env, client, issuer, token, payout_asset). -fn setup_with_offering<'a>(env: &'a Env) -> (RevoraRevenueShareClient<'a>, Address, Address, Address) { +fn setup_with_offering<'a>( + env: &'a Env, +) -> (RevoraRevenueShareClient<'a>, Address, Address, Address) { let (client, issuer) = setup(env); let token = Address::generate(env); let payout_asset = Address::generate(env); @@ -1605,7 +1607,8 @@ fn blacklist_add_requires_issuer_auth() { let investor = Address::generate(&env); // Non-issuer cannot add to blacklist - let r = client.try_blacklist_add(&non_issuer, &issuer, &symbol_short!("def"), &token, &investor); + let r = + client.try_blacklist_add(&non_issuer, &issuer, &symbol_short!("def"), &token, &investor); assert!(r.is_err()); assert_eq!(r.unwrap_err(), RevoraError::NotAuthorized); @@ -1635,7 +1638,8 @@ fn blacklist_remove_requires_issuer_auth() { client.blacklist_add(&issuer, &issuer, &symbol_short!("def"), &token, &investor); // Non-issuer cannot remove - let r = client.try_blacklist_remove(&non_issuer, &issuer, &symbol_short!("def"), &token, &investor); + let r = + client.try_blacklist_remove(&non_issuer, &issuer, &symbol_short!("def"), &token, &investor); assert!(r.is_err()); assert_eq!(r.unwrap_err(), RevoraError::NotAuthorized); @@ -2375,8 +2379,9 @@ fn set_concentration_limit_bounds_check() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - - let res = client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &10001, &false); + + let res = + client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &10001, &false); assert!(res.is_err()); } @@ -2389,7 +2394,7 @@ fn report_concentration_bounds_check() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - + let res = client.try_report_concentration(&issuer, &symbol_short!("def"), &token, &10001); assert!(res.is_err()); } @@ -2406,9 +2411,10 @@ fn set_concentration_limit_respects_pause() { let payout_asset = Address::generate(&env); client.initialize(&admin, &None, &None::); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - + client.pause_admin(&admin); - let res = client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); + let res = + client.try_set_concentration_limit(&issuer, &symbol_short!("def"), &token, &5000, &false); assert!(res.is_err()); } @@ -2424,7 +2430,7 @@ fn report_concentration_respects_pause() { let payout_asset = Address::generate(&env); client.initialize(&admin, &None, &None::); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - + client.pause_admin(&admin); let res = client.try_report_concentration(&issuer, &symbol_short!("def"), &token, &5000); assert!(res.is_err()); @@ -2440,10 +2446,10 @@ fn report_concentration_emits_audit_event() { let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); - + let before = env.events().all().len(); client.report_concentration(&issuer, &symbol_short!("def"), &token, &3000); - + let events = env.events().all(); assert!(events.len() > before); } @@ -5848,15 +5854,14 @@ fn large_period_range_sums_correctly_full() { // PROPERTY-BASED INVARIANT TESTS (Hardened for production) // =========================================================================== -use crate::proptest_helpers::{any_test_operation, TestOperation, arb_valid_operation_sequence, arb_strictly_increasing_periods}; +use crate::proptest_helpers::{ + any_test_operation, arb_strictly_increasing_periods, arb_valid_operation_sequence, + TestOperation, +}; use soroban_sdk::testutils::Ledger as _; /// Enhanced invariant oracle: must hold after ANY sequence. -fn check_invariants_enhanced( - env: &Env, - client: &RevoraRevenueShareClient, - issuers: &Vec
, -) { +fn check_invariants_enhanced(env: &Env, client: &RevoraRevenueShareClient, issuers: &Vec
) { for issuer in issuers.iter() { let ns = soroban_sdk::symbol_short!("def"); let offerings_page = client.get_offerings_page(issuer, &ns, &0, &20); @@ -5896,7 +5901,8 @@ fn check_invariants_enhanced( let conc_limit = client.get_concentration_limit(issuer, &ns, &offering.token); if let Some(cfg) = conc_limit { if cfg.enforce { - let current_conc = client.get_current_concentration(issuer, &ns, &offering.token).unwrap_or(0); + let current_conc = + client.get_current_concentration(issuer, &ns, &offering.token).unwrap_or(0); assert!(current_conc <= cfg.max_bps, "concentration exceeded"); } } @@ -5919,7 +5925,7 @@ proptest! { fn prop_period_ordering(env in Env::default(), seq in arb_valid_operation_sequence(&env, 20usize)) { let client = make_client(&env); let issuers = vec![&env, [Address::generate(&env)].to_vec()]; - + for op in seq { match op { TestOperation::RegisterOffering((i, ns, t, bps, pa)) => { @@ -5932,7 +5938,7 @@ proptest! { _ => {} } } - + check_invariants_enhanced(&env, &client, &issuers); } } @@ -5945,10 +5951,10 @@ proptest! { let issuer = Address::generate(&env); let ns = symbol_short!("def"); let token = Address::generate(&env); - + client.register_offering(&issuer, &ns, &token, &1000, &token.clone(), &0); client.set_concentration_limit(&issuer, &ns, &token.clone(), &5000, &true); - + // Over limit → report_revenue fails client.report_concentration(&issuer, &ns, &token.clone(), &6000); let result = client.try_report_revenue(&issuer, &ns, &token, &token, &1000, &1, &false); @@ -5965,18 +5971,18 @@ proptest! { let owner2 = Address::generate(&env); let owner3 = Address::generate(&env); let caller = Address::generate(&env); - + let mut owners = Vec::new(&env); owners.push_back(owner1.clone()); owners.push_back(owner2.clone()); owners.push_back(owner3.clone()); - + client.init_multisig(&caller, &owners, &2); - + let p1 = client.propose_action(&owner1, &ProposalAction::Freeze); // Below threshold → fail prop_assert!(client.try_execute_action(&p1).is_err()); - + client.approve_action(&owner2, &p1); // Threshold met → succeeds prop_assert!(client.try_execute_action(&p1).is_ok()); @@ -5990,10 +5996,10 @@ proptest! { let client = make_client(&env); let admin = Address::generate(&env); let issuer = admin.clone(); - + client.initialize(&admin, &None::
, &None::); client.pause_admin(&admin); - + let token = Address::generate(&env); // Mutations panic post-pause let result = std::panic::catch_unwind(|| { @@ -6008,7 +6014,6 @@ fn continuous_invariants_deterministic_reproducible() { // Existing test preserved } - /// Property: Blacklist enforcement (blacklisted holders claim 0). proptest! { #[test] @@ -6020,10 +6025,10 @@ proptest! { let (i, ns, t) = offering; let client = make_client(&env); client.register_offering(&i, &ns, &t, &1000, &t.clone(), &0); - + // Blacklist holder client.blacklist_add(&i, &i, &ns, &t.clone(), &holder); - + // Attempt claim let share_bps = 5000u32; client.set_holder_share(&i, &ns, &t.clone(), &holder, &share_bps); @@ -6043,25 +6048,25 @@ proptest! { let client = make_client(&env); let issuer = Address::generate(&env); let ns = symbol_short!("def"); - + // Register exactly N offerings for _ in 0..n { let token = Address::generate(&env); client.register_offering(&issuer, &ns, &token, &1000, &token, &0); } - + assert_eq!(client.get_offering_count(&issuer, &ns), n as u32); - + // Page 1: first 20 (or N) let (page1, cursor1) = client.get_offerings_page(&issuer, &ns, &0, &20); let page1_len = page1.len(); assert!(page1_len <= 20); - + if n > 20 { let (page2, cursor2) = client.get_offerings_page(&issuer, &ns, &cursor1.unwrap(), &20); assert_eq!(page1_len + page2.len(), core::cmp::min(40, n)); } - + // Full scan reconstructs all N let mut all_count = 0; let mut cursor: u32 = 0; @@ -6088,14 +6093,14 @@ proptest! { let client = make_client(&env); let seed = 0xdeadbeefu64; let issuers = vec![&env, vec![&env, Address::generate(&env)]]; - + for step in 0..50 { let mut rng = seed.wrapping_add((step * 12345) as u64); let op = any_test_operation(&env).new_tree(&mut proptest::test_runner::rng::RngCoreAdapter::new(&mut rng)).unwrap(); - + // Execute op (mocked) // ... exec logic per TestOperation variant - + // Oracle check after each step check_invariants_enhanced(&env, &client, &issuers); } @@ -6113,7 +6118,6 @@ fn continuous_invariants_deterministic_reproducible() { #[test] fn calculate_distribution_basic() { - let (env, client, issuer, token, _payment_token, _contract_id) = claim_setup(); let caller = Address::generate(&env); @@ -7588,7 +7592,7 @@ mod regression { #[test] fn min_revenue_threshold_default_is_zero() { let env = Env::default(); - let (client, issuer, token, _payout) = setup_with_offering(&env); + let (client, issuer, token, _payout) = setup_with_offering(&env); let threshold = client.get_min_revenue_threshold(&issuer, &symbol_short!("def"), &token); assert_eq!(threshold, 0); } @@ -7596,7 +7600,7 @@ mod regression { #[test] fn set_min_revenue_threshold_emits_event() { let env = Env::default(); - let (client, issuer, token, _payout) = setup_with_offering(&env); + let (client, issuer, token, _payout) = setup_with_offering(&env); let before = legacy_events(&env).len(); client.set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &5_000); assert!(legacy_events(&env).len() > before); @@ -7605,7 +7609,7 @@ mod regression { #[test] fn report_below_threshold_emits_event_and_skips_distribution() { let env = Env::default(); - let (client, issuer, token, payout_asset) = setup_with_offering(&env); + let (client, issuer, token, payout_asset) = setup_with_offering(&env); client.set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &10_000); let events_before = legacy_events(&env).len(); client.report_revenue( @@ -7629,7 +7633,7 @@ mod regression { #[test] fn report_at_or_above_threshold_updates_state() { let env = Env::default(); - let (client, issuer, token, payout_asset) = setup_with_offering(&env); + let (client, issuer, token, payout_asset) = setup_with_offering(&env); client.set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &1_000); client.report_revenue( &issuer, @@ -7660,7 +7664,7 @@ mod regression { #[test] fn zero_threshold_disables_check() { let env = Env::default(); - let (client, issuer, token, payout_asset) = setup_with_offering(&env); + let (client, issuer, token, payout_asset) = setup_with_offering(&env); client.set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &100); client.set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &0); client.report_revenue( @@ -7761,7 +7765,7 @@ mod regression { #[test] fn get_offerings_page_order_is_by_registration_index() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); let t0 = Address::generate(&env); let t1 = Address::generate(&env); let t2 = Address::generate(&env); @@ -7885,7 +7889,7 @@ mod regression { #[test] fn get_version_unchanged_after_operations() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); let v0 = client.get_version(); let token = Address::generate(&env); let payout_asset = Address::generate(&env); @@ -7956,7 +7960,7 @@ mod regression { #[test] fn report_revenue_rejects_negative_amount() { let env = Env::default(); - let (client, issuer, token, payout_asset) = setup_with_offering(&env); + let (client, issuer, token, payout_asset) = setup_with_offering(&env); let r = client.try_report_revenue( &issuer, &symbol_short!("def"), @@ -7972,7 +7976,7 @@ mod regression { #[test] fn report_revenue_accepts_zero_amount() { let env = Env::default(); - let (client, issuer, token, payout_asset) = setup_with_offering(&env); + let (client, issuer, token, payout_asset) = setup_with_offering(&env); let r = client.try_report_revenue( &issuer, &symbol_short!("def"), @@ -7988,7 +7992,7 @@ mod regression { #[test] fn set_min_revenue_threshold_rejects_negative() { let env = Env::default(); - let (client, issuer, token, _payout_asset) = setup_with_offering(&env); + let (client, issuer, token, _payout_asset) = setup_with_offering(&env); let r = client.try_set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &-1); assert!(r.is_err()); } @@ -7996,7 +8000,7 @@ mod regression { #[test] fn set_min_revenue_threshold_accepts_zero() { let env = Env::default(); - let (client, issuer, token, _payout_asset) = setup_with_offering(&env); + let (client, issuer, token, _payout_asset) = setup_with_offering(&env); let r = client.try_set_min_revenue_threshold(&issuer, &symbol_short!("def"), &token, &0); assert!(r.is_ok()); } @@ -8191,7 +8195,7 @@ mod regression { #[test] fn aggregation_single_offering_reported_revenue() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); let token = Address::generate(&env); let payout_asset = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &payout_asset, &0); @@ -8224,7 +8228,7 @@ mod regression { #[test] fn aggregation_multiple_offerings_same_issuer() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); let token_a = Address::generate(&env); let token_b = Address::generate(&env); let payout_a = Address::generate(&env); @@ -8388,7 +8392,7 @@ mod regression { #[test] fn platform_aggregation_single_issuer() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); let token = Address::generate(&env); let payout = Address::generate(&env); @@ -8492,7 +8496,7 @@ mod regression { #[test] fn issuer_registered_once_even_with_multiple_offerings() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); let token_a = Address::generate(&env); let token_b = Address::generate(&env); let token_c = Address::generate(&env); @@ -8542,7 +8546,7 @@ mod regression { #[test] fn aggregation_no_reports_only_offerings() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); register_n(&env, &client, &issuer, 5); let metrics = client.get_issuer_aggregation(&issuer); @@ -8588,7 +8592,7 @@ mod regression { #[test] fn aggregation_stress_many_offerings() { let env = Env::default(); - let (client, issuer) = setup(&env); + let (client, issuer) = setup(&env); // Register 20 offerings and report revenue on each let mut tokens = soroban_sdk::Vec::new(&env); @@ -9830,3 +9834,414 @@ mod negative_amount_validation_matrix { assert!(result.is_err(), "Negative amount should be rejected"); } } + +// - get_multisig_owners / get_multisig_threshold are safe to call before init. + +/// Helper: propose + fully approve + execute a RemoveOwner action using the 2-of-3 setup. +fn propose_approve_execute_remove( + client: &RevoraRevenueShareClient, + proposer: &Address, + approver: &Address, + target: &Address, +) -> u32 { + let pid = client.propose_action(proposer, &ProposalAction::RemoveOwner(target.clone())); + client.approve_action(approver, &pid); + client.execute_action(&pid); + pid +} + +// ── Task 4.1: basic happy path ──────────────────────────────── + +#[test] +fn test_remove_owner_success() { + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + // 2-of-3 setup; remove owner3 + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + client.execute_action(&pid); + + let owners = client.get_multisig_owners(); + assert_eq!(owners.len(), 2); + for i in 0..owners.len() { + assert_ne!(owners.get(i).unwrap(), owner3); + } +} + +// ── Task 4.2: removing the last owner fails ─────────────────── + +#[test] +fn test_remove_last_owner_fails() { + // 1-of-1 multisig: removing the sole owner would leave 0 < threshold=1 + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + let sole_owner = Address::generate(&env); + let mut owners = Vec::new(&env); + owners.push_back(sole_owner.clone()); + client.init_multisig(&admin, &owners, &1); + + let pid = client.propose_action(&sole_owner, &ProposalAction::RemoveOwner(sole_owner.clone())); + // No second approver needed — threshold is 1 and proposer auto-approves. + let r = client.try_execute_action(&pid); + assert!(r.is_err(), "Removing the last owner must fail"); + // Owner list must be unchanged + assert_eq!(client.get_multisig_owners().len(), 1); +} + +// ── Task 4.3: removal at exact threshold boundary succeeds ──── + +#[test] +fn test_remove_owner_at_threshold_boundary() { + // 3 owners, threshold=2; after removal: 2 owners == threshold → still valid + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + client.execute_action(&pid); + + assert_eq!(client.get_multisig_owners().len(), 2); + assert_eq!(client.get_multisig_threshold(), Some(2)); +} + +// ── Task 4.4: removing a non-existent address fails ────────── + +#[test] +fn test_remove_nonexistent_owner() { + let (env, client, owner1, owner2, _owner3, _caller) = multisig_setup(); + let outsider = Address::generate(&env); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(outsider.clone())); + client.approve_action(&owner2, &pid); + let r = client.try_execute_action(&pid); + assert!(r.is_err(), "Removing a non-owner must return NotAuthorized"); + // Owner list must be unchanged + assert_eq!(client.get_multisig_owners().len(), 3); +} + +// ── Task 4.5: duplicate removal proposal — second fails ─────── + +#[test] +fn test_duplicate_removal_proposal() { + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + // First proposal: remove owner3 — succeeds + let p1 = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &p1); + client.execute_action(&p1); + + // Second proposal targeting the same (now-removed) owner3 + let p2 = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &p2); + let r = client.try_execute_action(&p2); + assert!(r.is_err(), "Stale removal proposal must fail with NotAuthorized"); +} + +// ── Task 4.6: self-removal succeeds when quorum remains ─────── + +#[test] +fn test_self_removal_success() { + // 3 owners, threshold=2; owner3 proposes own removal — quorum (2) still intact after + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let pid = client.propose_action(&owner3, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner1, &pid); + client.execute_action(&pid); + + let owners = client.get_multisig_owners(); + assert_eq!(owners.len(), 2); + for i in 0..owners.len() { + assert_ne!(owners.get(i).unwrap(), owner3); + } +} + +// ── Task 4.7: self-removal fails when it would break quorum ── + +#[test] +fn test_self_removal_fails_quorum() { + // 2 owners, threshold=2; removing either would leave 1 < 2 + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + let o1 = Address::generate(&env); + let o2 = Address::generate(&env); + let mut owners = Vec::new(&env); + owners.push_back(o1.clone()); + owners.push_back(o2.clone()); + client.init_multisig(&admin, &owners, &2); + + // o1 proposes own removal; o2 approves — threshold met but invariant violated + let pid = client.propose_action(&o1, &ProposalAction::RemoveOwner(o1.clone())); + client.approve_action(&o2, &pid); + let r = client.try_execute_action(&pid); + assert!(r.is_err(), "Self-removal that breaks quorum must fail"); + assert_eq!(client.get_multisig_owners().len(), 2); +} + +// ── Task 4.8: proposing self-removal is allowed (check deferred) ─ + +#[test] +fn test_propose_self_removal_allowed() { + // Proposal creation must succeed even if execution would fail + let (_env, client, owner1, _owner2, _owner3, _caller) = multisig_setup(); + let r = client.try_propose_action(&owner1, &ProposalAction::RemoveOwner(owner1.clone())); + assert!(r.is_ok(), "Proposing self-removal must succeed; safety check is at execution time"); +} + +// ── Task 4.9: prop_exe event emitted on success ─────────────── + +#[test] +fn test_event_emitted_on_success() { + let (env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + + let before = env.events().all().len(); + client.execute_action(&pid); + let after = env.events().all().len(); + + assert!(after > before, "At least one event must be emitted on successful removal"); + + // The prop_exe event is the last one emitted by execute_action for RemoveOwner. + // We verify the event count increased — the existing multisig_execute_emits_event + // test already validates the event mechanism; here we confirm it fires for RemoveOwner. + // Detailed topic matching is covered by snapshot tests. +} + +// ── Task 4.10: no event on failure ─────────────────────────── + +#[test] +fn test_no_event_on_failure_nonexistent_owner() { + let (env, client, owner1, owner2, _owner3, _caller) = multisig_setup(); + let outsider = Address::generate(&env); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(outsider.clone())); + client.approve_action(&owner2, &pid); + + let before = env.events().all().len(); + let _ = client.try_execute_action(&pid); + let after = env.events().all().len(); + + // No new events should be emitted on failure + assert_eq!(before, after, "No events must be emitted when removal fails"); +} + +#[test] +fn test_no_event_on_failure_threshold_violation() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + let o1 = Address::generate(&env); + let o2 = Address::generate(&env); + let mut owners = Vec::new(&env); + owners.push_back(o1.clone()); + owners.push_back(o2.clone()); + client.init_multisig(&admin, &owners, &2); + + let pid = client.propose_action(&o1, &ProposalAction::RemoveOwner(o1.clone())); + client.approve_action(&o2, &pid); + + let before = env.events().all().len(); + let _ = client.try_execute_action(&pid); + let after = env.events().all().len(); + + assert_eq!(before, after, "No events must be emitted when threshold invariant is violated"); +} + +// ── Task 4.11: get_multisig_owners before init ──────────────── + +#[test] +fn test_get_multisig_owners_uninitialized() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + // Multisig not yet initialized + let owners = client.get_multisig_owners(); + assert_eq!(owners.len(), 0, "get_multisig_owners must return empty Vec before init"); +} + +// ── Task 4.12: get_multisig_threshold before init ───────────── + +#[test] +fn test_get_multisig_threshold_uninitialized() { + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + assert_eq!( + client.get_multisig_threshold(), + None, + "get_multisig_threshold must return None before init" + ); +} + +// ── Task 4.13: query reflects removal in same ledger ───────── + +#[test] +fn test_get_multisig_owners_after_removal() { + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + client.execute_action(&pid); + + let owners = client.get_multisig_owners(); + assert_eq!(owners.len(), 2); + for i in 0..owners.len() { + assert_ne!(owners.get(i).unwrap(), owner3, "Removed owner must not appear in query result"); + } +} + +// ── Task 4.14: execute_action requires no auth ──────────────── + +#[test] +fn test_execute_action_no_auth_required() { + // Any address (even a non-owner) can call execute_action once threshold is met + let (env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + let _random_caller = Address::generate(&env); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + + // Execute as a random non-owner — must succeed + client.execute_action(&pid); + assert_eq!(client.get_multisig_owners().len(), 2); +} + +// ── Task 4.15: propose_action requires auth ─────────────────── + +#[test] +fn test_propose_requires_auth() { + let (env, client, _owner1, _owner2, _owner3, _caller) = multisig_setup(); + // Without mock_all_auths the auth check fires; we use try_ to catch the panic + let outsider = Address::generate(&env); + let r = client.try_propose_action(&outsider, &ProposalAction::Freeze); + assert!(r.is_err(), "propose_action must reject non-owners"); +} + +// ── Task 4.16: approve_action requires auth ─────────────────── + +#[test] +fn test_approve_requires_auth() { + let (env, client, owner1, _owner2, _owner3, _caller) = multisig_setup(); + let outsider = Address::generate(&env); + let pid = client.propose_action(&owner1, &ProposalAction::Freeze); + let r = client.try_approve_action(&outsider, &pid); + assert!(r.is_err(), "approve_action must reject non-owners"); +} + +// ── Task 4.17: re-execution of executed proposal fails ──────── + +#[test] +fn test_re_execute_fails() { + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + client.execute_action(&pid); + + let r = client.try_execute_action(&pid); + assert!(r.is_err(), "Re-executing an executed proposal must fail"); +} + +// ── Task 4.18: get_proposal returns executed=true after execution ─ + +#[test] +fn test_get_proposal_executed_flag() { + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + + // Before execution: executed must be false + let before = client.get_proposal(&pid).unwrap(); + assert!(!before.executed); + + client.execute_action(&pid); + + // After execution: executed must be true + let after = client.get_proposal(&pid).unwrap(); + assert!(after.executed, "get_proposal must return executed=true after execution"); +} + +// ── Task 4.19: get_proposal returns None for unknown ID ─────── + +#[test] +fn test_get_proposal_unknown_id() { + let (_env, client, _owner1, _owner2, _owner3, _caller) = multisig_setup(); + assert!(client.get_proposal(&9999).is_none(), "get_proposal must return None for unknown ID"); +} + +// ── Task 4.20: threshold unchanged after removal ────────────── + +#[test] +fn test_threshold_not_adjusted_after_removal() { + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let threshold_before = client.get_multisig_threshold(); + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + client.execute_action(&pid); + + let threshold_after = client.get_multisig_threshold(); + assert_eq!(threshold_before, threshold_after, "Threshold must not change after owner removal"); +} + +// ── Invariant: post-removal threshold <= owner count ───────── + +#[test] +fn test_post_removal_threshold_invariant() { + // After any successful removal: threshold <= len(owners) and len(owners) >= 1 + let (_env, client, owner1, owner2, owner3, _caller) = multisig_setup(); + + let pid = client.propose_action(&owner1, &ProposalAction::RemoveOwner(owner3.clone())); + client.approve_action(&owner2, &pid); + client.execute_action(&pid); + + let owners = client.get_multisig_owners(); + let threshold = client.get_multisig_threshold().unwrap(); + assert!(owners.len() >= 1, "Owner count must be >= 1 after removal"); + assert!(threshold <= owners.len(), "Threshold must be <= owner count after removal"); +} + +// ── Guard order: existence check fires before threshold check ─ + +#[test] +fn test_guard_order_nonexistent_takes_priority() { + // Even if removal would also violate threshold, NotAuthorized fires first + // Setup: 1 owner, threshold=1; target is a non-member + let env = Env::default(); + env.mock_all_auths(); + let id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &id); + let admin = Address::generate(&env); + client.initialize(&admin, &None, &None); + + let sole_owner = Address::generate(&env); + let outsider = Address::generate(&env); + let mut owners = Vec::new(&env); + owners.push_back(sole_owner.clone()); + client.init_multisig(&admin, &owners, &1); + + let pid = client.propose_action(&sole_owner, &ProposalAction::RemoveOwner(outsider.clone())); + // Threshold met (1-of-1, proposer auto-approves) + let r = client.try_execute_action(&pid); + // Must fail — outsider is not an owner (NotAuthorized, not LimitReached) + assert!(r.is_err()); +} diff --git a/src/test_namespaces.rs b/src/test_namespaces.rs index 455327b5e..296a8788a 100644 --- a/src/test_namespaces.rs +++ b/src/test_namespaces.rs @@ -90,11 +90,11 @@ fn test_cross_namespace_blacklist_isolation() { // Blacklist in NS 1 client.blacklist_add(&issuer, &issuer, &ns_1, &token, &investor); - + // Verify isolated assert!(client.is_blacklisted(&issuer, &ns_1, &token, &investor)); assert!(!client.is_blacklisted(&issuer, &ns_2, &token, &investor)); - + assert_eq!(client.get_blacklist(&issuer, &ns_1, &token).len(), 1); assert_eq!(client.get_blacklist(&issuer, &ns_2, &token).len(), 0); } @@ -130,13 +130,19 @@ fn test_unauthorized_issuer_access_fails() { client.register_offering(&issuer_real, &ns_1, &token, &1000, &token, &0); // Attacker tries to blacklist for real issuer's offering - // Note: mock_all_auths will allow the call to reach the contract, + // Note: mock_all_auths will allow the call to reach the contract, // but the contract should check that issuer_attacker is not current_issuer. - - let res = client.try_blacklist_add(&issuer_attacker, &issuer_real, &ns_1, &token, &Address::generate(&env)); - + + let res = client.try_blacklist_add( + &issuer_attacker, + &issuer_real, + &ns_1, + &token, + &Address::generate(&env), + ); + // Should fail with NotAuthorized (#10) or OfferingNotFound (if we strictly check issuer in ID) - // Actually our implementation returns NotAuthorized if issuer matches but caller doesn't, + // Actually our implementation returns NotAuthorized if issuer matches but caller doesn't, // but here the issuer_real in the ID matches the real one, but the caller is attacker. assert!(res.is_err()); } @@ -172,7 +178,6 @@ fn test_transfer_maintains_namespace_isolation() { assert!(res.is_err()); } - /// @dev Verifies that double-registration of the exact same (issuer, namespace, token) is rejected to prevent state clobbering. #[test] fn test_duplicate_registration_fails() { @@ -185,7 +190,7 @@ fn test_duplicate_registration_fails() { let ns = symbol_short!("ns1"); client.register_offering(&issuer, &ns, &token, &1000, &token, &0); - + // Exact same registration should fail let res = client.try_register_offering(&issuer, &ns, &token, &1000, &token, &0); assert!(res.is_err()); @@ -206,7 +211,7 @@ fn test_aggregation_across_namespaces() { client.register_offering(&issuer, &ns_1, &token1, &1000, &token1, &0); client.register_offering(&issuer, &ns_2, &token2, &1000, &token2, &0); - + // Report revenue in both namespaces client.report_revenue(&issuer, &ns_1, &token1, &token1, &50000, &1, &false); client.report_revenue(&issuer, &ns_2, &token2, &token2, &25000, &1, &false); diff --git a/src/test_period_id_boundary.rs b/src/test_period_id_boundary.rs index de8369763..a2dc1ca63 100644 --- a/src/test_period_id_boundary.rs +++ b/src/test_period_id_boundary.rs @@ -301,15 +301,8 @@ fn negative_amount_rejected_before_period_id_check() { client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &token, &0); // Negative amount with valid period_id - let r = client.try_report_revenue( - &issuer, - &symbol_short!("def"), - &token, - &token, - &-1, - &5, - &false, - ); + let r = + client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &-1, &5, &false); assert_eq!(r, Err(Ok(RevoraError::InvalidAmount))); } @@ -323,15 +316,8 @@ fn zero_amount_accepted_by_report_revenue() { let token = Address::generate(&env); client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &token, &0); - let r = client.try_report_revenue( - &issuer, - &symbol_short!("def"), - &token, - &token, - &0, - &3, - &false, - ); + let r = + client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &0, &3, &false); assert!(r.is_ok()); } @@ -412,22 +398,29 @@ fn period_id_isolated_across_offerings() { let token_b = Address::generate(&env); let (payment_token, _) = create_payment_token(&env); - client.register_offering(&issuer_a, &symbol_short!("def"), &token_a, &1_000, &payment_token, &0); - client.register_offering(&issuer_b, &symbol_short!("def"), &token_b, &1_000, &payment_token, &0); + client.register_offering( + &issuer_a, + &symbol_short!("def"), + &token_a, + &1_000, + &payment_token, + &0, + ); + client.register_offering( + &issuer_b, + &symbol_short!("def"), + &token_b, + &1_000, + &payment_token, + &0, + ); mint(&env, &payment_token, &issuer_a, 1_000_000); mint(&env, &payment_token, &issuer_b, 1_000_000); // Deposit period 5 for offering A client - .deposit_revenue( - &issuer_a, - &symbol_short!("def"), - &token_a, - &payment_token, - &1_000, - &5u64, - ) + .deposit_revenue(&issuer_a, &symbol_short!("def"), &token_a, &payment_token, &1_000, &5u64) .unwrap(); // Offering B period 5 must still be available (not yet deposited) @@ -435,14 +428,7 @@ fn period_id_isolated_across_offerings() { // Deposit period 5 for offering B independently client - .deposit_revenue( - &issuer_b, - &symbol_short!("def"), - &token_b, - &payment_token, - &2_000, - &5u64, - ) + .deposit_revenue(&issuer_b, &symbol_short!("def"), &token_b, &payment_token, &2_000, &5u64) .unwrap(); assert_eq!(client.get_period_count(&issuer_a, &symbol_short!("def"), &token_a), 1); @@ -492,15 +478,8 @@ fn frozen_contract_rejects_report_revenue() { client.register_offering(&issuer, &symbol_short!("def"), &token, &1_000, &token, &0); client.freeze(); - let r = client.try_report_revenue( - &issuer, - &symbol_short!("def"), - &token, - &token, - &100, - &1, - &false, - ); + let r = + client.try_report_revenue(&issuer, &symbol_short!("def"), &token, &token, &100, &1, &false); assert_eq!(r, Err(Ok(RevoraError::ContractFrozen))); } diff --git a/src/test_utils.rs b/src/test_utils.rs index 94592773f..d010a1091 100644 --- a/src/test_utils.rs +++ b/src/test_utils.rs @@ -5,8 +5,7 @@ use crate::{RevoraRevenueShare, RevoraRevenueShareClient}; use soroban_sdk::{testutils::Address as _, Address, Env}; /// Core test utilities avoiding self-referential struct lifetime errors. -pub fn setup_context( -) -> (Env, RevoraRevenueShareClient, Address, Address, Address, Address) { +pub fn setup_context() -> (Env, RevoraRevenueShareClient, Address, Address, Address, Address) { let env = Env::default(); env.mock_all_auths(); let contract_id = env.register_contract(None, RevoraRevenueShare); diff --git a/src/vesting.rs b/src/vesting.rs index 61b0d2ab3..549f10fff 100644 --- a/src/vesting.rs +++ b/src/vesting.rs @@ -175,11 +175,7 @@ impl RevoraVesting { ); env.events().publish( (EVENT_VESTING_CANCELLED_V1, admin, beneficiary), - ( - VESTING_EVENT_SCHEMA_VERSION, - schedule_index, - schedule.token.clone(), - ), + (VESTING_EVENT_SCHEMA_VERSION, schedule_index, schedule.token.clone()), ); Ok(()) } @@ -241,12 +237,7 @@ impl RevoraVesting { ); env.events().publish( (EVENT_VESTING_CLAIMED_V1, beneficiary.clone(), admin), - ( - VESTING_EVENT_SCHEMA_VERSION, - schedule_index, - schedule.token, - claimable, - ), + (VESTING_EVENT_SCHEMA_VERSION, schedule_index, schedule.token, claimable), ); Ok(claimable) } @@ -321,9 +312,11 @@ impl RevoraVesting { schedule_index: u32, claim_index: u32, ) -> Option<(u64, i128)> { - env.storage() - .persistent() - .get(&VestingDataKey::ClaimRecord(admin, schedule_index, claim_index)) + env.storage().persistent().get(&VestingDataKey::ClaimRecord( + admin, + schedule_index, + claim_index, + )) } /// Query a schedule by admin and index.