diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 00000000..4ddb6146 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,8 @@ +# cargo-mutants configuration file +# Ref: https://mutants.rs/ + +# Additional cargo args to pass to every cargo invocation +additional_cargo_args = ["--features", "std"] + +# Only mutate the property-token contract file +examine_globs = ["contracts/property-token/src/lib.rs"] diff --git a/.github/workflows/nightly-mutation-test.yml b/.github/workflows/nightly-mutation-test.yml new file mode 100644 index 00000000..71256c1f --- /dev/null +++ b/.github/workflows/nightly-mutation-test.yml @@ -0,0 +1,33 @@ +name: Nightly Mutation Test + +on: + schedule: + # Run every night at 2:00 AM UTC + - cron: '0 2 * * *' + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + mutation-test: + name: Mutation Testing (property-token) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - uses: Swatinem/rust-cache@v2 + + - name: Install cargo-mutants + run: | + mkdir -p ~/.local/bin + curl -L https://github.com/sourcefrog/cargo-mutants/releases/download/v24.5.0/cargo-mutants-v24.5.0-x86_64-unknown-linux-gnu.tar.gz | tar xz -C ~/.local/bin --strip-components=1 + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Run cargo mutants + run: | + cargo mutants -p property-token --additional-cargo-arguments="--features std" diff --git a/.gitignore b/.gitignore index 507adfca..29a9eaef 100644 --- a/.gitignore +++ b/.gitignore @@ -139,3 +139,8 @@ plan.md # Codex CLI artifacts .codex + +# cargo-mutants artifacts +/mutants.out/ +/mutants.out.old/ + diff --git a/contracts/identity/lib.rs b/contracts/identity/lib.rs index ac938441..74d5f14a 100644 --- a/contracts/identity/lib.rs +++ b/contracts/identity/lib.rs @@ -224,6 +224,13 @@ pub mod propchain_identity { )] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] pub enum KycTier { +<<<<<<< feat/mutation-testing-issue-483 + Tier0Unverified, // No KYC, basic access only + Tier1Basic, // Basic identity verification + Tier2Standard, // Standard KYC with document verification + Tier3Enhanced, // Enhanced due diligence + Tier4Premium, // Premium verification with full background check +======= Tier0Unverified, // No KYC, basic access only Tier1Basic, // Basic identity verification Tier2Standard, // Standard KYC with document verification @@ -234,6 +241,7 @@ pub mod propchain_identity { Tier2_Standard, // Standard KYC with document verification Tier3_Enhanced, // Enhanced due diligence Tier4_Premium, // Premium verification with full background check +>>>>>>> main } /// KYC Tier privileges diff --git a/contracts/lib/src/lib.rs b/contracts/lib/src/lib.rs index 285aa772..9a57ff7d 100644 --- a/contracts/lib/src/lib.rs +++ b/contracts/lib/src/lib.rs @@ -110,12 +110,8 @@ pub mod propchain_contracts { SelfTransferNotAllowed, /// Range is invalid (min > max) InvalidRange, - /// External dependency circuit breaker is open - ExternalDependencyUnavailable, /// Reentrancy guard detected a reentrant call ReentrantCall, - /// External dependency is temporarily unavailable because its circuit breaker is open - ExternalDependencyUnavailable, } impl From for Error { @@ -124,91 +120,9 @@ pub mod propchain_contracts { } } - /// Dependency type for circuit breaker - #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - scale::Encode, - scale::Decode, - ink::storage::traits::StorageLayout, - )] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] - pub enum ExternalDependency { - ComplianceRegistry, - IdentityRegistry, - FeeManager, - Oracle, - } - /// Circuit breaker state for external dependencies - #[derive(Debug, Clone, Copy, PartialEq, Eq, scale::Encode, scale::Decode)] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] - pub enum ExternalDependency { - Oracle, - ComplianceRegistry, - FeeManager, - IdentityRegistry, - PropertyManagement, - Bridge, - Insurance, - Governance, - } - #[derive( - Debug, - Clone, - PartialEq, - Eq, - Default, - scale::Encode, - scale::Decode, - scale::Encode, - scale::Decode, - Default, - ink::storage::traits::StorageLayout, - )] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] - pub struct CircuitBreakerState { - pub failure_count: u8, - pub total_failures: u32, - pub failure_count: u64, - pub total_failures: u64, - pub last_failure_at: Option, - pub open_until: Option, - } - /// Configuration for circuit breakers - #[derive( - Debug, - Clone, - PartialEq, - Eq, - scale::Encode, - scale::Decode, - Default, - ink::storage::traits::StorageLayout, - )] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] - pub struct CircuitBreakerConfig { - pub failure_threshold: u8, - pub cooldown_period_secs: u64, - } - - impl Default for CircuitBreakerConfig { - fn default() -> Self { - Self { - failure_threshold: 3, - cooldown_period_secs: 300, // 5 minutes default - } - } - } - - pub failure_threshold: u64, - pub cooldown_period_secs: u64, - } /// Property Registry contract #[ink(storage)] @@ -284,88 +198,11 @@ pub mod propchain_contracts { /// Shared external call circuit breaker configuration. external_call_config: CircuitBreakerConfig, - /// Circuit breakers for external calls - external_call_breakers: Mapping, - /// Circuit breaker configuration - external_call_config: CircuitBreakerConfig, - /// Reentrancy protection guard reentrancy_guard: ReentrancyGuard, - /// Circuit breaker configuration for external calls - external_call_config: CircuitBreakerConfig, - /// Circuit breaker states per external dependency - external_call_breakers: Mapping, - } - - #[derive( - Debug, - Clone, - Copy, - PartialEq, - Eq, - scale::Encode, - scale::Decode, - ink::storage::traits::StorageLayout, - )] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] - pub enum ExternalDependency { - FeeManager, - Oracle, - ComplianceRegistry, - IdentityRegistry, - } - - #[derive( - Debug, - Clone, - PartialEq, - Eq, - scale::Encode, - scale::Decode, - ink::storage::traits::StorageLayout, - )] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] - pub struct CircuitBreakerState { - pub failure_count: u8, - pub total_failures: u64, - pub last_failure_at: Option, - pub open_until: Option, } - impl Default for CircuitBreakerState { - fn default() -> Self { - Self { - failure_count: 0, - total_failures: 0, - last_failure_at: None, - open_until: None, - } - } - } - #[derive( - Debug, - Clone, - PartialEq, - Eq, - scale::Encode, - scale::Decode, - ink::storage::traits::StorageLayout, - )] - #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] - pub struct CircuitBreakerConfig { - pub failure_threshold: u8, - pub cooldown_period_secs: u64, - } - - impl Default for CircuitBreakerConfig { - fn default() -> Self { - Self { - failure_threshold: 3, - cooldown_period_secs: 300, - } - } - } /// Escrow information #[derive( @@ -1515,8 +1352,6 @@ pub mod propchain_contracts { cached_analytics: CachedAnalytics::default(), load_metrics: LoadMetrics::default(), reentrancy_guard: ReentrancyGuard::new(), - external_call_config: CircuitBreakerConfig::default(), - external_call_breakers: Mapping::default(), }; // Emit contract initialization event @@ -1816,11 +1651,11 @@ pub mod propchain_contracts { if !self.ensure_admin_rbac() { return Err(Error::Unauthorized); } - if failure_threshold == 0 || cooldown_period_secs == 0 { + if failure_threshold == 0 || failure_threshold > 255 || cooldown_period_secs == 0 { return Err(Error::ValueOutOfBounds); } self.external_call_config = CircuitBreakerConfig { - failure_threshold, + failure_threshold: failure_threshold as u8, cooldown_period_secs, }; Ok(()) @@ -1902,7 +1737,6 @@ pub mod propchain_contracts { self.record_dependency_success(ExternalDependency::Oracle); val } - Ok(valuation) => valuation, Err(_) => { self.record_dependency_failure(ExternalDependency::Oracle); return Err(Error::OracleError); @@ -1915,7 +1749,6 @@ pub mod propchain_contracts { self.properties.insert(&property_id, &property); } else { return Err(Error::PropertyNotFound); - Ok(()) } self.record_dependency_success(ExternalDependency::Oracle); diff --git a/contracts/property-token/src/lib.rs b/contracts/property-token/src/lib.rs index 1e336281..d6d3d8a4 100644 --- a/contracts/property-token/src/lib.rs +++ b/contracts/property-token/src/lib.rs @@ -114,6 +114,14 @@ pub mod property_token { /// Custom URI overrides for tokens token_uris: Mapping, + // Staking state (Issue #197) + share_stakes: Mapping<(AccountId, TokenId), ShareStakeInfo>, + share_total_staked: Mapping, + share_reward_pool: Mapping, + share_reward_rate_bps: Mapping, + share_acc_reward_per_share: Mapping, + share_last_reward_block: Mapping, + /// Reentrancy protection guard reentrancy_guard: ReentrancyGuard, /// Snapshot functionality for governance voting (Issue #194) @@ -1470,46 +1478,6 @@ pub mod property_token { .insert((to, token_id), &(to_balance.saturating_add(amount))); Ok(()) }) - if amount == 0 { - return Err(Error::InvalidAmount); - } - let caller = self.env().caller(); - if caller != from && !self.is_approved_for_all(from, caller) { - return Err(Error::Unauthorized); - } - if !self.pass_compliance(from)? || !self.pass_compliance(to)? { - return Err(Error::ComplianceFailed); - } - - // Check KYC-based transfer restrictions for share transfers - self.verify_kyc_transfer(&from, &to, token_id, amount)?; - - let from_balance = self.balances.get((from, token_id)).unwrap_or(0); - if from_balance < amount { - return Err(Error::InsufficientBalance); - } - - // Update user transfer quota tracking - let mut quota = - self.user_transfer_quotas - .get((token_id, from)) - .unwrap_or(UserTransferQuota { - amount_transferred: 0, - period_start_block: self.env().block_number(), - acquisition_block: self.env().block_number(), - }); - - quota.amount_transferred = quota.amount_transferred.saturating_add(amount); - self.user_transfer_quotas.insert((token_id, from), "a); - - self.update_dividend_credit_on_change(from, token_id)?; - self.update_dividend_credit_on_change(to, token_id)?; - self.balances - .insert((from, token_id), &(from_balance.saturating_sub(amount))); - let to_balance = self.balances.get((to, token_id)).unwrap_or(0); - self.balances - .insert((to, token_id), &(to_balance.saturating_add(amount))); - Ok(()) } /// Deposits dividends for distribution to all share holders of a token. @@ -2317,7 +2285,7 @@ pub mod property_token { }; // Check if period has expired and reset if needed - if current_block.saturating_sub(from_quota.period_start_block as u64) + if (current_block as u64).saturating_sub(from_quota.period_start_block as u64) >= config.quota_period as u64 { from_quota.amount_transferred = 0; @@ -3851,4 +3819,6 @@ pub mod property_token { } } } + + include!("tests.rs"); } diff --git a/contracts/property-token/src/tests.rs b/contracts/property-token/src/tests.rs index c1c6b616..dd4936fe 100644 --- a/contracts/property-token/src/tests.rs +++ b/contracts/property-token/src/tests.rs @@ -698,4 +698,312 @@ mod tests { ); assert_eq!(result, Err(Error::Unauthorized)); } + + #[ink::test] + fn test_batch_transfer_size_exceeded() { + let mut contract = setup_contract(); + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + // max_batch_size is 50. Let's create 51 items. + let ids: Vec = (1..=51).collect(); + let amounts: Vec = vec![1; 51]; + + let result = contract.safe_batch_transfer_from( + accounts.alice, + accounts.bob, + ids, + amounts, + vec![], + ); + assert_eq!(result, Err(Error::BatchSizeExceeded)); + } + + #[ink::test] + fn test_batch_transfer_size_exact_max() { + let mut contract = setup_contract(); + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + // max_batch_size is 50. Let's create exactly 50 items. + let ids: Vec = (1..=50).collect(); + let amounts: Vec = vec![1; 50]; + + // Seed balances + for &id in &ids { + contract.balances.insert((&accounts.alice, &id), &10u128); + } + + let result = contract.safe_batch_transfer_from( + accounts.alice, + accounts.bob, + ids, + amounts, + vec![], + ); + // It might return other compliance or kyc errors, but not BatchSizeExceeded. + assert_ne!(result, Err(Error::BatchSizeExceeded)); + } + + #[ink::test] + fn test_batch_transfer_exact_balance() { + let mut contract = setup_contract(); + let accounts = test::default_accounts::(); + test::set_caller::(accounts.alice); + + let token_id: TokenId = 1; + contract.balances.insert((&accounts.alice, &token_id), &100u128); + + let result = contract.safe_batch_transfer_from( + accounts.alice, + accounts.bob, + vec![token_id], + vec![100u128], + vec![], + ); + assert!(result.is_ok()); + assert_eq!(contract.balances.get((&accounts.alice, &token_id)).unwrap_or(0), 0); + assert_eq!(contract.balances.get((&accounts.bob, &token_id)).unwrap_or(0), 100); + } + + #[ink::test] + fn test_issue_shares_by_owner_not_admin() { + let mut contract = setup_contract(); + let accounts = test::default_accounts::(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + // Deployer (admin) registers property, making alice the owner of token 1 + test::set_caller::(contract.admin()); + let token_id = contract.register_property_with_token(metadata).unwrap(); + contract.token_owner.insert(token_id, &accounts.alice); + + // Alice (owner, not admin) issues shares + test::set_caller::(accounts.alice); + let result = contract.issue_shares(token_id, accounts.bob, 500); + assert!(result.is_ok()); + assert_eq!(contract.share_balance_of(accounts.bob, token_id), 500); + } + + #[ink::test] + fn test_issue_shares_unauthorized() { + let mut contract = setup_contract(); + let accounts = test::default_accounts::(); + + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + + test::set_caller::(contract.admin()); + let token_id = contract.register_property_with_token(metadata).unwrap(); + + // Bob (not admin, not owner) tries to issue shares + test::set_caller::(accounts.bob); + let result = contract.issue_shares(token_id, accounts.charlie, 500); + assert_eq!(result, Err(Error::Unauthorized)); + } + + #[ink::test] + fn test_redeem_shares_success() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.redeem_shares(token_id, accounts.alice, 400); + assert!(result.is_ok()); + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 601); + } + + #[ink::test] + fn test_redeem_shares_approved_operator() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + contract.set_approval_for_all(accounts.bob, true).unwrap(); + + test::set_caller::(accounts.bob); + let result = contract.redeem_shares(token_id, accounts.alice, 400); + assert!(result.is_ok()); + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 601); + } + + #[ink::test] + fn test_redeem_shares_unauthorized() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + let result = contract.redeem_shares(token_id, accounts.alice, 400); + assert_eq!(result, Err(Error::Unauthorized)); + } + + #[ink::test] + fn test_redeem_shares_zero_amount() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.redeem_shares(token_id, accounts.alice, 0); + assert_eq!(result, Err(Error::InvalidAmount)); + } + + #[ink::test] + fn test_redeem_shares_insufficient_balance() { + let (mut contract, token_id) = setup_token_with_shares(100); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.redeem_shares(token_id, accounts.alice, 102); // 100 + 1 = 101, so 102 is insufficient + assert_eq!(result, Err(Error::InsufficientBalance)); + } + + #[ink::test] + fn test_redeem_shares_exact_balance() { + let (mut contract, token_id) = setup_token_with_shares(100); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.redeem_shares(token_id, accounts.alice, 101); // 100 + 1 = 101 + assert!(result.is_ok()); + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 0); + } + + #[ink::test] + fn test_deposit_dividends_success() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + test::set_value_transferred::(10_000); + let result = contract.deposit_dividends(token_id); + assert!(result.is_ok()); + + let dividends = contract.dividends_per_share.get(token_id).unwrap_or(0); + assert!(dividends > 0); + } + + #[ink::test] + fn test_deposit_dividends_zero_amount() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + test::set_value_transferred::(0); + let result = contract.deposit_dividends(token_id); + assert_eq!(result, Err(Error::InvalidAmount)); + } + + #[ink::test] + fn test_deposit_dividends_no_shares() { + let mut contract = setup_contract(); + let metadata = PropertyMetadata { + location: String::from("123 Main St"), + size: 1000, + legal_description: String::from("Sample property"), + valuation: 500000, + documents_url: String::from("ipfs://sample-docs"), + }; + let token_id = contract.register_property_with_token(metadata).unwrap(); + + test::set_caller::(contract.admin()); + test::set_value_transferred::(10_000); + let result = contract.deposit_dividends(token_id); + assert_eq!(result, Err(Error::InvalidRequest)); + } + + #[ink::test] + fn test_distribute_rental_income_unauthorized() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.bob); + test::set_value_transferred::(10_000); + let result = contract.distribute_rental_income(token_id); + assert_eq!(result, Err(Error::Unauthorized)); + } + + #[ink::test] + fn test_withdraw_dividends_exact_amount() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(contract.admin()); + test::set_value_transferred::(10_000); + contract.distribute_rental_income(token_id).unwrap(); + + test::set_caller::(accounts.alice); + let withdrawn = contract.withdraw_dividends(token_id).unwrap(); + assert_eq!(withdrawn, 10_010); + } + + #[ink::test] + fn test_place_ask_success() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.place_ask(token_id, 10, 400); + assert!(result.is_ok()); + + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 601); + assert_eq!(contract.escrowed_shares.get((token_id, accounts.alice)).unwrap_or(0), 400); + + let ask = contract.asks.get((token_id, accounts.alice)).unwrap(); + assert_eq!(ask.amount, 400); + assert_eq!(ask.price_per_share, 10); + } + + #[ink::test] + fn test_place_ask_invalid_price() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.place_ask(token_id, 0, 400); + assert_eq!(result, Err(Error::InvalidAmount)); + } + + #[ink::test] + fn test_place_ask_invalid_amount() { + let (mut contract, token_id) = setup_token_with_shares(1000); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.place_ask(token_id, 10, 0); + assert_eq!(result, Err(Error::InvalidAmount)); + } + + #[ink::test] + fn test_place_ask_insufficient_balance() { + let (mut contract, token_id) = setup_token_with_shares(100); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.place_ask(token_id, 10, 102); // 100 + 1 = 101 + assert_eq!(result, Err(Error::InsufficientBalance)); + } + + #[ink::test] + fn test_place_ask_exact_balance() { + let (mut contract, token_id) = setup_token_with_shares(100); + let accounts = test::default_accounts::(); + + test::set_caller::(accounts.alice); + let result = contract.place_ask(token_id, 10, 101); // 100 + 1 = 101 + assert!(result.is_ok()); + assert_eq!(contract.share_balance_of(accounts.alice, token_id), 0); + assert_eq!(contract.escrowed_shares.get((token_id, accounts.alice)).unwrap_or(0), 101); + } } + diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 7119af76..7ac8eef2 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -385,6 +385,42 @@ Tests run automatically in CI/CD pipeline: run: cargo tarpaulin --out Xml ``` +## Mutation Testing + +### Overview +Mutation testing automatically inserts bugs (mutants) into the smart contracts to verify if the unit test suite is thorough enough to detect them. We use `cargo-mutants` to perform mutation testing, specifically focusing on critical financial functions in `property-token`. + +### Configuration +A workspace-level configuration is stored in `.cargo/mutants.toml`. This file excludes non-essential targets and defines defaults: + +```toml +# .cargo/mutants.toml +additional_cargo_args = ["--features", "std"] +``` + +### Running Locally +To run mutation testing on the `property-token` contract: + +```bash +# Ensure cargo-mutants is in your PATH +cargo mutants -p property-token +``` + +To run mutation testing on a specific set of functions (e.g. transfers, dividends, asks): + +```bash +cargo mutants -p property-token -F "transfer_from|place_ask|redeem_shares" +``` + +### Interpreting Results +- **Caught**: A test failed when the mutant was applied. This means the test suite successfully detected the bug. +- **Missed**: All tests passed even with the mutated code. This indicates a gap in test coverage or assertions. +- **Goal**: Maintain a missed mutation rate of **<10%** on critical financial functions. + +### Nightly CI Integration +Mutation testing is resource-intensive and is therefore run on a nightly schedule rather than on every pull request. The workflow is configured in `.github/workflows/nightly-mutation-test.yml` and can also be triggered manually via `workflow_dispatch`. + + ## Troubleshooting ### Common Issues