diff --git a/contracts/rescue/src/events.rs b/contracts/rescue/src/events.rs index 1ba1c23f..5dd8bf87 100644 --- a/contracts/rescue/src/events.rs +++ b/contracts/rescue/src/events.rs @@ -157,6 +157,19 @@ pub fn event_version_v1(env: &Env) -> Symbol { Symbol::new(env, "callora.v1") } +/// Returns the Symbol for the `"rescue_cap_set"` event topic. +pub fn event_rescue_cap_set(env: &Env) -> Symbol { + Symbol::new(env, "rescue_cap_set") +} + +/// Emit `"rescue_cap_set"` when the per-token rescue cap is updated in storage. +pub fn emit_rescue_cap_set(env: &Env, admin: &Address, token: &Address, cap: Option) { + env.events().publish( + (event_rescue_cap_set(env), admin.clone(), token.clone()), + cap, + ); +} + // ─── Emit helpers ──────────────────────────────────────────────────────────── /// Emit `"initialized"` once when the rescue contract is first set up. diff --git a/contracts/rescue/src/lib.rs b/contracts/rescue/src/lib.rs index 9b02196c..d9cb9699 100644 --- a/contracts/rescue/src/lib.rs +++ b/contracts/rescue/src/lib.rs @@ -59,6 +59,8 @@ pub enum DataKey { Admin, /// Running total of all tokens rescued across all calls (instance storage). TotalRescued, + /// Per-token rescue cap stored on-chain (persistent storage). + RescueCap(Address), } // --------------------------------------------------------------------------- @@ -133,6 +135,16 @@ impl CalloraRescue { return Err(RescueError::AmountNotPositive); } + let stored_cap: Option = env + .storage() + .persistent() + .get(&DataKey::RescueCap(token.clone())); + if let Some(cap) = stored_cap { + if amount > cap { + return Err(RescueError::ExceedsCap); + } + } + let token_client = token::Client::new(&env, &token); let vault = env.current_contract_address(); let on_ledger = token_client.balance(&vault); @@ -153,14 +165,12 @@ impl CalloraRescue { let next = prev.checked_add(amount).ok_or(RescueError::Overflow)?; env.storage().instance().set(&DataKey::TotalRescued, &next); - let payload = events::RescueEvent::new( - &env, - token.clone(), - to.clone(), - amount, - events::RescueCap::None, - next, - ); + let event_cap = match stored_cap { + Some(c) => events::RescueCap::Some(c), + None => events::RescueCap::None, + }; + let payload = + events::RescueEvent::new(&env, token.clone(), to.clone(), amount, event_cap, next); events::emit_rescue(&env, &admin, &payload); Ok(()) @@ -177,6 +187,41 @@ impl CalloraRescue { /// # Errors /// All errors from [`rescue`], plus: /// - [`RescueError::ExceedsCap`] if `amount > cap`. + /// Configure or clear the stored per-token rescue cap enforced by [`Self::rescue`]. + /// + /// Passing `Some(cap)` requires `cap > 0`. Passing `None` removes the per-token cap. + pub fn set_rescue_cap( + env: Env, + admin: Address, + token: Address, + cap: Option, + ) -> Result<(), RescueError> { + admin.require_auth(); + Self::assert_admin(&env, &admin)?; + + let key = DataKey::RescueCap(token.clone()); + match cap { + Some(c) => { + if c <= 0 { + return Err(RescueError::ExceedsCap); + } + env.storage().persistent().set(&key, &c); + } + None => { + env.storage().persistent().remove(&key); + } + } + + events::emit_rescue_cap_set(&env, &admin, &token, cap); + Ok(()) + } + + /// Return the stored per-token rescue cap, if configured. + pub fn get_rescue_cap(env: Env, token: Address) -> Option { + env.storage().persistent().get(&DataKey::RescueCap(token)) + } + + #[deprecated(note = "Configure per-token caps via set_rescue_cap and call rescue instead")] pub fn rescue_capped( env: Env, admin: Address, @@ -193,8 +238,17 @@ impl CalloraRescue { return Err(RescueError::AmountNotPositive); } + let stored_cap: Option = env + .storage() + .persistent() + .get(&DataKey::RescueCap(token.clone())); + let effective_cap = match stored_cap { + Some(sc) => sc.min(cap), + None => cap, + }; + // cap must also be positive; use checked comparison. - if cap <= 0 || amount > cap { + if effective_cap <= 0 || amount > effective_cap { return Err(RescueError::ExceedsCap); } diff --git a/contracts/rescue/tests/proptest.rs b/contracts/rescue/tests/proptest.rs index 2a2a2289..25caa0f5 100644 --- a/contracts/rescue/tests/proptest.rs +++ b/contracts/rescue/tests/proptest.rs @@ -576,3 +576,40 @@ fn rescue_capped_enforces_cap_above_balance() { assert_eq!(client.total_rescued(), 0); assert_eq!(token_client.balance(&contract_id), 10_000); } + +/// Rescue above the stored per-token cap fails with ExceedsCap and leaves state unchanged. +#[test] +fn rescue_enforces_stored_per_token_cap_and_emits_cap_event() { + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + let contract_id = env.register(CalloraRescue, ()); + let client = CalloraRescueClient::new(&env, &contract_id); + let to = Address::generate(&env); + + let token_admin = Address::generate(&env); + let token_addr = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + let token_client = token::Client::new(&env, &token_addr); + token::StellarAssetClient::new(&env, &token_addr).mint(&contract_id, &10_000); + + client.init(&admin); + client.set_rescue_cap(&admin, &token_addr, &Some(1_500)); + assert_eq!(client.get_rescue_cap(&token_addr), Some(1_500)); + + // Rescue above stored cap must fail with ExceedsCap + let err = client + .try_rescue(&admin, &token_addr, &to, &1_501) + .unwrap_err() + .unwrap(); + assert_eq!(err, RescueError::ExceedsCap); + assert_eq!(client.total_rescued(), 0); + assert_eq!(token_client.balance(&contract_id), 10_000); + + // Rescue within stored cap succeeds + client.rescue(&admin, &token_addr, &to, &1_500); + assert_eq!(client.total_rescued(), 1_500); + assert_eq!(token_client.balance(&contract_id), 8_500); +}