From 134ff4985b68ed3ca432881e3685e7cd2694f3a7 Mon Sep 17 00:00:00 2001 From: Liam Phung Date: Fri, 16 Oct 2026 14:55:33 +0700 Subject: [PATCH] feat(freeze,revenue_pool): wire CalloraFreeze into RevenuePool distributions (#1218) --- contracts/freeze/README.md | 7 ++++ contracts/freeze/tests/malformed_freeze.rs | 43 +++++++++++++++++++- contracts/revenue_pool/src/lib.rs | 47 +++++++++++++++++++++- 3 files changed, 94 insertions(+), 3 deletions(-) diff --git a/contracts/freeze/README.md b/contracts/freeze/README.md index e0b5c3b4..2d7c7a86 100644 --- a/contracts/freeze/README.md +++ b/contracts/freeze/README.md @@ -148,6 +148,13 @@ after `unfreeze()` or before the first `freeze()`. Returns `FreezeError::NotInitialized` if `init` has not been called. +## Revenue Pool Integration Model + +`CalloraFreeze` can operate either as a standalone circuit-breaker or wired into `RevenuePool`: + +1. **Unconfigured (default)**: When `RevenuePool::get_freeze_contract()` is `None`, `RevenuePool` relies solely on its internal `pause` / `emergency_pause` flags. +2. **Wired via `RevenuePool::set_freeze_contract`**: The pool admin calls `set_freeze_contract(admin, Some(freeze_contract_addr))`. Every `RevenuePool::is_paused`, `distribute`, and `batch_distribute` call then invokes `CalloraFreeze::is_frozen()` across contracts, rejecting distributions with `RevenuePoolError::Paused` whenever `CalloraFreeze` is frozen. + ## Running tests ```bash diff --git a/contracts/freeze/tests/malformed_freeze.rs b/contracts/freeze/tests/malformed_freeze.rs index 6a6383b4..2fc6a0b9 100644 --- a/contracts/freeze/tests/malformed_freeze.rs +++ b/contracts/freeze/tests/malformed_freeze.rs @@ -2,11 +2,11 @@ extern crate std; -use callora_freeze::{FreezeOp, MAX_FREEZE_OPS}; +use callora_freeze::{CalloraFreeze, CalloraFreezeClient, FreezeOp, MAX_FREEZE_OPS}; use callora_revenue_pool::{RevenuePool, RevenuePoolClient}; use soroban_sdk::testutils::Address as _; use soroban_sdk::token::StellarAssetClient; -use soroban_sdk::{Address, Env}; +use soroban_sdk::{Address, Env, Symbol}; use std::panic::{catch_unwind, AssertUnwindSafe}; fn setup(env: &Env) -> (Address, Address, Address, Address, RevenuePoolClient<'_>) { @@ -147,3 +147,42 @@ fn fuzz_style_sequence_with_malformed_mix() { assert_eq!(pool.is_paused(), paused); } } + +#[test] +fn freeze_contract_blocks_revenue_pool_distribute_when_wired() { + let env = Env::default(); + let (admin, _, recipient, pool_addr, pool) = setup(&env); + let usdc_addr = pool.get_usdc_token(); + let usdc = soroban_sdk::token::Client::new(&env, &usdc_addr); + + let freeze_addr = env.register(CalloraFreeze, ()); + let freeze = CalloraFreezeClient::new(&env, &freeze_addr); + freeze.init(&admin); + + // Unconfigured freeze address keeps current behaviour: freezing CalloraFreeze + // before wiring does not pause the revenue pool. + assert_eq!(pool.get_freeze_contract(), None); + freeze.freeze(&admin, &Symbol::new(&env, "standalone")); + assert!(!pool.is_paused()); + pool.distribute(&admin, &recipient, &100); + assert_eq!(usdc.balance(&recipient), 100); + + // Wire CalloraFreeze into RevenuePool: active freeze immediately blocks distribute + // and batch_distribute. + pool.set_freeze_contract(&admin, &Some(freeze_addr.clone())); + assert_eq!(pool.get_freeze_contract(), Some(freeze_addr)); + assert!(pool.is_paused()); + + let before = usdc.balance(&pool_addr); + assert!(catch_unwind(AssertUnwindSafe(|| { + pool.distribute(&admin, &recipient, &100) + })) + .is_err()); + assert_eq!(usdc.balance(&pool_addr), before); + + // Unfreezing via CalloraFreeze restores RevenuePool distributions. + freeze.unfreeze(&admin); + assert!(!pool.is_paused()); + pool.distribute(&admin, &recipient, &150); + assert_eq!(usdc.balance(&recipient), 250); +} diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/revenue_pool/src/lib.rs index a59e9eb6..07b638c6 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/revenue_pool/src/lib.rs @@ -37,6 +37,7 @@ const PAUSED_KEY: &str = "paused"; const EMERGENCY_PAUSED_KEY: &str = "emergency_paused"; const PENDING_ADMIN_KEY: &str = "pending_admin"; const PAUSE_GUARDIAN_KEY: &str = "pause_guardian"; +const FREEZE_CONTRACT_KEY: &str = "freeze_contract"; const MAX_DISTRIBUTE_KEY: &str = "max_distribute"; const CUMULATIVE_YIELD_DEPOSITED_KEY: &str = "cumulative_yield"; const VERSION_KEY: &str = "version"; @@ -179,6 +180,18 @@ impl RevenuePool { .extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT); } + fn is_external_frozen(env: &Env) -> bool { + if let Some(freeze_addr) = env + .storage() + .instance() + .get::<_, Address>(&Symbol::new(env, FREEZE_CONTRACT_KEY)) + { + env.invoke_contract::(&freeze_addr, &Symbol::new(env, "is_frozen"), Vec::new(env)) + } else { + false + } + } + fn require_not_paused(env: &Env) { Self::require_not_emergency_paused(env); if env @@ -186,6 +199,7 @@ impl RevenuePool { .instance() .get::<_, bool>(&Symbol::new(env, PAUSED_KEY)) .unwrap_or(false) + || Self::is_external_frozen(env) { env.panic_with_error(RevenuePoolError::Paused); } @@ -466,13 +480,44 @@ impl RevenuePool { .publish((events::event_pause_set(&env), caller), false); } - /// Return `true` if the revenue pool is currently paused. + /// Configure or clear an external `CalloraFreeze` circuit-breaker contract. + /// + /// When configured, `is_paused` and `require_not_paused` (`distribute` / + /// `batch_distribute`) consult `is_frozen()` on the freeze contract via a + /// cross-contract call. Passing `None` disconnects the external freeze contract. + /// + /// # Errors + /// * [`RevenuePoolError::Unauthorized`] - caller is not the current admin. + pub fn set_freeze_contract(env: Env, caller: Address, freeze_contract: Option
) { + caller.require_auth(); + Self::require_not_emergency_paused(&env); + Self::require_admin(&env, &caller); + let inst = env.storage().instance(); + let key = Symbol::new(&env, FREEZE_CONTRACT_KEY); + match freeze_contract { + Some(addr) => inst.set(&key, &addr), + None => inst.remove(&key), + } + inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT); + } + + /// Return the configured external freeze contract address, or `None` if unset. + pub fn get_freeze_contract(env: Env) -> Option
{ + Self::bump_instance_ttl(&env); + env.storage() + .instance() + .get(&Symbol::new(&env, FREEZE_CONTRACT_KEY)) + } + + /// Return `true` if the revenue pool is currently paused locally or frozen + /// via a configured external `CalloraFreeze` contract. pub fn is_paused(env: Env) -> bool { Self::bump_instance_ttl(&env); env.storage() .instance() .get::<_, bool>(&Symbol::new(&env, PAUSED_KEY)) .unwrap_or(false) + || Self::is_external_frozen(&env) } /// Activate recovery-only emergency mode.