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"); +}