Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions contracts/freeze/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 41 additions & 2 deletions contracts/freeze/tests/malformed_freeze.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<'_>) {
Expand Down Expand Up @@ -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);
}
47 changes: 46 additions & 1 deletion contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -179,13 +180,26 @@ 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::<bool>(&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
.storage()
.instance()
.get::<_, bool>(&Symbol::new(env, PAUSED_KEY))
.unwrap_or(false)
|| Self::is_external_frozen(env)
{
env.panic_with_error(RevenuePoolError::Paused);
}
Expand Down Expand Up @@ -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<Address>) {
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<Address> {
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.
Expand Down