From 742cdf8b8b58897b59f4c0bec1a0f20098b25dd8 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 12 Aug 2026 23:06:17 +0200 Subject: [PATCH 1/2] fix: restore investment_vault compilation and repair deposit-lock (Closes #310, #311, #314) The withdrawal-window merge (#36) left the crate non-compiling: get_withdrawal_window and get_volume_fee_tier were missing closing braces, the withdrawal_window_set and funding_round_ended event functions were missing closing braces, check_deposit_lock referenced an undefined last_seq and merged two incompatible lock models, and VaultError had three variants sharing discriminant 41. This change adds a LastDepositSeq(Address) storage key recorded by lock_deposit, rewrites check_deposit_lock to enforce the ledger-sequence sliding window (#36), renumbers FundingRoundActive to 42 and InvestmentCapExceeded to 43, and restores all missing closing braces. Signed-off-by: laurentketterle-hub --- investment_vault/src/events.rs | 2 ++ investment_vault/src/lib.rs | 21 ++++++++++++++++----- investment_vault/src/types.rs | 9 ++++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/investment_vault/src/events.rs b/investment_vault/src/events.rs index 03570e06..7361b73e 100644 --- a/investment_vault/src/events.rs +++ b/investment_vault/src/events.rs @@ -504,6 +504,7 @@ pub struct WithdrawalWindowSet { pub fn withdrawal_window_set(env: &Env, ledgers: u32) { WithdrawalWindowSet { ledgers }.publish(env); +} /// Emitted when the admin opens a funding round (#38). #[contractevent] pub struct FundingRoundStarted {} @@ -518,6 +519,7 @@ pub struct FundingRoundEnded {} pub fn funding_round_ended(env: &Env) { FundingRoundEnded {}.publish(env); +} /// Emitted when the admin changes the per-project investment cap (#32). #[contractevent] pub struct InvestmentCapSet { diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index 1f384090..0aa23604 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -1127,7 +1127,7 @@ impl InvestmentVault { .instance() .get(&VaultKey::WithdrawalWindowLedgers) .unwrap_or(1) - // ── Dynamic fee structure (#39) ─────────────────────────────────────────── + } /// Configure a two-tier volume-discount fee schedule for deposits (#39). /// @@ -1179,6 +1179,7 @@ impl InvestmentVault { .get(&VaultKey::VolumeTierFeeBps) .unwrap_or(0); (threshold, bps) + } // ── Per-project investment cap (#32) ────────────────────────────────────── /// Set the maximum total USDC the vault may invest in any single project. Admin-only. @@ -2083,14 +2084,25 @@ fn lock_deposit(env: &Env, address: &Address) { &VaultKey::LastDeposit(address.clone()), &env.ledger().timestamp(), ); + env.storage().persistent().set( + &VaultKey::LastDepositSeq(address.clone()), + &env.ledger().sequence(), + ); } -/// Reject a withdrawal if the caller's deposit lock has not yet expired (#33). +/// Reject a withdrawal if the deposit lock has not yet expired (#36). +/// +/// Enforces the withdrawal sliding window: at least `WithdrawalWindowLedgers` +/// ledgers must elapse after the most recent deposit (or share receipt) of the +/// caller before a withdrawal is permitted. The default window of 1 ledger +/// blocks same-ledger deposit-then-withdraw exits. The older timestamp-based +/// `MIN_LOCK_PERIOD` cooldown (#33) remains exposed via +/// `get_deposit_lock_expiry` but is no longer enforced here. fn check_deposit_lock(env: &Env, address: &Address) { - if let Some(deposited_at) = env + if let Some(last_seq) = env .storage() .persistent() - .get::<_, u64>(&VaultKey::LastDeposit(address.clone())) + .get::<_, u32>(&VaultKey::LastDepositSeq(address.clone())) { let window: u32 = env .storage() @@ -2098,7 +2110,6 @@ fn check_deposit_lock(env: &Env, address: &Address) { .get(&VaultKey::WithdrawalWindowLedgers) .unwrap_or(1); if env.ledger().sequence() < last_seq.saturating_add(window) { - if env.ledger().timestamp() < deposited_at + MIN_LOCK_PERIOD { panic_with_error!(env, VaultError::DepositLocked); } } diff --git a/investment_vault/src/types.rs b/investment_vault/src/types.rs index e09392d3..91be2c88 100644 --- a/investment_vault/src/types.rs +++ b/investment_vault/src/types.rs @@ -90,9 +90,9 @@ pub enum VaultError { /// batch_deposit received an empty investor list (#178). EmptyBatchDeposit = 41, /// Share transfers are blocked because a funding round is active (#38). - FundingRoundActive = 41, + FundingRoundActive = 42, /// Funding would push cumulative investment in a project above its per-project cap (#32). - InvestmentCapExceeded = 41, + InvestmentCapExceeded = 43, } #[contracttype] @@ -154,7 +154,7 @@ pub enum VaultKey { MultiSigThreshold, /// Circuit breaker pause state. Paused, - /// Last deposit ledger sequence per address. + /// Last deposit ledger timestamp (seconds) per address (#33). LastDeposit(Address), /// Optional emergency-admin address that may pause/unpause without /// holding full owner privileges (#43). Unset means no emergency admin. @@ -178,6 +178,9 @@ pub enum VaultKey { /// Ledger timestamp (seconds) at which a project was first funded (#34). /// Used for time-weighted expected-returns calculation. InvestmentTimestamp(u32), + /// Last deposit ledger sequence per address (#36). + /// Used by `check_deposit_lock` to enforce the withdrawal sliding window. + LastDepositSeq(Address), } /// Container for wormhole bridge data keys. From efa1c03010772c0227212d023b165fae7d4c38b3 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Wed, 12 Aug 2026 23:18:50 +0200 Subject: [PATCH 2/2] test: regression coverage for deposit-lock, funding-round, investment-cap and carbon-credits (#314, #319, #321, #318) Signed-off-by: laurentketterle-hub --- investment_vault/src/test.rs | 271 +++++++++++++++++++++++++++++++++++ 1 file changed, 271 insertions(+) diff --git a/investment_vault/src/test.rs b/investment_vault/src/test.rs index 4be8ed36..46957bed 100644 --- a/investment_vault/src/test.rs +++ b/investment_vault/src/test.rs @@ -2128,6 +2128,276 @@ fn test_withdrawal_rate_limiting_transfer_locked() { s.vault_client.withdraw(&investor2, &shares, &0); } +// ── #36: withdrawal sliding window with a configured (non-default) window ────── + +#[test] +#[should_panic(expected = "Error(Contract, #36)")] +fn test_withdrawal_lock_rejects_within_configured_window() { + let s = setup(); + let investor = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &investor, 1_000_0000000i128); + let shares = s.vault_client.deposit(&investor, &1_000_0000000i128); + + // Configure a 5-ledger cooldown. + s.vault_client.set_withdrawal_window(&5u32); + assert_eq!(s.vault_client.get_withdrawal_window(), 5u32); + + // Advance only 3 ledgers — still inside the window. + s.env.ledger().with_mut(|li| { + li.sequence_number += 3; + }); + + // Withdrawal must be rejected while inside the configured window. + s.vault_client.withdraw(&investor, &shares, &0); +} + +#[test] +fn test_withdrawal_lock_allows_after_configured_window() { + let s = setup(); + let investor = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &investor, 1_000_0000000i128); + let shares = s.vault_client.deposit(&investor, &1_000_0000000i128); + + s.vault_client.set_withdrawal_window(&5u32); + + // Advance exactly 5 ledgers, exiting the configured window. + s.env.ledger().with_mut(|li| { + li.sequence_number += 5; + }); + + let returned = s.vault_client.withdraw(&investor, &shares, &0); + assert!(returned > 0); +} + +// ── #38: funding-round share-transfer block ──────────────────────────────────── + +#[test] +#[should_panic(expected = "Error(Contract, #42)")] +fn test_funding_round_blocks_share_transfer() { + let s = setup(); + let investor1 = Address::generate(&s.env); + let investor2 = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &investor1, 1_000_0000000i128); + let shares = s.vault_client.deposit(&investor1, &1_000_0000000i128); + + // Advance past the deposit-lock window so the transfer itself is what fails. + s.env.ledger().with_mut(|li| { + li.sequence_number += 1; + }); + + assert!(!s.vault_client.is_funding_round_active()); + s.vault_client.start_funding_round(); + assert!(s.vault_client.is_funding_round_active()); + + // Share transfer must be rejected while a funding round is active. + s.vault_client.transfer( + &investor1, + &soroban_sdk::MuxedAddress::from(investor2.clone()), + &shares, + ); +} + +#[test] +fn test_funding_round_transfer_succeeds_after_close() { + let s = setup(); + let investor1 = Address::generate(&s.env); + let investor2 = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &investor1, 1_000_0000000i128); + let shares = s.vault_client.deposit(&investor1, &1_000_0000000i128); + + s.env.ledger().with_mut(|li| { + li.sequence_number += 1; + }); + + s.vault_client.start_funding_round(); + assert!(s.vault_client.is_funding_round_active()); + s.vault_client.end_funding_round(); + assert!(!s.vault_client.is_funding_round_active()); + + // Transfer now succeeds once the round has closed. + s.vault_client.transfer( + &investor1, + &soroban_sdk::MuxedAddress::from(investor2.clone()), + &shares, + ); + assert_eq!(s.vault_client.balance(&investor2), shares); +} + +// ── #32: per-project investment cap ──────────────────────────────────────────── + +#[test] +fn test_investment_cap_respected_and_capacity_tracked() { + let s = setup(); + let investor = Address::generate(&s.env); + let creator = Address::generate(&s.env); + + mint_usdc(&s.env, &s.usdc_sac, &investor, 10_000_0000000i128); + s.vault_client.deposit(&investor, &10_000_0000000i128); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://QmCap"), + &0u64, + &test_metadata_hash(&s.env), + ); + + // Set a 100 USDC cap and confirm remaining capacity reflects it. + s.vault_client.set_max_investment_per_project(&100_0000000i128); + assert_eq!( + s.vault_client.investment_capacity(&project_id), + 100_0000000i128 + ); + + // Funding exactly at the cap succeeds. + s.vault_client.fund_project(&project_id, &100_0000000i128); + assert_eq!(s.vault_client.investment_capacity(&project_id), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #43)")] +fn test_investment_cap_exceeded_panics() { + let s = setup(); + let investor = Address::generate(&s.env); + let creator = Address::generate(&s.env); + + mint_usdc(&s.env, &s.usdc_sac, &investor, 10_000_0000000i128); + s.vault_client.deposit(&investor, &10_000_0000000i128); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://QmCapOver"), + &0u64, + &test_metadata_hash(&s.env), + ); + + s.vault_client.set_max_investment_per_project(&100_0000000i128); + s.vault_client.fund_project(&project_id, &100_0000000i128); + + // One stroop over the cap must panic with InvestmentCapExceeded. + s.vault_client.fund_project(&project_id, &1i128); +} + +#[test] +fn test_investment_cap_zero_restores_default() { + let s = setup(); + + s.vault_client.set_max_investment_per_project(&100_0000000i128); + s.vault_client.set_max_investment_per_project(&0i128); + + // Passing 0 restores the compile-time default (5 M USDC), never disables it. + assert_eq!( + s.vault_client.investment_capacity(&0), + MAX_INVESTMENT_PER_PROJECT + ); +} + +// ── #184: carbon credit unit + integration coverage ──────────────────────────── + +#[test] +fn test_carbon_credit_calculation_issuance_and_balance() { + let s = setup(); + let creator = Address::generate(&s.env); + let recipient = Address::generate(&s.env); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://QmCarbon"), + &0u64, + &test_metadata_hash(&s.env), + ); + // green_impact = 100 (max) so credits = amount * 100 / 10^10. + registry_client.update_impact_score(&project_id, &100u32, &100u32); + + // 100 USDC * 100 / 10^10 = 10 credits. + let calc = s + .vault_client + .calculate_carbon_credits(&project_id, &100_0000000i128); + assert_eq!(calc.credits, 10); + assert_eq!(calc.amount_invested, 100_0000000i128); + + let issued = s + .vault_client + .issue_carbon_credits(&recipient, &project_id, &100_0000000i128); + assert_eq!(issued, 10); + assert_eq!(s.vault_client.carbon_credit_balance(&recipient), 10); +} + +#[test] +#[should_panic(expected = "no carbon credits to issue")] +fn test_carbon_credit_issue_no_credits_panics() { + let s = setup(); + let creator = Address::generate(&s.env); + let recipient = Address::generate(&s.env); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://QmCarbonZero"), + &0u64, + &test_metadata_hash(&s.env), + ); + // green_impact defaults to 0 → zero credits → issuance must panic. + s.vault_client + .issue_carbon_credits(&recipient, &project_id, &100_0000000i128); +} + +#[test] +#[should_panic(expected = "insufficient carbon credits")] +fn test_carbon_credit_transfer_insufficient_balance_panics() { + let s = setup(); + let creator = Address::generate(&s.env); + let from = Address::generate(&s.env); + let to = Address::generate(&s.env); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://QmCarbonInsuff"), + &0u64, + &test_metadata_hash(&s.env), + ); + registry_client.update_impact_score(&project_id, &100u32, &100u32); + + // Issue 10 credits to `from`, then attempt to transfer 11. + s.vault_client + .issue_carbon_credits(&from, &project_id, &100_0000000i128); + s.vault_client.transfer_carbon_credits(&from, &to, &11i128); +} + +#[test] +fn test_carbon_credit_transfer_success() { + let s = setup(); + let creator = Address::generate(&s.env); + let from = Address::generate(&s.env); + let to = Address::generate(&s.env); + + let registry_client = registry_contract::Client::new(&s.env, &s.registry); + registry_client.set_whitelist(&creator, &true); + let project_id = registry_client.create_project( + &creator, + &String::from_str(&s.env, "ipfs://QmCarbonTransfer"), + &0u64, + &test_metadata_hash(&s.env), + ); + registry_client.update_impact_score(&project_id, &100u32, &100u32); + + s.vault_client + .issue_carbon_credits(&from, &project_id, &100_0000000i128); + assert_eq!(s.vault_client.carbon_credit_balance(&from), 10); + + s.vault_client.transfer_carbon_credits(&from, &to, &4i128); + assert_eq!(s.vault_client.carbon_credit_balance(&from), 6); + assert_eq!(s.vault_client.carbon_credit_balance(&to), 4); +} + // ── Consolidated admin-only enumeration (#266) ───────────────────────────────── // // Several admin-only functions already have their own dedicated @@ -2586,6 +2856,7 @@ fn test_volume_fee_tier_is_admin_only() { }, }]); s.vault_client.set_volume_fee_tier(&500_0000000i128, &50u32); +} // ── #179: convert_to_shares() overflow guard on extremely large deposits ────── /// Verify that `convert_to_shares` panics (rather than silently wrapping) when