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/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/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
+ );
+ }
+ }
+}
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.