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
188 changes: 188 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
# Contract Upgrade and Migration Strategy

This document covers the strategy for upgrading deployed `ProjectRegistry` and
`InvestmentVault` contracts while preserving on-chain state. It addresses
issue #64.

## Overview

Both contracts implement:

- **In-place WASM upgrade** via `upgrade(new_wasm_hash)` — replaces bytecode
while storage is preserved.
- **State schema versioning** via `STATE_VERSION` constant, `state_version()`,
`stored_state_version()`, and `migrate_state(from_version)`.

The current schema version for both contracts is **1**. Increment this constant
whenever a storage layout change requires a migration step.

## Storage Layout Versioning

| Key | Type | Notes |
|-----|------|-------|
| `StateVersion` (instance) | `u32` | Written at construction; read by `require_current_state`. |

`require_current_state` rejects calls if the stored version does not match the
compiled `STATE_VERSION`. This prevents accidentally running new logic against
an old storage layout.

`stored_state_version` returns 0 for pre-versioned deployments (before v1 was
introduced).

## Upgrade Procedure

### Step 1 — Build the new WASM

```bash
stellar contract build
# Artifacts: target/wasm32v1-none/release/project_registry.wasm
# target/wasm32v1-none/release/investment_vault.wasm
```

Run the full test suite before proceeding:

```bash
cargo test --all --quiet
```

### Step 2 — Upload the new WASM

```bash
REGISTRY_HASH=$(stellar contract upload \
--wasm target/wasm32v1-none/release/project_registry.wasm \
--source "$STELLAR_SECRET_KEY" \
--network testnet)

VAULT_HASH=$(stellar contract upload \
--wasm target/wasm32v1-none/release/investment_vault.wasm \
--source "$STELLAR_SECRET_KEY" \
--network testnet)
```

### Step 3 — Pause both contracts (recommended)

Before upgrading, pause user-facing operations to prevent state changes during
the upgrade window:

```bash
stellar contract invoke --id "$REGISTRY_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- pause

stellar contract invoke --id "$VAULT_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- pause
```

### Step 4 — Run the upgrade

Upgrade the registry first (vault depends on its interface):

```bash
stellar contract invoke --id "$REGISTRY_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- upgrade --new_wasm_hash "$REGISTRY_HASH"

stellar contract invoke --id "$VAULT_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- upgrade --new_wasm_hash "$VAULT_HASH"
```

### Step 5 — Run state migration (if STATE_VERSION changed)

If `STATE_VERSION` was incremented (e.g., from 1 to 2), run `migrate_state`
on each contract:

```bash
stellar contract invoke --id "$REGISTRY_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- migrate_state --from_version 1

stellar contract invoke --id "$VAULT_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- migrate_state --from_version 1
```

`migrate_state` panics with `UnsupportedStateVersion` if `from_version` does
not match the stored version, preventing double-migration.

### Step 6 — Verify and unpause

```bash
stellar contract invoke --id "$REGISTRY_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- stored_state_version
# Expected: 2 (or the new version)

stellar contract invoke --id "$VAULT_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- stored_state_version

stellar contract invoke --id "$REGISTRY_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- unpause

stellar contract invoke --id "$VAULT_ID" --source "$STELLAR_SECRET_KEY" \
--network testnet -- unpause
```

## Rollback

Soroban WASM upgrades are irreversible on-chain. The only rollback option is to
re-upload and re-invoke `upgrade` with the previous WASM hash (which must have
been retained). State written by the new version may be incompatible with the
old WASM if the storage layout changed.

**Best practice:** keep all uploaded WASM hashes in `deploy/testnet.json` and
always test migration end-to-end on testnet before mainnet.

## Adding a New Storage Layout Version

When a storage layout change is required:

1. Increment `STATE_VERSION` in the affected contract (e.g., from 1 to 2).
2. Implement the migration logic inside `migrate_state`:

```rust
pub fn migrate_state(env: Env, from_version: u32) -> u32 {
let current = read_state_version(&env);
if current != from_version || current > STATE_VERSION {
panic_with_error!(&env, RegistryError::UnsupportedStateVersion);
}
if current == 1 {
// v1 → v2: example — populate new FooBar key from existing data
// let old: OldType = env.storage().persistent().get(&DataKey::OldKey).unwrap();
// env.storage().persistent().set(&DataKey::NewKey, &NewType::from(old));
env.storage().instance().set(&DataKey::StateVersion, &2u32);
}
STATE_VERSION
}
```

3. Write an integration test that exercises the full v(n-1) → vn path.
4. Update this document with the new version entry.

## Version History

| Version | Contract | Description |
|---------|----------|-------------|
| 0 | Both | Pre-versioning deployments (treat as v1 state layout). |
| 1 | Both | Initial versioned deployment. No layout changes from v0. |

## Versioned Storage Patterns

- Instance storage: use for small, frequently-read values (admin, counters).
Billed per ledger close regardless of reads.
- Persistent storage: use for per-project or per-address data. Billed only
when entries exist; remove entries (not set-to-zero) when they become empty.
- Temporary storage: not used; would be lost after ledger expiry.

## Build Order for Cross-Contract Dependencies

The vault imports the registry ABI at build time via `contractimport!`. Always
build and upload the registry before the vault when both change in the same
release.

```bash
stellar contract build --package project-registry
stellar contract build --package investment-vault
```

> **Hard prerequisite — upgrading the vault before the registry is a
> point of no return.** If the vault WASM is deployed first (calling
> `registry.is_paused()`) while the registry still runs old WASM (without
> that method), every `fund_project` call will fail with a host-level error
> until the registry is also upgraded. There is no automatic rollback. Always
> upgrade the registry first; only proceed to vault upgrade once
> `registry.stored_state_version()` confirms the new version is live.
7 changes: 7 additions & 0 deletions investment_vault/proptest-regressions/test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc e773fd6da1d5a4edad363f0325827369748f30199b596a47173fa68efcde8225 # shrinks to deposit_amount = 1000000000, withdraw_shares = 1000000000
46 changes: 46 additions & 0 deletions investment_vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,15 @@ impl InvestmentVault {
}
}

/// Return the total USDC amount currently invested in `project_id` from this vault.
pub fn get_project_investment(env: Env, project_id: u32) -> i128 {
require_current_state(&env);
env.storage()
.persistent()
.get(&VaultKey::ProjectInvestment(project_id))
.unwrap_or(0)
}

// ── Bridge ────────────────────────────────────────────────────────────────

#[only_owner]
Expand Down Expand Up @@ -1672,6 +1681,43 @@ impl InvestmentVault {
events::unpaused(&env);
}

/// Return whether the vault is currently paused (#72).
pub fn is_paused(env: Env) -> bool {
env.storage()
.instance()
.get(&VaultKey::Paused)
.unwrap_or(false)
}

// ── Storage compaction (#88) ───────────────────────────────────────────────

/// Remove zero-value `ProjectInvestment` persistent storage entries. Admin-only.
///
/// After a project fully repays, its investment counter drops to 0 but the storage
/// slot remains. This function iterates projects 1..=`total_projects` and removes
/// any zero entries, reducing ongoing rent costs.
/// Returns the number of entries removed.
#[only_owner]
pub fn compact_storage(env: Env) -> u32 {
require_current_state(&env);
let registry_addr: Address = env.storage().instance().get(&VaultKey::Registry).unwrap();
let registry = registry_interface::Client::new(&env, &registry_addr);
let total = registry.total_projects();

let mut removed: u32 = 0;
for id in 1..=total {
let key = VaultKey::ProjectInvestment(id);
if env.storage().persistent().has(&key) {
let val: i128 = env.storage().persistent().get(&key).unwrap_or(0);
if val == 0 {
env.storage().persistent().remove(&key);
removed += 1;
}
}
}
removed
}

#[only_owner]
pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
env.deployer().update_current_contract_wasm(new_wasm_hash);
Expand Down
20 changes: 10 additions & 10 deletions investment_vault/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
use soroban_sdk::{Address, Env};
use crate::types::VaultKey;

pub fn read_usdc_sac(env: &Env) -> Address {
env.storage().instance().get(&VaultKey::UsdcSac).unwrap()
}

pub fn read_registry(env: &Env) -> Address {
env.storage().instance().get(&VaultKey::Registry).unwrap()
}
use soroban_sdk::{Address, Env};
use crate::types::VaultKey;
pub fn read_usdc_sac(env: &Env) -> Address {
env.storage().instance().get(&VaultKey::UsdcSac).unwrap()
}
pub fn read_registry(env: &Env) -> Address {
env.storage().instance().get(&VaultKey::Registry).unwrap()
}
97 changes: 87 additions & 10 deletions investment_vault/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,84 @@ fn test_concurrent_deposits_and_fund_project() {
assert!(s.vault_client.total_assets() >= 0);
}

// ── Circuit breaker tests (#72) ────────────────────────────────────────────────

#[test]
fn test_vault_is_paused_getter_default_false() {
let s = setup();
assert!(!s.vault_client.is_paused());
}

#[test]
fn test_vault_pause_and_unpause() {
let s = setup();
s.vault_client.pause();
assert!(s.vault_client.is_paused());
s.vault_client.unpause();
assert!(!s.vault_client.is_paused());
}

#[test]
#[should_panic]
fn test_deposit_blocked_when_vault_paused() {
let s = setup();
let investor = Address::generate(&s.env);
mint_usdc(&s.env, &s.usdc_sac, &investor, 1_000_0000000i128);
s.vault_client.pause();
s.vault_client.deposit(&investor, &1_000_0000000i128);
}

// ── Storage compaction tests (#88) ────────────────────────────────────────────

#[test]
fn test_compact_storage_removes_zero_project_investment() {
let s = setup();
let investor = Address::generate(&s.env);
let deposit = 1_000_0000000i128;
mint_usdc(&s.env, &s.usdc_sac, &investor, deposit);
s.vault_client.deposit(&investor, &deposit);

// Create a project in the registry and fund it.
// Available for deployment = liquid(1000) - insurance_reserve(5) = 995 USDC.
let registry_client = registry_contract::Client::new(&s.env, &s.registry);
registry_client.set_whitelist(&s.admin, &true);
let pid = registry_client.create_project(
&s.admin,
&soroban_sdk::String::from_str(&s.env, "ipfs://QmCompact"),
&0u64,
);
let fund_amount = 500_0000000i128; // 500 USDC, within available limit
s.vault_client.fund_project(&pid, &fund_amount);
assert_eq!(s.vault_client.get_project_investment(&pid), fund_amount);

// compact_storage should find 0 removals (entry has a non-zero value)
let removed = s.vault_client.compact_storage();
assert_eq!(removed, 0u32);
}

#[test]
fn test_get_project_investment_zero_for_unfunded() {
let s = setup();
assert_eq!(s.vault_client.get_project_investment(&1u32), 0i128);
assert_eq!(s.vault_client.get_project_investment(&999u32), 0i128);
}

// ── Migration tests (#64) ──────────────────────────────────────────────────────

#[test]
fn test_vault_state_version() {
let s = setup();
assert_eq!(s.vault_client.state_version(), 1u32);
assert_eq!(s.vault_client.stored_state_version(), 1u32);
}

#[test]
#[should_panic]
fn test_vault_migrate_state_rejects_wrong_version() {
let s = setup();
s.vault_client.migrate_state(&0u32);
}

proptest! {
#[test]
fn test_vault_arithmetic_fuzz(
Expand All @@ -1253,21 +1331,20 @@ proptest! {
mint_usdc(&s.env, &s.usdc_sac, &investor, deposit_amount);

let shares = s.vault_client.deposit(&investor, &deposit_amount);


// Insurance premium stays in vault USDC balance; total_assets includes it.
// shares = investable (1:1 first deposit), total_assets = deposit_amount,
// so convert_to_assets(shares) = shares * deposit_amount / shares = deposit_amount.
let assets = s.vault_client.convert_to_assets(&shares);
let premium = deposit_amount * 50 / 10_000;
let investable = deposit_amount - premium;

// Due to integer math, it might not be exact if there are precision issues,
// but for a fresh vault and 1:1, it should be exact.
assert_eq!(assets, investable);

assert_eq!(assets, deposit_amount);

// Round-tripping through convert_to_shares must recover the original shares.
let shares_from_assets = s.vault_client.convert_to_shares(&assets);
assert_eq!(shares_from_assets, shares);

if withdraw_shares <= shares && withdraw_shares >= 100_0000000i128 {
let withdrawn = s.vault_client.withdraw(&investor, &withdraw_shares, &0);
assert!(withdrawn <= investable);
assert!(withdrawn <= deposit_amount);
}
}
}
Loading
Loading