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
13 changes: 13 additions & 0 deletions contracts/rescue/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i128>) {
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.
Expand Down
72 changes: 63 additions & 9 deletions contracts/rescue/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -133,6 +135,16 @@ impl CalloraRescue {
return Err(RescueError::AmountNotPositive);
}

let stored_cap: Option<i128> = 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);
Expand All @@ -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(())
Expand All @@ -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<i128>,
) -> 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<i128> {
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,
Expand All @@ -193,8 +238,17 @@ impl CalloraRescue {
return Err(RescueError::AmountNotPositive);
}

let stored_cap: Option<i128> = 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);
}

Expand Down
37 changes: 37 additions & 0 deletions contracts/rescue/tests/proptest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}