Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions contracts/revenue_pool/proptest-regressions/test_proptest.txt
Original file line number Diff line number Diff line change
@@ -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]
14 changes: 14 additions & 0 deletions contracts/revenue_pool/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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"));
}
}
54 changes: 53 additions & 1 deletion contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down
183 changes: 178 additions & 5 deletions contracts/revenue_pool/src/test_proptest.rs
Original file line number Diff line number Diff line change
@@ -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>(
Expand Down Expand Up @@ -50,16 +52,16 @@ proptest! {
let dev_pool: std::vec::Vec<Address> = (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()) {
let dev = &dev_pool[(*r as usize) % dev_pool.len()];
if seen.contains(dev) {
has_duplicates = true;
}
seen.insert(dev.clone());
seen.push(dev.clone());
payments.push_back((dev.clone(), *a));
}

Expand Down Expand Up @@ -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::<u64>(), 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<Address> = (0..ADMIN_COUNT).map(|_| Address::generate(&env)).collect();
let devs: std::vec::Vec<Address> = (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
);
}
}
}
14 changes: 14 additions & 0 deletions contracts/settlement/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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"));
}
}
Loading