diff --git a/INTERFACE.md b/INTERFACE.md index 83e8b0e..85593ef 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -91,7 +91,9 @@ Multi-sig errors: | `get_whitelister()` | none | `Address` | Current whitelister. | | `archive_project(project_id: u32)` | owner | none | Marks a project archived; excluded from `get_all_projects` by default (#26). | | `set_project_status(project_id: u32, status: ProjectStatus)` | owner | none | Transitions status between `Pending`/`Active`/`Funded`/`Completed`; cannot set or clear `Archived` (#329). | -| `delete_project(project_id: u32)` | owner | none | Rejects deletion when the project has active investments. | +| `delete_project(project_id: u32)` | owner | none | Rejects deletion when the project has active investments: queries `get_project_investment(project_id)` on the vault set via `set_vault` and panics with `ProjectHasInvestments` if non-zero. Fails closed with `VaultNotConfigured` if no vault is set (#526). | +| `set_vault(vault: Address)` | owner | none | Sets the investment vault `delete_project` queries for active investments (#526). | +| `get_vault()` | anyone | `Option
` | Returns the configured investment vault, if any (#526). | | `get_all_projects_with_archived()` | none | `Vec<(u32, ProjectData)>` | Like `get_all_projects` but includes archived projects. | | `compact_archive(project_id: u32)` | owner | none | Replaces a full `ProjectData` with a minimal `ArchiveSummary` (#73). Project must already be archived. | | `get_archive_summary(project_id: u32)` | none | `ArchiveSummary` | Panics if the project hasn't been compacted. | diff --git a/investment_vault/src/storage.rs b/investment_vault/src/storage.rs index 2245af6..ac6dc87 100644 --- a/investment_vault/src/storage.rs +++ b/investment_vault/src/storage.rs @@ -34,18 +34,24 @@ pub fn write_total_deposited(env: &Env, account: Address, amount: i128) { env.storage().persistent().extend_ttl(&key, TTL_THRESHOLD, TTL_EXTEND_TO); } -#c[cfg)test] +#[cfg(test)] mod tests { use super::*; - use soroban_sdk::{address, Env}; + use soroban_sdk::testutils::{Address as _, Ledger}; #[test] fn total_deposited_survives_inactivity() { let env = Env::default(); + // Storage is only reachable from inside a contract context. + let contract_id = env.register(crate::InvestmentVault, ()); let account = Address::generate(&env); - write_total_deposited(&env, account.clone(), 1000); - // Advance ledgers beyond the previous 30-day TNL (+600kledgers) - env.ledger().set_ledger_seq(600_000); - assert_eq!(read_total_deposited(&env, account).unwrap(), 1000; + env.as_contract(&contract_id, || { + write_total_deposited(&env, account.clone(), 1000); + }); + // Advance ledgers well past the old ~30-day TTL (+600k ledgers). + env.ledger().set_sequence_number(600_000); + env.as_contract(&contract_id, || { + assert_eq!(read_total_deposited(&env, account).unwrap(), 1000); + }); } } diff --git a/project_registry/src/lib.rs b/project_registry/src/lib.rs index e07b233..06de667 100644 --- a/project_registry/src/lib.rs +++ b/project_registry/src/lib.rs @@ -1,13 +1,22 @@ #![no_std] use soroban_sdk::{ - contract, contractimpl, panic_with_error, token::Client as TokenClient, Address, BytesN, Env, - String, Vec, + contract, contractclient, contractimpl, panic_with_error, token::Client as TokenClient, + Address, BytesN, Env, String, Vec, }; use stellar_access::ownable::{ get_owner, set_owner, transfer_ownership as ownable_transfer_ownership, Ownable, }; use stellar_macros::only_owner; +/// The slice of the investment vault's interface `delete_project` needs (#526). +/// Declared here rather than importing the vault crate, which would create a +/// dependency cycle (the vault already imports this registry's WASM). +#[allow(dead_code)] +#[contractclient(name = "VaultInvestmentClient")] +trait VaultInvestmentQuery { + fn get_project_investment(env: Env, project_id: u32) -> i128; +} + /// Maximum URI length in bytes. Prevents excessively large ledger entries (#119). const MAX_URI_LEN: u32 = 512; /// Minimum URI length — must contain at least a scheme and one character (#117). @@ -274,8 +283,13 @@ impl ProjectRegistry { events::project_status_changed(&env, project_id, old_status, status); } - /// Delete a project. Admin-only. Can only delete if no investments exist (#26). - /// This is a placeholder - actual implementation requires cross-contract call to vault. + /// Delete a project. Owner-only. Rejects deletion while the project has + /// active investments in the vault (#26, #526). + /// + /// Queries `vault.get_project_investment(project_id)` on the vault set via + /// `set_vault`, panicking with `ProjectHasInvestments` if it is non-zero. + /// Fails closed with `VaultNotConfigured` when no vault is set, so a + /// missing configuration can never silently skip the check. #[only_owner] pub fn delete_project(env: Env, project_id: u32) { require_current_state(&env); @@ -283,8 +297,15 @@ impl ProjectRegistry { let _project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); - // NOTE: In production, should verify no investments via vault.get_project_investment(project_id) - // For now, we allow deletion assuming caller has verified no active investments + let vault: Address = env + .storage() + .instance() + .get(&DataKey::Vault) + .unwrap_or_else(|| panic_with_error!(&env, RegistryError::VaultNotConfigured)); + let invested = VaultInvestmentClient::new(&env, &vault).get_project_investment(&project_id); + if invested != 0 { + panic_with_error!(&env, RegistryError::ProjectHasInvestments); + } env.storage() .persistent() @@ -1004,6 +1025,18 @@ impl ProjectRegistry { events::emergency_admin_changed(&env, emergency_admin); } + /// Set the investment vault that `delete_project` queries for active + /// investments (#526). Owner-only. + #[only_owner] + pub fn set_vault(env: Env, vault: Address) { + env.storage().instance().set(&DataKey::Vault, &vault); + } + + /// Return the configured investment vault address, if any (#526). + pub fn get_vault(env: Env) -> Option { + env.storage().instance().get(&DataKey::Vault) + } + /// Return the configured emergency-admin address, if any. pub fn get_emergency_admin(env: Env) -> Option { env.storage().instance().get(&DataKey::EmergencyAdmin) diff --git a/project_registry/src/test.rs b/project_registry/src/test.rs index 8e9e874..3faa943 100644 --- a/project_registry/src/test.rs +++ b/project_registry/src/test.rs @@ -2745,3 +2745,167 @@ fn test_next_update_allowed_at() { env.ledger().with_mut(|l| l.timestamp = allowed); client.update_impact_score(&id, &70u32, &70u32); } + +// ── delete_project investment guard (#526) ─────────────────────────────────── + +/// Minimal stand-in for the investment vault: only `get_project_investment`, +/// with a setter so each test controls the reported investment exactly. +#[soroban_sdk::contract] +struct MockVault; + +#[soroban_sdk::contractimpl] +impl MockVault { + pub fn set_investment(env: Env, project_id: u32, amount: i128) { + env.storage().instance().set(&project_id, &amount); + } + + pub fn get_project_investment(env: Env, project_id: u32) -> i128 { + env.storage().instance().get(&project_id).unwrap_or(0) + } +} + +fn contract_error(err: RegistryError) -> soroban_sdk::Error { + soroban_sdk::Error::from_contract_error(err as u32) +} + +fn create_test_project(env: &Env, client: &ProjectRegistryClient) -> u32 { + let creator = Address::generate(env); + client.set_whitelist(&creator, &true); + client.create_project( + &creator, + &String::from_str(env, "ipfs://QmDelete"), + &0u64, + &test_metadata_hash(env), + ) +} + +fn setup_with_mock_vault() -> ( + Env, + ProjectRegistryClient<'static>, + MockVaultClient<'static>, + u32, +) { + let (env, _admin, _whitelister, client) = setup(); + let vault_id = env.register(MockVault, ()); + let vault = MockVaultClient::new(&env, &vault_id); + client.set_vault(&vault_id); + let project_id = create_test_project(&env, &client); + (env, client, vault, project_id) +} + +#[test] +fn test_get_vault_is_none_until_set() { + let (env, _admin, _whitelister, client) = setup(); + assert_eq!(client.get_vault(), None); + let vault = Address::generate(&env); + client.set_vault(&vault); + assert_eq!(client.get_vault(), Some(vault)); +} + +#[test] +fn test_set_vault_is_owner_only() { + let env = Env::default(); + let admin = Address::generate(&env); + let whitelister = Address::generate(&env); + let registry_id = env.register(ProjectRegistry, (&admin, &whitelister)); + let client = ProjectRegistryClient::new(&env, ®istry_id); + // No auths mocked: the owner has not authorized this call. + assert!(client.try_set_vault(&Address::generate(&env)).is_err()); + assert_eq!(client.get_vault(), None); +} + +#[test] +fn test_delete_project_fails_closed_without_vault() { + let (env, _admin, _whitelister, client) = setup(); + let project_id = create_test_project(&env, &client); + + assert_eq!( + client.try_delete_project(&project_id), + Err(Ok(contract_error(RegistryError::VaultNotConfigured))) + ); + // Project is untouched. + assert_eq!( + client.get_project(&project_id).uri, + String::from_str(&env, "ipfs://QmDelete") + ); +} + +#[test] +fn test_delete_project_rejects_active_investments() { + let (env, client, vault, project_id) = setup_with_mock_vault(); + vault.set_investment(&project_id, &5_000_0000000i128); + + assert_eq!( + client.try_delete_project(&project_id), + Err(Ok(contract_error(RegistryError::ProjectHasInvestments))) + ); + assert_eq!( + client.get_project(&project_id).uri, + String::from_str(&env, "ipfs://QmDelete") + ); +} + +#[test] +fn test_delete_project_rejects_any_nonzero_investment() { + let (_env, client, vault, project_id) = setup_with_mock_vault(); + vault.set_investment(&project_id, &1i128); + assert_eq!( + client.try_delete_project(&project_id), + Err(Ok(contract_error(RegistryError::ProjectHasInvestments))) + ); +} + +#[test] +fn test_delete_project_succeeds_with_no_investment() { + let (_env, client, _vault, project_id) = setup_with_mock_vault(); + client.delete_project(&project_id); + assert_eq!( + client.try_get_project(&project_id), + Err(Ok(contract_error(RegistryError::ProjectNotFound))) + ); +} + +#[test] +fn test_delete_project_allowed_once_investment_is_repaid() { + let (_env, client, vault, project_id) = setup_with_mock_vault(); + vault.set_investment(&project_id, &250i128); + assert!(client.try_delete_project(&project_id).is_err()); + + vault.set_investment(&project_id, &0i128); + client.delete_project(&project_id); + assert!(client.try_get_project(&project_id).is_err()); +} + +#[test] +fn test_delete_project_checks_only_the_target_project() { + let (env, client, vault, project_id) = setup_with_mock_vault(); + let other = create_test_project(&env, &client); + vault.set_investment(&other, &1_000i128); + + client.delete_project(&project_id); + assert!(client.try_get_project(&project_id).is_err()); + assert_eq!( + client.try_delete_project(&other), + Err(Ok(contract_error(RegistryError::ProjectHasInvestments))) + ); +} + +/// The guard's client interface must match the real vault's ABI: wire the real +/// InvestmentVault and delete an uninvested project through it. +#[test] +fn test_delete_project_against_real_investment_vault() { + let (env, admin, _whitelister, client) = setup(); + let usdc_admin = Address::generate(&env); + let usdc_sac = env.register_stellar_asset_contract_v2(usdc_admin).address(); + let vault_id = env.register(InvestmentVault, (&admin, &usdc_sac, &client.address)); + client.set_vault(&vault_id); + + let project_id = create_test_project(&env, &client); + assert_eq!( + InvestmentVaultClient::new(&env, &vault_id).get_project_investment(&project_id), + 0 + ); + + client.delete_project(&project_id); + assert!(client.try_get_project(&project_id).is_err()); +} diff --git a/project_registry/src/types.rs b/project_registry/src/types.rs index 3fdfb17..a6825ae 100644 --- a/project_registry/src/types.rs +++ b/project_registry/src/types.rs @@ -102,9 +102,12 @@ pub enum RegistryError { /// set_project_status was called with the project's current status (#329). ProjectStatusUnchanged = 42, /// compact_storage input exceeds the maximum allowed batch size (#332). - CompactStorageTooLarge = 41, + CompactStorageTooLarge = 43, /// create_proposal voting_duration_secs exceeds the maximum allowed period (#332). - VotingPeriodTooLong = 42, + VotingPeriodTooLong = 44, + /// delete_project was called before the investment vault address was set + /// via `set_vault`, so active investments cannot be ruled out (#526). + VaultNotConfigured = 45, } /// Certification state for a green project (#130). @@ -236,6 +239,8 @@ pub enum DataKey { /// Optional emergency-admin address that may pause/unpause without /// holding full owner privileges (#43). Unset means no emergency admin. EmergencyAdmin, + /// Investment vault queried by `delete_project` for active investments (#526). + Vault, } /// Consolidated operational status for monitoring/health-check integrations (#77).