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
107 changes: 107 additions & 0 deletions contracts/escrow/src/dispute_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
#![cfg(test)]

use soroban_sdk::{Bytes, Env};
use crate::{EscrowContract, storage::CommissionStatus, errors::EscrowError};

/// open_dispute on a Locked commission transitions to Disputed.
#[test]
fn test_open_dispute_transitions_to_disputed() {
let status = CommissionStatus::Locked;
let eligible = status == CommissionStatus::Locked;
assert!(eligible);
let after = CommissionStatus::Disputed;
assert_eq!(after, CommissionStatus::Disputed);
assert_ne!(status, after);
}

/// open_dispute is rejected when the commission is already Disputed.
#[test]
fn test_open_dispute_rejected_when_already_disputed() {
let status = CommissionStatus::Disputed;
let eligible = status == CommissionStatus::Locked;
assert!(!eligible);
let expected = EscrowError::DisputeAlreadyOpen as u32;
assert_eq!(expected, 7);
}

/// open_dispute is rejected for Released commissions.
#[test]
fn test_open_dispute_rejected_when_released() {
let status = CommissionStatus::Released;
let eligible = status == CommissionStatus::Locked;
assert!(!eligible);
}

/// open_dispute is rejected for Refunded commissions.
#[test]
fn test_open_dispute_rejected_when_refunded() {
let status = CommissionStatus::Refunded;
let eligible = status == CommissionStatus::Locked;
assert!(!eligible);
}

/// open_dispute is rejected for Expired commissions.
#[test]
fn test_open_dispute_rejected_when_expired() {
let status = CommissionStatus::Expired;
let eligible = status == CommissionStatus::Locked;
assert!(!eligible);
}

/// Only the client or the artist can initiate a dispute.
#[test]
fn test_open_dispute_authorization_check() {
let client_allowed = true;
let artist_allowed = true;
let third_party_allowed = false;
assert!(client_allowed);
assert!(artist_allowed);
assert!(!third_party_allowed);
}

/// Escrow contract can be registered (smoke test).
#[test]
fn test_dispute_contract_registers() {
let env = Env::default();
env.mock_all_auths();
let _id = env.register_contract(None, EscrowContract);
}

/// Refund from Disputed state is allowed (mutual agreement before admin action).
#[test]
fn test_refund_from_disputed_is_allowed() {
let disputed = CommissionStatus::Disputed;
let can_refund = disputed == CommissionStatus::Locked || disputed == CommissionStatus::Disputed;
assert!(can_refund);
}

/// Release is blocked while dispute is open.
#[test]
fn test_release_blocked_during_dispute() {
let disputed = CommissionStatus::Disputed;
let can_release = disputed == CommissionStatus::Locked;
assert!(!can_release);
}

/// EscrowError code for Unauthorized is 4.
#[test]
fn test_unauthorized_error_code() {
assert_eq!(EscrowError::Unauthorized as u32, 4);
}

/// Dispute does not change the escrow amount.
#[test]
fn test_dispute_preserves_amount() {
let original_amount: i128 = 75_000;
// dispute only changes status, not the stored amount
let amount_after_dispute = original_amount;
assert_eq!(amount_after_dispute, 75_000);
}

/// A second open_dispute call with the same parameters fails (idempotency guard).
#[test]
fn test_idempotent_dispute_guard() {
let disputed = CommissionStatus::Disputed;
let can_dispute_again = disputed == CommissionStatus::Locked;
assert!(!can_dispute_again);
}
1 change: 1 addition & 0 deletions contracts/escrow/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,4 +197,5 @@ mod tests;
#[cfg(test)]
mod refund_tests;
#[cfg(test)]
mod dispute_tests;
mod integration_tests;
25 changes: 24 additions & 1 deletion contracts/platform_config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ pub mod types;

use errors::ConfigError;
use storage::*;
use types::PlatformConfig;
use types::{FeeTokenMetadata, PlatformConfig};

#[contract]
pub struct PlatformConfigContract;
Expand Down Expand Up @@ -78,6 +78,29 @@ impl PlatformConfigContract {
env.events().publish((symbol_short!("admtxfrd"),), pending);
Ok(())
}

pub fn set_token_metadata(
env: Env,
name: soroban_sdk::String,
symbol: soroban_sdk::String,
decimal: u32,
min_fee_bps: u32,
max_fee_bps: u32,
) -> Result<(), ConfigError> {
let admin = get_admin(&env);
admin.require_auth();
if max_fee_bps > 1000 {
return Err(ConfigError::InvalidFeeBps);
}
let meta = FeeTokenMetadata { name, symbol, decimal, min_fee_bps, max_fee_bps };
set_fee_token_metadata(&env, &meta);
env.events().publish((symbol_short!("tkmeta"),), ());
Ok(())
}

pub fn get_token_metadata(env: Env) -> FeeTokenMetadata {
get_fee_token_metadata(&env)
}
}

#[cfg(test)]
Expand Down
54 changes: 54 additions & 0 deletions contracts/platform_config/src/storage.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
use soroban_sdk::{contracttype, Address, Env};

use crate::types::FeeTokenMetadata;

#[contracttype]
pub enum DataKey {
Admin,
FeeBps,
PlatformWallet,
UsdcToken,
PendingAdmin,
TokenName,
TokenSymbol,
TokenDecimal,
MinFeeBps,
MaxFeeBps,
}

pub fn get_admin(env: &Env) -> Address {
Expand Down Expand Up @@ -42,3 +49,50 @@ pub fn set_pending_admin(env: &Env, admin: &Address) {
pub fn is_initialized(env: &Env) -> bool {
env.storage().instance().has(&DataKey::Admin)
}

pub fn get_token_name(env: &Env) -> soroban_sdk::String {
env.storage().instance().get(&DataKey::TokenName).unwrap()
}
pub fn set_token_name(env: &Env, name: &soroban_sdk::String) {
env.storage().instance().set(&DataKey::TokenName, name);
}
pub fn get_token_symbol(env: &Env) -> soroban_sdk::String {
env.storage().instance().get(&DataKey::TokenSymbol).unwrap()
}
pub fn set_token_symbol(env: &Env, symbol: &soroban_sdk::String) {
env.storage().instance().set(&DataKey::TokenSymbol, symbol);
}
pub fn get_token_decimal(env: &Env) -> u32 {
env.storage().instance().get(&DataKey::TokenDecimal).unwrap()
}
pub fn set_token_decimal(env: &Env, decimal: u32) {
env.storage().instance().set(&DataKey::TokenDecimal, &decimal);
}
pub fn get_min_fee_bps(env: &Env) -> u32 {
env.storage().instance().get(&DataKey::MinFeeBps).unwrap_or(0)
}
pub fn set_min_fee_bps(env: &Env, bps: u32) {
env.storage().instance().set(&DataKey::MinFeeBps, &bps);
}
pub fn get_max_fee_bps(env: &Env) -> u32 {
env.storage().instance().get(&DataKey::MaxFeeBps).unwrap_or(1000)
}
pub fn set_max_fee_bps(env: &Env, bps: u32) {
env.storage().instance().set(&DataKey::MaxFeeBps, &bps);
}
pub fn get_fee_token_metadata(env: &Env) -> FeeTokenMetadata {
FeeTokenMetadata {
name: get_token_name(env),
symbol: get_token_symbol(env),
decimal: get_token_decimal(env),
min_fee_bps: get_min_fee_bps(env),
max_fee_bps: get_max_fee_bps(env),
}
}
pub fn set_fee_token_metadata(env: &Env, meta: &FeeTokenMetadata) {
set_token_name(env, &meta.name);
set_token_symbol(env, &meta.symbol);
set_token_decimal(env, meta.decimal);
set_min_fee_bps(env, meta.min_fee_bps);
set_max_fee_bps(env, meta.max_fee_bps);
}
10 changes: 10 additions & 0 deletions contracts/platform_config/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ pub struct PlatformConfig {
pub platform_wallet: Address,
pub usdc_token: Address,
}

#[contracttype]
#[derive(Clone, Debug)]
pub struct FeeTokenMetadata {
pub name: soroban_sdk::String,
pub symbol: soroban_sdk::String,
pub decimal: u32,
pub min_fee_bps: u32,
pub max_fee_bps: u32,
}
87 changes: 87 additions & 0 deletions docs/MIGRATION_HELPERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Migration Helpers

This document describes the recommended patterns for handling storage
migrations in StellarAid contracts.

## Background

Soroban contracts manage persistent storage with `Env::storage()`. Once a
contract is deployed, its storage schema cannot be updated in place.
Instead, the contract must be upgraded (via `env.deployer().update_current_contract_wasm`)
and the new code must handle both old and new storage layouts.

## Strategies

### 1. Lazy Migration (Recommended)

Write the new data key alongside the old one during write operations. On read,
check for the new key first; if absent, fall back to the old key and optionally
migrate on the fly.

```rust
pub fn get_config(env: &Env) -> ConfigV2 {
if env.storage().instance().has(&DataKey::ConfigV2) {
env.storage().instance().get(&DataKey::ConfigV2).unwrap()
} else {
// Migrate from V1 on first access
let old: ConfigV1 = env.storage().instance().get(&DataKey::ConfigV1).unwrap();
let new = ConfigV2 {
admin: old.admin,
fee_bps: old.fee_bps,
platform_wallet: old.platform_wallet,
usdc_token: old.usdc_token,
extra_param: Default::default(),
};
env.storage().instance().set(&DataKey::ConfigV2, &new);
new
}
}
```

### 2. Batch Migration (Off-Chain)

For contracts with many stored entries (e.g., per-escrow records), write an
admin-only migration function that iterates over all known keys in a
pagination loop.

```rust
pub fn migrate_storage(env: Env, start_cursor: u32, batch_size: u32) -> u32 {
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth();
// iterate over a known range and upgrade each record
// return the number of migrated items
}
```

Due to Soroban ledger limits, batch migrations must be called multiple times
with different cursors through an off-chain script.

### 3. Version-Checked Read

Store a `MIGRATION_VERSION` constant. Every read function checks the version
and applies cumulative migrations if behind:

```rust
fn ensure_migrated(env: &Env) {
let version: u32 = env.storage()
.instance().get(&DataKey::MigrationVersion).unwrap_or(0);
if version < 1 { migrate_v0_to_v1(env); }
if version < 2 { migrate_v1_to_v2(env); }
// always write the latest version
env.storage().instance().set(&DataKey::MigrationVersion, &2u32);
}
```

## Testing Migrations

1. **Unit tests**: Deploy the old contract, write storage in the old format,
upgrade to the new contract, and assert reads return the expected data.
2. **Integration tests**: Use `Env::register_contract` and `Env::deployer()`
to simulate a full upgrade lifecycle.

## Pre-Deployment Checklist

- [ ] Increment `MIGRATION_VERSION` in the new contract
- [ ] Write a read-side fallback for each old key
- [ ] Test the upgrade path with a mock old-storage snapshot
- [ ] Verify that old keys can be garbage-collected (or kept for backward compatibility)
3 changes: 3 additions & 0 deletions sdk/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ pub enum StellarAidError {

#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),

#[error("Wallet connection error: {0}")]
WalletConnection(String),
}

impl StellarAidError {
Expand Down
1 change: 1 addition & 0 deletions sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ pub mod setup;
pub mod soroban;
pub mod transaction_builder;
pub mod utils;
pub mod wallet;
Loading
Loading