名称:multiversx-property-testing 描述:使用财产测试和模糊测试来发现智能合约逻辑中的边缘案例。适用于编写全面测试、验证不变量或使用随机输入搜索意外行为时。
MultiversX财产测试
注意:
proptest是一个外部crate(不属于MultiversX SDK)。在您的Cargo.toml中将其添加为dev-dependency。
使用财产测试(模糊测试)自动发现MultiversX智能合约逻辑中的边缘案例和违反不变量情况。这种方法生成随机输入以发现手动测试遗漏的错误。
何时使用
- 编写全面测试套件
- 验证数学不变量成立
- 测试复杂状态机
- 发现业务逻辑中的边缘案例
- 验证输入处理跨范围
1. 工具和设置
Rust财产测试库
proptest - 最常用:
# Cargo.toml (dev-dependencies)
[dev-dependencies]
proptest = "1.4"
cargo-fuzz - 基于LLVM的模糊测试:
# 安装
cargo install cargo-fuzz
# 初始化模糊目标
cargo fuzz init
MultiversX测试环境
[dev-dependencies]
multiversx-sc-scenario = "0.64.0"
2. 定义不变量
不变量是无论输入或状态如何都必须始终成立的属性。
常见智能合约不变量
| 不变量 | 描述 | 示例 |
|---|---|---|
| 守恒 | 部分之和等于总量 | 总供应量 == 所有余额之和 |
| 单调性 | 值只朝一个方向移动 | 质押金额不减除非明确解质押 |
| 边界 | 值保持在限制内 | 用户余额 <= 总供应量 |
| 一致性 | 相关值保持同步 | 白名单计数 == 白名单集合长度 |
| 幂等性 | 重复调用有相同效果 | 二次索赔返回0 |
在代码中定义不变量
// 不变量:总供应量等于所有余额之和
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
}
// 不变量:用户余额从不超出总供应量
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. 使用proptest进行财产测试
基本财产测试
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);
}
}
测试多个输入
proptest! {
#[test]
fn test_transfer_conservation(
sender_initial in 1000u64..1_000_000u64,
amount in 1u64..1000u64
) {
prop_assume!(amount <= sender_initial); // 前提条件
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;
// 守恒不变量
prop_assert_eq!(total_before, total_after);
}
}
自定义策略
use proptest::strategy::Strategy;
// 生成有效MultiversX地址
fn arb_address() -> impl Strategy<Value = String> {
"[a-f0-9]{64}".prop_map(|hex| format!("erd1{}", &hex[..62]))
}
// 生成有效令牌金额(避免溢出)
fn arb_token_amount() -> impl Strategy<Value = BigUint> {
(0u64..u64::MAX / 2).prop_map(BigUint::from)
}
// 生成有效令牌标识符
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()
) {
// 测试逻辑在此
}
}
4. 状态财产测试
测试操作序列,而不仅仅是单个调用。
use proptest::prelude::*;
use proptest_state_machine::*;
// 定义可能操作
#[derive(Debug, Clone)]
enum Operation {
Deposit { user: usize, amount: u64 },
Withdraw { user: usize, amount: u64 },
Transfer { from: usize, to: usize, amount: u64 },
}
// 模型预期状态
#[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("余额不足");
}
*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("余额不足");
}
*self.balances.get_mut(from).unwrap() -= amount;
*self.balances.entry(*to).or_default() += amount;
Ok(())
}
}
}
fn check_invariants(&self) -> bool {
// 守恒:余额之和 == 总供应量
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);
// 模型和合约应同意成功/失败
prop_assert_eq!(model_result.is_ok(), contract_result.is_ok());
// 每次操作后不变量应成立
prop_assert!(model.check_invariants());
prop_assert!(contract.check_invariants());
}
}
}
5. 使用cargo-fuzz进行模糊测试
设置模糊目标
// 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();
// 这应该无论输入如何都不会恐慌
let _ = setup.try_deposit(amount);
// 不变量应始终成立
assert!(setup.check_invariants());
});
运行模糊器
# 运行模糊测试
cargo +nightly fuzz run deposit_fuzz
# 运行特定持续时间
cargo +nightly fuzz run deposit_fuzz -- -max_total_time=300
# 运行特定种子语料库
cargo +nightly fuzz run deposit_fuzz corpus/deposit_fuzz
6. 与场景测试集成
从财产测试生成场景测试:
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)
}
// ... 其他操作
};
steps.push(step);
}
format!(r#"{{
"name": "生成的财产测试",
"steps": [{}]
}}"#, steps.join(",
"))
}
7. 常见测试模式
溢出测试
proptest! {
#[test]
fn test_no_overflow_on_addition(a in 0u64..u64::MAX, b in 0u64..u64::MAX) {
let mut setup = TestSetup::new();
// 应优雅处理溢出
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());
}
}
}
边界测试
proptest! {
#[test]
fn test_boundaries(amount in prop_oneof![
Just(0u64), // 零
Just(1u64), // 最小正数
Just(u64::MAX - 1), // 接近最大值
Just(u64::MAX), // 最大值
0u64..u64::MAX // 随机
]) {
let mut setup = TestSetup::new();
let result = setup.try_process(amount);
// 验证边界处的正确行为
if amount == 0 {
prop_assert!(result.is_err()); // 应拒绝零
} else {
prop_assert!(result.is_ok());
}
}
}
幂等性测试
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);
// 第一次索赔获得奖励,第二次无
prop_assert_eq!(first_claim, 1000);
prop_assert_eq!(second_claim, 0);
}
}
8. 最佳实践
- 从简单不变量开始:从如守恒等明显属性开始
- 使用缩小:proptest自动缩小失败案例为最小示例
- 种子您的语料库:添加已知边缘案例到模糊语料库
- 持续运行:财产测试应在每次提交时在CI中运行
- 记录不变量:每个不变量应有注释解释为何必须成立
- 测试失败模式:验证无效输入被正确拒绝