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 03570e0..7361b73 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 1f38409..0aa2360 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 e09392d..91be2c8 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 bbada015c6068c940cc8f215d6623928a809bed3 Mon Sep 17 00:00:00 2001 From: laurentketterle-hub Date: Thu, 13 Aug 2026 01:26:32 +0200 Subject: [PATCH 2/2] test: add coverage for issues #318-#321 and #326-#327; fix bridge double-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - investment_vault: test coverage for per-project investment cap (#321), funding-round transfer block (#319), carbon credits (#318), and outbound Wormhole bridge path (#320). - project_registry: test coverage for governance proposals (#326) and the project archive/delete/compact lifecycle (#327). - fix: remove redundant from.require_auth() in initiate_bridge_transfer — Base::burn already enforces auth, and the duplicate call triggered a 'frame is already authorized' host error. - test: repair stale create_project calls (short URIs) and a dangling test body left by an upstream merge, and make the score-history ordering test advance ledger time between updates. Signed-off-by: laurentketterle-hub --- investment_vault/src/lib.rs | 3 +- investment_vault/src/test.rs | 282 +++++++++++++++++++++++++++++++++-- project_registry/src/test.rs | 187 +++++++++++++++++++++++ 3 files changed, 457 insertions(+), 15 deletions(-) diff --git a/investment_vault/src/lib.rs b/investment_vault/src/lib.rs index 0aa2360..49e674b 100644 --- a/investment_vault/src/lib.rs +++ b/investment_vault/src/lib.rs @@ -1318,7 +1318,8 @@ impl InvestmentVault { nonce: u64, ) -> u64 { require_current_state(&env); - from.require_auth(); + // Note: from.require_auth() is called inside Base::burn — calling it + // here too triggers a double-auth ("frame is already authorized") error. if amount <= 0 { panic!("amount must be positive"); } diff --git a/investment_vault/src/test.rs b/investment_vault/src/test.rs index 4be8ed3..9d27726 100644 --- a/investment_vault/src/test.rs +++ b/investment_vault/src/test.rs @@ -2283,18 +2283,14 @@ fn test_get_project_investments_batch_returns_correct_amounts() { registry_client.set_whitelist(&creator2, &true); let pid1 = registry_client.create_project( &creator1, - &String::from_str(&s.env, "Alpha"), - &String::from_str(&s.env, "desc"), - &100u32, - &80u32, + &String::from_str(&s.env, "ipfs://QmBatchAlpha"), + &0u64, &test_metadata_hash(&s.env), ); let pid2 = registry_client.create_project( &creator2, - &String::from_str(&s.env, "Beta"), - &String::from_str(&s.env, "desc"), - &90u32, - &70u32, + &String::from_str(&s.env, "ipfs://QmBatchBeta"), + &0u64, &test_metadata_hash(&s.env), ); @@ -2311,8 +2307,6 @@ fn test_get_project_investments_batch_returns_correct_amounts() { assert_eq!(amounts.get(1).unwrap(), fund2); } -#[test] -fn test_get_all_project_investments_returns_all() { // ── Issue #176: deposit() must reject a zero-amount deposit ────────────────── #[test] @@ -2393,10 +2387,8 @@ fn test_claim_queued_is_idempotent_against_double_claim() { registry_client.set_whitelist(&creator, &true); let pid = registry_client.create_project( &creator, - &String::from_str(&s.env, "Gamma"), - &String::from_str(&s.env, "desc"), - &100u32, - &100u32, + &String::from_str(&s.env, "ipfs://QmBatchGamma"), + &0u64, &test_metadata_hash(&s.env), ); @@ -2461,6 +2453,12 @@ fn test_get_set_withdrawal_window() { s.vault_client.set_withdrawal_window(&10u32); assert_eq!(s.vault_client.get_withdrawal_window(), 10u32); } + +#[test] +fn test_claim_settles_queued_redemption_without_double_payout() { + let s = setup(); + let investor = Address::generate(&s.env); + let creator = 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); @@ -2586,6 +2584,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 @@ -2733,3 +2732,258 @@ fn test_flash_loan_fails_without_repayment() { &soroban_sdk::Bytes::new(&s.env), ); } +// ── Issue #321: per-project investment cap coverage ─────────────────────────── + +#[test] +fn test_investment_cap_respects_and_enforces_limit() { + 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://QmCapTest"), + &0u64, + &test_metadata_hash(&s.env), + ); + + // Set a tight per-project cap well below the compile-time default. + let cap = 100_0000000i128; + s.vault_client.set_max_investment_per_project(&cap); + + // Full headroom is available before any funding. + assert_eq!(s.vault_client.investment_capacity(&project_id), cap); + + // Funding exactly at the cap succeeds. + s.vault_client.fund_project(&project_id, &cap); + assert_eq!(s.vault_client.investment_capacity(&project_id), 0); + + // Funding one stroop over the cap must fail with InvestmentCapExceeded. + let over = s.vault_client.try_fund_project(&project_id, &1i128); + assert!(over.is_err()); + + // Passing 0 restores the compile-time default rather than disabling the cap. + s.vault_client.set_max_investment_per_project(&0i128); + assert_eq!( + s.vault_client.investment_capacity(&project_id), + MAX_INVESTMENT_PER_PROJECT - cap + ); +} + +#[test] +fn test_set_max_investment_per_project_rejects_negative_cap() { + let s = setup(); + let r = s.vault_client.try_set_max_investment_per_project(&(-1i128)); + assert!(r.is_err()); +} +// ── Issue #319: funding-round transfer block coverage ───────────────────────── + +#[test] +#[should_panic] +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 the ledger so the deposit lock from the deposit above cannot + // interfere with the transfer path under test. + s.env.ledger().with_mut(|li| { + li.sequence_number += 1; + }); + + s.vault_client.start_funding_round(); + assert!(s.vault_client.is_funding_round_active()); + + // A share transfer must panic with FundingRoundActive while the round is open. + s.vault_client.transfer( + &investor1, + &soroban_sdk::MuxedAddress::from(investor2.clone()), + &shares, + ); +} + +#[test] +fn test_share_transfer_resumes_after_funding_round_ends() { + 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; + }); + + // Round is inactive before start. + assert!(!s.vault_client.is_funding_round_active()); + + 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()); + + // The same transfer now succeeds once the round is closed. + s.vault_client.transfer( + &investor1, + &soroban_sdk::MuxedAddress::from(investor2.clone()), + &shares, + ); + assert_eq!(s.vault_client.balance(&investor2), shares); + assert_eq!(s.vault_client.balance(&investor1), 0); +} +// ── Issue #318: carbon credit feature coverage ──────────────────────────────── + +/// Create a project with the given green-impact score and return its id. +fn setup_carbon_project(s: &TestSetup, green_impact: u32) -> u32 { + let creator = 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), + ); + if green_impact > 0 { + registry_client.update_impact_score(&project_id, &50u32, &green_impact); + } + project_id +} + +#[test] +fn test_calculate_carbon_credits_uses_green_impact() { + let s = setup(); + let project_id = setup_carbon_project(&s, 50u32); + let amount = 1_000_0000000i128; + let calc = s.vault_client.calculate_carbon_credits(&project_id, &amount); + assert_eq!(calc.project_id, project_id); + assert_eq!(calc.amount_invested, amount); + // amount (1e10 stroops) * green_impact (50) / CARBON_UNIT (1e10) = 50. + assert_eq!(calc.credits, 50i128); +} + +#[test] +fn test_issue_carbon_credits_fails_without_green_impact() { + let s = setup(); + // green_impact = 0 → credits = 0 → issuance must panic. + let project_id = setup_carbon_project(&s, 0u32); + let recipient = Address::generate(&s.env); + let r = s + .vault_client + .try_issue_carbon_credits(&recipient, &project_id, &1_000_0000000i128); + assert!(r.is_err()); +} + +#[test] +fn test_issue_and_transfer_carbon_credits() { + let s = setup(); + let project_id = setup_carbon_project(&s, 50u32); + let recipient = Address::generate(&s.env); + let amount = 1_000_0000000i128; + + let issued = s.vault_client.issue_carbon_credits(&recipient, &project_id, &amount); + assert_eq!(issued, 50i128); + assert_eq!(s.vault_client.carbon_credit_balance(&recipient), 50i128); + + let other = Address::generate(&s.env); + s.vault_client.transfer_carbon_credits(&recipient, &other, &20i128); + assert_eq!(s.vault_client.carbon_credit_balance(&recipient), 30i128); + assert_eq!(s.vault_client.carbon_credit_balance(&other), 20i128); + + // Insufficient balance → transfer must panic. + let r = s + .vault_client + .try_transfer_carbon_credits(&recipient, &other, &999i128); + assert!(r.is_err()); +} +// ── Issue #320: outbound Wormhole bridge path coverage ──────────────────────── + +mod mock_wormhole_core { + use soroban_sdk::{contract, contractimpl, Bytes, Env}; + + #[contract] + pub struct MockWormholeCore; + + #[contractimpl] + impl MockWormholeCore { + /// Return a fixed sequence number for every published message. + pub fn publish_message(_env: Env, _consistency_level: u32, _payload: Bytes) -> u64 { + 7u64 + } + } +} + +#[test] +fn test_bridge_payload_serialize_parse_round_trip() { + let s = setup(); + let payload = BridgeTransferPayload { + token_address: BytesN::from_array(&s.env, &[1u8; 32]), + recipient: BytesN::from_array(&s.env, &[2u8; 32]), + amount: 123_456_789i128, + source_chain: wormhole::chain_id::STELLAR, + target_chain: wormhole::chain_id::ETHEREUM, + nonce: 42u64, + }; + let bytes = wormhole::serialize_bridge_payload(&s.env, &payload); + let parsed = wormhole::parse_bridge_payload(&s.env, &bytes); + // Round-trip must preserve every field exactly. + assert_eq!(parsed.token_address, payload.token_address); + assert_eq!(parsed.recipient, payload.recipient); + assert_eq!(parsed.amount, payload.amount); + assert_eq!(parsed.source_chain, payload.source_chain); + assert_eq!(parsed.target_chain, payload.target_chain); + assert_eq!(parsed.nonce, payload.nonce); +} + +#[test] +fn test_initiate_bridge_transfer_burns_shares_and_publishes() { + 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); + + let core = s.env.register(mock_wormhole_core::MockWormholeCore, ()); + s.vault_client.set_wormhole_core(&core); + + let recipient = BytesN::from_array(&s.env, &[9u8; 32]); + let amount = shares / 2; + let before = s.vault_client.balance(&investor); + + let sequence = s.vault_client.initiate_bridge_transfer( + &investor, + &amount, + &wormhole::chain_id::ETHEREUM, + &recipient, + &123u64, + ); + assert_eq!(sequence, 7u64); + assert_eq!(s.vault_client.balance(&investor), before - amount); +} + +#[test] +fn test_initiate_bridge_transfer_rejects_non_positive_amount() { + let s = setup(); + let investor = Address::generate(&s.env); + mint_usdc(&s.env, &s.usdc_sac, &investor, 1_000_0000000i128); + s.vault_client.deposit(&investor, &1_000_0000000i128); + + let core = s.env.register(mock_wormhole_core::MockWormholeCore, ()); + s.vault_client.set_wormhole_core(&core); + + let recipient = BytesN::from_array(&s.env, &[9u8; 32]); + let r = s.vault_client.try_initiate_bridge_transfer( + &investor, + &0i128, + &wormhole::chain_id::ETHEREUM, + &recipient, + &123u64, + ); + assert!(r.is_err()); +} diff --git a/project_registry/src/test.rs b/project_registry/src/test.rs index fbcb624..93d7fdf 100644 --- a/project_registry/src/test.rs +++ b/project_registry/src/test.rs @@ -1659,7 +1659,9 @@ fn test_score_history_multiple_updates_ordered() { ); client.update_impact_score(&id, &10u32, &20u32); + env.ledger().with_mut(|li| li.timestamp += 1); client.update_impact_score(&id, &30u32, &40u32); + env.ledger().with_mut(|li| li.timestamp += 1); client.update_impact_score(&id, &50u32, &60u32); let history = client.get_score_history(&id); @@ -2178,3 +2180,188 @@ proptest! { prop_assert!(result.is_err()); } } +// ── Issue #326: governance proposal coverage ────────────────────────────────── + +#[test] +fn test_create_proposal_and_get_proposal() { + let (env, _admin, _whitelister, client) = setup(); + let proposer = Address::generate(&env); + let id = client.create_proposal( + &proposer, + &String::from_str(&env, "Increase green threshold"), + &MIN_VOTING_PERIOD, + ); + assert_eq!(id, 1); + + let p = client.get_proposal(&id); + assert_eq!(p.proposer, proposer); + assert_eq!(p.votes_for, 0); + assert_eq!(p.votes_against, 0); + assert!(!p.executed); + assert!(p.voting_ends_at >= MIN_VOTING_PERIOD); +} + +#[test] +fn test_create_proposal_rejects_too_short_voting_period() { + let (env, _admin, _whitelister, client) = setup(); + let proposer = Address::generate(&env); + let r = client.try_create_proposal( + &proposer, + &String::from_str(&env, "Too short"), + &(MIN_VOTING_PERIOD - 1), + ); + assert!(r.is_err()); +} + +#[test] +fn test_cast_vote_validation() { + let (env, _admin, _whitelister, client) = setup(); + let proposer = Address::generate(&env); + let voter = Address::generate(&env); + let id = client.create_proposal( + &proposer, + &String::from_str(&env, "Vote validation"), + &MIN_VOTING_PERIOD, + ); + + // weight <= 0 rejected. + assert!(client.try_cast_vote(&voter, &id, &true, &0i128).is_err()); + // Unknown proposal rejected. + assert!(client.try_cast_vote(&voter, &999u32, &true, &1i128).is_err()); + // First vote succeeds; a second vote from the same voter is rejected. + client.cast_vote(&voter, &id, &true, &10i128); + assert!(client.try_cast_vote(&voter, &id, &true, &5i128).is_err()); +} + +#[test] +fn test_proposal_full_flow_pass_and_double_execution_guard() { + let (env, _admin, _whitelister, client) = setup(); + let proposer = Address::generate(&env); + let voter_for = Address::generate(&env); + let voter_against = Address::generate(&env); + let id = client.create_proposal( + &proposer, + &String::from_str(&env, "Full governance flow"), + &MIN_VOTING_PERIOD, + ); + + // Voting still open → execution rejected. + assert!(client.try_execute_proposal(&id).is_err()); + + client.cast_vote(&voter_for, &id, &true, &100i128); + client.cast_vote(&voter_against, &id, &false, &40i128); + + // Advance time past the voting deadline. + let deadline = client.get_proposal(&id).voting_ends_at; + env.ledger().with_mut(|li| { + li.timestamp = deadline + 1; + }); + + // Voting ended → further votes rejected. + assert!(client.try_cast_vote(&voter_for, &id, &true, &1i128).is_err()); + + // Execution passes (100 > 40). + assert!(client.execute_proposal(&id)); + + // Double execution rejected. + assert!(client.try_execute_proposal(&id).is_err()); + + let p = client.get_proposal(&id); + assert!(p.executed); + assert_eq!(p.votes_for, 100i128); + assert_eq!(p.votes_against, 40i128); +} + +#[test] +fn test_get_proposal_not_found() { + let (env, _admin, _whitelister, client) = setup(); + assert!(client.try_get_proposal(&999u32).is_err()); +} +// ── Issue #327: project archive/delete/compact lifecycle coverage ───────────── + +#[test] +fn test_archive_project_flips_status_and_excludes_from_listings() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project( + &creator, + &String::from_str(&env, "ipfs://QmArchive"), + &0u64, + &test_metadata_hash(&env), + ); + + client.archive_project(&id); + + let project = client.get_project(&id); + assert_eq!(project.status, crate::types::ProjectStatus::Archived); + + // get_all_projects excludes archived entries. + let active = client.get_all_projects(); + assert!(active.iter().all(|entry| entry.0 != id)); + + // get_all_projects_with_archived still includes it. + let all = client.get_all_projects_with_archived(); + assert!(all.iter().any(|entry| entry.0 == id)); +} + +#[test] +fn test_delete_project_removes_entry() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project( + &creator, + &String::from_str(&env, "ipfs://QmDelete"), + &0u64, + &test_metadata_hash(&env), + ); + + client.delete_project(&id); + + // get_project must fail with ProjectNotFound after deletion. + assert!(client.try_get_project(&id).is_err()); +} + +#[test] +fn test_compact_archive_matches_pre_compaction_data() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project( + &creator, + &String::from_str(&env, "ipfs://QmCompact"), + &12345u64, + &test_metadata_hash(&env), + ); + + let before = client.get_project(&id); + client.archive_project(&id); + client.compact_archive(&id); + + let summary = client.get_archive_summary(&id); + assert_eq!(summary.owner, before.owner); + assert_eq!(summary.final_credit_quality, before.credit_quality); + assert_eq!(summary.final_green_impact, before.green_impact); + assert_eq!(summary.maturity_date, before.maturity_date); + assert_eq!(summary.certification_status, before.certification_status); + + // Full project data is gone after compaction. + assert!(client.try_get_project(&id).is_err()); +} + +#[test] +fn test_compact_archive_requires_prior_archiving() { + let (env, _admin, _whitelister, client) = setup(); + let creator = Address::generate(&env); + client.set_whitelist(&creator, &true); + let id = client.create_project( + &creator, + &String::from_str(&env, "ipfs://QmCompactGuard"), + &0u64, + &test_metadata_hash(&env), + ); + + // Not archived yet → compact_archive must panic with ProjectNotArchived. + assert!(client.try_compact_archive(&id).is_err()); +}