diff --git a/.github/workflows/soroban_fuzz.yml b/.github/workflows/soroban_fuzz.yml new file mode 100644 index 00000000..2f65ceff --- /dev/null +++ b/.github/workflows/soroban_fuzz.yml @@ -0,0 +1,226 @@ +name: Soroban Fuzz (Smoke — 60 s per target) + +on: + push: + branches: [main] + paths: + - 'quantara/soroban/contracts/*/fuzz/**' + - '.github/workflows/soroban_fuzz.yml' + pull_request: + branches: [main] + paths: + - 'quantara/soroban/contracts/*/fuzz/**' + - '.github/workflows/soroban_fuzz.yml' + workflow_dispatch: + inputs: + fuzz_time: + description: 'Seconds to fuzz per target' + default: '60' + required: false + +env: + CARGO_TERM_COLOR: always + FUZZ_TIME: ${{ github.event.inputs.fuzz_time || '60' }} + +jobs: + # ── Vault fuzz targets ──────────────────────────────────────────────────── + fuzz-vault: + name: Fuzz vault (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - fuzz_vault_deposit + - fuzz_vault_withdraw + + defaults: + run: + working-directory: quantara/soroban/contracts/vault + + steps: + - uses: actions/checkout@v4 + + - name: Install latest Rust nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ runner.os }}-cargo-fuzz-${{ github.sha }} + restore-keys: ${{ runner.os }}-cargo-fuzz- + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Pin ed25519-dalek and rand_core to 0.6 family + # soroban-env-host 22.1.3's testutils.rs passes `ChaCha20Rng` into + # `ed25519_dalek::SigningKey::generate`, which requires CryptoRng + # from the rand_core 0.6 family. ed25519-dalek 3.x bumped to + # rand_core 0.10, breaking the trait bound. Pin ed25519-dalek to + # 2.1.1 (last 2.x release) and rand_core to 0.6.4; fall through + # with `|| true` for compatibility — cargo refuses to demote a + # rand_core 0.10 instance to 0.6.4 directly, but the ed25519-dalek + # pin upstream of it is the real lever. + # The fuzz crate is an isolated workspace ([workspace] in + # fuzz/Cargo.toml), so it has its OWN fuzz/Cargo.lock. Without + # `cd fuzz`, `cargo update` would mutate the parent contract + # workspace's lockfile, which `cargo fuzz run` ignores — hence + # the silent failure of the previous (no-cd) variant. + run: | + cd fuzz + cargo update -p ed25519-dalek@3.0.0 --precise 2.1.1 || true + cargo update -p ed25519-dalek --precise 2.1.1 || true + cargo update -p rand_core@0.10.1 --precise 0.6.4 || true + cargo update -p rand_core@0.9.5 --precise 0.6.4 || true + + - name: Run fuzz target for ${{ env.FUZZ_TIME }}s + run: | + cargo fuzz run ${{ matrix.target }} \ + -- -max_total_time=${{ env.FUZZ_TIME }} -jobs=1 + + - name: Upload crash corpus on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: vault-${{ matrix.target }}-crash-corpus + path: fuzz/corpus/${{ matrix.target }} + if-no-files-found: ignore + + # ── Looping fuzz targets ─────────────────────────────────────────────────── + fuzz-looping: + name: Fuzz looping (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - fuzz_looping_open_position + + defaults: + run: + working-directory: quantara/soroban/contracts/looping + + steps: + - uses: actions/checkout@v4 + + - name: Install latest Rust nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ runner.os }}-cargo-fuzz-${{ github.sha }} + restore-keys: ${{ runner.os }}-cargo-fuzz- + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Pin ed25519-dalek and rand_core to 0.6 family + # soroban-env-host 22.1.3's testutils.rs passes `ChaCha20Rng` into + # `ed25519_dalek::SigningKey::generate`, which requires CryptoRng + # from the rand_core 0.6 family. ed25519-dalek 3.x bumped to + # rand_core 0.10, breaking the trait bound. Pin ed25519-dalek to + # 2.1.1 (last 2.x release) and rand_core to 0.6.4; fall through + # with `|| true` for compatibility — cargo refuses to demote a + # rand_core 0.10 instance to 0.6.4 directly, but the ed25519-dalek + # pin upstream of it is the real lever. + # The fuzz crate is an isolated workspace ([workspace] in + # fuzz/Cargo.toml), so it has its OWN fuzz/Cargo.lock. Without + # `cd fuzz`, `cargo update` would mutate the parent contract + # workspace's lockfile, which `cargo fuzz run` ignores — hence + # the silent failure of the previous (no-cd) variant. + run: | + cd fuzz + cargo update -p ed25519-dalek@3.0.0 --precise 2.1.1 || true + cargo update -p ed25519-dalek --precise 2.1.1 || true + cargo update -p rand_core@0.10.1 --precise 0.6.4 || true + cargo update -p rand_core@0.9.5 --precise 0.6.4 || true + + - name: Run fuzz target for ${{ env.FUZZ_TIME }}s + run: | + cargo fuzz run ${{ matrix.target }} \ + -- -max_total_time=${{ env.FUZZ_TIME }} -jobs=1 + + - name: Upload crash corpus on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: looping-${{ matrix.target }}-crash-corpus + path: fuzz/corpus/${{ matrix.target }} + if-no-files-found: ignore + + # ── Rewards fuzz targets ─────────────────────────────────────────────────── + fuzz-rewards: + name: Fuzz rewards (${{ matrix.target }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - fuzz_rewards_accrue_claim + + defaults: + run: + working-directory: quantara/soroban/contracts/rewards + + steps: + - uses: actions/checkout@v4 + + - name: Install latest Rust nightly + uses: dtolnay/rust-toolchain@nightly + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: ${{ runner.os }}-cargo-fuzz-${{ github.sha }} + restore-keys: ${{ runner.os }}-cargo-fuzz- + + - name: Install cargo-fuzz + run: cargo install cargo-fuzz + + - name: Pin ed25519-dalek and rand_core to 0.6 family + # soroban-env-host 22.1.3's testutils.rs passes `ChaCha20Rng` into + # `ed25519_dalek::SigningKey::generate`, which requires CryptoRng + # from the rand_core 0.6 family. ed25519-dalek 3.x bumped to + # rand_core 0.10, breaking the trait bound. Pin ed25519-dalek to + # 2.1.1 (last 2.x release) and rand_core to 0.6.4; fall through + # with `|| true` for compatibility — cargo refuses to demote a + # rand_core 0.10 instance to 0.6.4 directly, but the ed25519-dalek + # pin upstream of it is the real lever. + # The fuzz crate is an isolated workspace ([workspace] in + # fuzz/Cargo.toml), so it has its OWN fuzz/Cargo.lock. Without + # `cd fuzz`, `cargo update` would mutate the parent contract + # workspace's lockfile, which `cargo fuzz run` ignores — hence + # the silent failure of the previous (no-cd) variant. + run: | + cd fuzz + cargo update -p ed25519-dalek@3.0.0 --precise 2.1.1 || true + cargo update -p ed25519-dalek --precise 2.1.1 || true + cargo update -p rand_core@0.10.1 --precise 0.6.4 || true + cargo update -p rand_core@0.9.5 --precise 0.6.4 || true + + - name: Run fuzz target for ${{ env.FUZZ_TIME }}s + run: | + cargo fuzz run ${{ matrix.target }} \ + -- -max_total_time=${{ env.FUZZ_TIME }} -jobs=1 + + - name: Upload crash corpus on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: rewards-${{ matrix.target }}-crash-corpus + path: fuzz/corpus/${{ matrix.target }} + if-no-files-found: ignore diff --git a/quantara/soroban/contracts/looping/Cargo.toml b/quantara/soroban/contracts/looping/Cargo.toml index 1810f56a..c7922a32 100644 --- a/quantara/soroban/contracts/looping/Cargo.toml +++ b/quantara/soroban/contracts/looping/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" publish = false [lib] -crate-type = ["cdylib"] +crate-type = ["rlib", "cdylib"] doctest = false [dependencies] diff --git a/quantara/soroban/contracts/looping/fuzz/Cargo.toml b/quantara/soroban/contracts/looping/fuzz/Cargo.toml new file mode 100644 index 00000000..8756607b --- /dev/null +++ b/quantara/soroban/contracts/looping/fuzz/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "looping-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +# Fuzz targets call contract functions via the Soroban test environment. +# testutils provides Env::default(), mock_all_auths(), and Address::generate(). +soroban-sdk = { version = "22.0.0", features = ["testutils"] } +looping = { path = ".." } +# Pin rand_core to <= 0.6 so the whole dep tree unifies on the rand_core +# 0.6 family. Without this, a transitive dep can resolve to rand_core 0.10, +# while soroban-env-host's `testutils` (which uses ChaCha20Rng through +# ed25519-dalek::rand_core) still expects the rand_core 0.6 `CryptoRng` +# trait, breaking compilation. +rand_core = "<0.7" + +# Prevent this from interfering with the workspace. +[workspace] + +[[bin]] +name = "fuzz_looping_open_position" +path = "fuzz_targets/fuzz_looping_open_position.rs" +test = false +doc = false diff --git a/quantara/soroban/contracts/looping/fuzz/corpus/fuzz_looping_open_position/seed_100x_leverage b/quantara/soroban/contracts/looping/fuzz/corpus/fuzz_looping_open_position/seed_100x_leverage new file mode 100644 index 00000000..a4a964d2 Binary files /dev/null and b/quantara/soroban/contracts/looping/fuzz/corpus/fuzz_looping_open_position/seed_100x_leverage differ diff --git a/quantara/soroban/contracts/looping/fuzz/fuzz_targets/fuzz_looping_open_position.rs b/quantara/soroban/contracts/looping/fuzz/fuzz_targets/fuzz_looping_open_position.rs new file mode 100644 index 00000000..d64359a0 --- /dev/null +++ b/quantara/soroban/contracts/looping/fuzz/fuzz_targets/fuzz_looping_open_position.rs @@ -0,0 +1,51 @@ +//! cargo-fuzz harness for LoopingContract::open_position entry-point. +//! +//! Invariants checked: +//! - Valid inputs (collateral > 0, leverage 100–500) must succeed. +//! - Returned position IDs are >= 1. +//! +//! Run: +//! ```bash +//! cargo +nightly fuzz run fuzz_looping_open_position -- -max_total_time=60 +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal, Symbol}; +use looping::LoopingContract; + +fuzz_target!(|data: &[u8]| { + if data.len() < 12 { + return; + } + let collateral = i64::from_le_bytes(data[..8].try_into().unwrap()) as i128; + let leverage = u32::from_le_bytes(data[8..12].try_into().unwrap()); + + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(LoopingContract, ()); + let user = Address::generate(&env); + + // try_invoke_contract returns `Result, HostError>`; + // explicitly specify the host error type so type inference succeeds. + let result = env.try_invoke_contract::( + &contract_id, + &Symbol::new(&env, "open_position"), + soroban_sdk::vec![ + &env, + user.to_val(), + collateral.into_val(&env), + leverage.into_val(&env), + ], + ); + + if collateral > 0 && (100..=500).contains(&leverage) { + // Unwrap the outer (host) Err first, then the inner (contract) Err. + let position_id = result + .expect("open_position returned a host error") + .expect("open_position with valid args returned a contract error"); + assert!(position_id >= 1, "position_id must be >= 1, got {position_id}"); + } + // Invalid inputs may error; no further assertion needed. +}); diff --git a/quantara/soroban/contracts/rewards/Cargo.toml b/quantara/soroban/contracts/rewards/Cargo.toml index 80a73a5b..e943f918 100644 --- a/quantara/soroban/contracts/rewards/Cargo.toml +++ b/quantara/soroban/contracts/rewards/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" publish = false [lib] -crate-type = ["cdylib"] +crate-type = ["rlib", "cdylib"] doctest = false [dependencies] diff --git a/quantara/soroban/contracts/rewards/fuzz/Cargo.toml b/quantara/soroban/contracts/rewards/fuzz/Cargo.toml new file mode 100644 index 00000000..35b6706c --- /dev/null +++ b/quantara/soroban/contracts/rewards/fuzz/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "rewards-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +# Fuzz targets call contract functions via the Soroban test environment. +# testutils provides Env::default(), mock_all_auths(), and Address::generate(). +soroban-sdk = { version = "22.0.0", features = ["testutils"] } +rewards = { path = ".." } +# Pin rand_core to <= 0.6 so the whole dep tree unifies on the rand_core +# 0.6 family. Without this, a transitive dep can resolve to rand_core 0.10, +# while soroban-env-host's `testutils` (which uses ChaCha20Rng through +# ed25519-dalek::rand_core) still expects the rand_core 0.6 `CryptoRng` +# trait, breaking compilation. +rand_core = "<0.7" + +# Prevent this from interfering with the workspace. +[workspace] + +[[bin]] +name = "fuzz_rewards_accrue_claim" +path = "fuzz_targets/fuzz_rewards_accrue_claim.rs" +test = false +doc = false diff --git a/quantara/soroban/contracts/rewards/fuzz/corpus/fuzz_rewards_accrue_claim/seed_two_accruals b/quantara/soroban/contracts/rewards/fuzz/corpus/fuzz_rewards_accrue_claim/seed_two_accruals new file mode 100644 index 00000000..5d1d9ff6 Binary files /dev/null and b/quantara/soroban/contracts/rewards/fuzz/corpus/fuzz_rewards_accrue_claim/seed_two_accruals differ diff --git a/quantara/soroban/contracts/rewards/fuzz/fuzz_targets/fuzz_rewards_accrue_claim.rs b/quantara/soroban/contracts/rewards/fuzz/fuzz_targets/fuzz_rewards_accrue_claim.rs new file mode 100644 index 00000000..9c0695a5 --- /dev/null +++ b/quantara/soroban/contracts/rewards/fuzz/fuzz_targets/fuzz_rewards_accrue_claim.rs @@ -0,0 +1,68 @@ +//! cargo-fuzz harness for RewardsContract::accrue and claim entry-points. +//! +//! Invariants checked: +//! - pending_rewards is always >= 0. +//! - After two accruals, pending == sum of accruals. +//! - After claim, pending resets to 0. +//! +//! Run: +//! ```bash +//! cargo +nightly fuzz run fuzz_rewards_accrue_claim -- -max_total_time=60 +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal, Symbol}; +use rewards::RewardsContract; + +fuzz_target!(|data: &[u8]| { + if data.len() < 16 { + return; + } + // Use unsigned abs to keep accruals non-negative. + let accrual_a = i64::from_le_bytes(data[..8].try_into().unwrap()).unsigned_abs() as i128; + let accrual_b = i64::from_le_bytes(data[8..16].try_into().unwrap()).unsigned_abs() as i128; + + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(RewardsContract, ()); + let user = Address::generate(&env); + + // Accrue twice. + let _: () = env.invoke_contract( + &contract_id, + &soroban_sdk::symbol_short!("accrue"), + soroban_sdk::vec![&env, user.to_val(), accrual_a.into_val(&env)], + ); + let _: () = env.invoke_contract( + &contract_id, + &soroban_sdk::symbol_short!("accrue"), + soroban_sdk::vec![&env, user.to_val(), accrual_b.into_val(&env)], + ); + + // Check pending equals sum. + let pending: i128 = env.invoke_contract( + &contract_id, + &Symbol::new(&env, "pending_rewards"), + soroban_sdk::vec![&env, user.to_val()], + ); + assert!(pending >= 0, "pending_rewards negative: {pending}"); + let expected = accrual_a + accrual_b; + assert_eq!(pending, expected, "pending={pending} expected={expected}"); + + // Claim and verify reset. + let claimed: i128 = env.invoke_contract( + &contract_id, + &soroban_sdk::symbol_short!("claim"), + soroban_sdk::vec![&env, user.to_val()], + ); + assert_eq!(claimed, expected, "claimed={claimed} expected={expected}"); + + let after: i128 = env.invoke_contract( + &contract_id, + &Symbol::new(&env, "pending_rewards"), + soroban_sdk::vec![&env, user.to_val()], + ); + assert_eq!(after, 0, "pending after claim should be 0, got {after}"); +}); diff --git a/quantara/soroban/contracts/vault/Cargo.toml b/quantara/soroban/contracts/vault/Cargo.toml index 4681304d..c8944e11 100644 --- a/quantara/soroban/contracts/vault/Cargo.toml +++ b/quantara/soroban/contracts/vault/Cargo.toml @@ -6,7 +6,7 @@ license = "MIT" publish = false [lib] -crate-type = ["cdylib"] +crate-type = ["rlib", "cdylib"] doctest = false [dependencies] diff --git a/quantara/soroban/contracts/vault/fuzz/Cargo.toml b/quantara/soroban/contracts/vault/fuzz/Cargo.toml new file mode 100644 index 00000000..5babc462 --- /dev/null +++ b/quantara/soroban/contracts/vault/fuzz/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "vault-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +# Fuzz targets call contract functions via the Soroban test environment. +# testutils provides Env::default(), mock_all_auths(), and Address::generate(). +soroban-sdk = { version = "22.0.0", features = ["testutils"] } +vault = { path = ".." } +# Pin rand_core to <= 0.6 so the whole dep tree unifies on the rand_core +# 0.6 family. Without this, a transitive dep can resolve to rand_core 0.10, +# while soroban-env-host's `testutils` (which uses ChaCha20Rng through +# ed25519-dalek::rand_core) still expects the rand_core 0.6 `CryptoRng` +# trait, breaking compilation. +rand_core = "<0.7" + +# Prevent this from interfering with the workspace. +[workspace] + +[[bin]] +name = "fuzz_vault_deposit" +path = "fuzz_targets/fuzz_vault_deposit.rs" +test = false +doc = false + +[[bin]] +name = "fuzz_vault_withdraw" +path = "fuzz_targets/fuzz_vault_withdraw.rs" +test = false +doc = false diff --git a/quantara/soroban/contracts/vault/fuzz/corpus/fuzz_vault_deposit/seed_positive_amount b/quantara/soroban/contracts/vault/fuzz/corpus/fuzz_vault_deposit/seed_positive_amount new file mode 100644 index 00000000..20d5cb86 Binary files /dev/null and b/quantara/soroban/contracts/vault/fuzz/corpus/fuzz_vault_deposit/seed_positive_amount differ diff --git a/quantara/soroban/contracts/vault/fuzz/corpus/fuzz_vault_withdraw/seed_deposit_withdraw b/quantara/soroban/contracts/vault/fuzz/corpus/fuzz_vault_withdraw/seed_deposit_withdraw new file mode 100644 index 00000000..9c7e14ef Binary files /dev/null and b/quantara/soroban/contracts/vault/fuzz/corpus/fuzz_vault_withdraw/seed_deposit_withdraw differ diff --git a/quantara/soroban/contracts/vault/fuzz/fuzz_targets/fuzz_vault_deposit.rs b/quantara/soroban/contracts/vault/fuzz/fuzz_targets/fuzz_vault_deposit.rs new file mode 100644 index 00000000..472a1603 --- /dev/null +++ b/quantara/soroban/contracts/vault/fuzz/fuzz_targets/fuzz_vault_deposit.rs @@ -0,0 +1,60 @@ +//! cargo-fuzz harness for VaultContract::deposit entry-point. +//! +//! Fuzz strategy: feed arbitrary (amount: i64) as input. +//! +//! Invariants checked: +//! - Positive amounts must succeed (no panic). +//! - After a successful deposit, balance equals the deposited amount. +//! - Non-positive amounts may panic (documented assertion); we just +//! ensure no memory safety issues occur. +//! +//! Run: +//! ```bash +//! cargo +nightly fuzz run fuzz_vault_deposit -- -max_total_time=60 +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal}; +use vault::VaultContract; + +fuzz_target!(|data: &[u8]| { + if data.len() < 8 { + return; + } + let amount = i64::from_le_bytes(data[..8].try_into().unwrap()) as i128; + + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(VaultContract, ()); + let user = Address::generate(&env); + + // Invoke deposit directly via the registered contract. + // try_invoke_contract returns `Result, HostError>`; + // specify the host error type as soroban_sdk::Error so type inference can succeed. + let result = env.try_invoke_contract::<(), soroban_sdk::Error>( + &contract_id, + &soroban_sdk::symbol_short!("deposit"), + soroban_sdk::vec![&env, user.to_val(), amount.into_val(&env)], + ); + + if amount > 0 { + // Must succeed — any error here is a bug. + assert!( + result.is_ok(), + "deposit with positive amount={amount} returned error" + ); + + // Balance must equal the deposited amount. + let balance: i128 = env + .invoke_contract( + &contract_id, + &soroban_sdk::symbol_short!("balance"), + soroban_sdk::vec![&env, user.to_val()], + ); + assert_eq!(balance, amount, "balance mismatch after deposit"); + } + // Non-positive: may return error (documented); no further assertion. +}); diff --git a/quantara/soroban/contracts/vault/fuzz/fuzz_targets/fuzz_vault_withdraw.rs b/quantara/soroban/contracts/vault/fuzz/fuzz_targets/fuzz_vault_withdraw.rs new file mode 100644 index 00000000..69b8f678 --- /dev/null +++ b/quantara/soroban/contracts/vault/fuzz/fuzz_targets/fuzz_vault_withdraw.rs @@ -0,0 +1,57 @@ +//! cargo-fuzz harness for VaultContract::withdraw entry-point. +//! +//! Fuzz strategy: feed arbitrary (initial_deposit: u64, withdraw_amount: i64). +//! +//! Invariants checked: +//! - Balance never goes negative. +//! - Valid withdrawals (0 < amount <= balance) succeed. +//! +//! Run: +//! ```bash +//! cargo +nightly fuzz run fuzz_vault_withdraw -- -max_total_time=60 +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal}; +use vault::VaultContract; + +fuzz_target!(|data: &[u8]| { + if data.len() < 16 { + return; + } + let initial_deposit = u64::from_le_bytes(data[..8].try_into().unwrap()) as i128; + let withdraw_amount = i64::from_le_bytes(data[8..16].try_into().unwrap()) as i128; + + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(VaultContract, ()); + let user = Address::generate(&env); + + // Seed a known balance if initial_deposit is positive. + if initial_deposit > 0 { + let _: () = env.invoke_contract( + &contract_id, + &soroban_sdk::symbol_short!("deposit"), + soroban_sdk::vec![&env, user.to_val(), initial_deposit.into_val(&env)], + ); + } + + // Attempt withdrawal. try_invoke_contract returns + // `Result, HostError>`; specify the host error + // type to enable type inference (otherwise rustc can't pick `E`). + let _ = env.try_invoke_contract::<(), soroban_sdk::Error>( + &contract_id, + &soroban_sdk::symbol_short!("withdraw"), + soroban_sdk::vec![&env, user.to_val(), withdraw_amount.into_val(&env)], + ); + + // Balance must never be negative. + let balance: i128 = env.invoke_contract( + &contract_id, + &soroban_sdk::symbol_short!("balance"), + soroban_sdk::vec![&env, user.to_val()], + ); + assert!(balance >= 0, "balance went negative: {balance}"); +});