Skip to content
Open
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
1 change: 1 addition & 0 deletions context/progress-tracker.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Update this file after every completed contract change, fix, or architectural de
- Added TTL constants (`PERSISTENT_TTL_THRESHOLD`, `PERSISTENT_TTL_EXTEND_TO`) to `creditline-contract/src/storage.rs`
- Added `upgrade()` function to all 5 contracts: reputation, creditline, liquidity-pool, vendor-registry, parameters
- All 5 contracts build cleanly: `cargo build` passes with zero errors (3 minor unused constant warnings — acceptable)
- Added numeric `VERSION` instance key, `get_version()` API, and `CONTRACTUPGRADED` event across contracts; added unit tests asserting admin gating and version bump on upgrade

### Deployment
- Created `scripts/deploy-testnet.sh` — full deployment script covering all 5 contracts in correct dependency order
Expand Down
21 changes: 21 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Contracts — Upgrade Flow

This document describes the on-chain upgrade flow supported by StepFi contracts.

Upgrade overview
- Contracts expose an `upgrade(env: Env, new_wasm_hash: BytesN<32>)` entrypoint. Only the configured admin may call this function.
- Upgrades preserve the contract address and instance storage; only the WASM binary is swapped.
- Each contract persists a numeric `VERSION` key in instance storage (default `1`). Successful upgrades increment this value.
- On successful upgrade an event `CONTRACTUPGRADED` is emitted with `(old_version, new_version, timestamp)` to aid off-chain indexing and monitoring.

Developer notes
- `upgrade()` performs an admin auth check immediately and then bumps `VERSION` before swapping WASM.
- Use `get_version(env)` to query the current numeric version.
- Instance storage is used for admin and version keys; persistent storage TTL rules do not apply to instance storage.

Testing
- Unit tests verify that non-admin callers are rejected and that admin callers succeed (version bump + event emitted).

CI
- Run `cargo build` and `cargo test` to validate the changes.

8 changes: 8 additions & 0 deletions contracts/creditline-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,11 @@ pub fn emit_loan_in_grace_period(
),
);
}

/// Emit a contract-upgraded event with old and new version plus timestamp.
pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) {
env.events().publish(
(Symbol::new(env, "CONTRACTUPGRADED"),),
(old_version, new_version, env.ledger().timestamp()),
);
}
11 changes: 9 additions & 2 deletions contracts/creditline-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ pub struct CreditLineContract;

#[contractimpl]
impl CreditLineContract {
pub fn get_version() -> Symbol {
symbol_short!("v1_0_0")
pub fn get_version(env: Env) -> u32 {
storage::get_version(&env).unwrap_or_else(|err| panic_with_error!(&env, err))
}

pub fn initialize(
Expand Down Expand Up @@ -190,7 +190,14 @@ impl CreditLineContract {
pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();

// bump stored version and emit event
let old_version = storage::get_version(&env).unwrap_or(1u32);
let new_version = old_version.checked_add(1).unwrap_or(old_version);
storage::set_version(&env, new_version);

env.deployer().update_current_contract_wasm(new_wasm_hash);
events::emit_contract_upgraded(&env, old_version, new_version);
}
pub fn get_admin(env: Env) -> Result<Address, CreditLineError> {
storage::get_admin(&env)
Expand Down
12 changes: 12 additions & 0 deletions contracts/creditline-contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,8 @@ pub fn set_reentrancy_locked(env: &Env, locked: bool) {
// 120 days in ledgers
pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800;
pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600;
// Version key (instance storage) — defaults to 1 when missing
pub const VERSION_KEY: Symbol = symbol_short!("VERSION");

/// Extend TTL for a persistent storage entry
fn extend_persistent_ttl(env: &Env, key: &DataKey) {
Expand All @@ -257,3 +259,13 @@ fn extend_persistent_ttl(env: &Env, key: &DataKey) {
fn extend_persistent_ttl_loan(env: &Env, key: &DataKey) {
extend_persistent_ttl(env, key);
}

/// Get the contract version (instance storage). Defaults to 1 when not set.
pub fn get_version(env: &Env) -> Result<u32, CreditLineError> {
Ok(env.storage().instance().get(&VERSION_KEY).unwrap_or(1u32))
}

/// Set the contract version in instance storage.
pub fn set_version(env: &Env, version: u32) {
env.storage().instance().set(&VERSION_KEY, &version);
}
69 changes: 66 additions & 3 deletions contracts/creditline-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,11 @@ fn test_initialize_twice_fails() {

#[test]
fn test_get_version() {
let version = CreditLineContract::get_version();
assert_eq!(version, soroban_sdk::symbol_short!("v1_0_0"));
let env = Env::default();
let contract_id = env.register(CreditLineContract, ());
let client = CreditLineContractClient::new(&env, &contract_id);

assert_eq!(client.get_version(), 1u32);
}

#[test]
Expand Down Expand Up @@ -397,8 +400,68 @@ fn test_set_vendor_registry() {

// Update vendor registry address
client.set_vendor_registry(&admin, &new_vendor_registry);
}

// Verify it was updated (we can't directly query, but no panic means success)
#[test]
#[should_panic(expected = "Error(Contract")] // non-admin should be rejected
fn test_upgrade_requires_admin() {
let env = Env::default();
// do NOT mock auths - should reject unauthorized caller

let contract_id = env.register(CreditLineContract, ());
let client = CreditLineContractClient::new(&env, &contract_id);

// Attempt upgrade without initialization/admin auth
let wasm_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
client.upgrade(&wasm_hash);
}

#[test]
fn test_admin_upgrade_succeeds_and_bumps_version() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(CreditLineContract, ());
let client = CreditLineContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let rep_id = env.register(MockReputation, ());
let vendor_registry_id = env.register(vendor_registry_contract::VendorRegistryContract, ());
let lp_id = env.register(MockLiquidityPool, ());
let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone()).address();

client.initialize(&admin, &rep_id, &vendor_registry_id, &lp_id, &token_id);

let wasm_hash = env.deployer().upload_contract_wasm(soroban_sdk::Bytes::from_slice(&env, include_bytes!("../../../contracts/test-fixtures/contract.wasm")));
client.upgrade(&wasm_hash);

use soroban_sdk::IntoVal;
let events: soroban_sdk::Vec<(soroban_sdk::Address, soroban_sdk::Vec<soroban_sdk::Val>, soroban_sdk::Val)> = env.events().all();
let mut upgraded_new: Option<u32> = None;
for e in events.iter() {
let topic: soroban_sdk::Symbol = e.1.get_unchecked(0).into_val(&env);
if topic == soroban_sdk::Symbol::new(&env, "CONTRACTUPGRADED") {
let (_old, new_v, _ts): (u32, u32, u64) = e.2.into_val(&env);
upgraded_new = Some(new_v);
break;
}
}
assert_eq!(upgraded_new, Some(2u32), "CONTRACTUPGRADED new_version should be 2");
}

fn assert_event(env: &Env, expected: soroban_sdk::Symbol) {
use soroban_sdk::IntoVal;

let events: soroban_sdk::Vec<(soroban_sdk::Address, soroban_sdk::Vec<soroban_sdk::Val>, soroban_sdk::Val)> = env.events().all();
for event in events.iter() {
let topics = event.1.clone();
let topic: soroban_sdk::Symbol = topics.get_unchecked(0).into_val(env);
if topic == expected {
return;
}
}
panic!("expected event was not emitted");
}

#[test]
Expand Down
7 changes: 7 additions & 0 deletions contracts/liquidity-pool-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,10 @@ pub fn emit_interest_distributed(
(total_interest, lp_amount, protocol_amount, merchant_amount),
);
}

pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) {
env.events().publish(
(soroban_sdk::Symbol::new(env, "CONTRACTUPGRADED"),),
(old_version, new_version, env.ledger().timestamp()),
);
}
10 changes: 10 additions & 0 deletions contracts/liquidity-pool-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,22 @@ impl LiquidityPoolContract {
pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();
// bump and persist version
let old_version = storage::get_version(&env).unwrap_or(1u32);
let new_version = old_version.checked_add(1).unwrap_or(old_version);
storage::set_version(&env, new_version);

env.deployer().update_current_contract_wasm(new_wasm_hash);
events::emit_contract_upgraded(&env, old_version, new_version);
}
pub fn get_admin(env: Env) -> Result<Address, LiquidityPoolError> {
storage::get_admin(&env)
}

pub fn get_version(env: Env) -> u32 {
storage::get_version(&env).unwrap_or_else(|err| panic_with_error!(&env, err))
}

// -------------------------------------------------------------------------
// LP Operations
// -------------------------------------------------------------------------
Expand Down
10 changes: 10 additions & 0 deletions contracts/liquidity-pool-contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ pub const REENTRANCY_LOCK_KEY: Symbol = symbol_short!("LOCKED");
pub const LP_SHARES_PREFIX: Symbol = symbol_short!("LPSHRS");
pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800;
pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600;
// Version key (instance storage)
pub const VERSION_KEY: Symbol = symbol_short!("VERSION");

// --- Admin ---

Expand Down Expand Up @@ -147,3 +149,11 @@ pub fn is_reentrancy_locked(env: &Env) -> Result<bool, LiquidityPoolError> {
pub fn set_reentrancy_locked(env: &Env, locked: bool) {
env.storage().instance().set(&REENTRANCY_LOCK_KEY, &locked);
}

pub fn get_version(env: &Env) -> Result<u32, LiquidityPoolError> {
Ok(env.storage().instance().get(&VERSION_KEY).unwrap_or(1u32))
}

pub fn set_version(env: &Env, v: u32) {
env.storage().instance().set(&VERSION_KEY, &v);
}
40 changes: 38 additions & 2 deletions contracts/liquidity-pool-contract/src/tests.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::{LiquidityPoolContract, LiquidityPoolContractClient, LiquidityPoolError};
use soroban_sdk::{
testutils::Address as _,
testutils::{Address as _, Events},
token::{Client as TokenClient, StellarAssetClient},
Address, Env,
Address, Env, IntoVal,
};

// ─── helpers ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -423,6 +423,42 @@ fn test_double_counting_does_not_compound_across_multiple_loan_cycles() {
assert_eq!(returned, 1_000);
}

#[test]
#[should_panic(expected = "Error(Contract")] // non-admin should be rejected
fn test_upgrade_requires_admin() {
let env = Env::default();
let contract_id = env.register(LiquidityPoolContract, ());
let client = LiquidityPoolContractClient::new(&env, &contract_id);

let wasm_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
client.upgrade(&wasm_hash);
}

#[test]
fn test_admin_upgrade_bumps_version() {
let t = TestEnv::setup();
// default version should be 1
assert_eq!(t.client.get_version(), 1u32);

let wasm_hash = t.env.deployer().upload_contract_wasm(soroban_sdk::Bytes::from_slice(
&t.env,
include_bytes!("../../../contracts/test-fixtures/contract.wasm"),
));
t.client.upgrade(&wasm_hash);

// event observed
let events: soroban_sdk::Vec<(soroban_sdk::Address, soroban_sdk::Vec<soroban_sdk::Val>, soroban_sdk::Val)> = t.env.events().all();
let mut found = false;
for e in events.iter() {
let topic: soroban_sdk::Symbol = e.1.get_unchecked(0).into_val(&t.env);
if topic == soroban_sdk::Symbol::new(&t.env, "CONTRACTUPGRADED") {
found = true;
break;
}
}
assert!(found, "CONTRACTUPGRADED event not found");
}

// ─── distribute_interest (SC-17 core) ────────────────────────────────────────

#[test]
Expand Down
7 changes: 7 additions & 0 deletions contracts/parameters-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,10 @@ pub fn emit_admin_updated(env: &Env, old_admin: &Address, new_admin: &Address) {
env.events()
.publish((ADMIN_UPDATED, old_admin), new_admin.clone());
}

pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) {
env.events().publish(
(soroban_sdk::Symbol::new(env, "CONTRACTUPGRADED"),),
(old_version, new_version, env.ledger().timestamp()),
);
}
9 changes: 9 additions & 0 deletions contracts/parameters-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,22 @@ impl ParametersContract {
let admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
admin.require_auth();
Self::enter_non_reentrant(&env);
let old = storage::get_version(&env).unwrap_or(1u32);
let new = old.checked_add(1).unwrap_or(old);
storage::set_version(&env, new);

env.deployer().update_current_contract_wasm(new_wasm_hash);
events::emit_contract_upgraded(&env, old, new);
Self::exit_non_reentrant(&env);
}
pub fn get_admin(env: Env) -> Result<Address, ParametersError> {
storage::get_admin(&env)
}

pub fn get_version(env: Env) -> u32 {
storage::get_version(&env).unwrap_or_else(|err| panic_with_error!(&env, err))
}

pub fn set_admin(env: Env, new_admin: Address) {
let old_admin = storage::get_admin(&env).unwrap_or_else(|err| panic_with_error!(&env, err));
old_admin.require_auth();
Expand Down
9 changes: 9 additions & 0 deletions contracts/parameters-contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::types::ProtocolParameters;
pub const ADMIN_KEY: Symbol = symbol_short!("ADMIN");
pub const PARAMS_KEY: Symbol = symbol_short!("PARAMS");
pub const REENTRANCY_LOCK: Symbol = symbol_short!("LOCKED");
pub const VERSION_KEY: Symbol = symbol_short!("VERSION");

pub fn has_admin(env: &Env) -> bool {
env.storage().instance().has(&ADMIN_KEY)
Expand Down Expand Up @@ -44,3 +45,11 @@ pub fn is_reentrancy_locked(env: &Env) -> Result<bool, ParametersError> {
pub fn set_reentrancy_locked(env: &Env, locked: bool) {
env.storage().instance().set(&REENTRANCY_LOCK, &locked);
}

pub fn get_version(env: &Env) -> Result<u32, ParametersError> {
Ok(env.storage().instance().get(&VERSION_KEY).unwrap_or(1u32))
}

pub fn set_version(env: &Env, v: u32) {
env.storage().instance().set(&VERSION_KEY, &v);
}
37 changes: 36 additions & 1 deletion contracts/parameters-contract/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::{
default_parameters, ParametersContract, ParametersContractClient, ParametersError,
ProtocolParameters,
};
use soroban_sdk::{testutils::Address as _, Address, Env};
use soroban_sdk::{testutils::{Address as _, Events}, Address, Env, IntoVal};

fn setup() -> (Env, ParametersContractClient<'static>, Address) {
let env = Env::default();
Expand Down Expand Up @@ -88,3 +88,38 @@ fn test_invalid_parameters_rejected() {

client.initialize(&admin, &params);
}

#[test]
#[should_panic(expected = "Error(Contract")] // non-admin rejected
fn test_upgrade_rejected_for_non_admin() {
let env = Env::default();
let contract_id = env.register(ParametersContract, ());
let client = ParametersContractClient::new(&env, &contract_id);

let wasm_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
client.upgrade(&wasm_hash);
}

#[test]
fn test_admin_upgrade_increments_version() {
let (env, client, admin) = setup();
client.initialize_defaults(&admin);
assert_eq!(client.get_version(), 1u32);

let wasm_hash = env.deployer().upload_contract_wasm(soroban_sdk::Bytes::from_slice(
&env,
include_bytes!("../../../contracts/test-fixtures/contract.wasm"),
));
client.upgrade(&wasm_hash);

let events: soroban_sdk::Vec<(soroban_sdk::Address, soroban_sdk::Vec<soroban_sdk::Val>, soroban_sdk::Val)> = env.events().all();
let mut found = false;
for e in events.iter() {
let topic: soroban_sdk::Symbol = e.1.get_unchecked(0).into_val(&env);
if topic == soroban_sdk::Symbol::new(&env, "CONTRACTUPGRADED") {
found = true;
break;
}
}
assert!(found, "CONTRACTUPGRADED event not found");
}
7 changes: 7 additions & 0 deletions contracts/reputation-contract/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ pub fn emit_admin_changed(env: &Env, old_admin: &Address, new_admin: &Address) {
env.events()
.publish((ADMIN_CHANGED,), (old_admin, new_admin));
}

pub fn emit_contract_upgraded(env: &Env, old_version: u32, new_version: u32) {
env.events().publish(
(Symbol::new(env, "CONTRACTUPGRADED"),),
(old_version, new_version, env.ledger().timestamp()),
);
}
Loading