From 5247fee4816f8f1d227785b345000f845c59ce32 Mon Sep 17 00:00:00 2001 From: Thompson <140930314+Godbrand0@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:58:46 -0400 Subject: [PATCH] fix(project_registry): wire dead storage/logic modules, refresh TTL on write, add status transitions, sync EVENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Route lib.rs's Project/Proposal/Whitelist reads and writes through storage.rs's accessors instead of inlining env.storage() calls, and make compute_rate delegate to logic::calculate_interest_rate — the two modules were fully unused/duplicated (#331). - Move TTL extension into storage::write_project so every mutating write to a Project entry re-extends its rent, not just create_project (#328). - Add an admin-only set_project_status transition (Active/Funded/ Completed), since nothing previously advanced ProjectData::status past Pending; it cannot set or clear Archived, which stays owned by archive_project (#329). - Document every event actually emitted by events.rs in EVENTS.md, and fix project_created's documented field name (creator -> owner) (#330). - Fix a pre-existing missing closing brace in test.rs (from a prior merge) that made the whole test module fail to compile. Closes #328, #329, #330, #331. --- EVENTS.md | 92 +++- INTERFACE.md | 1 + project_registry/src/events.rs | 25 +- project_registry/src/lib.rs | 195 +++----- project_registry/src/logic.rs | 3 - project_registry/src/storage.rs | 18 +- project_registry/src/test.rs | 121 +++++ project_registry/src/types.rs | 12 + ...t_write_extends_ttl_beyond_creation.1.json | 473 ++++++++++++++++++ ..._rejects_archived_target_and_source.1.json | 387 ++++++++++++++ ...est_set_project_status_rejects_noop.1.json | 347 +++++++++++++ ..._status_transitions_and_emits_event.1.json | 433 ++++++++++++++++ 12 files changed, 1969 insertions(+), 138 deletions(-) create mode 100644 project_registry/test_snapshots/test/test_project_write_extends_ttl_beyond_creation.1.json create mode 100644 project_registry/test_snapshots/test/test_set_project_status_rejects_archived_target_and_source.1.json create mode 100644 project_registry/test_snapshots/test/test_set_project_status_rejects_noop.1.json create mode 100644 project_registry/test_snapshots/test/test_set_project_status_transitions_and_emits_event.1.json diff --git a/EVENTS.md b/EVENTS.md index 4ea9e7ab..64c7db2e 100644 --- a/EVENTS.md +++ b/EVENTS.md @@ -8,10 +8,28 @@ Each event lists the public [`INTERFACE.md`](INTERFACE.md) function(s) that emit ### `project_created` - **Topics**: `["project", "created"]` -- **Data**: `(project_id: u32, creator: Address)` +- **Data**: `(project_id: u32, owner: Address)` - **Description**: Emitted when a new project is created in the registry. - **Emitted by**: [`create_project`](INTERFACE.md#projectregistry) +### `project_updated` +- **Topics**: `["project", "updated"]` +- **Data**: `(project_id: u32, credit_quality: u32, green_impact: u32)` +- **Description**: Emitted when both impact scores are updated together. +- **Emitted by**: [`update_impact_score`](INTERFACE.md#projectregistry) / [`update_impact_score_approved`](INTERFACE.md#projectregistry) + +### `credit_quality_updated` +- **Topics**: `["project", "credit_quality_updated"]` +- **Data**: `(project_id: u32, credit_quality: u32)` +- **Description**: Emitted when only the credit-quality score is updated. +- **Emitted by**: [`update_credit_quality_score`](INTERFACE.md#projectregistry) + +### `rate_updated` +- **Topics**: `["project", "rate_updated"]` +- **Data**: `(project_id: u32, rate_bps: u32)` +- **Description**: Emitted alongside `project_updated`/`score_changed` whenever a project's interest rate is recalculated. +- **Emitted by**: [`update_impact_score`](INTERFACE.md#projectregistry) / [`update_impact_score_approved`](INTERFACE.md#projectregistry) / [`update_impact_scores_batch`](INTERFACE.md#projectregistry) / [`update_scores_batch_approved`](INTERFACE.md#projectregistry) + ### `score_changed` - **Topics**: `["score_changed", project_id: u32]` - **Data** (Map, keyed by field name): `{old_credit_quality: u32, new_credit_quality: u32, old_green_impact: u32, new_green_impact: u32, old_rate_bps: u32, new_rate_bps: u32}` @@ -24,6 +42,12 @@ Each event lists the public [`INTERFACE.md`](INTERFACE.md) function(s) that emit - **Description**: Emitted when a project is archived. - **Emitted by**: [`archive_project`](INTERFACE.md#projectregistry) +### `project_status_changed` +- **Topics**: `["project_status_changed", project_id: u32]` +- **Data**: `(old_status: ProjectStatus, new_status: ProjectStatus)` +- **Description**: Emitted when a project's lifecycle status transitions between `Pending`, `Active`, `Funded`, or `Completed` (#329). Not emitted for `Archived` — see `project_archived`. +- **Emitted by**: [`set_project_status`](INTERFACE.md#projectregistry) + ### `project_deleted` - **Topics**: `["project", "deleted"]` - **Data**: `(project_id: u32)` @@ -48,6 +72,72 @@ Each event lists the public [`INTERFACE.md`](INTERFACE.md) function(s) that emit - **Description**: Emitted when collateral is returned to the project owner. - **Emitted by**: [`release_collateral`](INTERFACE.md#projectregistry) +### `collateral_liquidated` +- **Topics**: `["project", "collateral_liquidated"]` +- **Data**: `(project_id: u32, token: Address, recipient: Address, amount: i128)` +- **Description**: Emitted when collateral is seized to `recipient` for a defaulted project. +- **Emitted by**: [`liquidate_collateral`](INTERFACE.md#projectregistry) / [`liquidate_collateral_approved`](INTERFACE.md#projectregistry) + +### `whitelist_set` +- **Topics**: `["whitelist_set"]` +- **Data**: `(account: Address, status: bool)` +- **Description**: Emitted when an account's project-creation whitelist status is granted or revoked. +- **Emitted by**: [`set_whitelist`](INTERFACE.md#projectregistry) + +### `project_certified` +- **Topics**: `["project", "certified"]` +- **Data**: `(project_id: u32, status: CertificationStatus)` +- **Description**: Emitted when a project's certification status changes. +- **Emitted by**: [`certify_project`](INTERFACE.md#projectregistry) + +### `proposal_created` +- **Topics**: `["proposal_created"]` +- **Data**: `(proposal_id: u32, proposer: Address, voting_ends_at: u64)` +- **Description**: Emitted when a governance proposal is created. +- **Emitted by**: [`create_proposal`](INTERFACE.md#projectregistry) + +### `vote_cast` +- **Topics**: `["vote_cast"]` +- **Data**: `(proposal_id: u32, voter: Address, support: bool, weight: i128)` +- **Description**: Emitted when a vote is cast on an open proposal. +- **Emitted by**: [`cast_vote`](INTERFACE.md#projectregistry) + +### `proposal_executed` +- **Topics**: `["proposal_executed"]` +- **Data**: `(proposal_id: u32, passed: bool)` +- **Description**: Emitted when a proposal is finalised after its voting period ends. +- **Emitted by**: [`execute_proposal`](INTERFACE.md#projectregistry) + +### `reputation_updated` +- **Topics**: `["reputation_updated"]` +- **Data**: `(creator: Address, score: u32)` +- **Description**: Emitted when a creator's reputation score is set. +- **Emitted by**: [`set_creator_reputation`](INTERFACE.md#projectregistry) + +### `registry_paused` / `registry_unpaused` +- **Topics**: `["registry_paused"]` / `["registry_unpaused"]` +- **Data**: none +- **Description**: Emitted when the circuit breaker is engaged or released. +- **Emitted by**: [`pause`](INTERFACE.md#projectregistry) / [`unpause`](INTERFACE.md#projectregistry) / [`emergency_pause`](INTERFACE.md#projectregistry) / [`emergency_unpause`](INTERFACE.md#projectregistry) + +### `emergency_admin_changed` +- **Topics**: `["emergency_admin_changed"]` +- **Data**: `(new_emergency_admin: Option
)` +- **Description**: Emitted when the owner sets or clears the emergency-admin address. +- **Emitted by**: [`set_emergency_admin`](INTERFACE.md#projectregistry) + +### `whitelister_changed` +- **Topics**: `["whitelister_changed"]` +- **Data**: `(old_whitelister: Address, new_whitelister: Address)` +- **Description**: Emitted when the owner replaces the whitelister address. +- **Emitted by**: [`set_whitelister`](INTERFACE.md#projectregistry) + +### `ownership_transferred` +- **Topics**: `["ownership_transferred", old_owner: Address, new_owner: Address]` +- **Data**: none +- **Description**: Emitted alongside the underlying `stellar-access` ownership-transfer event when a two-step ownership transfer is initiated. +- **Emitted by**: `transfer_ownership` (from the `Ownable` trait) + ## Investment Vault Events ### `deposit` diff --git a/INTERFACE.md b/INTERFACE.md index b1979e4e..23daa3a5 100644 --- a/INTERFACE.md +++ b/INTERFACE.md @@ -89,6 +89,7 @@ Multi-sig errors: | `set_whitelister(new_whitelister: Address)` | owner | none | Replaces whitelister. | | `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. | | `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. | diff --git a/project_registry/src/events.rs b/project_registry/src/events.rs index 711411db..979c6332 100644 --- a/project_registry/src/events.rs +++ b/project_registry/src/events.rs @@ -1,4 +1,4 @@ -use crate::types::CertificationStatus; +use crate::types::{CertificationStatus, ProjectStatus}; use soroban_sdk::{contractevent, Address, Env}; /// Emitted when collateral is deposited for a project (#128). @@ -187,6 +187,29 @@ pub fn project_archived(env: &Env, project_id: u32) { ProjectArchived { project_id }.publish(env); } +/// Emitted when a project's lifecycle status is changed via `set_project_status` (#329). +#[contractevent] +pub struct ProjectStatusChanged { + #[topic] + pub project_id: u32, + pub old_status: ProjectStatus, + pub new_status: ProjectStatus, +} + +pub fn project_status_changed( + env: &Env, + project_id: u32, + old_status: ProjectStatus, + new_status: ProjectStatus, +) { + ProjectStatusChanged { + project_id, + old_status, + new_status, + } + .publish(env); +} + pub fn project_deleted(env: &Env, project_id: u32) { ProjectDeleted { project_id }.publish(env); } diff --git a/project_registry/src/lib.rs b/project_registry/src/lib.rs index 8bb5b611..4ef517e5 100644 --- a/project_registry/src/lib.rs +++ b/project_registry/src/lib.rs @@ -37,8 +37,8 @@ mod storage; mod types; pub use types::{ - ArchiveSummary, CertificationStatus, DataKey, HealthStatus, ProjectData, Proposal, - RegistryError, ScoreHistoryEntry, + ArchiveSummary, CertificationStatus, DataKey, HealthStatus, ProjectData, ProjectStatus, + Proposal, RegistryError, ScoreHistoryEntry, }; /// Minimum voting period in seconds (~1 day at 5s/ledger, ≈ 17280 ledgers) (#134). @@ -47,14 +47,6 @@ const MIN_VOTING_PERIOD: u64 = 86_400; /// Minimum oracle update interval in seconds (1 hour). const MIN_UPDATE_INTERVAL: u64 = 3600; -/// Minimum remaining TTL in ledgers before extending persistent storage rent (#388). -/// At 5 s/ledger this equals ~1 day (17 280 ledgers). -const TTL_EXTEND_THRESHOLD_LEDGERS: u32 = 17_280; - -/// Target TTL in ledgers after extension (#388). -/// At 5 s/ledger this equals ~30 days (518 400 ledgers). -const TTL_EXTEND_TO_LEDGERS: u32 = 518_400; - pub const CONTRACT_NAME: &str = "Project Registry"; pub const CONTRACT_DESCRIPTION: &str = "Heliobond Project Registry"; pub const CONTRACT_VERSION: &str = "1.0.0"; @@ -121,9 +113,7 @@ impl ProjectRegistry { whitelister.require_auth(); // Validation: Soroban Address types inherently prevent null/zero addresses, // fulfilling explicit validation requirements for account. - env.storage() - .persistent() - .set(&DataKey::Whitelist(account.clone()), &status); + storage::write_whitelist(&env, account.clone(), status); events::whitelist_set(&env, &account, status); } @@ -141,11 +131,7 @@ impl ProjectRegistry { creator.require_auth(); // Validation: Soroban Address types inherently prevent null/zero addresses, // fulfilling explicit validation requirements for creator. - let is_whitelisted: bool = env - .storage() - .persistent() - .get(&DataKey::Whitelist(creator.clone())) - .unwrap_or(false); + let is_whitelisted: bool = storage::read_whitelist(&env, creator.clone()); if !is_whitelisted { panic_with_error!(&env, RegistryError::NotWhitelisted); } @@ -219,12 +205,7 @@ impl ProjectRegistry { metadata_hash: metadata_hash.clone(), }; - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); - env.storage() - .persistent() - .extend_ttl(&DataKey::Project(project_id), TTL_EXTEND_THRESHOLD_LEDGERS, TTL_EXTEND_TO_LEDGERS); // Add rent check/extend + storage::write_project(&env, project_id, &project); env.storage() .instance() .set(&DataKey::ProjectCounter, &project_id); @@ -237,10 +218,7 @@ impl ProjectRegistry { #[only_owner] pub fn archive_project(env: Env, project_id: u32) { require_current_state(&env); - let mut project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let mut project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.status == types::ProjectStatus::Archived { @@ -248,22 +226,44 @@ impl ProjectRegistry { } project.status = types::ProjectStatus::Archived; - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); + storage::write_project(&env, project_id, &project); events::project_archived(&env, project_id); } + /// Transition a project's lifecycle status to `Active`, `Funded`, or `Completed`. Admin-only (#329). + /// + /// Nothing in this contract advances `ProjectData::status` past `Pending` on + /// its own — this is the callable transition path until a real cross-contract + /// call from the vault on funding events lands (tracked separately). Cannot be + /// used to set or clear `Archived`; use `archive_project` for that, since + /// archived projects are otherwise immutable. Emits `ProjectStatusChanged`. + #[only_owner] + pub fn set_project_status(env: Env, project_id: u32, status: types::ProjectStatus) { + require_not_paused(&env); + require_current_state(&env); + let mut project: ProjectData = storage::read_project(&env, project_id) + .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); + if project.status == types::ProjectStatus::Archived + || status == types::ProjectStatus::Archived + { + panic_with_error!(&env, RegistryError::InvalidStatusTransition); + } + if project.status == status { + panic_with_error!(&env, RegistryError::ProjectStatusUnchanged); + } + let old_status = project.status.clone(); + project.status = status.clone(); + storage::write_project(&env, project_id, &project); + 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. #[only_owner] pub fn delete_project(env: Env, project_id: u32) { require_current_state(&env); // Verify project exists - let _project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + 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) @@ -285,10 +285,7 @@ impl ProjectRegistry { #[only_owner] pub fn compact_archive(env: Env, project_id: u32) { require_current_state(&env); - let project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.status != types::ProjectStatus::Archived { panic_with_error!(&env, RegistryError::ProjectNotArchived); @@ -323,9 +320,7 @@ impl ProjectRegistry { /// Return the `ProjectData` for `id`. Panics with `ProjectNotFound` if the ID is unknown. pub fn get_project(env: Env, id: u32) -> ProjectData { require_current_state(&env); - env.storage() - .persistent() - .get(&DataKey::Project(id)) + storage::read_project(&env, id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)) } @@ -335,11 +330,7 @@ impl ProjectRegistry { /// trustless proof the content matches what the creator committed to. pub fn verify_metadata_hash(env: Env, project_id: u32, candidate_hash: BytesN<32>) -> bool { require_current_state(&env); - if let Some(project) = env - .storage() - .persistent() - .get::(&DataKey::Project(project_id)) - { + if let Some(project) = storage::read_project(&env, project_id) { return project.metadata_hash == candidate_hash; } // Project may have been compacted (#73) — fall back to the archive summary, @@ -468,18 +459,13 @@ impl ProjectRegistry { if caller != whitelister && caller != owner { panic_with_error!(&env, RegistryError::NotAuthorizedToCertify); } - let mut project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let mut project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.certification_status == status { panic_with_error!(&env, RegistryError::AlreadyCertified); } project.certification_status = status.clone(); - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); + storage::write_project(&env, project_id, &project); events::project_certified(&env, project_id, status); } @@ -488,10 +474,7 @@ impl ProjectRegistry { /// date, false otherwise. Projects with no maturity date (0) are never mature. pub fn is_mature(env: Env, project_id: u32) -> bool { require_current_state(&env); - let project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.maturity_date == 0 { return false; @@ -510,11 +493,7 @@ impl ProjectRegistry { .unwrap_or(0); let mut result = Vec::new(&env); for i in 1..=counter { - if let Some(project) = env - .storage() - .persistent() - .get::(&DataKey::Project(i)) - { + if let Some(project) = storage::read_project(&env, i) { if project.status != types::ProjectStatus::Archived { result.push_back((i, project)); } @@ -544,11 +523,7 @@ impl ProjectRegistry { let start = offset.saturating_add(1); let end = start.saturating_add(limit).min(counter.saturating_add(1)); for i in start..end { - if let Some(project) = env - .storage() - .persistent() - .get::(&DataKey::Project(i)) - { + if let Some(project) = storage::read_project(&env, i) { if project.status != types::ProjectStatus::Archived { result.push_back((i, project)); } @@ -567,11 +542,7 @@ impl ProjectRegistry { .unwrap_or(0); let mut result = Vec::new(&env); for i in 1..=counter { - if let Some(project) = env - .storage() - .persistent() - .get::(&DataKey::Project(i)) - { + if let Some(project) = storage::read_project(&env, i) { result.push_back((i, project)); } } @@ -614,9 +585,7 @@ impl ProjectRegistry { votes_against: 0, executed: false, }; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); + storage::write_proposal(&env, proposal_id, &proposal); env.storage() .instance() .set(&DataKey::ProposalCounter, &proposal_id); @@ -646,10 +615,7 @@ impl ProjectRegistry { if already { panic_with_error!(&env, RegistryError::AlreadyVoted); } - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) + let mut proposal: Proposal = storage::read_proposal(&env, proposal_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProposalNotFound)); if env.ledger().timestamp() > proposal.voting_ends_at { panic_with_error!(&env, RegistryError::VotingPeriodEnded); @@ -662,9 +628,7 @@ impl ProjectRegistry { } else { proposal.votes_against += weight; } - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); + storage::write_proposal(&env, proposal_id, &proposal); env.storage() .persistent() .set(&DataKey::HasVoted(proposal_id, voter.clone()), &true); @@ -675,10 +639,7 @@ impl ProjectRegistry { /// Returns true if the proposal passed (votes_for > votes_against). pub fn execute_proposal(env: Env, proposal_id: u32) -> bool { require_current_state(&env); - let mut proposal: Proposal = env - .storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) + let mut proposal: Proposal = storage::read_proposal(&env, proposal_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProposalNotFound)); if env.ledger().timestamp() <= proposal.voting_ends_at { panic_with_error!(&env, RegistryError::VotingStillOpen); @@ -688,9 +649,7 @@ impl ProjectRegistry { } proposal.executed = true; let passed = proposal.votes_for > proposal.votes_against; - env.storage() - .persistent() - .set(&DataKey::Proposal(proposal_id), &proposal); + storage::write_proposal(&env, proposal_id, &proposal); events::proposal_executed(&env, proposal_id, passed); passed } @@ -704,10 +663,7 @@ impl ProjectRegistry { if credit_quality > MAX_SCORE { panic_with_error!(&env, RegistryError::CreditQualityOutOfRange); } - let mut project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let mut project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); // Rate-limit oracle updates: reject if too soon since the last update. @@ -725,9 +681,7 @@ impl ProjectRegistry { project.credit_quality = credit_quality; project.last_update_timestamp = env.ledger().timestamp(); let new_rate = compute_rate(credit_quality, project.green_impact); - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); + storage::write_project(&env, project_id, &project); events::credit_quality_updated(&env, project_id, credit_quality); events::score_changed( &env, @@ -745,9 +699,7 @@ impl ProjectRegistry { /// Return a proposal by ID. Panics with `ProposalNotFound` if unknown. pub fn get_proposal(env: Env, proposal_id: u32) -> Proposal { require_current_state(&env); - env.storage() - .persistent() - .get(&DataKey::Proposal(proposal_id)) + storage::read_proposal(&env, proposal_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProposalNotFound)) } @@ -768,10 +720,7 @@ impl ProjectRegistry { if amount <= 0 { panic_with_error!(&env, RegistryError::CollateralNotPositive); } - let project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.owner != depositor { panic_with_error!(&env, RegistryError::NotProjectOwner); @@ -805,10 +754,7 @@ impl ProjectRegistry { require_not_paused(&env); require_current_state(&env); caller.require_auth(); - let project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.owner != caller { panic_with_error!(&env, RegistryError::NotProjectOwner); @@ -891,10 +837,7 @@ impl ProjectRegistry { /// Rate range: 500 bps (5 %) for perfect scores → 1 000 bps (10 %) for zero scores. pub fn get_interest_rate(env: Env, project_id: u32) -> u32 { require_current_state(&env); - let project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); compute_rate(project.credit_quality, project.green_impact) } @@ -1041,10 +984,7 @@ impl ProjectRegistry { /// overwritten. Returns an empty vec if no scores have been recorded yet. pub fn get_score_history(env: Env, project_id: u32) -> Vec { require_current_state(&env); - let _: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let _: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); let total: u32 = env @@ -1123,10 +1063,7 @@ fn update_impact_score_internal(env: Env, project_id: u32, credit_quality: u32, if credit_quality > MAX_SCORE || green_impact > MAX_SCORE { panic_with_error!(&env, RegistryError::ScoresOutOfRange); } - let mut project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let mut project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); // Rate-limit oracle updates: reject if too soon since the last update. @@ -1149,9 +1086,7 @@ fn update_impact_score_internal(env: Env, project_id: u32, credit_quality: u32, project.last_update_timestamp = env.ledger().timestamp(); let new_rate = compute_rate(credit_quality, green_impact); - env.storage() - .persistent() - .set(&DataKey::Project(project_id), &project); + storage::write_project(&env, project_id, &project); events::project_updated(&env, project_id, credit_quality, green_impact); events::rate_updated(&env, project_id, new_rate); events::score_changed( @@ -1170,10 +1105,7 @@ fn update_impact_score_internal(env: Env, project_id: u32, credit_quality: u32, fn liquidate_collateral_internal(env: Env, project_id: u32, token: Address, recipient: Address) { require_not_paused(&env); require_current_state(&env); - let project: ProjectData = env - .storage() - .persistent() - .get(&DataKey::Project(project_id)) + let project: ProjectData = storage::read_project(&env, project_id) .unwrap_or_else(|| panic_with_error!(&env, RegistryError::ProjectNotFound)); if project.maturity_date > 0 && env.ledger().timestamp() < project.maturity_date { panic_with_error!(&env, RegistryError::ProjectNotMature); @@ -1250,9 +1182,12 @@ fn require_multisig_disabled(env: &Env) { } fn compute_rate(credit_quality: u32, green_impact: u32) -> u32 { - let avg = (credit_quality + green_impact) / 2; - let discount = avg * MAX_DISCOUNT_BPS / 100; - BASE_RATE_BPS - discount + logic::calculate_interest_rate( + BASE_RATE_BPS, + MAX_DISCOUNT_BPS, + credit_quality, + green_impact, + ) } fn read_state_version(env: &Env) -> u32 { diff --git a/project_registry/src/logic.rs b/project_registry/src/logic.rs index 2f5de543..4d174038 100644 --- a/project_registry/src/logic.rs +++ b/project_registry/src/logic.rs @@ -1,6 +1,3 @@ -use crate::types::{CertificationStatus, ProjectData}; -use soroban_sdk::{Address, Env, String}; - /// Interest-rate calculation for green-bond projects. /// /// Heliobond uses a two-dimensional scoring model to determine each project's diff --git a/project_registry/src/storage.rs b/project_registry/src/storage.rs index 0c7c83d8..68910140 100644 --- a/project_registry/src/storage.rs +++ b/project_registry/src/storage.rs @@ -1,14 +1,26 @@ use crate::types::{DataKey, ProjectData, Proposal}; use soroban_sdk::{Address, Env}; +/// Minimum remaining TTL in ledgers before extending persistent storage rent (#388). +pub(crate) const TTL_EXTEND_THRESHOLD_LEDGERS: u32 = 17_280; +/// Target TTL in ledgers after extension (#388). +pub(crate) const TTL_EXTEND_TO_LEDGERS: u32 = 518_400; + pub fn read_project(env: &Env, id: u32) -> Option { env.storage().persistent().get(&DataKey::Project(id)) } +/// Writes `project` and re-extends its persistent TTL (#328) — every +/// mutating call site goes through this so rent is refreshed on each write, +/// not just at creation. pub fn write_project(env: &Env, id: u32, project: &ProjectData) { - env.storage() - .persistent() - .set(&DataKey::Project(id), project); + let key = DataKey::Project(id); + env.storage().persistent().set(&key, project); + env.storage().persistent().extend_ttl( + &key, + TTL_EXTEND_THRESHOLD_LEDGERS, + TTL_EXTEND_TO_LEDGERS, + ); } pub fn read_proposal(env: &Env, id: u32) -> Option { diff --git a/project_registry/src/test.rs b/project_registry/src/test.rs index 6345f44e..68676c97 100644 --- a/project_registry/src/test.rs +++ b/project_registry/src/test.rs @@ -1942,6 +1942,50 @@ fn test_getters_work_when_paused() { assert_eq!(client.is_paused(), true); } +// ── Persistent-storage TTL extension on write (#328) ────────────────────────── + +#[test] +fn test_project_write_extends_ttl_beyond_creation() { + use soroban_sdk::testutils::storage::Persistent as _; + + 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://QmTtl"), + &0u64, + &test_metadata_hash(&env), + ); + + let key = crate::types::DataKey::Project(id); + let ttl_after_create = + env.as_contract(&client.address, || env.storage().persistent().get_ttl(&key)); + assert_eq!(ttl_after_create, crate::storage::TTL_EXTEND_TO_LEDGERS); + + // Advance the ledger until the entry's remaining TTL has decayed below the + // extension threshold, so the next write is guaranteed to trigger a real + // re-extension rather than a threshold-gated no-op. + let advance = + crate::storage::TTL_EXTEND_TO_LEDGERS - crate::storage::TTL_EXTEND_THRESHOLD_LEDGERS + 1; + env.ledger().with_mut(|l| { + l.sequence_number += advance; + }); + let ttl_before_update = + env.as_contract(&client.address, || env.storage().persistent().get_ttl(&key)); + assert!(ttl_before_update < crate::storage::TTL_EXTEND_THRESHOLD_LEDGERS); + + client.update_credit_quality_score(&id, &42u32); + + let ttl_after_update = + env.as_contract(&client.address, || env.storage().persistent().get_ttl(&key)); + assert_eq!( + ttl_after_update, + crate::storage::TTL_EXTEND_TO_LEDGERS, + "writing the project should re-extend its TTL, not just at creation" + ); +} + // ── Storage compaction tests (#88) ──────────────────────────────────────────── #[test] @@ -1983,6 +2027,78 @@ fn test_compact_storage_removes_zero_collateral() { assert_eq!(removed, 0u32); } +// ── Project status transitions (#329) ────────────────────────────────────────── + +#[test] +fn test_set_project_status_transitions_and_emits_event() { + 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://QmStatus"), + &0u64, + &test_metadata_hash(&env), + ); + assert_eq!(client.get_project(&id).status, ProjectStatus::Pending); + + client.set_project_status(&id, &ProjectStatus::Active); + + // Events are scoped to the most recent invocation, so check right after + // the mutating call before any other client call resets the log. + let events = env.events().all().filter_by_contract(&client.address); + assert_eq!( + events.events().len(), + 1, + "set_project_status should emit exactly one event" + ); + assert_eq!(client.get_project(&id).status, ProjectStatus::Active); + + client.set_project_status(&id, &ProjectStatus::Funded); + assert_eq!(client.get_project(&id).status, ProjectStatus::Funded); +} + +#[test] +fn test_set_project_status_rejects_noop() { + 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://QmStatusNoop"), + &0u64, + &test_metadata_hash(&env), + ); + + assert!(client + .try_set_project_status(&id, &ProjectStatus::Pending) + .is_err()); +} + +#[test] +fn test_set_project_status_rejects_archived_target_and_source() { + 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://QmStatusArch"), + &0u64, + &test_metadata_hash(&env), + ); + + // Cannot set Archived through this path — must use archive_project. + assert!(client + .try_set_project_status(&id, &ProjectStatus::Archived) + .is_err()); + + client.archive_project(&id); + // Cannot transition an already-archived project either. + assert!(client + .try_set_project_status(&id, &ProjectStatus::Active) + .is_err()); +} + // ── Migration tests (#64) ────────────────────────────────────────────────────── #[test] @@ -2105,6 +2221,9 @@ fn test_all_only_owner_functions_reject_non_admin_caller() { client .try_update_credit_quality_score(&project_id, &1u32) .is_err(), + client + .try_set_project_status(&project_id, &crate::types::ProjectStatus::Active) + .is_err(), client .try_liquidate_collateral(&project_id, &addr(), &addr()) .is_err(), @@ -2351,6 +2470,8 @@ fn test_clear_multisig_admin_resets_to_defaults() { let (signers, threshold) = client.get_multisig_admin(); assert_eq!(signers.len(), 0); assert_eq!(threshold, 0); +} + // ── Issue #390: health_check test coverage (project_registry) ──────────────── /// health_check returns default operational state for a fresh registry. diff --git a/project_registry/src/types.rs b/project_registry/src/types.rs index 8cc550f4..65bd3172 100644 --- a/project_registry/src/types.rs +++ b/project_registry/src/types.rs @@ -96,6 +96,11 @@ pub enum RegistryError { ProposalDescriptionTooLong = 39, /// update_impact_scores_batch received an empty updates list (#445). EmptyBatchUpdate = 40, + /// set_project_status was asked to set or clear `Archived` — use + /// `archive_project` instead, since archived projects are immutable (#329). + InvalidStatusTransition = 41, + /// set_project_status was called with the project's current status (#329). + ProjectStatusUnchanged = 42, } /// Certification state for a green project (#130). @@ -109,6 +114,13 @@ pub enum CertificationStatus { Revoked = 3, } +/// Lifecycle status of a project (#27). +/// +/// `Pending` is set at creation and `Archived` via `archive_project`. +/// `Active`, `Funded`, and `Completed` are set via `set_project_status` +/// (admin/vault-callable) — this contract does not transition into them on +/// its own; a cross-contract call from the vault on real funding events is +/// tracked separately (#329). #[contracttype] #[derive(Clone, Debug, PartialEq)] #[repr(u32)] diff --git a/project_registry/test_snapshots/test/test_project_write_extends_ttl_beyond_creation.1.json b/project_registry/test_snapshots/test/test_project_write_extends_ttl_beyond_creation.1.json new file mode 100644 index 00000000..7ee579d0 --- /dev/null +++ b/project_registry/test_snapshots/test/test_project_write_extends_ttl_beyond_creation.1.json @@ -0,0 +1,473 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "set_whitelist", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "create_project", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "string": "ipfs://QmTtl" + }, + { + "u64": "0" + }, + { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "update_credit_quality_score", + "args": [ + { + "u32": 1 + }, + { + "u32": 42 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 501121, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6813120 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Project" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "certification_status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "credit_quality" + }, + "val": { + "u32": 42 + } + }, + { + "key": { + "symbol": "green_impact" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "last_update_timestamp" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "maturity_date" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "uri" + }, + "val": { + "string": "ipfs://QmTtl" + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 1019521 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "ScoreHistorySlot" + }, + { + "u32": 1 + }, + { + "u32": 0 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "credit_quality" + }, + "val": { + "u32": 42 + } + }, + { + "key": { + "symbol": "green_impact" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "timestamp" + }, + "val": { + "u64": "0" + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 505216 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "ScoreHistoryTotal" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "u32": 1 + } + } + }, + "ext": "v0" + }, + "live_until": 505216 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Whitelist" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Owner" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProjectCounter" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProposalCounter" + } + ] + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "vec": [ + { + "symbol": "StateVersion" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Whitelister" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 505216 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/project_registry/test_snapshots/test/test_set_project_status_rejects_archived_target_and_source.1.json b/project_registry/test_snapshots/test/test_set_project_status_rejects_archived_target_and_source.1.json new file mode 100644 index 00000000..e236757e --- /dev/null +++ b/project_registry/test_snapshots/test/test_set_project_status_rejects_archived_target_and_source.1.json @@ -0,0 +1,387 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "set_whitelist", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "create_project", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "string": "ipfs://QmStatusArch" + }, + { + "u64": "0" + }, + { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "archive_project", + "args": [ + { + "u32": 1 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Project" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "certification_status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "credit_quality" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "green_impact" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "last_update_timestamp" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "maturity_date" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 4 + } + }, + { + "key": { + "symbol": "uri" + }, + "val": { + "string": "ipfs://QmStatusArch" + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Whitelist" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Owner" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProjectCounter" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProposalCounter" + } + ] + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "vec": [ + { + "symbol": "StateVersion" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Whitelister" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/project_registry/test_snapshots/test/test_set_project_status_rejects_noop.1.json b/project_registry/test_snapshots/test/test_set_project_status_rejects_noop.1.json new file mode 100644 index 00000000..6fd0fe90 --- /dev/null +++ b/project_registry/test_snapshots/test/test_set_project_status_rejects_noop.1.json @@ -0,0 +1,347 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "set_whitelist", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "create_project", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "string": "ipfs://QmStatusNoop" + }, + { + "u64": "0" + }, + { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Project" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "certification_status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "credit_quality" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "green_impact" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "last_update_timestamp" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "maturity_date" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "uri" + }, + "val": { + "string": "ipfs://QmStatusNoop" + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Whitelist" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Owner" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProjectCounter" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProposalCounter" + } + ] + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "vec": [ + { + "symbol": "StateVersion" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Whitelister" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file diff --git a/project_registry/test_snapshots/test/test_set_project_status_transitions_and_emits_event.1.json b/project_registry/test_snapshots/test/test_set_project_status_transitions_and_emits_event.1.json new file mode 100644 index 00000000..8dddf7ea --- /dev/null +++ b/project_registry/test_snapshots/test/test_set_project_status_transitions_and_emits_event.1.json @@ -0,0 +1,433 @@ +{ + "generators": { + "address": 4, + "nonce": 0, + "mux_id": 0 + }, + "auth": [ + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "set_whitelist", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "bool": true + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "create_project", + "args": [ + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + }, + { + "string": "ipfs://QmStatus" + }, + { + "u64": "0" + }, + { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "set_project_status", + "args": [ + { + "u32": 1 + }, + { + "u32": 1 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [], + [ + [ + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + { + "function": { + "contract_fn": { + "contract_address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "function_name": "set_project_status", + "args": [ + { + "u32": 1 + }, + { + "u32": 2 + } + ] + } + }, + "sub_invocations": [] + } + ] + ], + [] + ], + "ledger": { + "protocol_version": 26, + "sequence_number": 0, + "timestamp": 0, + "network_id": "0000000000000000000000000000000000000000000000000000000000000000", + "base_reserve": 0, + "min_persistent_entry_ttl": 4096, + "min_temp_entry_ttl": 16, + "max_entry_ttl": 6312000, + "ledger_entries": [ + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "1033654523790656264" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM", + "key": { + "ledger_key_nonce": { + "nonce": "4837995959683129791" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4", + "key": { + "ledger_key_nonce": { + "nonce": "801925984706572462" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Project" + }, + { + "u32": 1 + } + ] + }, + "durability": "persistent", + "val": { + "map": [ + { + "key": { + "symbol": "certification_status" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "created_at" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "credit_quality" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "green_impact" + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "symbol": "last_update_timestamp" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "maturity_date" + }, + "val": { + "u64": "0" + } + }, + { + "key": { + "symbol": "metadata_hash" + }, + "val": { + "bytes": "0707070707070707070707070707070707070707070707070707070707070707" + } + }, + { + "key": { + "symbol": "owner" + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + }, + { + "key": { + "symbol": "status" + }, + "val": { + "u32": 2 + } + }, + { + "key": { + "symbol": "uri" + }, + "val": { + "string": "ipfs://QmStatus" + } + } + ] + } + } + }, + "ext": "v0" + }, + "live_until": 518400 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": { + "vec": [ + { + "symbol": "Whitelist" + }, + { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4" + } + ] + }, + "durability": "persistent", + "val": { + "bool": true + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHK3M", + "key": "ledger_key_contract_instance", + "durability": "persistent", + "val": { + "contract_instance": { + "executable": { + "wasm": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + "storage": [ + { + "key": { + "vec": [ + { + "symbol": "Owner" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM" + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProjectCounter" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "ProposalCounter" + } + ] + }, + "val": { + "u32": 0 + } + }, + { + "key": { + "vec": [ + { + "symbol": "StateVersion" + } + ] + }, + "val": { + "u32": 1 + } + }, + { + "key": { + "vec": [ + { + "symbol": "Whitelister" + } + ] + }, + "val": { + "address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCT4" + } + } + ] + } + } + } + }, + "ext": "v0" + }, + "live_until": 4095 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_data": { + "ext": "v0", + "contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4", + "key": { + "ledger_key_nonce": { + "nonce": "5541220902715666415" + } + }, + "durability": "temporary", + "val": "void" + } + }, + "ext": "v0" + }, + "live_until": 6311999 + }, + { + "entry": { + "last_modified_ledger_seq": 0, + "data": { + "contract_code": { + "ext": "v0", + "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "code": "" + } + }, + "ext": "v0" + }, + "live_until": 4095 + } + ] + }, + "events": [] +} \ No newline at end of file