diff --git a/context/progress-tracker.md b/context/progress-tracker.md
index 1bdfc12..c820f79 100644
--- a/context/progress-tracker.md
+++ b/context/progress-tracker.md
@@ -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
diff --git a/contracts/README.md b/contracts/README.md
new file mode 100644
index 0000000..c590b21
--- /dev/null
+++ b/contracts/README.md
@@ -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.
+
diff --git a/contracts/creditline-contract/src/events.rs b/contracts/creditline-contract/src/events.rs
index dd2f17a..3a1654c 100644
--- a/contracts/creditline-contract/src/events.rs
+++ b/contracts/creditline-contract/src/events.rs
@@ -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()),
+ );
+}
diff --git a/contracts/creditline-contract/src/lib.rs b/contracts/creditline-contract/src/lib.rs
index 436bee9..0bf0eea 100644
--- a/contracts/creditline-contract/src/lib.rs
+++ b/contracts/creditline-contract/src/lib.rs
@@ -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(
@@ -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
{
storage::get_admin(&env)
diff --git a/contracts/creditline-contract/src/storage.rs b/contracts/creditline-contract/src/storage.rs
index d83117b..99d2c17 100644
--- a/contracts/creditline-contract/src/storage.rs
+++ b/contracts/creditline-contract/src/storage.rs
@@ -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) {
@@ -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 {
+ 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);
+}
diff --git a/contracts/creditline-contract/src/tests.rs b/contracts/creditline-contract/src/tests.rs
index f7d8327..c96cb74 100644
--- a/contracts/creditline-contract/src/tests.rs
+++ b/contracts/creditline-contract/src/tests.rs
@@ -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]
@@ -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)> = env.events().all();
+ let mut upgraded_new: Option = 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)> = 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]
diff --git a/contracts/liquidity-pool-contract/src/events.rs b/contracts/liquidity-pool-contract/src/events.rs
index 29b303f..c7a6bf4 100644
--- a/contracts/liquidity-pool-contract/src/events.rs
+++ b/contracts/liquidity-pool-contract/src/events.rs
@@ -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()),
+ );
+}
diff --git a/contracts/liquidity-pool-contract/src/lib.rs b/contracts/liquidity-pool-contract/src/lib.rs
index 5947cbd..c1a8622 100644
--- a/contracts/liquidity-pool-contract/src/lib.rs
+++ b/contracts/liquidity-pool-contract/src/lib.rs
@@ -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 {
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
// -------------------------------------------------------------------------
diff --git a/contracts/liquidity-pool-contract/src/storage.rs b/contracts/liquidity-pool-contract/src/storage.rs
index cc35085..5841059 100644
--- a/contracts/liquidity-pool-contract/src/storage.rs
+++ b/contracts/liquidity-pool-contract/src/storage.rs
@@ -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 ---
@@ -147,3 +149,11 @@ pub fn is_reentrancy_locked(env: &Env) -> Result {
pub fn set_reentrancy_locked(env: &Env, locked: bool) {
env.storage().instance().set(&REENTRANCY_LOCK_KEY, &locked);
}
+
+pub fn get_version(env: &Env) -> Result {
+ 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);
+}
diff --git a/contracts/liquidity-pool-contract/src/tests.rs b/contracts/liquidity-pool-contract/src/tests.rs
index 9d7cb50..c2aff34 100644
--- a/contracts/liquidity-pool-contract/src/tests.rs
+++ b/contracts/liquidity-pool-contract/src/tests.rs
@@ -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 ──────────────────────────────────────────────────────────────────
@@ -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)> = 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]
diff --git a/contracts/parameters-contract/src/events.rs b/contracts/parameters-contract/src/events.rs
index 66ebb1b..c9c2dca 100644
--- a/contracts/parameters-contract/src/events.rs
+++ b/contracts/parameters-contract/src/events.rs
@@ -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()),
+ );
+}
diff --git a/contracts/parameters-contract/src/lib.rs b/contracts/parameters-contract/src/lib.rs
index 00ecf6e..77b6248 100644
--- a/contracts/parameters-contract/src/lib.rs
+++ b/contracts/parameters-contract/src/lib.rs
@@ -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 {
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();
diff --git a/contracts/parameters-contract/src/storage.rs b/contracts/parameters-contract/src/storage.rs
index c1a408c..3fb6608 100644
--- a/contracts/parameters-contract/src/storage.rs
+++ b/contracts/parameters-contract/src/storage.rs
@@ -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)
@@ -44,3 +45,11 @@ pub fn is_reentrancy_locked(env: &Env) -> Result {
pub fn set_reentrancy_locked(env: &Env, locked: bool) {
env.storage().instance().set(&REENTRANCY_LOCK, &locked);
}
+
+pub fn get_version(env: &Env) -> Result {
+ 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);
+}
diff --git a/contracts/parameters-contract/src/tests.rs b/contracts/parameters-contract/src/tests.rs
index c31f68e..9fb3ece 100644
--- a/contracts/parameters-contract/src/tests.rs
+++ b/contracts/parameters-contract/src/tests.rs
@@ -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();
@@ -88,3 +88,38 @@ fn test_invalid_parameters_rejected() {
client.initialize(&admin, ¶ms);
}
+
+#[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)> = 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");
+}
diff --git a/contracts/reputation-contract/src/events.rs b/contracts/reputation-contract/src/events.rs
index 45be1ef..3467285 100644
--- a/contracts/reputation-contract/src/events.rs
+++ b/contracts/reputation-contract/src/events.rs
@@ -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()),
+ );
+}
diff --git a/contracts/reputation-contract/src/lib.rs b/contracts/reputation-contract/src/lib.rs
index 0e6e33a..e125889 100644
--- a/contracts/reputation-contract/src/lib.rs
+++ b/contracts/reputation-contract/src/lib.rs
@@ -1,5 +1,5 @@
#![no_std]
-use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol};
+use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env};
// Module imports
mod access;
@@ -20,8 +20,8 @@ pub struct ReputationContract;
#[contractimpl]
impl ReputationContract {
/// Get the version of this contract
- 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| soroban_sdk::panic_with_error!(&env, err))
}
/// Get the reputation score for a user
@@ -215,7 +215,11 @@ impl ReputationContract {
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 {
diff --git a/contracts/reputation-contract/src/storage.rs b/contracts/reputation-contract/src/storage.rs
index 3fa299c..c099f44 100644
--- a/contracts/reputation-contract/src/storage.rs
+++ b/contracts/reputation-contract/src/storage.rs
@@ -7,6 +7,7 @@ pub const ADMIN_KEY: Symbol = symbol_short!("ADMIN");
pub const UPDATERS_MAP: Symbol = symbol_short!("UPDATERS");
pub const SCORES_MAP: Symbol = symbol_short!("SCORES");
pub const REENTRANCY_LOCK: Symbol = symbol_short!("LOCKED");
+pub const VERSION_KEY: Symbol = symbol_short!("VERSION");
/// Get the admin address from storage
pub fn get_admin(env: &Env) -> Result {
@@ -83,3 +84,11 @@ pub fn is_reentrancy_locked(env: &Env) -> Result {
pub fn set_reentrancy_locked(env: &Env, locked: bool) {
env.storage().instance().set(&REENTRANCY_LOCK, &locked);
}
+
+pub fn get_version(env: &Env) -> Result {
+ 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);
+}
diff --git a/contracts/reputation-contract/src/tests.rs b/contracts/reputation-contract/src/tests.rs
index 1971a39..8a9b64d 100644
--- a/contracts/reputation-contract/src/tests.rs
+++ b/contracts/reputation-contract/src/tests.rs
@@ -277,7 +277,47 @@ fn it_gets_version() {
let client = ReputationContractClient::new(&env, &contract_id);
let version = client.get_version();
- assert_eq!(version, symbol_short!("v1_0_0"));
+ assert_eq!(version, 1u32);
+}
+
+#[test]
+#[should_panic(expected = "Error(Contract")] // non-admin rejected
+fn it_rejects_upgrade_from_non_admin() {
+ let env = Env::default();
+ let contract_id = env.register(ReputationContract, ());
+ let client = ReputationContractClient::new(&env, &contract_id);
+
+ let wasm_hash = soroban_sdk::BytesN::from_array(&env, &[0u8; 32]);
+ client.upgrade(&wasm_hash);
+}
+
+#[test]
+fn it_allows_admin_upgrade_and_bumps_version() {
+ let env = Env::default();
+ env.mock_all_auths();
+
+ let contract_id = env.register(ReputationContract, ());
+ let client = ReputationContractClient::new(&env, &contract_id);
+ let admin = Address::generate(&env);
+ client.set_admin(&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)> = 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");
}
/// Test: Revokes updater access after removal
diff --git a/contracts/test-fixtures/contract.wasm b/contracts/test-fixtures/contract.wasm
new file mode 100755
index 0000000..275050c
Binary files /dev/null and b/contracts/test-fixtures/contract.wasm differ
diff --git a/contracts/vendor-registry-contract/src/events.rs b/contracts/vendor-registry-contract/src/events.rs
index 74008e3..31dc270 100644
--- a/contracts/vendor-registry-contract/src/events.rs
+++ b/contracts/vendor-registry-contract/src/events.rs
@@ -9,3 +9,10 @@ pub fn publish_vendor_status(env: &Env, vendor: Address, active: bool) {
let topics = (Symbol::new(env, "MERCHTSTATUS"), vendor);
env.events().publish(topics, active);
}
+
+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()),
+ );
+}
diff --git a/contracts/vendor-registry-contract/src/lib.rs b/contracts/vendor-registry-contract/src/lib.rs
index e88029a..c4c193e 100644
--- a/contracts/vendor-registry-contract/src/lib.rs
+++ b/contracts/vendor-registry-contract/src/lib.rs
@@ -171,4 +171,22 @@ impl VendorRegistryContract {
storage::get_vendor_count(&env)
}
+
+ /// Get numeric contract version
+ pub fn get_version(env: Env) -> u32 {
+ storage::get_version(&env).unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err))
+ }
+
+ /// Upgrade the contract WASM — admin only
+ pub fn upgrade(env: Env, new_wasm_hash: soroban_sdk::BytesN<32>) {
+ let admin = storage::get_admin(&env).unwrap_or_else(|err| soroban_sdk::panic_with_error!(&env, err));
+ admin.require_auth();
+
+ 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);
+ }
}
diff --git a/contracts/vendor-registry-contract/src/storage.rs b/contracts/vendor-registry-contract/src/storage.rs
index b2b1ff7..a6865c3 100644
--- a/contracts/vendor-registry-contract/src/storage.rs
+++ b/contracts/vendor-registry-contract/src/storage.rs
@@ -6,6 +6,9 @@ use soroban_sdk::{Address, Env};
pub const PERSISTENT_TTL_THRESHOLD: u32 = 1_036_800;
pub const PERSISTENT_TTL_EXTEND_TO: u32 = 2_073_600;
+// Version stored in instance storage
+use soroban_sdk::symbol_short;
+pub const VERSION_KEY: soroban_sdk::Symbol = symbol_short!("VERSION");
pub fn has_admin(env: &Env) -> bool {
env.storage().instance().has(&DataKey::Admin)
@@ -75,3 +78,11 @@ fn extend_persistent_ttl(env: &Env, key: &DataKey) {
.persistent()
.extend_ttl(key, PERSISTENT_TTL_THRESHOLD, PERSISTENT_TTL_EXTEND_TO);
}
+
+pub fn get_version(env: &Env) -> Result {
+ 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);
+}
diff --git a/contracts/vendor-registry-contract/src/tests.rs b/contracts/vendor-registry-contract/src/tests.rs
index f2c9265..27757a7 100644
--- a/contracts/vendor-registry-contract/src/tests.rs
+++ b/contracts/vendor-registry-contract/src/tests.rs
@@ -1,7 +1,7 @@
use super::*;
use soroban_sdk::{
- testutils::{Address as _, Ledger},
- Address, Env, String,
+ testutils::{Address as _, Events, Ledger},
+ Address, Env, IntoVal, String, Val, Vec,
};
/// Helper function to set up the environment, contract, and test addresses.
@@ -269,3 +269,39 @@ fn test_reentrancy_guard_is_released_after_call() {
client.deactivate_vendor(&admin, &vendor);
client.activate_vendor(&admin, &vendor);
}
+
+#[test]
+#[should_panic(expected = "Error(Contract")] // non-admin upgrade rejected
+fn test_upgrade_rejected_for_non_admin() {
+ let env = Env::default();
+ let contract_id = env.register(VendorRegistryContract, ());
+ let client = VendorRegistryContractClient::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_and_emits_event() {
+ let env = Env::default();
+ let (client, admin, _vendor) = setup(&env);
+ env.mock_all_auths();
+
+ 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)> = 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");
+}