diff --git a/.github/workflows/contract-fuzz.yml b/.github/workflows/contract-fuzz.yml new file mode 100644 index 0000000..a0bf6ca --- /dev/null +++ b/.github/workflows/contract-fuzz.yml @@ -0,0 +1,31 @@ +name: Contract Fuzz Smoke Tests + +on: + pull_request: + branches: [main] + paths: + - 'contracts/vault/**' + - 'contracts/streaming/**' + - 'fuzz/**' + - '.github/workflows/contract-fuzz.yml' + push: + branches: [main, 'agent/**', 'feat/**'] + paths: + - 'contracts/vault/**' + - 'contracts/streaming/**' + - 'fuzz/**' + - '.github/workflows/contract-fuzz.yml' + workflow_dispatch: + +jobs: + fuzz: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@nightly + - name: Install cargo-fuzz + run: cargo install cargo-fuzz --locked + - name: Fuzz vault deposit math + run: cargo fuzz run vault_deposit -- -runs=10000 + - name: Fuzz stream release math + run: cargo fuzz run stream_release -- -runs=10000 \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index fcf9c60..f398411 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1693,6 +1693,7 @@ name = "soromint-streaming" version = "0.1.0" dependencies = [ "soroban-sdk", + "soromint-token", ] [[package]] @@ -1710,6 +1711,7 @@ version = "0.1.0" dependencies = [ "proptest", "soroban-sdk", + "soromint-access", "soromint-lifecycle", ] diff --git a/contracts/streaming/Cargo.toml b/contracts/streaming/Cargo.toml index 1187fc0..0c9cb49 100644 --- a/contracts/streaming/Cargo.toml +++ b/contracts/streaming/Cargo.toml @@ -13,3 +13,4 @@ soroban-sdk = "22.0.0" [dev-dependencies] soroban-sdk = { version = "22.0.0", features = ["testutils"] } soromint-token = { path = "../token" } +proptest = "1.6" diff --git a/contracts/streaming/src/lib.rs b/contracts/streaming/src/lib.rs index f81d2e5..e695b84 100644 --- a/contracts/streaming/src/lib.rs +++ b/contracts/streaming/src/lib.rs @@ -4,6 +4,7 @@ //! Supports real-time payroll, subscription payments, and milestone-based vesting. #![no_std] +pub mod math; use soroban_sdk::{contract, contractimpl, contracttype, token, Address, Env, Bytes, IntoVal, Symbol, symbol_short}; @@ -391,7 +392,7 @@ impl StreamingPayments { fn calculate_streamed(e: &Env, stream: &Stream, schedule: &Schedule) -> i128 { match schedule { - Schedule::Linear(_) => Self::calculate_linear_streamed(e, stream), + Schedule::Linear(total_amount) => math::vested_amount(*total_amount, stream.start_ledger, stream.stop_ledger, e.ledger().sequence()).expect("stream release overflow"), Schedule::Milestone(milestones) => { let current = e.ledger().sequence(); let mut streamed = 0i128; diff --git a/contracts/streaming/src/math.rs b/contracts/streaming/src/math.rs new file mode 100644 index 0000000..847ed7b --- /dev/null +++ b/contracts/streaming/src/math.rs @@ -0,0 +1,53 @@ +//! Overflow-safe release schedule arithmetic. + +pub fn vested_amount( + total_amount: i128, + start_ledger: u32, + stop_ledger: u32, + current_ledger: u32, +) -> Option { + if total_amount < 0 || stop_ledger <= start_ledger { + return None; + } + if current_ledger <= start_ledger { + return Some(0); + } + if current_ledger >= stop_ledger { + return Some(total_amount); + } + + let elapsed = i128::from(current_ledger - start_ledger); + let duration = i128::from(stop_ledger - start_ledger); + total_amount.checked_mul(elapsed)?.checked_div(duration) +} + +#[cfg(test)] +mod property_tests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn release_is_bounded_and_complete(total in 0i128..=1_000_000_000_000_000, start in 0u32..=1_000_000, duration in 1u32..=1_000_000) { + let stop = start + duration; + prop_assert_eq!(vested_amount(total, start, stop, start), Some(0)); + prop_assert_eq!(vested_amount(total, start, stop, stop), Some(total)); + let vested = vested_amount(total, start, stop, start + duration / 2).unwrap(); + prop_assert!((0..=total).contains(&vested)); + } + + #[test] + fn release_is_monotonic(total in 0i128..=1_000_000_000_000_000, start in 0u32..=1_000_000, duration in 1u32..=1_000_000, first in 0u32..=1_000_000, second in 0u32..=1_000_000) { + let stop = start + duration; + let a = start + first.min(duration); + let b = start + second.min(duration); + let (earlier, later) = if a <= b { (a, b) } else { (b, a) }; + prop_assert!(vested_amount(total, start, stop, later).unwrap() >= vested_amount(total, start, stop, earlier).unwrap()); + } + + #[test] + fn indivisible_totals_release_fully(total in 1i128..=1_000_000_000_000_000, duration in 1u32..=1_000_000) { + prop_assert_eq!(vested_amount(total, 0, duration, duration), Some(total)); + } + } +} diff --git a/contracts/vault/Cargo.toml b/contracts/vault/Cargo.toml index 787a2d7..ea815d0 100644 --- a/contracts/vault/Cargo.toml +++ b/contracts/vault/Cargo.toml @@ -12,6 +12,7 @@ soroban-sdk = "22.0.0" [dev-dependencies] soroban-sdk = { version = "22.0.0", features = ["testutils"] } +proptest = "1.6" [profile.release] opt-level = "z" diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 41b8d0a..0f4cf9a 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -5,6 +5,7 @@ mod oracle; mod liquidation; mod events; mod reentrancy; +pub mod math; #[cfg(test)] mod test; @@ -109,11 +110,11 @@ impl VaultContract { let smt_price = 1_0000000i128; // SMT pegged to $1 with 7 decimals // Calculate collateral value in USD - let collateral_value = (collateral_amount * collateral_price) / 1_0000000; + let collateral_value = math::collateral_value(collateral_amount, collateral_price, 1_0000000).expect("collateral value overflow"); let debt_value = (smt_amount * smt_price) / 1_0000000; // Check collateralization ratio - let ratio = (collateral_value * BP_DIVISOR as i128) / debt_value; + let ratio = math::collateralization_ratio(collateral_value, debt_value, BP_DIVISOR as i128).expect("collateral ratio overflow"); if ratio < config.min_collateral_ratio as i128 { panic!("insufficient collateral ratio"); } @@ -436,7 +437,7 @@ impl VaultContract { let smt_price = 1_0000000i128; let debt_value = (debt * smt_price) / 1_0000000; - let ratio = (collateral_value * BP_DIVISOR as i128) / debt_value; + let ratio = math::collateralization_ratio(collateral_value, debt_value, BP_DIVISOR as i128).expect("collateral ratio overflow"); // Check against the strictest min collateral ratio let mut min_ratio = MIN_COLLATERAL_RATIO; @@ -462,7 +463,7 @@ impl VaultContract { let smt_price = 1_0000000i128; let debt_value = (position.debt * smt_price) / 1_0000000; - let ratio = (collateral_value * BP_DIVISOR as i128) / debt_value; + let ratio = math::collateralization_ratio(collateral_value, debt_value, BP_DIVISOR as i128).expect("collateral ratio overflow"); // Check against the highest liquidation threshold let mut threshold = LIQUIDATION_THRESHOLD; diff --git a/contracts/vault/src/math.rs b/contracts/vault/src/math.rs new file mode 100644 index 0000000..6a7c132 --- /dev/null +++ b/contracts/vault/src/math.rs @@ -0,0 +1,50 @@ +//! Checked arithmetic used by vault valuation and fuzz/property tests. + +pub fn collateral_value(amount: i128, price: i128, scale: i128) -> Option { + if amount < 0 || price < 0 || scale <= 0 { + return None; + } + amount.checked_mul(price)?.checked_div(scale) +} + +pub fn collateralization_ratio( + collateral_value: i128, + debt_value: i128, + basis_points: i128, +) -> Option { + if collateral_value < 0 || debt_value <= 0 || basis_points <= 0 { + return None; + } + collateral_value + .checked_mul(basis_points)? + .checked_div(debt_value) +} + +#[cfg(test)] +mod property_tests { + use super::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn valuation_matches_checked_reference(amount in 0i128..=1_000_000_000_000_000, price in 0i128..=1_000_000_000_000, scale in 1i128..=1_000_000_000) { + let expected = amount.checked_mul(price).and_then(|v| v.checked_div(scale)); + prop_assert_eq!(collateral_value(amount, price, scale), expected); + } + + #[test] + fn valuation_is_monotonic(smaller in 0i128..=1_000_000_000_000, delta in 0i128..=1_000_000_000_000, price in 1i128..=1_000_000_000, scale in 1i128..=100_000_000) { + if let Some(larger) = smaller.checked_add(delta) { + let low = collateral_value(smaller, price, scale).unwrap(); + let high = collateral_value(larger, price, scale).unwrap(); + prop_assert!(high >= low); + } + } + + #[test] + fn ratio_matches_checked_reference(collateral in 0i128..=1_000_000_000_000_000, debt in 1i128..=1_000_000_000_000) { + let expected = collateral.checked_mul(10_000).and_then(|v| v.checked_div(debt)); + prop_assert_eq!(collateralization_ratio(collateral, debt, 10_000), expected); + } + } +} diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..ca3697f --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,4 @@ +artifacts/ +corpus/ +coverage/ +target/ \ No newline at end of file diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..04a68db --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "soromint-fuzz" +version = "0.0.0" +edition = "2021" +publish = false + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" + +[[bin]] +name = "vault_deposit" +path = "fuzz_targets/vault_deposit.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "stream_release" +path = "fuzz_targets/stream_release.rs" +test = false +doc = false +bench = false + +[workspace] +members = ["."] \ No newline at end of file diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..b3ec2af --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,11 @@ +# Contract fuzz targets + +Install cargo-fuzz, then run each target from the repository root: + +```bash +cargo install cargo-fuzz +cargo fuzz run vault_deposit -- -max_total_time=60 +cargo fuzz run stream_release -- -max_total_time=60 +``` + +Both targets exercise the same checked arithmetic used by the contracts. \ No newline at end of file diff --git a/fuzz/fuzz_targets/stream_release.rs b/fuzz/fuzz_targets/stream_release.rs new file mode 100644 index 0000000..ffbc3bc --- /dev/null +++ b/fuzz/fuzz_targets/stream_release.rs @@ -0,0 +1,33 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +#[path = "../../contracts/streaming/src/math.rs"] +mod stream_math; + +fuzz_target!(|data: &[u8]| { + if data.len() < 28 { + return; + } + let total = i128::from_le_bytes(data[0..16].try_into().unwrap()).saturating_abs(); + let start = u32::from_le_bytes(data[16..20].try_into().unwrap()); + let duration = u32::from_le_bytes(data[20..24].try_into().unwrap()).max(1); + let offset = u32::from_le_bytes(data[24..28].try_into().unwrap()); + let Some(stop) = start.checked_add(duration) else { + return; + }; + let current = start.saturating_add(offset.min(duration)); + let Some(vested) = stream_math::vested_amount(total, start, stop, current) else { + // Checked arithmetic deliberately rejects schedules whose intermediate + // multiplication cannot be represented. + return; + }; + assert!((0..=total).contains(&vested)); + assert_eq!( + stream_math::vested_amount(total, start, stop, stop), + Some(total) + ); + if current < stop { + if let Some(next) = stream_math::vested_amount(total, start, stop, current + 1) { + assert!(next >= vested); + } + } +}); diff --git a/fuzz/fuzz_targets/vault_deposit.rs b/fuzz/fuzz_targets/vault_deposit.rs new file mode 100644 index 0000000..43d7e3f --- /dev/null +++ b/fuzz/fuzz_targets/vault_deposit.rs @@ -0,0 +1,21 @@ +#![no_main] +use libfuzzer_sys::fuzz_target; +#[path = "../../contracts/vault/src/math.rs"] +mod vault_math; + +fuzz_target!(|data: &[u8]| { + if data.len() < 48 { + return; + } + let amount = i128::from_le_bytes(data[0..16].try_into().unwrap()).saturating_abs(); + let price = i128::from_le_bytes(data[16..32].try_into().unwrap()).saturating_abs(); + let debt = i128::from_le_bytes(data[32..48].try_into().unwrap()).saturating_abs(); + if let Some(value) = vault_math::collateral_value(amount, price, 10_000_000) { + assert!(value >= 0); + if debt > 0 { + if let Some(ratio) = vault_math::collateralization_ratio(value, debt, 10_000) { + assert!(ratio >= 0); + } + } + } +});