From 0d8e7e9da838639fc3db4bb26f3f21dc4fca9ed3 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Sat, 27 Jun 2026 14:19:55 +0100 Subject: [PATCH 1/4] feat: AdminBroadcast event channel --- contracts/revenue_pool/src/events.rs | 14 ++++++ contracts/revenue_pool/src/lib.rs | 54 ++++++++++++++++++++- contracts/settlement/src/events.rs | 14 ++++++ contracts/settlement/src/lib.rs | 41 +++++++++++++++- contracts/vault/src/events.rs | 15 ++++++ contracts/vault/src/lib.rs | 72 ++++++++++++++++++++++++++++ 6 files changed, 208 insertions(+), 2 deletions(-) diff --git a/contracts/revenue_pool/src/events.rs b/contracts/revenue_pool/src/events.rs index a34ad860..0ad6e497 100644 --- a/contracts/revenue_pool/src/events.rs +++ b/contracts/revenue_pool/src/events.rs @@ -89,6 +89,13 @@ pub fn event_upgraded(env: &Env) -> Symbol { Symbol::new(env, "upgraded") } +/// Returns the Symbol for the `"admin_broadcast"` event topic. +/// +/// Emitted when the admin broadcasts an emergency message. +pub fn event_admin_broadcast(env: &Env) -> Symbol { + Symbol::new(env, "admin_broadcast") +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +186,11 @@ mod tests { let env = Env::default(); assert_eq!(event_upgraded(&env), Symbol::new(&env, "upgraded")); } + + /// Snapshot: proves event_admin_broadcast still maps to exactly the bytes for "admin_broadcast". + #[test] + fn test_event_admin_broadcast_bytes() { + let env = Env::default(); + assert_eq!(event_admin_broadcast(&env), Symbol::new(&env, "admin_broadcast")); + } } diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/revenue_pool/src/lib.rs index 18a8bb61..40f669f3 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/revenue_pool/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] use soroban_sdk::{ - contract, contracterror, contractimpl, token, Address, BytesN, Env, Map, Symbol, Vec, + contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Map, String, Symbol, Vec, }; /// Revenue settlement contract: receives USDC from vault deducts and distributes to developers. @@ -51,6 +51,24 @@ pub const DEFAULT_MAX_DISTRIBUTE: i128 = i128::MAX; /// Caps CPU/memory usage well within Soroban resource limits and aligns with /// the vault's `MAX_BATCH_SIZE` for `batch_deduct`. pub const MAX_BATCH_SIZE: u32 = 50; +pub const MAX_MESSAGE_LEN: u32 = 256; + +/// Severity levels for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Severity { + Info, + Warn, + Crit, +} + +/// Event payload for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AdminBroadcast { + pub severity: Severity, + pub message: String, +} /// TTL bump constants for instance storage archival risk mitigation. /// Soroban archives ledger entries after ~7 days (631 ledgers) of inactivity. @@ -667,6 +685,40 @@ impl RevenuePool { .instance() .get(&Symbol::new(&env, VERSION_KEY)) } + + /// Broadcast an emergency message from the admin. + /// + /// Only the current admin may call this function. + /// The message length is capped at 256 characters. + /// + /// # Arguments + /// * `env` - The environment running the contract. + /// * `caller` - Must be the current admin; must authorize. + /// * `severity` - Severity level of the broadcast (Info/Warn/Crit). + /// * `message` - The broadcast message, capped at 256 characters. + /// + /// # Panics + /// * If the caller is not the current admin. + /// * If the message length exceeds 256 characters. + /// * If the message is empty. + pub fn broadcast(env: Env, caller: Address, severity: Severity, message: String) { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + panic!("unauthorized: caller is not admin"); + } + let len = message.len(); + if len == 0 { + panic!("message cannot be empty"); + } + if len > MAX_MESSAGE_LEN { + panic!("message length exceeds maximum of 256 characters"); + } + env.events().publish( + (events::event_admin_broadcast(&env), caller), + AdminBroadcast { severity, message }, + ); + } } mod events; diff --git a/contracts/settlement/src/events.rs b/contracts/settlement/src/events.rs index 0dc89ed6..c94fd08a 100644 --- a/contracts/settlement/src/events.rs +++ b/contracts/settlement/src/events.rs @@ -76,6 +76,13 @@ pub fn event_vault_accepted(env: &Env) -> Symbol { Symbol::new(env, "vault_accepted") } +/// Returns the Symbol for the `"admin_broadcast"` event topic. +/// +/// Emitted when the admin broadcasts an emergency message. +pub fn event_admin_broadcast(env: &Env) -> Symbol { + Symbol::new(env, "admin_broadcast") +} + #[cfg(test)] mod tests { use super::*; @@ -146,4 +153,11 @@ mod tests { let env = Env::default(); assert_eq!(event_vault_accepted(&env), Symbol::new(&env, "vault_accepted")); } + + /// Snapshot: proves event_admin_broadcast still maps to exactly the bytes for "admin_broadcast". + #[test] + fn test_event_admin_broadcast_bytes() { + let env = Env::default(); + assert_eq!(event_admin_broadcast(&env), Symbol::new(&env, "admin_broadcast")); + } } diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 98996654..319f4582 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -1,6 +1,6 @@ #![no_std] -use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec}; +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, String, Symbol, Vec}; /// Maximum number of items allowed in a single `batch_receive_payment` call. pub const MAX_BATCH_SIZE: u32 = 50; @@ -8,6 +8,26 @@ pub const MAX_BATCH_SIZE: u32 = 50; /// Maximum number of developer balances returned per page in paginated queries. pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; +/// Maximum length for admin broadcast messages. +pub const MAX_MESSAGE_LEN: u32 = 256; + +/// Severity levels for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Severity { + Info, + Warn, + Crit, +} + +/// Event payload for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AdminBroadcast { + pub severity: Severity, + pub message: String, +} + /// Typed errors for the settlement contract. /// /// Using `#[contracterror]` encodes each variant as a stable `u32` code. @@ -1173,6 +1193,25 @@ pub fn withdraw_developer_balance( /// Only the current admin may call. This will instruct the host to update /// the current contract WASM to `new_wasm_hash` and persist the version marker. /// Emits an `upgraded` event with the admin as topic and the new version as data. + pub fn broadcast(env: Env, caller: Address, severity: Severity, message: String) { + caller.require_auth(); + let admin = Self::get_admin(env.clone()); + if caller != admin { + env.panic_with_error(SettlementError::Unauthorized); + } + let len = message.len(); + if len == 0 { + panic!("message cannot be empty"); + } + if len > MAX_MESSAGE_LEN { + panic!("message length exceeds maximum of 256 characters"); + } + env.events().publish( + (events::event_admin_broadcast(&env), caller), + AdminBroadcast { severity, message }, + ); + } + pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) { caller.require_auth(); let admin = Self::get_admin(env.clone()); diff --git a/contracts/vault/src/events.rs b/contracts/vault/src/events.rs index bdc01a90..af42294d 100644 --- a/contracts/vault/src/events.rs +++ b/contracts/vault/src/events.rs @@ -213,6 +213,13 @@ pub fn event_revenue_pool_cancelled(env: &Env) -> Symbol { Symbol::new(env, "revenue_pool_cancelled") } +/// Returns the Symbol for the `"admin_broadcast"` event topic. +/// +/// Emitted when the admin broadcasts an emergency message. +pub fn event_admin_broadcast(env: &Env) -> Symbol { + Symbol::new(env, "admin_broadcast") +} + #[cfg(test)] mod tests { use super::*; @@ -421,4 +428,12 @@ mod tests { let sym = event_revenue_pool_cancelled(&env); assert_eq!(sym, Symbol::new(&env, "revenue_pool_cancelled")); } + + /// Snapshot: proves event_admin_broadcast still maps to exactly the bytes for "admin_broadcast". + #[test] + fn test_event_admin_broadcast_bytes() { + let env = soroban_sdk::Env::default(); + let sym = event_admin_broadcast(&env); + assert_eq!(sym, Symbol::new(&env, "admin_broadcast")); + } } diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 8d6a5975..1994161c 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -137,6 +137,23 @@ pub struct WithdrawEventData { pub new_balance: i128, } +/// Severity levels for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Severity { + Info, + Warn, + Crit, +} + +/// Event payload for admin broadcast messages. +#[contracttype] +#[derive(Clone, Debug)] +pub struct AdminBroadcast { + pub severity: Severity, + pub message: String, +} + /// Canonical storage keys for the Vault contract. #[contracttype] pub enum StorageKey { @@ -184,6 +201,7 @@ pub const DEFAULT_MAX_DEDUCT: i128 = i128::MAX; pub const DEFAULT_MIN_DEPOSIT: i128 = 1; pub const MAX_BATCH_SIZE: u32 = 50; pub const MAX_METADATA_LEN: u32 = 256; +pub const MAX_MESSAGE_LEN: u32 = 256; pub const MAX_OFFERING_ID_LEN: u32 = 64; pub const MAX_LIST_PRICES_LIMIT: u32 = 100; @@ -1413,6 +1431,26 @@ impl CalloraVault { /// After calling `upgrade`, you may need to invoke a separate `migrate` function /// (if implemented in the new WASM) to update storage schema or perform data migrations. /// See UPGRADE.md for the complete operational flow. + pub fn broadcast(env: Env, caller: Address, severity: Severity, message: String) -> Result<(), VaultError> { + caller.require_auth(); + let admin = Self::get_admin(env.clone())?; + if caller != admin { + return Err(VaultError::Unauthorized); + } + let len = message.len(); + if len == 0 { + panic!("message cannot be empty"); + } + if len > MAX_MESSAGE_LEN { + panic!("message length exceeds maximum of 256 characters"); + } + env.events().publish( + (events::event_admin_broadcast(&env), caller), + AdminBroadcast { severity, message }, + ); + Ok(()) + } + pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) { caller.require_auth(); let admin = Self::get_admin(env.clone()).expect("vault must be initialized before upgrade"); @@ -1532,6 +1570,40 @@ impl CalloraVault { } Ok(()) } + + /// Broadcast an emergency message from the admin. + /// + /// Only the current admin may call this function. + /// The message length is capped at 256 characters. + /// + /// # Arguments + /// * `env` - The environment running the contract. + /// * `caller` - Must be the current admin; must authorize. + /// * `severity` - Severity level of the broadcast (Info/Warn/Crit). + /// * `message` - The broadcast message, capped at 256 characters. + /// + /// # Errors + /// * `VaultError::Unauthorized` - If the caller is not the current admin. + /// * `VaultError::MetadataTooLong` - If the message length exceeds 256 characters. + pub fn broadcast(env: Env, caller: Address, severity: Severity, message: String) -> Result<(), VaultError> { + caller.require_auth(); + let admin = Self::get_admin(env.clone())?; + if caller != admin { + return Err(VaultError::Unauthorized); + } + let len = message.len(); + if len == 0 { + return Err(VaultError::MetadataTooLong); // Reusing existing error for message too long/empty + } + if len > MAX_MESSAGE_LEN { + return Err(VaultError::MetadataTooLong); + } + env.events().publish( + (events::event_admin_broadcast(&env), caller), + AdminBroadcast { severity, message }, + ); + Ok(()) + } } // Allowlist aliases — convenience wrappers used by tests and external callers. From c142a5090d435b17a03f40ad1e04736586fca1bb Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Sat, 27 Jun 2026 14:21:49 +0100 Subject: [PATCH 2/4] quick fix [ci skip] From c287bb6e25261dac7cde13a959a483fbacc13e87 Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Sat, 27 Jun 2026 14:34:28 +0100 Subject: [PATCH 3/4] test: stateful proptest for revenue_pool --- .github/workflows/ci.yml | 25 +++ .../proptest-regressions/test_proptest.txt | 7 + contracts/revenue_pool/src/test_proptest.rs | 183 +++++++++++++++++- 3 files changed, 210 insertions(+), 5 deletions(-) create mode 100644 contracts/revenue_pool/proptest-regressions/test_proptest.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2610ac5..e4e99c5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,8 @@ on: branches: [main, master, develop, 'feature/**', 'chore/**', 'ci/**'] pull_request: branches: [main, master, develop] + schedule: + - cron: '0 0 * * *' # Run nightly jobs: test: @@ -38,6 +40,29 @@ jobs: - name: Test (all workspace members) run: cargo test --workspace + test-nightly: + name: Test (Nightly) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust Nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-nightly-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml') }} + restore-keys: | + ${{ runner.os }}-cargo-nightly- + + - name: Test (with proptest long runs) + run: cargo test --workspace -- --nocapture + build: name: Build (release) runs-on: ubuntu-latest diff --git a/contracts/revenue_pool/proptest-regressions/test_proptest.txt b/contracts/revenue_pool/proptest-regressions/test_proptest.txt new file mode 100644 index 00000000..26807304 --- /dev/null +++ b/contracts/revenue_pool/proptest-regressions/test_proptest.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 4750be08fe0cc1ba7cbcd23ab5746214e9291523d14a6ff6ebd9ed665d28dcee # shrinks to seeds = [6433548184891969566] diff --git a/contracts/revenue_pool/src/test_proptest.rs b/contracts/revenue_pool/src/test_proptest.rs index acdbdcdf..bdcebb57 100644 --- a/contracts/revenue_pool/src/test_proptest.rs +++ b/contracts/revenue_pool/src/test_proptest.rs @@ -1,11 +1,13 @@ extern crate std; -use crate::{RevenuePool, RevenuePoolClient}; +use crate::{RevenuePool, RevenuePoolClient, Severity}; use proptest::prelude::*; +use proptest::strategy::ValueTree; use soroban_sdk::testutils::Address as _; use soroban_sdk::token::{self, StellarAssetClient}; -use soroban_sdk::{Address, Env, Vec}; +use soroban_sdk::{Address, Env}; +use soroban_sdk::Vec as SorobanVec; use std::panic::{catch_unwind, AssertUnwindSafe}; fn create_usdc<'a>( @@ -50,8 +52,8 @@ proptest! { let dev_pool: std::vec::Vec
= (0..20).map(|_| Address::generate(&env)).collect(); // Build the payments vector - let mut payments = Vec::new(&env); - let mut seen = std::collections::HashSet::new(); + let mut payments = SorobanVec::new(&env); + let mut seen = std::vec::Vec::new(); let mut has_duplicates = false; for (r, a) in recipients.iter().zip(amounts.iter()) { @@ -59,7 +61,7 @@ proptest! { if seen.contains(dev) { has_duplicates = true; } - seen.insert(dev.clone()); + seen.push(dev.clone()); payments.push_back((dev.clone(), *a)); } @@ -91,3 +93,174 @@ proptest! { } } } + +// --------------------------------------------------------------------------- +// Stateful testing harness +// --------------------------------------------------------------------------- + +/// Generate a list of valid actions and run them +proptest! { + #![proptest_config(ProptestConfig::with_cases(32))] + + #[test] + fn stateful_invariant_runner( + seeds in prop::collection::vec(any::(), 1..20) + ) { + const DEV_COUNT: usize = 10; + const ADMIN_COUNT: usize = 3; + + let env = Env::default(); + env.mock_all_auths(); + + let admins: std::vec::Vec
= (0..ADMIN_COUNT).map(|_| Address::generate(&env)).collect(); + let devs: std::vec::Vec
= (0..DEV_COUNT).map(|_| Address::generate(&env)).collect(); + + let (pool_addr, pool) = create_pool(&env); + let (usdc_addr, usdc, usdc_admin) = create_usdc(&env, &admins[0]); + + pool.init(&admins[0], &usdc_addr); + + let mut paused = false; + let mut admin_idx = 0; + let mut pending_admin_idx = None; + let mut max_distribute = i128::MAX; + let mut virtual_scheduled = 0; + + for &seed in &seeds { + // Simple PRNG from seed + let mut rng = seed; + let mut next_rand = || { + rng = rng.wrapping_mul(1103515245).wrapping_add(12345); + rng + }; + + let action_idx = next_rand() % 12; + + match action_idx { + // Fund + 0 | 1 => { + let amount = (next_rand() % 10_000_000) as i128 + 1000; + usdc_admin.mint(&pool_addr, &amount); + virtual_scheduled += amount; + } + // Distribute + 2 | 3 if !paused && virtual_scheduled > 0 => { + let idx = (next_rand() % DEV_COUNT as u64) as usize; + let amount = std::cmp::min( + (next_rand() % 1_000_000) as i128 + 1, + std::cmp::min(virtual_scheduled, max_distribute) + ); + let admin = &admins[admin_idx]; + let recipient = &devs[idx]; + let result = catch_unwind(AssertUnwindSafe(|| { + pool.distribute(admin, recipient, &amount); + })); + if result.is_ok() { + virtual_scheduled -= amount; + } + } + // Batch distribute + 4 | 5 if !paused && virtual_scheduled > 0 => { + let batch_size = (next_rand() % 10) as usize + 1; + let mut payments = SorobanVec::new(&env); + let mut total = 0; + for _ in 0..batch_size { + let idx = (next_rand() % DEV_COUNT as u64) as usize; + let remaining = virtual_scheduled - total; + if remaining <= 0 { + break; + } + let amount = std::cmp::min( + (next_rand() % 100_000) as i128 + 1, + std::cmp::min(remaining, max_distribute) + ); + payments.push_back((devs[idx].clone(), amount)); + total += amount; + } + if payments.len() > 0 { + let admin = &admins[admin_idx]; + let result = catch_unwind(AssertUnwindSafe(|| { + pool.batch_distribute(admin, &payments); + })); + if result.is_ok() { + virtual_scheduled -= total; + } + } + } + // Pause + 6 if !paused => { + let admin = &admins[admin_idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.pause(admin); + })); + paused = true; + } + // Unpause + 7 if paused => { + let admin = &admins[admin_idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.unpause(admin); + })); + paused = false; + } + // Set max distribute + 8 => { + let new_max = (next_rand() % 100_000_000) as i128 + 1; + let admin = &admins[admin_idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.set_max_distribute(admin, &new_max); + })); + max_distribute = new_max; + } + // Admin transfer start + 9 if pending_admin_idx.is_none() => { + let new_admin_idx = (next_rand() % ADMIN_COUNT as u64) as usize; + let admin = &admins[admin_idx]; + let new_admin = &admins[new_admin_idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.set_admin(admin, new_admin); + })); + pending_admin_idx = Some(new_admin_idx); + } + // Admin transfer accept/cancel + 10 if pending_admin_idx.is_some() => { + if next_rand() % 2 == 0 { + let idx = pending_admin_idx.unwrap(); + let pending_admin = &admins[idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.accept_admin(pending_admin); + })); + admin_idx = idx; + pending_admin_idx = None; + } else { + let admin = &admins[admin_idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.cancel_admin_transfer(admin); + })); + pending_admin_idx = None; + } + } + // Receive payment + 11 => { + let amount = (next_rand() % 10_000_000) as i128 + 1000; + let from_vault = next_rand() % 2 == 0; + let admin = &admins[admin_idx]; + let _ = catch_unwind(AssertUnwindSafe(|| { + pool.receive_payment(admin, &amount, &from_vault); + })); + virtual_scheduled += amount; + } + _ => {} + } + + // Verify invariant + let balance = usdc.balance(&pool_addr); + prop_assert!( + balance >= virtual_scheduled, + "Invariant violated: balance {} < virtual_scheduled {}", + balance, + virtual_scheduled + ); + } + } +} From e5a919fd0e96230ce590acc13915ca8adec0ac5e Mon Sep 17 00:00:00 2001 From: RemmyAcee Date: Sat, 27 Jun 2026 14:45:09 +0100 Subject: [PATCH 4/4] quick fix [ci skip]