Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions investment_vault/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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 {
Expand Down
21 changes: 16 additions & 5 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
///
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -2083,22 +2084,32 @@ 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()
.instance()
.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);
}
}
Expand Down
271 changes: 271 additions & 0 deletions investment_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions investment_vault/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
Loading