diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 1174c454..b16418aa 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -59,6 +59,11 @@ jobs: - name: Run contract tests run: cargo test + - name: Run contract fuzz tests + run: cargo test -p subscription_renewal -p escrow -p payment-channel fuzz_ + env: + PROPTEST_CASES: "8" + - name: Verify backend contract interface alignment working-directory: .. run: | diff --git a/contracts/.gitignore b/contracts/.gitignore index d74cc01f..950111b2 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -4,3 +4,6 @@ target # Local settings .soroban .stellar + +# Fuzz tests do not commit Soroban snapshot JSON +contracts/*/test_snapshots/fuzz/ diff --git a/contracts/Cargo.lock b/contracts/Cargo.lock index 7589420d..73f43188 100644 --- a/contracts/Cargo.lock +++ b/contracts/Cargo.lock @@ -178,7 +178,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ "num-traits", - "rand", + "rand 0.8.5", ] [[package]] @@ -211,6 +211,27 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + [[package]] name = "block-buffer" version = "0.10.4" @@ -322,7 +343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ "generic-array", - "rand_core", + "rand_core 0.6.4", "subtle", "zeroize", ] @@ -557,7 +578,7 @@ checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ "curve25519-dalek", "ed25519", - "rand_core", + "rand_core 0.6.4", "serde", "sha2", "subtle", @@ -594,7 +615,7 @@ dependencies = [ "ff", "generic-array", "group", - "rand_core", + "rand_core 0.6.4", "sec1", "subtle", "zeroize", @@ -626,6 +647,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "escape-bytes" version = "0.1.1" @@ -636,6 +667,7 @@ checksum = "2bfcf67fea2815c2fc3b90873fae90957be12ff417335dfadc7f52927feb03b2" name = "escrow" version = "0.1.0" dependencies = [ + "proptest", "soroban-sdk", ] @@ -645,13 +677,19 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "ff" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -697,6 +735,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "group" version = "0.13.0" @@ -704,7 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ "ff", - "rand_core", + "rand_core 0.6.4", "subtle", ] @@ -895,6 +956,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.29" @@ -987,6 +1054,14 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "payment-channel" +version = "0.1.0" +dependencies = [ + "proptest", + "soroban-sdk", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -1040,6 +1115,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.4", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.43" @@ -1049,6 +1149,18 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1056,8 +1168,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1067,7 +1189,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1076,7 +1208,25 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", ] [[package]] @@ -1099,6 +1249,12 @@ dependencies = [ "syn", ] +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" @@ -1118,12 +1274,37 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "schemars" version = "0.8.22" @@ -1287,7 +1468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1353,7 +1534,7 @@ dependencies = [ "ed25519-dalek", "elliptic-curve", "generic-array", - "getrandom", + "getrandom 0.2.17", "hex-literal", "hmac", "k256", @@ -1361,8 +1542,8 @@ dependencies = [ "num-integer", "num-traits", "p256", - "rand", - "rand_chacha", + "rand 0.8.5", + "rand_chacha 0.3.1", "sec1", "sha2", "sha3", @@ -1415,7 +1596,7 @@ dependencies = [ "ctor", "derive_arbitrary", "ed25519-dalek", - "rand", + "rand 0.8.5", "rustc_version", "serde", "serde_json", @@ -1581,6 +1762,7 @@ dependencies = [ name = "subscription_renewal" version = "0.0.1" dependencies = [ + "proptest", "soroban-sdk", ] @@ -1601,6 +1783,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1658,6 +1853,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -1688,12 +1889,30 @@ dependencies = [ "syn", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -1835,6 +2054,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "zerocopy" version = "0.8.33" diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 714b2c73..9572bd87 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -13,6 +13,7 @@ members = [ [workspace.dependencies] soroban-sdk = "26" +proptest = "1.6" [profile.release] opt-level = "z" diff --git a/contracts/FUZZING_EDGE_CASES.md b/contracts/FUZZING_EDGE_CASES.md new file mode 100644 index 00000000..0f2b7b80 --- /dev/null +++ b/contracts/FUZZING_EDGE_CASES.md @@ -0,0 +1,48 @@ +# Soroban Contract Fuzzing — Discovered Edge Cases + +Property-based fuzz tests (`proptest`) were added for `subscription_renewal`, `escrow`, and `payment-channel` as part of [#955](https://github.com/Calebux/SYNCRO/issues/955). + +Run locally: + +```bash +cd contracts +cargo test fuzz_ +``` + +## subscription_renewal + +| Edge case | Behavior | Test | +|-----------|----------|------| +| Renewal amount exceeds per-subscription spending cap | Panics with spending cap violation | `fuzz_renewal_respects_spending_cap` | +| Cumulative spend exceeds global user cap | Panics with global cap violation | `fuzz_global_cap_overflow_rejected` | +| Approval reuse after successful renewal | Panics — approvals are single-use | `fuzz_approval_single_use` | +| Admin operations on uninitialized contract | Panics — no admin in storage | `fuzz_uninitialized_contract_rejects_admin_ops` | +| Random amounts/intervals on init | Stored values match inputs; state stays `Active` | `fuzz_init_sub_amounts_and_intervals` | + +## escrow + +| Edge case | Behavior | Test | +|-----------|----------|------| +| Second deposit on funded escrow | Panics with `AlreadyFunded` | `fuzz_concurrent_deposit_rejected` | +| Zero or negative escrow amount | Panics with `InvalidAmount` | `fuzz_invalid_amounts_rejected` | +| Third-party dispute raise | Panics with `Unauthorized` | `fuzz_unauthorized_dispute_rejected` | +| Deposit then refund | Token balance restored to payer | `fuzz_deposit_refund_conservation` | +| Random positive amounts | `deposited` equals `amount` after funding | `fuzz_deposit_with_random_amounts` | + +## payment-channel + +| Edge case | Behavior | Test | +|-----------|----------|------| +| Counterparty attempts top-up | Returns `Unauthorized` | `fuzz_unauthorized_top_up_rejected` | +| Zero/negative open deposit | Returns `InvalidAmount` | `fuzz_invalid_deposit_amounts_rejected` | +| Stale sequence on state update | Returns `StaleState` | `fuzz_stale_sequence_rejected` | +| Top-up sequence | `balance_a + balance_b` conserved | `fuzz_top_up_balance_conservation` | +| Close → finalize after dispute window | State transitions `Open → Closing → Closed` | `fuzz_close_state_transitions` | +| State submit with valid sequence | Sequence monotonically increases | `fuzz_state_transition_sequence_monotonic` | + +## Notes + +- Fuzz tests use 8 cases per property (`ProptestConfig::with_cases(8)`) for fast CI runs. +- Fuzz tests disable Soroban snapshot capture (`EnvTestConfig::capture_snapshot_at_drop = false`); no snapshot JSON files are committed. +- Integer overflow is guarded by Rust `overflow-checks = true` in release profile and explicit `saturating_add` checks in fuzz assertions where applicable. +- Panic-based contracts (`subscription_renewal`, `escrow`) use `catch_unwind` to verify rejection paths; `Result`-based `payment-channel` checks `Err` variants directly. diff --git a/contracts/contracts/escrow/Cargo.toml b/contracts/contracts/escrow/Cargo.toml index f1b35a83..6cfc7ed5 100644 --- a/contracts/contracts/escrow/Cargo.toml +++ b/contracts/contracts/escrow/Cargo.toml @@ -8,4 +8,5 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = { workspace = true } diff --git a/contracts/contracts/escrow/src/fuzz.rs b/contracts/contracts/escrow/src/fuzz.rs new file mode 100644 index 00000000..06dd18c8 --- /dev/null +++ b/contracts/contracts/escrow/src/fuzz.rs @@ -0,0 +1,156 @@ +#![cfg(test)] +extern crate std; + +use proptest::prelude::*; +use soroban_sdk::{ + testutils::{Address as _, EnvTestConfig, Ledger}, + token::{StellarAssetClient, TokenClient}, + Address, Env, String, +}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use super::{EscrowContract, EscrowContractClient, EscrowState}; + +fn fuzz_env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + ..EnvTestConfig::default() + }) +} + +fn fuzz_setup() -> (Env, Address, Address, Address, Address, TokenClient<'static>) { + let env = fuzz_env(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let payer = Address::generate(&env); + let payee = Address::generate(&env); + let arbiter = Address::generate(&env); + + let sac = env.register_stellar_asset_contract_v2(admin.clone()); + let token_addr = sac.address(); + let token = TokenClient::new(&env, &token_addr); + let asset_client = StellarAssetClient::new(&env, &token_addr); + asset_client.mint(&payer, &100_000_000_000i128); + + (env, payer, payee, arbiter, token_addr, token) +} + +fn register_escrow(env: &Env) -> EscrowContractClient<'static> { + let contract_id = env.register_contract(None, EscrowContract); + EscrowContractClient::new(env, &contract_id) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(8))] + + #[test] + fn fuzz_deposit_with_random_amounts(amount in 1i128..=50_000_000_000i128) { + let (env, payer, payee, arbiter, token, token_client) = fuzz_setup(); + let escrow = register_escrow(&env); + let admin = Address::generate(&env); + escrow.init(&admin); + + let expiry = env.ledger().timestamp() + 86_400u64; + let desc = String::from_str(&env, "fuzz"); + + let payer_balance_before = token_client.balance(&payer); + let id = escrow.create_escrow( + &payer, &payee, &arbiter, &token, &amount, &expiry, &desc, + ); + + escrow.deposit(&id); + let agreement = escrow.get_escrow(&id); + prop_assert_eq!(agreement.deposited, amount); + prop_assert_eq!(agreement.state, EscrowState::Funded); + prop_assert_eq!(token_client.balance(&payer), payer_balance_before - amount); + } + + #[test] + fn fuzz_concurrent_deposit_rejected(amount in 1i128..=10_000_000_000i128) { + let (env, payer, payee, arbiter, token, _token_client) = fuzz_setup(); + let escrow = register_escrow(&env); + let admin = Address::generate(&env); + escrow.init(&admin); + + let expiry = env.ledger().timestamp() + 86_400u64; + let desc = String::from_str(&env, "fuzz"); + + let id = escrow.create_escrow( + &payer, &payee, &arbiter, &token, &amount, &expiry, &desc, + ); + escrow.deposit(&id); + + let result = catch_unwind(AssertUnwindSafe(|| { + escrow.deposit(&id); + })); + prop_assert!(result.is_err(), "double deposit must panic with AlreadyFunded"); + + let agreement = escrow.get_escrow(&id); + prop_assert_eq!(agreement.deposited, amount); + prop_assert_eq!(agreement.state, EscrowState::Funded); + } + + #[test] + fn fuzz_deposit_refund_conservation(amount in 1i128..=10_000_000_000i128) { + let (env, payer, payee, arbiter, token, token_client) = fuzz_setup(); + let escrow = register_escrow(&env); + let admin = Address::generate(&env); + escrow.init(&admin); + + let expiry = env.ledger().timestamp() + 86_400u64; + let desc = String::from_str(&env, "fuzz"); + let balance_before = token_client.balance(&payer); + + let id = escrow.create_escrow( + &payer, &payee, &arbiter, &token, &amount, &expiry, &desc, + ); + escrow.deposit(&id); + escrow.refund(&id); + + prop_assert_eq!(token_client.balance(&payer), balance_before); + prop_assert_eq!(escrow.get_escrow(&id).state, EscrowState::Refunded); + } + + #[test] + fn fuzz_invalid_amounts_rejected(amount in -1_000_000i128..=0i128) { + let (env, payer, payee, arbiter, token, _token_client) = fuzz_setup(); + let escrow = register_escrow(&env); + let admin = Address::generate(&env); + escrow.init(&admin); + + let expiry = env.ledger().timestamp() + 86_400u64; + let desc = String::from_str(&env, "fuzz"); + + let result = catch_unwind(AssertUnwindSafe(|| { + escrow.create_escrow( + &payer, &payee, &arbiter, &token, &amount, &expiry, &desc, + ); + })); + prop_assert!(result.is_err(), "non-positive amount must panic"); + } + + #[test] + fn fuzz_unauthorized_dispute_rejected(amount in 1i128..=1_000_000_000i128) { + let (env, payer, payee, arbiter, token, _token_client) = fuzz_setup(); + let escrow = register_escrow(&env); + let admin = Address::generate(&env); + escrow.init(&admin); + + let expiry = env.ledger().timestamp() + 86_400u64; + let desc = String::from_str(&env, "fuzz"); + + let id = escrow.create_escrow( + &payer, &payee, &arbiter, &token, &amount, &expiry, &desc, + ); + escrow.deposit(&id); + + let stranger = Address::generate(&env); + let result = catch_unwind(AssertUnwindSafe(|| { + escrow.raise_dispute(&id, &stranger); + })); + prop_assert!(result.is_err(), "unauthorized dispute must panic"); + + prop_assert_eq!(escrow.get_escrow(&id).state, EscrowState::Funded); + } +} diff --git a/contracts/contracts/escrow/src/lib.rs b/contracts/contracts/escrow/src/lib.rs index 1935e893..e79cea00 100644 --- a/contracts/contracts/escrow/src/lib.rs +++ b/contracts/contracts/escrow/src/lib.rs @@ -2,7 +2,7 @@ use soroban_sdk::{ contract, contracterror, contractevent, contractimpl, contracttype, - token, Address, Env, String, + panic_with_error, token, Address, Env, String, }; // ── Storage keys ────────────────────────────────────────────────────────────── @@ -74,7 +74,7 @@ pub enum EscrowError { InDispute = 15, NotInDispute = 16, SelfAsCounterparty = 17, - Same Arbiter As Party = 18, + SameArbiterAsParty = 18, } // ── Events ──────────────────────────────────────────────────────────────────── @@ -142,7 +142,7 @@ impl EscrowContract { pub fn init(env: Env, admin: Address) { if env.storage().instance().has(&DataKey::Admin) { - panic!(EscrowError::AlreadyInitialized); + panic_with_error!(&env, EscrowError::AlreadyInitialized); } env.storage().instance().set(&DataKey::Admin, &admin); } @@ -185,13 +185,13 @@ impl EscrowContract { payer.require_auth(); if amount <= 0 { - panic!(EscrowError::InvalidAmount); + panic_with_error!(&env, EscrowError::InvalidAmount); } if payer == payee { - panic!(EscrowError::SelfAsCounterparty); + panic_with_error!(&env, EscrowError::SelfAsCounterparty); } if arbiter == payer || arbiter == payee { - panic!(EscrowError::SameArbiterAsParty); + panic_with_error!(&env, EscrowError::SameArbiterAsParty); } let count: u64 = env @@ -203,7 +203,7 @@ impl EscrowContract { let now = env.ledger().timestamp(); if expires_at <= now { - panic!(EscrowError::Expired); + panic_with_error!(&env, EscrowError::Expired); } let escrow = EscrowAgreement { @@ -253,7 +253,7 @@ impl EscrowContract { .expect("escrow not found"); if escrow.state != EscrowState::Created { - panic!(EscrowError::AlreadyFunded); + panic_with_error!(&env, EscrowError::AlreadyFunded); } escrow.payer.require_auth(); @@ -295,10 +295,10 @@ impl EscrowContract { .expect("escrow not found"); if escrow.state != EscrowState::Funded && escrow.state != EscrowState::Disputed { - panic!(EscrowError::NotFunded); + panic_with_error!(&env, EscrowError::NotFunded); } if escrow.arbiter_approved { - panic!(EscrowError::AlreadyApproved); + panic_with_error!(&env, EscrowError::AlreadyApproved); } escrow.arbiter.require_auth(); @@ -331,10 +331,10 @@ impl EscrowContract { .expect("escrow not found"); if escrow.state == EscrowState::Released { - panic!(EscrowError::AlreadyReleased); + panic_with_error!(&env, EscrowError::AlreadyReleased); } if escrow.state != EscrowState::Approved { - panic!(EscrowError::NotApproved); + panic_with_error!(&env, EscrowError::NotApproved); } // Payee must authorize receipt @@ -376,13 +376,13 @@ impl EscrowContract { .expect("escrow not found"); if escrow.state == EscrowState::Refunded { - panic!(EscrowError::AlreadyRefunded); + panic_with_error!(&env, EscrowError::AlreadyRefunded); } if escrow.state == EscrowState::Released { - panic!(EscrowError::AlreadyReleased); + panic_with_error!(&env, EscrowError::AlreadyReleased); } if escrow.state != EscrowState::Funded && escrow.state != EscrowState::Approved { - panic!(EscrowError::NotFunded); + panic_with_error!(&env, EscrowError::NotFunded); } let now = env.ledger().timestamp(); @@ -394,7 +394,7 @@ impl EscrowContract { } else { // Before expiry — refund only if arbiter hasn't approved if escrow.arbiter_approved { - panic!(EscrowError::AlreadyApproved); + panic_with_error!(&env, EscrowError::AlreadyApproved); } escrow.payer.require_auth(); } @@ -430,11 +430,11 @@ impl EscrowContract { .expect("escrow not found"); if escrow.state != EscrowState::Funded && escrow.state != EscrowState::Approved { - panic!(EscrowError::NotFunded); + panic_with_error!(&env, EscrowError::NotFunded); } if caller != escrow.payer && caller != escrow.payee { - panic!(EscrowError::Unauthorized); + panic_with_error!(&env, EscrowError::Unauthorized); } caller.require_auth(); @@ -465,7 +465,7 @@ impl EscrowContract { .expect("escrow not found"); if escrow.state != EscrowState::Disputed { - panic!(EscrowError::NotInDispute); + panic_with_error!(&env, EscrowError::NotInDispute); } escrow.arbiter.require_auth(); @@ -491,7 +491,7 @@ impl EscrowContract { ); escrow.state = EscrowState::Refunded; } - _ => panic!(EscrowError::InvalidAmount), + _ => panic_with_error!(&env, EscrowError::InvalidAmount), } env.storage() @@ -572,13 +572,13 @@ mod test { // Create a Stellar asset token for testing let sac = env.register_stellar_asset_contract_v2(admin.clone()); - let token = TokenClient::new(&env, &sac.0); - let asset_client = StellarAssetClient::new(&env, &sac.0); + let token = TokenClient::new(&env, &sac.address()); + let asset_client = StellarAssetClient::new(&env, &sac.address()); // Mint tokens to payer asset_client.mint(&payer, &10_000_000_000i128); - (env, payer, payee, arbiter, sac.0, token) + (env, payer, payee, arbiter, sac.address(), token) } fn register_escrow(env: &Env) -> EscrowContractClient<'static> { @@ -624,7 +624,7 @@ mod test { } #[test] - #[should_panic(expected = "Unauthorized")] + #[should_panic(expected = "Error(Contract, #10)")] fn test_release_without_arbiter_approval_fails() { let (env, payer, payee, arbiter, token, _token_client) = setup(); let escrow = register_escrow(&env); @@ -667,7 +667,7 @@ mod test { } #[test] - #[should_panic(expected = "AlreadyApproved")] + #[should_panic(expected = "Error(Contract, #9)")] fn test_refund_after_approval_fails_before_expiry() { let (env, payer, payee, arbiter, token, _token_client) = setup(); let escrow = register_escrow(&env); @@ -714,7 +714,7 @@ mod test { } #[test] - #[should_panic(expected = "SameArbiterAsParty")] + #[should_panic(expected = "Error(Contract, #18)")] fn test_arbiter_cannot_be_party() { let (env, payer, payee, _arbiter, token, _token_client) = setup(); let escrow = register_escrow(&env); @@ -731,7 +731,7 @@ mod test { } #[test] - #[should_panic(expected = "SelfAsCounterparty")] + #[should_panic(expected = "Error(Contract, #17)")] fn test_payer_cannot_be_payee() { let (env, payer, _payee, arbiter, token, _token_client) = setup(); let escrow = register_escrow(&env); @@ -828,3 +828,6 @@ mod test { } } +#[cfg(test)] +mod fuzz; + diff --git a/contracts/contracts/payment-channel/Cargo.toml b/contracts/contracts/payment-channel/Cargo.toml index 88b157ca..5ee93223 100644 --- a/contracts/contracts/payment-channel/Cargo.toml +++ b/contracts/contracts/payment-channel/Cargo.toml @@ -8,3 +8,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = { workspace = true } diff --git a/contracts/contracts/payment-channel/src/fuzz.rs b/contracts/contracts/payment-channel/src/fuzz.rs new file mode 100644 index 00000000..673c0b7f --- /dev/null +++ b/contracts/contracts/payment-channel/src/fuzz.rs @@ -0,0 +1,193 @@ +#![cfg(test)] +extern crate std; + +use proptest::prelude::*; +use soroban_sdk::{testutils::{Address as _, EnvTestConfig, Ledger}, Address, Env}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use super::{ + ChannelState, PaymentChannelContract, PaymentChannelContractClient, +}; + +fn fuzz_env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + ..EnvTestConfig::default() + }) +} + +fn fuzz_setup() -> (Env, PaymentChannelContractClient<'static>, Address, Address, Address) { + let env = fuzz_env(); + env.mock_all_auths(); + + let contract_id = env.register_contract(None, PaymentChannelContract); + let client = PaymentChannelContractClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let depositor = Address::generate(&env); + let counterparty = Address::generate(&env); + + client.init(&admin); + (env, client, admin, depositor, counterparty) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(8))] + + #[test] + fn fuzz_open_channel_random_deposits( + deposit in 1i128..=1_000_000_000i128, + dispute_window in 1u64..=1_000_000u64, + ) { + let (env, client, _admin, depositor, counterparty) = fuzz_setup(); + let channel_id = + client.open_channel(&depositor, &counterparty, &deposit, &dispute_window); + + let channel = client.get_channel(&channel_id).unwrap(); + prop_assert_eq!(channel.balance_a, deposit); + prop_assert_eq!(channel.balance_b, 0); + prop_assert_eq!(channel.state, ChannelState::Open); + prop_assert_eq!(channel.sequence, 0); + prop_assert!(channel.dispute_deadline >= env.ledger().timestamp()); + } + + #[test] + fn fuzz_top_up_balance_conservation( + initial in 1i128..=1_000_000i128, + top_ups in prop::collection::vec(1i128..=100_000i128, 1..=5), + ) { + let (_env, client, _admin, depositor, counterparty) = fuzz_setup(); + let channel_id = client.open_channel(&depositor, &counterparty, &initial, &100u64); + + let mut expected_a = initial; + for amount in &top_ups { + client.top_up(&channel_id, amount, &depositor); + expected_a = expected_a.saturating_add(*amount); + } + + let channel = client.get_channel(&channel_id).unwrap(); + let total = channel.balance_a.saturating_add(channel.balance_b); + prop_assert_eq!(channel.balance_a, expected_a); + prop_assert_eq!(total, expected_a); + } + + #[test] + fn fuzz_state_transition_sequence_monotonic( + deposit in 100i128..=1_000_000i128, + seq_delta in 1u64..=100u64, + split in 0i128..=100i128, + ) { + let (_env, client, _admin, depositor, counterparty) = fuzz_setup(); + let channel_id = client.open_channel(&depositor, &counterparty, &deposit, &100u64); + + let balance_b = split.min(deposit); + let balance_a = deposit - balance_b; + let seq = seq_delta; + + client.submit_state( + &channel_id, + &balance_a, + &balance_b, + &seq, + &depositor, + &counterparty, + ); + + let channel = client.get_channel(&channel_id).unwrap(); + prop_assert_eq!(channel.sequence, seq); + prop_assert_eq!(channel.balance_a, balance_a); + prop_assert_eq!(channel.balance_b, balance_b); + prop_assert_eq!( + channel.balance_a.saturating_add(channel.balance_b), + deposit + ); + } + + #[test] + fn fuzz_stale_sequence_rejected( + deposit in 100i128..=1_000_000i128, + stale_seq in 0u64..=5u64, + ) { + let (_env, client, _admin, depositor, counterparty) = fuzz_setup(); + let channel_id = client.open_channel(&depositor, &counterparty, &deposit, &100u64); + + client.submit_state( + &channel_id, + &deposit, + &0i128, + &10u64, + &depositor, + &counterparty, + ); + + let result = catch_unwind(AssertUnwindSafe(|| { + client.submit_state( + &channel_id, + &deposit, + &0i128, + &stale_seq, + &depositor, + &counterparty, + ); + })); + prop_assert!(result.is_err(), "stale sequence must be rejected"); + } + + #[test] + fn fuzz_unauthorized_top_up_rejected( + deposit in 1i128..=1_000_000i128, + top_up_amount in 1i128..=100_000i128, + ) { + let (_env, client, _admin, depositor, counterparty) = fuzz_setup(); + let channel_id = client.open_channel(&depositor, &counterparty, &deposit, &100u64); + + let result = catch_unwind(AssertUnwindSafe(|| { + client.top_up(&channel_id, &top_up_amount, &counterparty); + })); + prop_assert!(result.is_err(), "unauthorized top-up must be rejected"); + + let channel = client.get_channel(&channel_id).unwrap(); + prop_assert_eq!(channel.balance_a, deposit); + } + + #[test] + fn fuzz_invalid_deposit_amounts_rejected(amount in -1_000_000i128..=0i128) { + let (_env, client, _admin, depositor, counterparty) = fuzz_setup(); + let result = catch_unwind(AssertUnwindSafe(|| { + client.open_channel(&depositor, &counterparty, &amount, &100u64); + })); + prop_assert!(result.is_err(), "invalid deposit amount must be rejected"); + } + + #[test] + fn fuzz_close_state_transitions( + deposit in 100i128..=1_000_000i128, + balance_b in 0i128..=100i128, + ) { + let (env, client, _admin, depositor, counterparty) = fuzz_setup(); + let balance_b = balance_b.min(deposit); + let balance_a = deposit - balance_b; + + let channel_id = client.open_channel(&depositor, &counterparty, &deposit, &1u64); + + client.initiate_close( + &channel_id, + &balance_a, + &balance_b, + &1u64, + &depositor, + ); + + let closing = client.get_channel(&channel_id).unwrap(); + prop_assert_eq!(closing.state, ChannelState::Closing); + + env.ledger().set_timestamp(closing.dispute_deadline + 1); + client.finalize(&channel_id); + + let closed = client.get_channel(&channel_id).unwrap(); + prop_assert_eq!(closed.state, ChannelState::Closed); + prop_assert_eq!( + closed.balance_a.saturating_add(closed.balance_b), + deposit + ); + } +} diff --git a/contracts/contracts/payment-channel/src/lib.rs b/contracts/contracts/payment-channel/src/lib.rs index d0fafc69..4c205dae 100644 --- a/contracts/contracts/payment-channel/src/lib.rs +++ b/contracts/contracts/payment-channel/src/lib.rs @@ -288,3 +288,6 @@ impl PaymentChannelContract { } mod test; + +#[cfg(test)] +mod fuzz; diff --git a/contracts/contracts/payment-channel/src/test.rs b/contracts/contracts/payment-channel/src/test.rs index 2c1f3003..ca51c9ca 100644 --- a/contracts/contracts/payment-channel/src/test.rs +++ b/contracts/contracts/payment-channel/src/test.rs @@ -1,7 +1,7 @@ #![cfg(test)] use super::*; -use soroban_sdk::{testutils::Address as _, Env}; +use soroban_sdk::{testutils::{Address as _, Ledger}, Env}; #[test] fn happy_path_open_close_and_top_up() { @@ -15,20 +15,23 @@ fn happy_path_open_close_and_top_up() { let depositor = Address::generate(&env); let counterparty = Address::generate(&env); - client.init(&admin).unwrap(); - let channel_id = client.open_channel(&depositor, &counterparty, &100, &10).unwrap(); - client.top_up(&channel_id, &25, &depositor).unwrap(); + client.init(&admin); + let channel_id = client.open_channel(&depositor, &counterparty, &100, &10); + client.top_up(&channel_id, &25, &depositor); let channel = client.get_channel(&channel_id).unwrap(); assert_eq!(channel.balance_a, 125); assert_eq!(channel.state, ChannelState::Open); - client.initiate_close(&channel_id, &120, &5, &1, &depositor).unwrap(); - client.submit_state(&channel_id, &120, &5, &2, &depositor, &counterparty).unwrap(); + client.initiate_close(&channel_id, &120, &5, &1, &depositor); + + let closing = client.get_channel(&channel_id).unwrap(); + env.ledger().set_timestamp(closing.dispute_deadline + 1); + client.finalize(&channel_id); let closed = client.get_channel(&channel_id).unwrap(); assert_eq!(closed.state, ChannelState::Closed); - assert_eq!(closed.sequence, 2); + assert_eq!(closed.sequence, 1); } #[test] @@ -43,10 +46,10 @@ fn dispute_path_overrides_stale_close() { let depositor = Address::generate(&env); let counterparty = Address::generate(&env); - client.init(&admin).unwrap(); - let channel_id = client.open_channel(&depositor, &counterparty, &100, &100).unwrap(); - client.initiate_close(&channel_id, &90, &10, &1, &depositor).unwrap(); - client.dispute(&channel_id, &80, &20, &2, &depositor, &counterparty).unwrap(); + client.init(&admin); + let channel_id = client.open_channel(&depositor, &counterparty, &100, &100); + client.initiate_close(&channel_id, &90, &10, &1, &depositor); + client.dispute(&channel_id, &80, &20, &2, &depositor, &counterparty); let channel = client.get_channel(&channel_id).unwrap(); assert_eq!(channel.state, ChannelState::Dispute); @@ -67,12 +70,12 @@ fn finalize_releases_after_timeout() { let depositor = Address::generate(&env); let counterparty = Address::generate(&env); - client.init(&admin).unwrap(); - let channel_id = client.open_channel(&depositor, &counterparty, &100, &1).unwrap(); - client.initiate_close(&channel_id, &70, &30, &1, &depositor).unwrap(); + client.init(&admin); + let channel_id = client.open_channel(&depositor, &counterparty, &100, &1); + client.initiate_close(&channel_id, &70, &30, &1, &depositor); env.ledger().set_timestamp(10); - client.finalize(&channel_id).unwrap(); + client.finalize(&channel_id); let channel = client.get_channel(&channel_id).unwrap(); assert_eq!(channel.state, ChannelState::Closed); diff --git a/contracts/contracts/subscription_renewal/Cargo.toml b/contracts/contracts/subscription_renewal/Cargo.toml index 80100fe4..65262e1b 100644 --- a/contracts/contracts/subscription_renewal/Cargo.toml +++ b/contracts/contracts/subscription_renewal/Cargo.toml @@ -12,3 +12,4 @@ soroban-sdk = { workspace = true } [dev-dependencies] soroban-sdk = { workspace = true, features = ["testutils"] } +proptest = { workspace = true } diff --git a/contracts/contracts/subscription_renewal/src/fuzz.rs b/contracts/contracts/subscription_renewal/src/fuzz.rs new file mode 100644 index 00000000..0ca6b4a0 --- /dev/null +++ b/contracts/contracts/subscription_renewal/src/fuzz.rs @@ -0,0 +1,168 @@ +#![cfg(test)] +extern crate std; + +use proptest::prelude::*; +use soroban_sdk::{ + testutils::{Address as _, EnvTestConfig}, + Address, Env, +}; +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use super::{ + SubscriptionRenewalContract, SubscriptionRenewalContractClient, SubscriptionState, +}; + +fn fuzz_env() -> Env { + Env::new_with_config(EnvTestConfig { + capture_snapshot_at_drop: false, + ..EnvTestConfig::default() + }) +} + +fn fuzz_setup() -> (Env, Address, Address) { + let env = fuzz_env(); + env.mock_all_auths(); + let id = env.register_contract(None, SubscriptionRenewalContract); + let admin = Address::generate(&env); + let client = SubscriptionRenewalContractClient::new(&env, &id); + client.init(&admin); + (env, id, admin) +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(8))] + + /// Fuzz subscription init with random amounts and billing intervals. + #[test] + fn fuzz_init_sub_amounts_and_intervals( + amount in 1i128..=1_000_000_000i128, + frequency in 1u64..=31_536_000u64, + spending_cap in 0i128..=10_000_000_000i128, + sub_id in 1u64..=10_000u64, + ) { + let (env, id, _admin) = fuzz_setup(); + let client = SubscriptionRenewalContractClient::new(&env, &id); + let user = Address::generate(&env); + let merchant = Address::generate(&env); + + client.init_sub(&user, &merchant, &amount, &frequency, &spending_cap, &sub_id); + + let data = client.get_sub(&sub_id); + prop_assert_eq!(data.amount, amount); + prop_assert_eq!(data.frequency, frequency); + prop_assert_eq!(data.spending_cap, spending_cap); + prop_assert_eq!(data.state, SubscriptionState::Active); + } + + /// Successful renewals must never exceed the per-subscription spending cap. + #[test] + fn fuzz_renewal_respects_spending_cap( + amount in 1i128..=500_000i128, + spending_cap in 1i128..=1_000_000i128, + renew_amount in 1i128..=2_000_000i128, + ) { + let (env, id, _admin) = fuzz_setup(); + let client = SubscriptionRenewalContractClient::new(&env, &id); + let user = Address::generate(&env); + let merchant = Address::generate(&env); + let sub_id = 42u64; + + client.init_sub(&user, &merchant, &amount, &86400u64, &spending_cap, &sub_id); + client.approve_renewal(&sub_id, &1u64, &renew_amount, &10_000u32); + client.acquire_renewal_lock(&sub_id, &200u32); + + let exceeds_cap = spending_cap > 0 && renew_amount > spending_cap; + + if exceeds_cap { + let result = catch_unwind(AssertUnwindSafe(|| { + client.renew(&sub_id, &1u64, &renew_amount, &3u32, &10u32, &20260101u64, &true); + })); + prop_assert!(result.is_err(), "renewal exceeding cap must panic"); + } else { + let ok = client.renew(&sub_id, &1u64, &renew_amount, &3u32, &10u32, &20260101u64, &true); + prop_assert!(ok); + let spent = client.get_user_spent(&user); + prop_assert_eq!(spent, renew_amount); + } + } + + /// Global user cap overflow: current_spent + amount must not exceed cap. + #[test] + fn fuzz_global_cap_overflow_rejected( + cap in 100i128..=10_000i128, + first_amount in 1i128..=5_000i128, + second_amount in 1i128..=10_000i128, + ) { + let (env, id, _admin) = fuzz_setup(); + let client = SubscriptionRenewalContractClient::new(&env, &id); + let user = Address::generate(&env); + let merchant = Address::generate(&env); + + client.set_user_cap(&user, &cap); + + let sub_a = 1u64; + let sub_b = 2u64; + client.init_sub(&user, &merchant, &100i128, &86400u64, &0i128, &sub_a); + client.init_sub(&user, &merchant, &100i128, &86400u64, &0i128, &sub_b); + + client.approve_renewal(&sub_a, &1u64, &first_amount, &10_000u32); + client.acquire_renewal_lock(&sub_a, &200u32); + if first_amount <= cap { + let _ = client.renew(&sub_a, &1u64, &first_amount, &3u32, &10u32, &20260101u64, &true); + } + + let spent = client.get_user_spent(&user); + let remaining = cap.saturating_sub(spent); + + client.approve_renewal(&sub_b, &1u64, &second_amount, &10_000u32); + client.acquire_renewal_lock(&sub_b, &200u32); + + if second_amount > remaining { + let result = catch_unwind(AssertUnwindSafe(|| { + client.renew(&sub_b, &1u64, &second_amount, &3u32, &10u32, &20260201u64, &true); + })); + prop_assert!(result.is_err(), "global cap overflow must panic"); + } + } + + /// Admin-only set_paused must reject when contract is not initialized for random callers + /// (uninitialized contract panics on missing admin storage). + #[test] + fn fuzz_uninitialized_contract_rejects_admin_ops(_seed in 0u64..=1000u64) { + let env = fuzz_env(); + env.mock_all_auths(); + let id = env.register_contract(None, SubscriptionRenewalContract); + let client = SubscriptionRenewalContractClient::new(&env, &id); + + let result = catch_unwind(AssertUnwindSafe(|| { + client.set_paused(&true); + })); + prop_assert!(result.is_err(), "admin ops on uninitialized contract must panic"); + } + + /// Approval single-use: consuming an approval twice must fail. + #[test] + fn fuzz_approval_single_use( + amount in 1i128..=100_000i128, + approval_max in 1i128..=1_000_000i128, + ) { + let (env, id, _admin) = fuzz_setup(); + let client = SubscriptionRenewalContractClient::new(&env, &id); + let user = Address::generate(&env); + let merchant = Address::generate(&env); + let sub_id = 7u64; + let renew_amount = amount.min(approval_max); + + client.init_sub(&user, &merchant, &amount, &86400u64, &0i128, &sub_id); + client.approve_renewal(&sub_id, &1u64, &approval_max, &10_000u32); + + client.acquire_renewal_lock(&sub_id, &200u32); + let _ = client.renew(&sub_id, &1u64, &renew_amount, &3u32, &10u32, &20260101u64, &true); + + client.acquire_renewal_lock(&sub_id, &200u32); + let result = catch_unwind(AssertUnwindSafe(|| { + client.renew(&sub_id, &1u64, &renew_amount, &3u32, &10u32, &20260201u64, &true); + })); + prop_assert!(result.is_err(), "reused approval must panic"); + } +} diff --git a/contracts/contracts/subscription_renewal/src/lib.rs b/contracts/contracts/subscription_renewal/src/lib.rs index 11328ac8..510d3452 100644 --- a/contracts/contracts/subscription_renewal/src/lib.rs +++ b/contracts/contracts/subscription_renewal/src/lib.rs @@ -923,3 +923,6 @@ impl SubscriptionRenewalContract { #[cfg(test)] mod test; + +#[cfg(test)] +mod fuzz;