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)]