Skip to content

fix(project_registry): reject delete_project while the vault holds investments - #650

Merged
abayomicornelius merged 3 commits into
Heliobond:mainfrom
floraispretty:fix/526-delete-project-investment-guard
Sep 27, 2026
Merged

abayomicornelius merged 3 commits into
Heliobond:mainfrom
floraispretty:fix/526-delete-project-investment-guard

Conversation

@floraispretty

@floraispretty floraispretty commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Problem

delete_project was still the #26 placeholder: it deleted unconditionally, with only a NOTE: comment where the vault check belonged. INTERFACE.md meanwhile claimed it "Rejects deletion when the project has active investments", and RegistryError::ProjectHasInvestments (30) was defined but never used. #325 was closed without a fix landing, as the issue documents.

This PR implements option 1 from the issue: a real cross-contract check against the vault.

The fix (commit 3)

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);
}
  • set_vault(vault) (owner-only) and get_vault() are new. The registry never knew the vault's address; only the vault knew the registry.
  • The vault interface is declared locally with #[contractclient] (get_project_investment(project_id) -> i128) instead of depending on the investment-vault crate. The vault already contractimport!s the registry's WASM, so a crate dependency would be circular.
  • Fails closed: if no vault is configured, deletion is rejected with the new VaultNotConfigured = 45 instead of silently skipping the check. That makes the guarantee in INTERFACE.md true unconditionally.
  • ProjectHasInvestments (30) is finally used.
  • INTERFACE.md: the delete_project row now describes the actual behavior, and rows were added for set_vault / get_vault. scripts/check_interface_docs.py no longer reports these functions. It still reports existing, unrelated drift for other functions on main.

⚠️ Deployment note: after upgrading, the owner must call set_vault(<vault address>) once, or delete_project will (intentionally) reject every call with VaultNotConfigured.

Tests (commit 3): 9 new

A small in-test MockVault exposes get_project_investment with a setter, so the tests control the reported investment exactly:

Test Asserts
test_get_vault_is_none_until_set None by default, then round-trips
test_set_vault_is_owner_only rejected without owner auth
test_delete_project_fails_closed_without_vault VaultNotConfigured, project untouched
test_delete_project_rejects_active_investments ProjectHasInvestments, project untouched
test_delete_project_rejects_any_nonzero_investment even 1 stroop blocks deletion
test_delete_project_succeeds_with_no_investment deleted → ProjectNotFound
test_delete_project_allowed_once_investment_is_repaid blocked at 250, allowed at 0
test_delete_project_checks_only_the_target_project another project's investment doesn't block this one
test_delete_project_against_real_investment_vault uses the real InvestmentVault, which pins that the #[contractclient] interface matches its ABI

Two pre-existing build breaks fixed so this could be tested (commits 1–2)

project_registry did not compile on main, so no test could run without these:

  1. Duplicate RegistryError discriminants (E0081). InvalidStatusTransition/ProjectStatusUnchanged (ProjectStatus::Active/Funded/Completed are unreachable — status never advances past Pending #329) and CompactStorageTooLarge/VotingPeriodTooLong (compact_storage and create_proposal accept unbounded caller-supplied sizes with no resource-limit guard #332) both used 41/42, from two same-day merges. I moved the latter pair to 43/44. Since the crate couldn't compile with either numbering, no deployed build can depend on these values, and nothing in the repo references them numerically.
  2. investment_vault/src/storage.rs test module. #[cfg(test)] was garbled as #c[cfg)test], and an assert_eq! was missing its ). rustc parses cfg(test) items too, so this broke the vault library, which project_registry's tests depend on. I also fixed the test body: testutils imports, running storage calls inside a contract context, and set_sequence_number.

Verification

  • cargo test -p project-registry: 132 passed, 2 failed. The 2 failures (test_full_heliobond_flow with Error(Contract, #36), and test_score_history_multiple_updates_ordered, a timestamp assertion) fail the same way before this PR (baseline: 123 passed, 2 failed). All 9 new tests pass; no regressions.
  • New code is rustfmt-clean. I didn't reformat existing unformatted code, to keep the diff focused.
  • Build notes for reviewers: the pinned toolchain (1.84.0) can't parse a newer transitive crate manifest (time-core 0.1.9), so I ran checks with Rust 1.94. The registry WASM the vault imports was built with SOROBAN_SDK_BUILD_SYSTEM_SUPPORTS_SPEC_SHAKING_V2=1 in place of stellar contract build. Regenerated test_snapshots/ files were not committed.

Still broken on main (not addressed here)

investment_vault/src/test.rs has a syntax error around line 354–361 (an unmatched }), so cargo test -p investment-vault can't compile. It doesn't affect the vault library or the registry tests, and is out of scope for #526.
Closes #526
Closes #527
Closes #528
Closes #529

InvalidStatusTransition/ProjectStatusUnchanged (Heliobond#329) and
CompactStorageTooLarge/VotingPeriodTooLong (Heliobond#332) both claimed 41/42,
so the crate failed to compile (E0081). Move the latter pair to 43/44.

Refs Heliobond#526
#[cfg(test)] was garbled as #c[cfg)test] and the assert_eq! was missing
its closing paren. Because rustc parses cfg(test) items too, this broke
compilation of the vault library itself (and so project_registry's tests,
which depend on it). Also fixes the test body: testutils imports, a
contract context for storage access, and set_sequence_number.

Refs Heliobond#526
…vestments

delete_project was a placeholder that deleted unconditionally. It now
queries get_project_investment(project_id) on the vault set via the new
owner-only set_vault, panicking with ProjectHasInvestments (30) when
non-zero, and fails closed with the new VaultNotConfigured (45) when no
vault is set. The vault interface is declared with #[contractclient] to
avoid a crate cycle (the vault imports the registry WASM).

Adds get_vault, updates INTERFACE.md, and 9 tests including one against
the real InvestmentVault to pin ABI compatibility.

Closes Heliobond#526
@drips-wave

drips-wave Bot commented Sep 26, 2026

Copy link
Copy Markdown

@floraispretty Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@abayomicornelius
abayomicornelius merged commit 4f94d53 into Heliobond:main Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment