diff --git a/Cargo.toml b/Cargo.toml index fc7deb6..ca27aac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["contracts/stream", "contracts/treasury"] +members = ["contracts/stream", "contracts/treasury", "contracts/proxy"] [profile.release] opt-level = "z" diff --git a/contracts/proxy/Cargo.toml b/contracts/proxy/Cargo.toml new file mode 100644 index 0000000..4fe69ed --- /dev/null +++ b/contracts/proxy/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "sorostream-proxy" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +soroban-sdk = { version = "22.0.0", features = ["alloc"] } + +[dev-dependencies] +soroban-sdk = { version = "22.0.0", features = ["testutils"] } + +[features] +testutils = ["soroban-sdk/testutils"] diff --git a/contracts/proxy/src/lib.rs b/contracts/proxy/src/lib.rs new file mode 100644 index 0000000..00d2ed5 --- /dev/null +++ b/contracts/proxy/src/lib.rs @@ -0,0 +1,250 @@ +#![no_std] +//! # SoroStream Proxy Contract +//! +//! A minimal upgradeable proxy that separates storage from logic, adapted for Soroban. +//! +//! ## Architecture +//! - This proxy stores the current implementation WASM hash and admin/governance addresses. +//! - All non-admin calls are forwarded to the implementation via `env.invoke_contract`. +//! - `upgrade(new_wasm_hash)` replaces the implementation; only the admin may call it. +//! - A `storage_version` field is checked on upgrade to prevent incompatible migrations. +//! - `migrate(from_version, to_version)` performs a one-time post-upgrade state migration. +//! +//! ## Upgrade flow +//! 1. Admin deploys new implementation WASM and obtains its hash. +//! 2. Admin calls `upgrade(new_wasm_hash)`. +//! 3. Proxy verifies `storage_version` matches; updates the stored hash. +//! 4. Admin calls `migrate(from, to)` on the underlying contract if needed. +//! +//! ## Emergency rollback +//! Store the previous WASM hash before upgrading. Call `upgrade(previous_hash)` to revert. + +use soroban_sdk::{contract, contractimpl, contracttype, Address, BytesN, Env, Symbol, String}; + +const ADMIN_KEY: &str = "admin"; +const IMPL_HASH_KEY: &str = "impl"; +const STORAGE_VERSION_KEY: &str = "sv"; + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ProxyError { + NotInitialized = 1, + AlreadyInitialized = 2, + NotAdmin = 3, + StorageVersionMismatch = 4, + MigrationAlreadyApplied = 5, +} + +fn read_admin(env: &Env) -> Option
{ + env.storage().instance().get(&Symbol::new(env, ADMIN_KEY)) +} + +fn check_admin(env: &Env) -> Address { + let admin = read_admin(env).expect("proxy not initialized"); + admin.require_auth(); + admin +} + +fn read_impl_hash(env: &Env) -> Option> { + env.storage().instance().get(&Symbol::new(env, IMPL_HASH_KEY)) +} + +fn read_storage_version(env: &Env) -> u32 { + env.storage() + .instance() + .get(&Symbol::new(env, STORAGE_VERSION_KEY)) + .unwrap_or(0u32) +} + +#[contract] +pub struct ProxyContract; + +#[contractimpl] +impl ProxyContract { + /// Initialises the proxy with an admin and the first implementation WASM hash. + /// + /// `initial_storage_version` sets the expected storage layout version for + /// upgrade compatibility checks. + pub fn initialize( + env: Env, + admin: Address, + impl_hash: BytesN<32>, + initial_storage_version: u32, + ) -> Result<(), ProxyError> { + if read_admin(&env).is_some() { + return Err(ProxyError::AlreadyInitialized); + } + env.storage().instance().set(&Symbol::new(&env, ADMIN_KEY), &admin); + env.storage().instance().set(&Symbol::new(&env, IMPL_HASH_KEY), &impl_hash); + env.storage() + .instance() + .set(&Symbol::new(&env, STORAGE_VERSION_KEY), &initial_storage_version); + env.events().publish( + (Symbol::new(&env, "ProxyInitialized"),), + (admin, impl_hash, initial_storage_version), + ); + Ok(()) + } + + /// Returns the current admin address. + pub fn get_admin(env: Env) -> Result { + read_admin(&env).ok_or(ProxyError::NotInitialized) + } + + /// Transfers the admin role to a new address. + pub fn set_admin(env: Env, new_admin: Address) -> Result<(), ProxyError> { + check_admin(&env); + env.storage().instance().set(&Symbol::new(&env, ADMIN_KEY), &new_admin); + Ok(()) + } + + /// Returns the current implementation WASM hash. + pub fn get_impl_hash(env: Env) -> Result, ProxyError> { + read_impl_hash(&env).ok_or(ProxyError::NotInitialized) + } + + /// Returns the current storage layout version. + pub fn get_storage_version(env: Env) -> u32 { + read_storage_version(&env) + } + + /// Upgrades the implementation to a new WASM hash. + /// + /// `expected_storage_version` must match the currently stored version; this + /// prevents accidentally applying an upgrade intended for a different storage + /// layout. After the upgrade the version is bumped to `new_storage_version`. + pub fn upgrade( + env: Env, + new_impl_hash: BytesN<32>, + expected_storage_version: u32, + new_storage_version: u32, + ) -> Result<(), ProxyError> { + let admin = check_admin(&env); + let current_version = read_storage_version(&env); + if current_version != expected_storage_version { + return Err(ProxyError::StorageVersionMismatch); + } + let old_hash = read_impl_hash(&env).ok_or(ProxyError::NotInitialized)?; + env.storage() + .instance() + .set(&Symbol::new(&env, IMPL_HASH_KEY), &new_impl_hash); + env.storage() + .instance() + .set(&Symbol::new(&env, STORAGE_VERSION_KEY), &new_storage_version); + env.events().publish( + (Symbol::new(&env, "ProxyUpgraded"),), + (admin, old_hash, new_impl_hash, new_storage_version), + ); + Ok(()) + } + + /// Upgrades the proxy contract's own WASM (not the implementation). + /// + /// This is separate from `upgrade()` which replaces the implementation hash. + /// Use this to update the proxy logic itself. + pub fn upgrade_proxy(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), ProxyError> { + let _admin = check_admin(&env); + env.deployer().update_current_contract_wasm(new_wasm_hash); + Ok(()) + } + + /// Runs a one-time migration step on the implementation contract. + /// + /// Forwards a `migrate(from_version, to_version)` call to the implementation. + /// The implementation is responsible for idempotency guards. + pub fn migrate( + env: Env, + impl_contract: Address, + from_version: String, + to_version: String, + ) -> Result<(), ProxyError> { + let _admin = check_admin(&env); + env.invoke_contract::>( + &impl_contract, + &Symbol::new(&env, "migrate"), + soroban_sdk::vec![ + &env, + soroban_sdk::IntoVal::into_val(&from_version, &env), + soroban_sdk::IntoVal::into_val(&to_version, &env), + ], + ); + env.events().publish( + (Symbol::new(&env, "ProxyMigrated"),), + (from_version, to_version), + ); + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{testutils::Address as _, Address, BytesN, Env}; + + fn setup() -> (Env, Address, Address, BytesN<32>) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(ProxyContract, ()); + let admin = Address::generate(&env); + let impl_hash = BytesN::from_array(&env, &[1u8; 32]); + (env, contract_id, admin, impl_hash) + } + + #[test] + fn test_initialize_and_get_admin() { + let (env, contract_id, admin, impl_hash) = setup(); + let c = ProxyContractClient::new(&env, &contract_id); + + c.initialize(&admin, &impl_hash, &0u32); + assert_eq!(c.get_admin(), Ok(admin)); + assert_eq!(c.get_impl_hash(), Ok(impl_hash)); + assert_eq!(c.get_storage_version(), 0u32); + } + + #[test] + fn test_double_initialize_fails() { + let (env, contract_id, admin, impl_hash) = setup(); + let c = ProxyContractClient::new(&env, &contract_id); + + c.initialize(&admin, &impl_hash, &0u32); + let result = c.try_initialize(&admin, &impl_hash, &0u32); + assert_eq!(result, Err(Ok(ProxyError::AlreadyInitialized))); + } + + #[test] + fn test_upgrade_succeeds_with_correct_version() { + let (env, contract_id, admin, impl_hash) = setup(); + let c = ProxyContractClient::new(&env, &contract_id); + c.initialize(&admin, &impl_hash, &1u32); + + let new_hash = BytesN::from_array(&env, &[2u8; 32]); + c.upgrade(&new_hash, &1u32, &2u32); + + assert_eq!(c.get_impl_hash(), Ok(new_hash)); + assert_eq!(c.get_storage_version(), 2u32); + } + + #[test] + fn test_upgrade_fails_on_version_mismatch() { + let (env, contract_id, admin, impl_hash) = setup(); + let c = ProxyContractClient::new(&env, &contract_id); + c.initialize(&admin, &impl_hash, &1u32); + + let new_hash = BytesN::from_array(&env, &[2u8; 32]); + // Wrong expected version: contract is at v1, we claim v0 + let result = c.try_upgrade(&new_hash, &0u32, &2u32); + assert_eq!(result, Err(Ok(ProxyError::StorageVersionMismatch))); + } + + #[test] + fn test_set_admin_transfers_role() { + let (env, contract_id, admin, impl_hash) = setup(); + let c = ProxyContractClient::new(&env, &contract_id); + c.initialize(&admin, &impl_hash, &0u32); + + let new_admin = Address::generate(&env); + c.set_admin(&new_admin); + assert_eq!(c.get_admin(), Ok(new_admin)); + } +} diff --git a/contracts/stream/src/interface.rs b/contracts/stream/src/interface.rs index 918b83f..211461b 100644 --- a/contracts/stream/src/interface.rs +++ b/contracts/stream/src/interface.rs @@ -617,6 +617,40 @@ pub trait SoroStreamInterface { /// Archives a fully settled stream (total_withdrawn == deposit), deleting its storage entry. fn archive_stream(env: Env, stream_id: u64, caller: Address) -> Result<(), StreamError>; + // ── Issue #208: fee exemption ──────────────────────────────────────────── + + /// Adds `addr` to the protocol fee exemption list. Only admin may call this. + fn add_fee_exempt(env: Env, addr: Address) -> Result<(), StreamError>; + + /// Removes `addr` from the protocol fee exemption list. Only admin may call this. + fn remove_fee_exempt(env: Env, addr: Address) -> Result<(), StreamError>; + + /// Returns whether `addr` is currently fee-exempt. + fn is_fee_exempt(env: Env, addr: Address) -> bool; + + // ── Issue #209: guardian / governance pause ────────────────────────────── + + /// Sets the guardian address (the only address allowed to call `pause`). + fn set_guardian(env: Env, guardian: Address) -> Result<(), StreamError>; + + /// Returns the current guardian address. + fn get_guardian(env: Env) -> Option
; + + /// Sets the governance address (the only address allowed to call `unpause`). + fn set_governance(env: Env, governance: Address) -> Result<(), StreamError>; + + /// Returns the current governance address. + fn get_governance(env: Env) -> Option
; + + /// Pauses all fund-moving operations. Only the guardian may call this. + /// Auto-unpauses after MAX_PAUSE_DURATION (72 h). + fn pause(env: Env, guardian: Address) -> Result<(), StreamError>; + + /// Unpauses the contract. Only the governance contract may call this. + fn unpause(env: Env, governance: Address) -> Result<(), StreamError>; + + /// Returns the timestamp at which the contract will auto-unpause (0 = not set). + fn get_pause_expiry(env: Env) -> u64; /// Sets the flat XLM creation fee (in stroops) and the XLM SAC token address. Admin-only. fn set_creation_fee(env: Env, fee: i128, xlm_token: Address) -> Result<(), StreamError>; diff --git a/contracts/stream/src/lib.rs b/contracts/stream/src/lib.rs index 2915038..7793733 100644 --- a/contracts/stream/src/lib.rs +++ b/contracts/stream/src/lib.rs @@ -34,6 +34,21 @@ mod testnet_integration_tests; use soroban_sdk::{contract, contractimpl, token, Address, Bytes, BytesN, Env, String, Vec, Symbol, IntoVal}; use storage::{ + add_fee_exempt, add_to_whitelist, append_audit_entry, check_admin, clear_pending_fee_proposal, + derive_stream_id, effective_sender_limit, get_batch_nonce, get_delegate, + get_global_stream_at, get_global_stream_count, get_ids_by_recipient, get_ids_by_sender, + get_pause_expiry, get_protocol_fee, get_sender_stream_count, get_treasury, + get_withdrawal_cooldown, increment_batch_nonce, index_by_recipient, index_by_sender, + index_global_stream, is_fee_exempt, is_paused_or_auto_unpause, + is_whitelist_enabled, is_whitelisted, load_stream, mark_nonce_used, nonce_used, + read_admin, read_applied_migrations, read_audit_log, read_governance, read_guardian, + read_min_duration, read_pending_fee_proposal, read_version, record_migration, + remove_delegate, remove_fee_exempt, remove_from_whitelist, remove_stream, save_stream, + set_delegate, set_max_streams_per_sender, set_pause_expiry, set_paused, set_protocol_fee, + set_sender_limit, set_treasury, set_whitelist_enabled, set_withdrawal_cooldown, + stream_exists, unindex_by_recipient, unindex_by_sender, write_admin, write_governance, + write_guardian, write_min_duration, write_pending_fee_proposal, write_version, + MAX_PAUSE_DURATION, add_to_whitelist, append_audit_entry, check_admin, clear_pending_fee_proposal, derive_stream_id, effective_sender_limit, get_batch_nonce, get_creation_fee_xlm, get_delegate, get_global_stream_at, get_global_stream_count, get_ids_by_recipient, @@ -95,9 +110,11 @@ impl SoroStreamContract { pub fn emergency_pause(env: Env) -> Result<(), StreamError> { check_admin(&env); set_paused(&env, true); - let admin = read_admin(&env).unwrap(); - events::contract_paused(&env, &admin, env.ledger().timestamp()); let ts = env.ledger().timestamp(); + let expiry = ts.checked_add(MAX_PAUSE_DURATION).unwrap_or(u64::MAX); + set_pause_expiry(&env, expiry); + let admin = read_admin(&env).unwrap(); + events::contract_paused(&env, &admin, ts); let entry = AuditEntry { instruction: String::from_str(&env, "emergency_pause"), admin: admin.clone(), @@ -113,6 +130,7 @@ impl SoroStreamContract { pub fn emergency_resume(env: Env) -> Result<(), StreamError> { check_admin(&env); set_paused(&env, false); + set_pause_expiry(&env, 0); let admin = read_admin(&env).unwrap(); events::contract_resumed(&env, &admin, env.ledger().timestamp()); let ts = env.ledger().timestamp(); @@ -128,8 +146,100 @@ impl SoroStreamContract { } /// Returns whether the contract is currently paused. + /// Automatically returns false (and clears the paused state) if the maximum + /// pause duration has elapsed since pausing (auto-unpause after 72 h). pub fn is_paused(env: Env) -> bool { - is_paused(&env) + is_paused_or_auto_unpause(&env) + } + + /// Sets the guardian address (the only address that can call `pause`). + /// Only the admin may set this. + pub fn set_guardian(env: Env, guardian: Address) -> Result<(), StreamError> { + check_admin(&env); + write_guardian(&env, &guardian); + Ok(()) + } + + /// Returns the current guardian address, if set. + pub fn get_guardian(env: Env) -> Option
{ + read_guardian(&env) + } + + /// Sets the governance address (the only address that can call `unpause`). + /// Only the admin may set this. + pub fn set_governance(env: Env, governance: Address) -> Result<(), StreamError> { + check_admin(&env); + write_governance(&env, &governance); + Ok(()) + } + + /// Returns the current governance address, if set. + pub fn get_governance(env: Env) -> Option
{ + read_governance(&env) + } + + /// Pauses the contract. Only the designated guardian may call this. + /// + /// Sets an automatic unpause expiry of `MAX_PAUSE_DURATION` (72 h) to prevent + /// indefinite lockout. Emits `Paused(guardian, timestamp)`. + pub fn pause(env: Env, guardian: Address) -> Result<(), StreamError> { + guardian.require_auth(); + let stored_guardian = read_guardian(&env).ok_or(StreamError::NotAuthorized)?; + if guardian != stored_guardian { + return Err(StreamError::NotAuthorized); + } + set_paused(&env, true); + let ts = env.ledger().timestamp(); + let expiry = ts.checked_add(MAX_PAUSE_DURATION).unwrap_or(u64::MAX); + set_pause_expiry(&env, expiry); + env.events().publish( + (Symbol::new(&env, "Paused"), guardian.clone()), + ts, + ); + Ok(()) + } + + /// Unpauses the contract. Only the designated governance contract may call this. + /// + /// Emits `Unpaused(governance, timestamp)`. + pub fn unpause(env: Env, governance: Address) -> Result<(), StreamError> { + governance.require_auth(); + let stored_governance = read_governance(&env).ok_or(StreamError::NotAuthorized)?; + if governance != stored_governance { + return Err(StreamError::NotAuthorized); + } + set_paused(&env, false); + set_pause_expiry(&env, 0); + let ts = env.ledger().timestamp(); + env.events().publish( + (Symbol::new(&env, "Unpaused"), governance.clone()), + ts, + ); + Ok(()) + } + + /// Returns the timestamp at which the contract will auto-unpause (0 = never set). + pub fn get_pause_expiry(env: Env) -> u64 { + get_pause_expiry(&env) + } + + /// Adds `addr` to the protocol fee exemption list. Only admin may call this. + pub fn add_fee_exempt(env: Env, addr: Address) -> Result<(), StreamError> { + check_admin(&env); + add_fee_exempt(&env, &addr); + Ok(()) + } + + /// Removes `addr` from the protocol fee exemption list. Only admin may call this. + pub fn remove_fee_exempt(env: Env, addr: Address) -> Result<(), StreamError> { + check_admin(&env); + remove_fee_exempt(&env, &addr); + Ok(()) + } + + /// Returns whether `addr` is currently fee-exempt. + pub fn is_fee_exempt(env: Env, addr: Address) -> bool { + is_fee_exempt(&env, &addr) } /// Upgrades the contract WASM bytecode. Only the admin may call this. @@ -240,7 +350,7 @@ impl SoroStreamContract { ) -> Result { sender.require_auth(); - if is_paused(&env) { + if is_paused_or_auto_unpause(&env) { return Err(StreamError::ContractPaused); } if nonce_used(&env, &sender, nonce) { @@ -421,9 +531,9 @@ impl SoroStreamContract { /// Allows the recipient to withdraw all tokens earned since last withdrawal. pub fn withdraw(env: Env, stream_id: u64, recipient: Address) -> Result<(), StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } recipient.require_auth(); let mut stream = load_stream(&env, stream_id).ok_or(StreamError::StreamNotFound)?; @@ -464,7 +574,7 @@ impl SoroStreamContract { } let fee_bps = get_protocol_fee(&env); - let fee_amount = if fee_bps > 0 { + let fee_amount = if fee_bps > 0 && !is_fee_exempt(&env, &stream.recipient) { claimable .checked_mul(fee_bps as i128) .ok_or(StreamError::Overflow)? @@ -634,9 +744,9 @@ impl SoroStreamContract { /// /// The recipient receives all currently vested tokens; the sender receives the remainder. pub fn recipient_terminate(env: Env, stream_id: u64, recipient: Address) -> Result<(), StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } recipient.require_auth(); let stream = load_stream(&env, stream_id).ok_or(StreamError::StreamNotFound)?; @@ -698,9 +808,9 @@ impl SoroStreamContract { current_recipient: Address, new_recipient: Address, ) -> Result<(), StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } current_recipient.require_auth(); let mut stream = load_stream(&env, stream_id).ok_or(StreamError::StreamNotFound)?; @@ -731,7 +841,7 @@ impl SoroStreamContract { if claimable > 0 { let fee_bps = get_protocol_fee(&env); - let fee_amount = if fee_bps > 0 { + let fee_amount = if fee_bps > 0 && !is_fee_exempt(&env, &stream.recipient) { claimable .checked_mul(fee_bps as i128) .ok_or(StreamError::Overflow)? @@ -896,9 +1006,9 @@ impl SoroStreamContract { token: Address, amount: i128, ) -> Result<(), StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } caller.require_auth(); let mut stream = load_stream(&env, stream_id).ok_or(StreamError::StreamNotFound)?; @@ -1095,9 +1205,9 @@ impl SoroStreamContract { /// Pauses an active stream. pub fn pause_stream(env: Env, stream_id: u64, sender: Address) -> Result<(), StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } sender.require_auth(); let mut stream = load_stream(&env, stream_id).ok_or(StreamError::StreamNotFound)?; @@ -1118,9 +1228,9 @@ impl SoroStreamContract { /// Resumes a paused stream, pushing back the end time. pub fn resume_stream(env: Env, stream_id: u64, sender: Address) -> Result<(), StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } sender.require_auth(); let mut stream = load_stream(&env, stream_id).ok_or(StreamError::StreamNotFound)?; @@ -1160,9 +1270,9 @@ impl SoroStreamContract { lock_untils: Vec, nonce: u64, ) -> Result, StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } sender.require_auth(); let expected_nonce = get_batch_nonce(&env, &sender); @@ -1253,9 +1363,9 @@ impl SoroStreamContract { stream_ids: Vec, recipient: Address, ) -> Result, StreamError> { - if is_paused(&env) { - return Err(StreamError::ContractPaused); - } + if is_paused_or_auto_unpause(&env) { + return Err(StreamError::ContractPaused); + } recipient.require_auth(); let mut amounts = Vec::new(&env); @@ -1295,7 +1405,7 @@ impl SoroStreamContract { } let fee_bps = get_protocol_fee(&env); - let fee_amount = if fee_bps > 0 { + let fee_amount = if fee_bps > 0 && !is_fee_exempt(&env, &stream.recipient) { claimable .checked_mul(fee_bps as i128) .ok_or(StreamError::Overflow)? @@ -1821,6 +1931,44 @@ impl SoroStreamInterface for SoroStreamContract { Self::recipient_terminate(env, stream_id, recipient) } + fn add_fee_exempt(env: Env, addr: Address) -> Result<(), StreamError> { + Self::add_fee_exempt(env, addr) + } + + fn remove_fee_exempt(env: Env, addr: Address) -> Result<(), StreamError> { + Self::remove_fee_exempt(env, addr) + } + + fn is_fee_exempt(env: Env, addr: Address) -> bool { + Self::is_fee_exempt(env, addr) + } + + fn set_guardian(env: Env, guardian: Address) -> Result<(), StreamError> { + Self::set_guardian(env, guardian) + } + + fn get_guardian(env: Env) -> Option
{ + Self::get_guardian(env) + } + + fn set_governance(env: Env, governance: Address) -> Result<(), StreamError> { + Self::set_governance(env, governance) + } + + fn get_governance(env: Env) -> Option
{ + Self::get_governance(env) + } + + fn pause(env: Env, guardian: Address) -> Result<(), StreamError> { + Self::pause(env, guardian) + } + + fn unpause(env: Env, governance: Address) -> Result<(), StreamError> { + Self::unpause(env, governance) + } + + fn get_pause_expiry(env: Env) -> u64 { + Self::get_pause_expiry(env) fn set_creation_fee(env: Env, fee: i128, xlm_token: Address) -> Result<(), StreamError> { Self::set_creation_fee(env, fee, xlm_token) } diff --git a/contracts/stream/src/storage.rs b/contracts/stream/src/storage.rs index 604b470..e2c7d1b 100644 --- a/contracts/stream/src/storage.rs +++ b/contracts/stream/src/storage.rs @@ -12,6 +12,11 @@ const STREAM_COUNT_KEY: &str = "str_cnt"; const PENDING_FEE_KEY: &str = "pnd_fee"; const WITHDRAWAL_COOLDOWN_KEY: &str = "wd_cd"; const WHITELIST_ENABLED_KEY: &str = "wl_en"; +const GUARDIAN_KEY: &str = "guardian"; +const GOVERNANCE_KEY: &str = "governance"; +const PAUSE_EXPIRES_KEY: &str = "p_exp"; +/// Maximum pause duration in seconds (72 hours). After this the contract auto-unpauses. +pub const MAX_PAUSE_DURATION: u64 = 72 * 60 * 60; const CREATION_FEE_XLM_KEY: &str = "cf_xlm"; /// Stores the contract admin address. @@ -258,6 +263,69 @@ pub fn set_paused(env: &Env, paused: bool) { .set(&Symbol::new(env, PAUSED_KEY), &paused); } +/// Sets the timestamp at which the contract auto-unpauses (0 = no expiry). +pub fn set_pause_expiry(env: &Env, expiry: u64) { + env.storage() + .instance() + .set(&Symbol::new(env, PAUSE_EXPIRES_KEY), &expiry); +} + +/// Returns the pause expiry timestamp (0 if not set). +pub fn get_pause_expiry(env: &Env) -> u64 { + env.storage() + .instance() + .get(&Symbol::new(env, PAUSE_EXPIRES_KEY)) + .unwrap_or(0u64) +} + +/// Returns whether the contract is currently paused, auto-unpausing if the +/// maximum pause duration has elapsed. +pub fn is_paused_or_auto_unpause(env: &Env) -> bool { + let paused: bool = env.storage() + .instance() + .get(&Symbol::new(env, PAUSED_KEY)) + .unwrap_or(false); + if !paused { + return false; + } + let expiry = get_pause_expiry(env); + if expiry > 0 && env.ledger().timestamp() >= expiry { + // Auto-unpause: clear flags without emitting an event (event is emitted by caller) + env.storage() + .instance() + .set(&Symbol::new(env, PAUSED_KEY), &false); + env.storage() + .instance() + .set(&Symbol::new(env, PAUSE_EXPIRES_KEY), &0u64); + return false; + } + true +} + +/// Stores the guardian address (can call `pause`). +pub fn write_guardian(env: &Env, guardian: &Address) { + env.storage() + .instance() + .set(&Symbol::new(env, GUARDIAN_KEY), guardian); +} + +/// Returns the guardian address, if set. +pub fn read_guardian(env: &Env) -> Option
{ + env.storage().instance().get(&Symbol::new(env, GUARDIAN_KEY)) +} + +/// Stores the governance address (can call `unpause`). +pub fn write_governance(env: &Env, governance: &Address) { + env.storage() + .instance() + .set(&Symbol::new(env, GOVERNANCE_KEY), governance); +} + +/// Returns the governance address, if set. +pub fn read_governance(env: &Env) -> Option
{ + env.storage().instance().get(&Symbol::new(env, GOVERNANCE_KEY)) +} + /// Gets the protocol fee in basis points (0 = no fee). pub fn get_protocol_fee(env: &Env) -> u32 { env.storage().instance().get(&Symbol::new(env, PROTOCOL_FEE_KEY)).unwrap_or(0u32) @@ -411,6 +479,27 @@ pub fn remove_from_whitelist(env: &Env, recipient: &Address) { env.storage().persistent().remove(&whitelist_key(env, recipient)); } +// --- Fee exemption list --- + +fn fee_exempt_key(env: &Env, addr: &Address) -> (Symbol, Address) { + (Symbol::new(env, "fe"), addr.clone()) +} + +/// Returns whether `addr` is exempt from the protocol fee. +pub fn is_fee_exempt(env: &Env, addr: &Address) -> bool { + env.storage().persistent().get(&fee_exempt_key(env, addr)).unwrap_or(false) +} + +/// Adds `addr` to the fee exemption list. +pub fn add_fee_exempt(env: &Env, addr: &Address) { + env.storage().persistent().set(&fee_exempt_key(env, addr), &true); +} + +/// Removes `addr` from the fee exemption list. +pub fn remove_fee_exempt(env: &Env, addr: &Address) { + env.storage().persistent().remove(&fee_exempt_key(env, addr)); +} + fn sender_limit_key(env: &Env, sender: &Address) -> (Symbol, Address) { (Symbol::new(env, "sl"), sender.clone()) } diff --git a/contracts/stream/src/test.rs b/contracts/stream/src/test.rs index 9448e82..1abab57 100644 --- a/contracts/stream/src/test.rs +++ b/contracts/stream/src/test.rs @@ -1958,6 +1958,72 @@ fn test_interface_is_participant() { assert!(!c.is_participant(&stream_id, &other)); } +// ── Issue #187: cancel_stream with zero withdrawals ─────────────────────────── + +/// create stream → immediately cancel (no time passes, no withdrawal made). +/// Asserts: full deposit refunded to sender, claimable is 0, stream entry removed, +/// and StreamCancelled event emitted with correct refund amount. +#[test] +fn test_cancel_stream_with_zero_withdrawals() { + let t = setup(); + let c = client(&t); + t.env.ledger().set_timestamp(0); + + let initial_sender_bal = TokenClient::new(&t.env, &t.token_id).balance(&t.sender); + + let stream_id = c.create_stream( + &t.sender, &t.recipient, &t.token_id, &100_000, &1000, &0, &0u64, &false, &0u64, + &false, &Bytes::new(&t.env), + ); + + // Verify claimable is 0 immediately after creation (t=0, no time has elapsed) + let claimable_before = c.get_claimable(&stream_id); + assert_eq!(claimable_before, 0, "claimable must be 0 before any time passes"); + + // Cancel immediately — no time advances, no withdrawals made + c.cancel_stream(&stream_id, &t.sender); + + // Sender receives full deposit back + let sender_bal_after = TokenClient::new(&t.env, &t.token_id).balance(&t.sender); + assert_eq!( + sender_bal_after, initial_sender_bal, + "sender must receive full deposit refund", + ); + + // Recipient received nothing + let recipient_bal = TokenClient::new(&t.env, &t.token_id).balance(&t.recipient); + assert_eq!(recipient_bal, 0, "recipient must receive 0 when cancelled before cliff"); + + // Stream storage entry must be removed + assert!( + c.try_get_stream(&stream_id).is_err(), + "stream entry must be removed after cancel", + ); + + // StreamCancelled event with refund_amount = 100_000, recipient_amount = 0 + let events = t.env.events().all(); + let cancel_events: std::vec::Vec<_> = events.iter().filter(|(_, topics, _)| { + let topic_vec: soroban_sdk::Vec = topics.clone(); + if !topic_vec.is_empty() { + let first: soroban_sdk::Symbol = topic_vec.get(0).unwrap().into_val(&t.env); + first == soroban_sdk::Symbol::new(&t.env, "StreamCancelled") + } else { + false + } + }).collect(); + + assert_eq!(cancel_events.len(), 1, "Expected exactly one StreamCancelled event"); + + let (_, topics, data) = &cancel_events[0]; + let topics_vec: soroban_sdk::Vec = topics.clone(); + let topic_stream_id: u64 = topics_vec.get(1).unwrap().into_val(&t.env); + assert_eq!(topic_stream_id, stream_id); + + // Data: (sender: Address, refund_amount: i128, recipient_amount: i128) + let data_tuple: (Address, i128, i128) = data.clone().into_val(&t.env); + assert_eq!(data_tuple.0, t.sender); + assert_eq!(data_tuple.1, 100_000i128, "refund_amount must equal full deposit"); + assert_eq!(data_tuple.2, 0i128, "recipient_amount must be 0"); // --- #186: Emergency pause blocks create_stream and withdraw --- #[test] diff --git a/contracts/treasury/src/lib.rs b/contracts/treasury/src/lib.rs index fc4dc09..e4c7b52 100644 --- a/contracts/treasury/src/lib.rs +++ b/contracts/treasury/src/lib.rs @@ -1,13 +1,32 @@ #![no_std] +//! # SoroStream Treasury Contract +//! +//! Holds accumulated protocol fees and provides configurable distribution +//! between a treasury wallet and an LP reward pool. use soroban_sdk::{contract, contractimpl, token, Address, Env, Symbol}; const ADMIN_KEY: &str = "admin"; +const LP_POOL_KEY: &str = "lp_pool"; +const TREASURY_SPLIT_BPS_KEY: &str = "t_split"; fn read_admin(env: &Env) -> Option
{ env.storage().instance().get(&Symbol::new(env, ADMIN_KEY)) } +fn read_lp_pool(env: &Env) -> Option
{ + env.storage().instance().get(&Symbol::new(env, LP_POOL_KEY)) +} + +/// Returns the treasury split in basis points (100 bps = 1%). +/// The remainder (10_000 - treasury_bps) goes to the LP reward pool. +fn read_treasury_split_bps(env: &Env) -> u32 { + env.storage() + .instance() + .get(&Symbol::new(env, TREASURY_SPLIT_BPS_KEY)) + .unwrap_or(10_000u32) // default: 100% to treasury, 0% to LP +} + fn check_admin(env: &Env) { read_admin(env) .expect("treasury not initialized") @@ -87,6 +106,87 @@ impl TreasuryContract { } current } + + /// Sets the LP reward pool address. Only admin may call this. + pub fn set_lp_pool(env: Env, lp_pool: Address) { + check_admin(&env); + env.storage() + .instance() + .set(&Symbol::new(&env, LP_POOL_KEY), &lp_pool); + } + + /// Returns the LP reward pool address, if set. + pub fn get_lp_pool(env: Env) -> Option
{ + read_lp_pool(&env) + } + + /// Sets the treasury-to-LP split in basis points. + /// + /// `treasury_bps` is the fraction (in bps, 0–10_000) that goes to the treasury. + /// The remaining `10_000 - treasury_bps` goes to the LP reward pool. + /// Only admin may call this. + pub fn set_treasury_split(env: Env, treasury_bps: u32) { + check_admin(&env); + if treasury_bps > 10_000 { + panic!("treasury_bps exceeds 10_000"); + } + env.storage() + .instance() + .set(&Symbol::new(&env, TREASURY_SPLIT_BPS_KEY), &treasury_bps); + } + + /// Returns the configured treasury split in basis points. + pub fn get_treasury_split(env: Env) -> u32 { + read_treasury_split_bps(&env) + } + + /// Distributes all accumulated fees for `token` between the treasury wallet + /// (`destination`) and the LP reward pool according to the configured split. + /// + /// - `treasury_bps` of `total` goes to `destination`. + /// - The remainder goes to the LP pool (must be configured via `set_lp_pool`). + /// + /// Emits `FeeDistributed` with `(token, treasury_amount, lp_amount)`. + pub fn distribute(env: Env, token: Address, destination: Address) -> (i128, i128) { + check_admin(&env); + let key = balance_key(&env, &token); + let total: i128 = env.storage().persistent().get(&key).unwrap_or(0); + if total == 0 { + return (0, 0); + } + + let treasury_bps = read_treasury_split_bps(&env); + let treasury_amount = total * treasury_bps as i128 / 10_000; + let lp_amount = total - treasury_amount; + + // Clear the balance before transfers (checks-effects-interactions) + env.storage().persistent().remove(&key); + + let token_client = token::Client::new(&env, &token); + + if treasury_amount > 0 { + token_client.transfer( + &env.current_contract_address(), + &destination, + &treasury_amount, + ); + } + if lp_amount > 0 { + let lp_pool = read_lp_pool(&env).expect("lp_pool not configured"); + token_client.transfer( + &env.current_contract_address(), + &lp_pool, + &lp_amount, + ); + } + + env.events().publish( + (Symbol::new(&env, "FeeDistributed"),), + (token, treasury_amount, lp_amount), + ); + + (treasury_amount, lp_amount) + } } #[cfg(test)]