diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e23cb5..e2dd004 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,7 @@ jobs: restore-keys: | ${{ runner.os }}-cargo- + - name: Build WASM fixtures for factory integration tests - name: Check formatting working-directory: soroban run: cargo fmt --all -- --check @@ -42,7 +43,7 @@ jobs: - name: Build farming-pool WASM fixture for factory integration tests working-directory: soroban - run: cargo build -p farming-pool --target wasm32v1-none --release + run: cargo build --workspace --target wasm32v1-none --release - name: Run tests working-directory: soroban diff --git a/Makefile b/Makefile index 62921e6..4f84241 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ build: cd soroban && cargo build --target wasm32-unknown-unknown --release test: - cd soroban && cargo build -p farming-pool --target wasm32v1-none --release + cd soroban && cargo build --workspace --target wasm32v1-none --release cd soroban && cargo test --workspace deploy-testnet: diff --git a/soroban/contracts/factory/src/lib.rs b/soroban/contracts/factory/src/lib.rs index 8f713b6..23112a9 100644 --- a/soroban/contracts/factory/src/lib.rs +++ b/soroban/contracts/factory/src/lib.rs @@ -2,7 +2,9 @@ mod types; -use soroban_sdk::{contract, contractimpl, symbol_short, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec}; +use soroban_sdk::{ + contract, contractimpl, symbol_short, vec, Address, BytesN, Env, IntoVal, Symbol, Val, Vec, +}; use types::{DataKey, FactoryError, ListPoolsResponse, PoolRecord}; // ~30 days at ~5 s/ledger; extend to ~60 days when below threshold. @@ -255,6 +257,35 @@ impl Factory { ); } + /// Upgrade one registered farming pool in place. Admin-only. + /// + /// This deliberately does not update the factory-level `WasmHash`; it is a + /// pool-by-pool hot swap for a pre-installed WASM hash. + pub fn upgrade_pool( + env: Env, + pool_id: u32, + new_wasm_hash: BytesN<32>, + ) -> Result<(), FactoryError> { + let admin = load_admin(&env); + admin.require_auth(); + bump_instance(&env); + + let key = DataKey::Pool(pool_id); + let record = env + .storage() + .persistent() + .get::(&key) + .ok_or(FactoryError::PoolNotFound)?; + bump_pool(&env, pool_id); + + let upgrade_args: Vec = vec![&env, new_wasm_hash.clone().into_val(&env)]; + let _: () = + env.invoke_contract(&record.address, &Symbol::new(&env, "upgrade"), upgrade_args); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("factory"), symbol_short!("pool_upg")), + (pool_id, record.address, new_wasm_hash), /// Update the WASM hash used for future `create_pool` deployments. Admin-only. /// /// Allows the admin to point future pool deployments at a corrected or upgraded diff --git a/soroban/contracts/factory/src/test.rs b/soroban/contracts/factory/src/test.rs index 6ad760c..9029b97 100644 --- a/soroban/contracts/factory/src/test.rs +++ b/soroban/contracts/factory/src/test.rs @@ -28,10 +28,21 @@ mod farming_pool_wasm { soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/farming_pool.wasm"); } +/// A distinct, valid contract WASM used to prove that `upgrade_pool` changes +/// the registered pool's executable hash rather than merely re-installing its +/// current farming-pool WASM. +mod replacement_wasm { + soroban_sdk::contractimport!(file = "../../target/wasm32v1-none/release/factory.wasm"); +} + fn upload_farming_pool_wasm(env: &Env) -> BytesN<32> { env.deployer().upload_contract_wasm(farming_pool_wasm::WASM) } +fn upload_replacement_wasm(env: &Env) -> BytesN<32> { + env.deployer().upload_contract_wasm(replacement_wasm::WASM) +} + /// Builds an initialised factory using the real farming-pool WASM. fn setup() -> TestEnv { let env = Env::default(); @@ -303,6 +314,95 @@ fn test_transfer_admin_emits_event_with_old_and_new_admin() { ); } +// ── upgrade_pool ────────────────────────────────────────────────────────────── + +#[test] +fn test_upgrade_pool_hot_swaps_registered_pool_without_changing_factory_hash() { + let t = setup(); + let original_factory_hash = t.client.pool_wasm_hash(); + let pool_id = t + .client + .create_pool(&Address::generate(&t.env), &1_728_000u128, &2u32, &10u64); + let pool_addr = t.client.get_pool(&pool_id).address; + + let new_wasm_hash = upload_replacement_wasm(&t.env); + t.client.upgrade_pool(&pool_id, &new_wasm_hash); + + assert_eq!( + t.env.events().all(), + vec![ + &t.env, + ( + pool_addr.clone(), + vec![ + &t.env, + symbol_short!("pool").into_val(&t.env), + symbol_short!("upgraded").into_val(&t.env), + ], + new_wasm_hash.clone().into_val(&t.env), + ), + ( + t.factory_addr.clone(), + vec![ + &t.env, + symbol_short!("factory").into_val(&t.env), + symbol_short!("pool_upg").into_val(&t.env), + ], + (pool_id, pool_addr.clone(), new_wasm_hash.clone()).into_val(&t.env), + ) + ] + ); + + assert_eq!( + t.client.pool_wasm_hash(), + original_factory_hash, + "pool-by-pool upgrades must not replace the factory default hash" + ); + assert_eq!( + pool_record_ttl(&t.env, &t.factory_addr, pool_id), + TTL_EXTEND_TO + ); + let record = t.client.get_pool(&pool_id); + assert_eq!(record.address, pool_addr); +} + +#[test] +fn test_upgrade_pool_missing_pool_returns_not_found() { + let t = setup(); + let new_wasm_hash = t.wasm_hash.clone(); + assert_eq!( + t.client.try_upgrade_pool(&0u32, &new_wasm_hash), + Err(Ok(FactoryError::PoolNotFound)) + ); +} + +#[test] +fn test_upgrade_pool_requires_factory_admin_auth() { + let t = setup(); + let pool_id = t + .client + .create_pool(&Address::generate(&t.env), &1_728_000u128, &2u32, &10u64); + + let not_admin = Address::generate(&t.env); + let new_wasm_hash = t.wasm_hash.clone(); + let args = (&pool_id, &new_wasm_hash).into_val(&t.env); + let invoke = MockAuthInvoke { + contract: &t.factory_addr, + fn_name: "upgrade_pool", + args, + sub_invokes: &[], + }; + let result = t + .client + .mock_auths(&[MockAuth { + address: ¬_admin, + invoke: &invoke, + }]) + .try_upgrade_pool(&pool_id, &new_wasm_hash); + + assert!(result.is_err(), "only the factory admin may upgrade pools"); +} + // ── create_pool auth gate ───────────────────────────────────────────────────── #[test] @@ -714,7 +814,9 @@ fn test_create_pool_configures_deployed_pool_matching_factory_record() { let t = setup(); let asset = Address::generate(&t.env); - let id = t.client.create_pool(&asset, &17_280_000u128, &3u32, &86_400u64); + let id = t + .client + .create_pool(&asset, &17_280_000u128, &3u32, &86_400u64); let record = t.client.get_pool(&id); assert_eq!(record.credit_rate, 1_000); diff --git a/soroban/contracts/farming-pool/src/lib.rs b/soroban/contracts/farming-pool/src/lib.rs index ceffa47..691782a 100644 --- a/soroban/contracts/farming-pool/src/lib.rs +++ b/soroban/contracts/farming-pool/src/lib.rs @@ -4,10 +4,12 @@ mod types; #[cfg(test)] mod mock_reentrant_token; -use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, Env}; +use soroban_sdk::{contract, contractimpl, symbol_short, token, Address, BytesN, Env}; pub use types::PoolError; use types::{BoostConfig, DataKey, Position, UserStake}; +pub const SCHEMA_VERSION: u32 = 1; + // Persistent-storage TTL: extend to ~60 days if below ~30 days (at ~5s/ledger). const USER_TTL_THRESHOLD: u32 = 518_400; const USER_TTL_EXTEND_TO: u32 = 1_036_800; @@ -81,6 +83,13 @@ fn pool_is_paused(env: &Env) -> bool { .unwrap_or(false) } +fn read_schema_version(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::SchemaVersion) + .unwrap_or(SCHEMA_VERSION) +} + fn get_user_boost(env: &Env, user: &Address) -> Option { let key = DataKey::UserBoost(user.clone()); let value: Option = env.storage().persistent().get(&key); @@ -211,6 +220,9 @@ impl FarmingPool { env.storage() .instance() .set(&DataKey::MinLockPeriod, &min_lock_period); + env.storage() + .instance() + .set(&DataKey::SchemaVersion, &SCHEMA_VERSION); bump_instance(&env); Ok(()) } @@ -233,6 +245,37 @@ impl FarmingPool { Ok(()) } + pub fn schema_version(env: Env) -> u32 { + bump_instance(&env); + read_schema_version(&env) + } + + pub fn migrate(env: Env) -> Result { + require_initialized(&env)?; + get_admin(&env)?.require_auth(); + bump_instance(&env); + + let current = read_schema_version(&env); + env.storage() + .instance() + .set(&DataKey::SchemaVersion, &SCHEMA_VERSION); + Ok(current) + } + + pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), PoolError> { + require_initialized(&env)?; + get_admin(&env)?.require_auth(); + bump_instance(&env); + + #[allow(deprecated)] + env.events().publish( + (symbol_short!("pool"), symbol_short!("upgraded")), + new_wasm_hash.clone(), + ); + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + pub fn lock_assets(env: Env, user: Address, amount: i128) -> Result<(), PoolError> { user.require_auth(); require_initialized(&env)?; diff --git a/soroban/contracts/farming-pool/src/test.rs b/soroban/contracts/farming-pool/src/test.rs index ce80b87..adab5a3 100644 --- a/soroban/contracts/farming-pool/src/test.rs +++ b/soroban/contracts/farming-pool/src/test.rs @@ -4,7 +4,7 @@ use super::*; use soroban_sdk::{ testutils::{Address as _, Events, Ledger, MockAuth, MockAuthInvoke}, token::{StellarAssetClient, TokenClient}, - Address, Env, IntoVal, + Address, BytesN, Env, IntoVal, Symbol, Val, }; // ── Test helpers ────────────────────────────────────────────────────────────── @@ -19,6 +19,50 @@ struct TestEnv { user: Address, } +fn upload_upgrade_target_wasm(env: &Env) -> BytesN<32> { + env.deployer().upload_contract_wasm(ADD_I32_WASM) +} + +const ADD_I32_WASM: &[u8] = &[ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x10, 0x03, 0x60, 0x02, 0x7e, 0x7e, 0x01, + 0x7e, 0x60, 0x02, 0x7f, 0x7f, 0x01, 0x7e, 0x60, 0x00, 0x00, 0x02, 0x0d, 0x02, 0x01, 0x78, 0x01, + 0x31, 0x00, 0x00, 0x01, 0x76, 0x01, 0x67, 0x00, 0x00, 0x03, 0x04, 0x03, 0x00, 0x01, 0x02, 0x05, + 0x03, 0x01, 0x00, 0x10, 0x06, 0x19, 0x03, 0x7f, 0x01, 0x41, 0x80, 0x80, 0xc0, 0x00, 0x0b, 0x7f, + 0x00, 0x41, 0x80, 0x80, 0xc0, 0x00, 0x0b, 0x7f, 0x00, 0x41, 0x80, 0x80, 0xc0, 0x00, 0x0b, 0x07, + 0x2f, 0x05, 0x06, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x02, 0x00, 0x03, 0x61, 0x64, 0x64, 0x00, + 0x02, 0x01, 0x5f, 0x00, 0x04, 0x0a, 0x5f, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x65, 0x6e, 0x64, + 0x03, 0x01, 0x0b, 0x5f, 0x5f, 0x68, 0x65, 0x61, 0x70, 0x5f, 0x62, 0x61, 0x73, 0x65, 0x03, 0x02, + 0x0a, 0xe3, 0x01, 0x03, 0xc5, 0x01, 0x02, 0x04, 0x7f, 0x01, 0x7e, 0x23, 0x00, 0x41, 0x20, 0x6b, + 0x22, 0x03, 0x24, 0x00, 0x02, 0x40, 0x20, 0x00, 0x42, 0xff, 0x01, 0x83, 0x42, 0x05, 0x52, 0x20, + 0x01, 0x42, 0xff, 0x01, 0x83, 0x42, 0x05, 0x52, 0x72, 0x45, 0x04, 0x40, 0x20, 0x00, 0x42, 0x20, + 0x88, 0xa7, 0x21, 0x04, 0x20, 0x01, 0x42, 0x20, 0x88, 0xa7, 0x21, 0x05, 0x20, 0x03, 0x42, 0x8e, + 0xd2, 0xa9, 0x13, 0x37, 0x03, 0x08, 0x42, 0x02, 0x21, 0x06, 0x41, 0x01, 0x21, 0x02, 0x03, 0x40, + 0x20, 0x02, 0x04, 0x40, 0x20, 0x02, 0x41, 0x01, 0x6b, 0x21, 0x02, 0x42, 0x8e, 0xd2, 0xa9, 0x13, + 0x21, 0x06, 0x0c, 0x01, 0x0b, 0x0b, 0x20, 0x03, 0x20, 0x06, 0x37, 0x03, 0x10, 0x20, 0x03, 0x41, + 0x10, 0x6a, 0x22, 0x02, 0x41, 0x01, 0x10, 0x03, 0x20, 0x03, 0x20, 0x01, 0x42, 0x80, 0x80, 0x80, + 0x80, 0x70, 0x83, 0x42, 0x05, 0x84, 0x37, 0x03, 0x18, 0x20, 0x03, 0x20, 0x00, 0x42, 0x80, 0x80, + 0x80, 0x80, 0x70, 0x83, 0x42, 0x05, 0x84, 0x37, 0x03, 0x10, 0x20, 0x02, 0x41, 0x02, 0x10, 0x03, + 0x10, 0x00, 0x1a, 0x20, 0x05, 0x41, 0x00, 0x48, 0x20, 0x04, 0x20, 0x05, 0x6a, 0x22, 0x02, 0x20, + 0x04, 0x48, 0x47, 0x0d, 0x01, 0x20, 0x03, 0x41, 0x20, 0x6a, 0x24, 0x00, 0x20, 0x02, 0xad, 0x42, + 0x20, 0x86, 0x42, 0x05, 0x84, 0x0f, 0x0b, 0x00, 0x0b, 0x00, 0x0b, 0x16, 0x00, 0x20, 0x00, 0xad, + 0x42, 0x20, 0x86, 0x42, 0x04, 0x84, 0x20, 0x01, 0xad, 0x42, 0x20, 0x86, 0x42, 0x04, 0x84, 0x10, + 0x01, 0x0b, 0x03, 0x00, 0x01, 0x0b, 0x00, 0x4b, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, + 0x74, 0x73, 0x70, 0x65, 0x63, 0x76, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x61, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x01, 0x62, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x05, 0x00, 0x1e, 0x11, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x65, 0x6e, + 0x76, 0x6d, 0x65, 0x74, 0x61, 0x76, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x14, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x73, 0x0e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x61, 0x63, 0x74, 0x6d, 0x65, + 0x74, 0x61, 0x76, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x05, 0x72, 0x73, 0x76, 0x65, + 0x72, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x31, 0x2e, 0x37, 0x34, 0x2e, 0x30, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x72, 0x73, 0x73, 0x64, 0x6b, 0x76, 0x65, 0x72, + 0x00, 0x00, 0x00, 0x33, 0x32, 0x30, 0x2e, 0x30, 0x2e, 0x30, 0x2d, 0x72, 0x63, 0x32, 0x23, 0x37, + 0x63, 0x31, 0x35, 0x34, 0x62, 0x34, 0x66, 0x65, 0x36, 0x61, 0x33, 0x37, 0x64, 0x37, 0x63, 0x61, + 0x37, 0x31, 0x37, 0x37, 0x33, 0x34, 0x32, 0x64, 0x65, 0x64, 0x62, 0x36, 0x39, 0x66, 0x33, 0x31, + 0x30, 0x38, 0x30, 0x39, 0x35, 0x65, 0x66, 0x00, +]; + fn setup(global_multiplier: u32, credit_rate: i128) -> TestEnv { setup_with_lock_period(global_multiplier, credit_rate, 0) } @@ -131,6 +175,84 @@ fn test_pause_uninitialized_returns_not_initialized() { } #[test] +fn test_schema_version_defaults_to_current_release() { + let t = setup(2, 1); + assert_eq!(t.client.schema_version(), SCHEMA_VERSION); +} + +#[test] +fn test_migrate_placeholder_requires_admin_and_stamps_current_version() { + let t = setup(2, 1); + assert_eq!(t.client.migrate(), SCHEMA_VERSION); + assert_eq!(t.client.schema_version(), SCHEMA_VERSION); +} + +#[test] +fn test_upgrade_preserves_stake_storage_and_enables_new_wasm() { + let t = setup(2, 1); + t.client.stake(&t.user, &1_000); + advance_ledgers(&t.env, 10); + + let before = t.client.get_stake(&t.user).expect("stake must exist"); + let new_wasm_hash = upload_upgrade_target_wasm(&t.env); + + t.client.upgrade(&new_wasm_hash); + + assert_eq!( + t.env.events().all(), + soroban_sdk::vec![ + &t.env, + ( + t.contract_id.clone(), + soroban_sdk::vec![ + &t.env, + soroban_sdk::symbol_short!("pool").into_val(&t.env), + soroban_sdk::symbol_short!("upgraded").into_val(&t.env) + ], + new_wasm_hash.clone().into_val(&t.env), + ) + ] + ); + + let stored = t.env.as_contract(&t.contract_id, || { + t.env + .storage() + .persistent() + .get::(&DataKey::UserStake(t.user.clone())) + }); + let stored = stored.expect("stake storage must survive wasm upgrade"); + assert_eq!(stored.amount, before.amount); + assert_eq!(stored.start_ledger, before.start_ledger); + assert_eq!(stored.credits_banked, before.credits_banked); + assert_eq!(stored.credit_rate, before.credit_rate); + + let args: soroban_sdk::Vec = + soroban_sdk::vec![&t.env, 2i32.into_val(&t.env), 40i32.into_val(&t.env)]; + let sum: i32 = t + .env + .invoke_contract(&t.contract_id, &Symbol::new(&t.env, "add"), args); + assert_eq!(sum, 42); +} + +#[test] +fn test_upgrade_requires_admin_auth() { + let (env, contract_id, client, admin, user) = setup_without_mocked_auth(); + let new_wasm_hash = upload_upgrade_target_wasm(&env); + + let result = client + .mock_auths(&[MockAuth { + address: &user, + invoke: &MockAuthInvoke { + contract: &contract_id, + fn_name: "upgrade", + args: (&new_wasm_hash,).into_val(&env), + sub_invokes: &[], + }, + }]) + .try_upgrade(&new_wasm_hash); + + assert!(result.is_err(), "non-admin upgrade must be rejected"); + assert_eq!(client.admin(), admin); fn test_admin_uninitialized_returns_not_initialized() { let (_env, client, _user) = setup_uninitialized(); match client.try_admin() { diff --git a/soroban/contracts/farming-pool/src/types.rs b/soroban/contracts/farming-pool/src/types.rs index ebfae06..d8dcffa 100644 --- a/soroban/contracts/farming-pool/src/types.rs +++ b/soroban/contracts/farming-pool/src/types.rs @@ -55,6 +55,7 @@ pub enum DataKey { CreditRate, StakeToken, MinLockPeriod, + SchemaVersion, Paused, UserBoost(Address), UserStake(Address),