Skip to content
Merged
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
4 changes: 3 additions & 1 deletion INTERFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Address>` | 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. |
Expand Down
18 changes: 12 additions & 6 deletions investment_vault/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
}
45 changes: 39 additions & 6 deletions project_registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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).
Expand Down Expand Up @@ -274,17 +283,29 @@ 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);
// Verify project exists
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()
Expand Down Expand Up @@ -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<Address> {
env.storage().instance().get(&DataKey::Vault)
}

/// Return the configured emergency-admin address, if any.
pub fn get_emergency_admin(env: Env) -> Option<Address> {
env.storage().instance().get(&DataKey::EmergencyAdmin)
Expand Down
164 changes: 164 additions & 0 deletions project_registry/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &registry_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());
}
9 changes: 7 additions & 2 deletions project_registry/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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).
Expand Down