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
8 changes: 8 additions & 0 deletions meridian-contracts/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions meridian-contracts/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ members = [
"contracts/risk_pool",
"contracts/governance",
"contracts/slashing",
"contracts/escrow",
]
resolver = "2"

Expand Down
56 changes: 56 additions & 0 deletions meridian-contracts/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,62 @@ To find zero-knowledge compliance proof, privacy preference, audit, and dashboar

The core contract connection interfaces can be found in [traits/src/lib.rs](traits/src/lib.rs).

## Storage Migration Policy

### Overview

All Soroban contracts in this workspace implement a standardized storage migration pattern to enable backward-compatible schema evolution. This ensures that existing on-chain deployments can be upgraded without data loss or breaking changes.

### Implementation Pattern

Each contract defines:

1. **StorageVersion enum**: An enum with variants representing each storage schema version (V1, V2, etc.)
2. **Version DataKey**: A storage key tracking the current schema version
3. **migrate() function**: An admin-only entry point that performs incremental migrations

### Migration Rules

- **Additive only**: Migrations may only add new fields or modify logic, never remove or rename existing storage keys
- **Idempotent**: Calling `migrate(env, to_version)` multiple times with the same target version is safe and no-op
- **Forward only**: Downgrades are rejected to prevent data corruption
- **Admin only**: Migration requires admin authorization (or appropriate role-based access)
- **Default values**: New fields are initialized with safe defaults (typically 0 or empty collections)

### Contract-Specific Implementations

#### Escrow Contract ([escrow/src/lib.rs](escrow/src/lib.rs))

- **Current version**: V2
- **V1 → V2 migration**: Adds `FeeBps` field (default: 0)
- **Storage keys**: See [escrow/src/storage.rs](escrow/src/storage.rs)
- **Migration tests**: [escrow/src/migration_test.rs](escrow/src/migration_test.rs)

#### Risk Pool Contract ([risk_pool/src/lib.rs](risk_pool/src/lib.rs))

- **Current version**: V2
- **V1 → V2 migration**: Adds `LockedCapital` field (default: 0)
- **Storage keys**: See [risk_pool/src/lib.rs](risk_pool/src/lib.rs)
- **Migration tests**: [risk_pool/src/migration_test.rs](risk_pool/src/migration_test.rs)

### Migration Process

When adding new fields to a contract:

1. Increment the current version in `StorageVersion::current()`
2. Add new `DataKey` variants for the new fields
3. Add a migration step in the `migrate()` function's match statement
4. Initialize new fields with safe defaults
5. Add tests verifying:
- Old data is preserved
- New fields default correctly
- Migration is idempotent
- Non-admins cannot migrate

### Historical Notes

- **escrow/src/lib.rs.new**: Removed - was an outdated draft that duplicated the main contract file without the modular structure (storage.rs, types.rs, validation.rs) now in use

## Tradeoffs

This README focuses on major contract entry points rather than documenting every test and helper file. That keeps the map useful for reviewers while the source-level comments explain function behavior closer to the code.
Expand Down
4 changes: 4 additions & 0 deletions meridian-contracts/contracts/escrow/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ publish = false

[dependencies]
soroban-sdk = { workspace = true }
stellar-insured-lib = { path = "../lib" }

[dev-dependencies]
soroban-sdk = { workspace = true, features = ["testutils"] }

[lib]
name = "propchain_escrow"
Expand Down
59 changes: 54 additions & 5 deletions meridian-contracts/contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ mod storage;
mod types;
mod validation;

#[cfg(test)]
mod migration_test;

use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Vec};
use stellar_insured_lib::{EscrowError, ValidationError};

use storage::DataKey;
use storage::{DataKey, StorageVersion};
use types::{ApprovalType, EscrowData, EscrowStatus, MultiSigConfig};
use validation::{
get_admin, require_future_timestamp, require_non_zero_address, require_non_zero_u64,
Expand All @@ -30,9 +33,10 @@ impl AdvancedEscrow {
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage()
.instance()
.set(&DataKey::Version, &CONTRACT_VERSION);
.set(&DataKey::Version, &StorageVersion::current());
env.storage().instance().set(&DataKey::EscrowCount, &0u64);
env.storage().instance().set(&DataKey::Paused, &false);
env.storage().instance().set(&DataKey::FeeBps, &0u32);

env.events()
.publish((symbol_short!("escrow"), symbol_short!("init")), admin);
Expand Down Expand Up @@ -88,7 +92,7 @@ impl AdvancedEscrow {
require_non_zero_address(&buyer).map_err(|_| EscrowError::Unauthorized)?;
require_non_zero_address(&seller).map_err(|_| EscrowError::Unauthorized)?;
for participant in participants.iter() {
require_non_zero_address(participant).map_err(|_| EscrowError::Unauthorized)?;
require_non_zero_address(&participant).map_err(|_| EscrowError::Unauthorized)?;
}
if let Some(time_lock) = release_time_lock {
require_future_timestamp(time_lock, env.ledger().timestamp(), "release_time_lock")
Expand Down Expand Up @@ -272,15 +276,60 @@ impl AdvancedEscrow {
);
Ok(())
}

pub fn migrate(env: Env, admin: Address, to_version: StorageVersion) -> Result<(), EscrowError> {
admin.require_auth();
require_non_zero_address(&admin).map_err(|_| EscrowError::Unauthorized)?;
if admin != get_admin(&env) {
return Err(EscrowError::Unauthorized);
}

let current_version: StorageVersion = env
.storage()
.instance()
.get(&DataKey::Version)
.unwrap_or(StorageVersion::V1);

if current_version == to_version {
// Already at target version - idempotent
return Ok(());
}

if to_version < current_version {
return Err(EscrowError::InvalidStatus);
}

match (current_version, to_version) {
(StorageVersion::V1, StorageVersion::V2) => {
// Migration V1 -> V2: Add FeeBps field with default value
if !env.storage().instance().has(&DataKey::FeeBps) {
env.storage().instance().set(&DataKey::FeeBps, &0u32);
}
env.storage().instance().set(&DataKey::Version, &StorageVersion::V2);
}
_ => return Err(EscrowError::InvalidStatus),
}

env.events()
.publish((symbol_short!("escrow"), symbol_short!("migrated")), to_version);
Ok(())
}
}

#[contractimpl]
impl AdvancedEscrow {
pub fn version(env: Env) -> u32 {
pub fn version(env: Env) -> StorageVersion {
env.storage()
.instance()
.get(&DataKey::Version)
.unwrap_or(CONTRACT_VERSION)
.unwrap_or(StorageVersion::V1)
}

pub fn get_fee_bps(env: Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::FeeBps)
.unwrap_or(0)
}

pub fn get_admin(env: Env) -> Address {
Expand Down
Loading