Skip to content
Open
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
31 changes: 31 additions & 0 deletions .github/workflows/contract-fuzz.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions contracts/streaming/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
3 changes: 2 additions & 1 deletion contracts/streaming/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions contracts/streaming/src/math.rs
Original file line number Diff line number Diff line change
@@ -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<i128> {
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));
}
}
}
1 change: 1 addition & 0 deletions contracts/vault/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod oracle;
mod liquidation;
mod events;
mod reentrancy;
pub mod math;

#[cfg(test)]
mod test;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
50 changes: 50 additions & 0 deletions contracts/vault/src/math.rs
Original file line number Diff line number Diff line change
@@ -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<i128> {
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<i128> {
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);
}
}
}
4 changes: 4 additions & 0 deletions fuzz/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
artifacts/
corpus/
coverage/
target/
28 changes: 28 additions & 0 deletions fuzz/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 = ["."]
11 changes: 11 additions & 0 deletions fuzz/README.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions fuzz/fuzz_targets/stream_release.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
});
21 changes: 21 additions & 0 deletions fuzz/fuzz_targets/vault_deposit.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
});
Loading