MultiversX Property Testing
Note:
proptestis an external crate (not part of the MultiversX SDK). Add it as a dev-dependency in yourCargo.toml.
Use property-based testing (fuzzing) to automatically discover edge cases and invariant violations in MultiversX smart contract logic. This approach generates random inputs to find bugs that manual testing misses.
When to Use
- Writing comprehensive test suites
- Verifying mathematical invariants hold
- Testing complex state machines
- Finding edge cases in business logic
- Validating input handling across ranges
1. Tools and Setup
Rust Property Testing Libraries
proptest - Most commonly used:
# Cargo.toml (dev-dependencies)
[dev-dependencies]
proptest = "1.4"
cargo-fuzz - LLVM-based fuzzing:
# Install
cargo install cargo-fuzz
# Initialize fuzz targets
cargo fuzz init
MultiversX Test Environment
[dev-dependencies]
multiversx-sc-scenario = "0.64.0"
2. Defining Invariants
Invariants are properties that must ALWAYS hold, regardless of inputs or state.
Common Smart Contract Invariants
| Invariant | Description | Example |
|---|---|---|
| Conservation | Sum of parts equals total | Total supply == sum of all balances |
| Monotonicity | Value only moves one direction | Stake amount never decreases without explicit unstake |
| Bounds | Values stay within limits | User balance <= total supply |
| Consistency | Related values stay in sync | Whitelist count == whitelist set length |
| Idempotency | Repeated calls have same effect | Double-claim returns 0 second time |
Defining Invariants in Code
// Invariant: Total supply equals sum of all balances
fn check_supply_invariant(state: &ContractState) -> bool {
let total_supply = state.total_supply;
let sum_of_balances: BigUint = state.balances.values().sum();
total_supply == sum_of_balances
}
// Invariant: User balance never exceeds total supply
fn check_balance_bounds(state: &ContractState, user: &Address) -> bool {
let user_balance = state.balances.get(user).unwrap_or_default();
user_balance <= state.total_supply
}
3. Property Testing with proptest
Basic Property Test
use proptest::prelude::*;
proptest! {
#[test]
fn test_deposit_increases_balance(amount in 1u64..1_000_000u64) {
let mut setup = TestSetup::new();
let initial_balance = setup.get_balance();
setup.deposit(amount);
let final_balance = setup.get_balance();
prop_assert_eq!(final_balance, initial_balance + amount);
}
}
Testing with Multiple Inputs
proptest! {
#[test]
fn test_transfer_conservation(
sender_initial in 1000u64..1_000_000u64,
amount in 1u64..1000u64
) {
prop_assume!(amount <= sender_initial); // Precondition
let mut setup = TestSetup::new();
setup.set_balance(SENDER, sender_initial);
setup.set_balance(RECEIVER, 0);
let total_before = sender_initial;
setup.transfer(SENDER, RECEIVER, amount);
let sender_after = setup.get_balance(SENDER);
let receiver_after = setup.get_balance(RECEIVER);
let total_after = sender_after + receiver_after;
// Conservation invariant
prop_assert_eq!(total_before, total_after);
}
}
Custom Strategies
use proptest::strategy::Strategy;
// Generate valid MultiversX addresses
fn arb_address() -> impl Strategy<Value = String> {
"[a-f0-9]{64}".prop_map(|hex| format!("erd1{}", &hex[..62]))
}
// Generate valid token amounts (avoiding overflow)
fn arb_token_amount() -> impl Strategy<Value = BigUint> {
(0u64..u64::MAX / 2).prop_map(BigUint::from)
}
// Generate valid token identifiers
fn arb_token_id() -> impl Strategy<Value = String> {
"[A-Z]{3,10}-[a-f0-9]{6}".prop_map(|s| s.to_uppercase())
}
proptest! {
#[test]
fn test_with_custom_strategies(
addr in arb_address(),
amount in arb_token_amount()
) {
// Test logic here
}
}
4. Stateful Property Testing
Test sequences of operations, not just individual calls.
use proptest::prelude::*;
use proptest_state_machine::*;
// Define possible operations
#[derive(Debug, Clone)]
enum Operation {
Deposit { user: usize, amount: u64 },
Withdraw { user: usize, amount: u64 },
Transfer { from: usize, to: usize, amount: u64 },
}
// Model the expected state
#[derive(Debug, Clone, Default)]
struct ModelState {
balances: HashMap<usize, u64>,
total_supply: u64,
}
impl ModelState {
fn apply(&mut self, op: &Operation) -> Result<(), &'static str> {
match op {
Operation::Deposit { user, amount } => {
*self.balances.entry(*user).or_default() += amount;
self.total_supply += amount;
Ok(())
}
Operation::Withdraw { user, amount } => {
let balance = self.balances.get(user).copied().unwrap_or(0);
if balance < *amount {
return Err("Insufficient balance");
}
*self.balances.get_mut(user).unwrap() -= amount;
self.total_supply -= amount;
Ok(())
}
Operation::Transfer { from, to, amount } => {
let from_balance = self.balances.get(from).copied().unwrap_or(0);
if from_balance < *amount {
return Err("Insufficient balance");
}
*self.balances.get_mut(from).unwrap() -= amount;
*self.balances.entry(*to).or_default() += amount;
Ok(())
}
}
}
fn check_invariants(&self) -> bool {
// Conservation: sum of balances == total supply
let sum: u64 = self.balances.values().sum();
sum == self.total_supply
}
}
proptest! {
#[test]
fn test_operation_sequence(ops in prop::collection::vec(arb_operation(), 0..100)) {
let mut model = ModelState::default();
let mut contract = TestContract::new();
for op in ops {
let model_result = model.apply(&op);
let contract_result = contract.execute(&op);
// Model and contract should agree on success/failure
prop_assert_eq!(model_result.is_ok(), contract_result.is_ok());
// Invariants should hold after every operation
prop_assert!(model.check_invariants());
prop_assert!(contract.check_invariants());
}
}
}
5. Fuzzing with cargo-fuzz
Setup Fuzz Target
// fuzz/fuzz_targets/deposit_fuzz.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
use my_contract::*;
fuzz_target!(|data: &[u8]| {
if data.len() < 8 {
return;
}
let amount = u64::from_le_bytes(data[..8].try_into().unwrap());
let mut setup = TestSetup::new();
// This should never panic regardless of input
let _ = setup.try_deposit(amount);
// Invariants should always hold
assert!(setup.check_invariants());
});
Running Fuzzer
# Run fuzzing
cargo +nightly fuzz run deposit_fuzz
# Run for specific duration
cargo +nightly fuzz run deposit_fuzz -- -max_total_time=300
# Run with specific seed corpus
cargo +nightly fuzz run deposit_fuzz corpus/deposit_fuzz
6. Integration with Scenario Tests
Generate scenario tests from property tests:
fn generate_scenario(ops: &[Operation]) -> String {
let mut steps = vec![];
for (i, op) in ops.iter().enumerate() {
let step = match op {
Operation::Deposit { user, amount } => {
format!(r#"{{
"step": "scCall",
"id": "step-{}",
"tx": {{
"from": "address:user{}",
"to": "sc:contract",
"function": "deposit",
"egldValue": "{}",
"gasLimit": "5,000,000"
}}
}}"#, i, user, amount)
}
// ... other operations
};
steps.push(step);
}
format!(r#"{{
"name": "Generated property test",
"steps": [{}]
}}"#, steps.join(",\n"))
}
7. Common Testing Patterns
Overflow Testing
proptest! {
#[test]
fn test_no_overflow_on_addition(a in 0u64..u64::MAX, b in 0u64..u64::MAX) {
let mut setup = TestSetup::new();
// Should handle overflow gracefully
let result = setup.try_add(a, b);
if a.checked_add(b).is_some() {
prop_assert!(result.is_ok());
} else {
prop_assert!(result.is_err());
}
}
}
Boundary Testing
proptest! {
#[test]
fn test_boundaries(amount in prop_oneof![
Just(0u64), // Zero
Just(1u64), // Minimum positive
Just(u64::MAX - 1), // Near maximum
Just(u64::MAX), // Maximum
0u64..u64::MAX // Random
]) {
let mut setup = TestSetup::new();
let result = setup.try_process(amount);
// Verify correct behavior at boundaries
if amount == 0 {
prop_assert!(result.is_err()); // Should reject zero
} else {
prop_assert!(result.is_ok());
}
}
}
Idempotency Testing
proptest! {
#[test]
fn test_claim_idempotency(user_id in 0usize..10) {
let mut setup = TestSetup::new();
setup.add_rewards(user_id, 1000);
let first_claim = setup.claim(user_id);
let second_claim = setup.claim(user_id);
// First claim gets rewards, second gets nothing
prop_assert_eq!(first_claim, 1000);
prop_assert_eq!(second_claim, 0);
}
}
8. Best Practices
- Start with simple invariants: Begin with obvious properties like conservation
- Use shrinking: proptest automatically shrinks failing cases to minimal examples
- Seed your corpus: Add known edge cases to fuzz corpus
- Run continuously: Property tests should run in CI on every commit
- Document invariants: Each invariant should have a comment explaining why it must hold
- Test failure modes: Verify that invalid inputs are rejected correctly