Skip to content
Merged
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
4 changes: 4 additions & 0 deletions contracts/vault/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,4 +114,8 @@ pub enum VaultError {
NewRevenuePoolSameAsCurrent = 33,
/// No revenue pool transfer is pending (code 34).
NoRevenuePoolTransferPending = 34,
/// Calculated fee in basis points exceeds the caller-supplied `max_fee_bps` limit (code 35).
Slippage = 35,
/// Rate limit exceeded for the developer (code 36).
RateLimited = 36,
}
42 changes: 40 additions & 2 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,16 @@ pub enum VaultError {
NoRevenuePoolTransferPending = 34,
/// Calculated fee in basis points exceeds the caller-supplied `max_fee_bps` limit (code 35).
Slippage = 35,
/// Rate limit exceeded for the developer (code 36).
RateLimited = 36,
}

#[contracttype]
#[derive(Clone)]
pub struct DeductItem {
pub amount: i128,
pub request_id: Option<Symbol>,
pub developer: Address,
}

#[contracttype]
Expand Down Expand Up @@ -184,6 +187,10 @@ pub enum StorageKey {
/// Monotonic u64 nonce incremented on every successful `set_authorized_caller`
/// rotation. Defaults to `0` before the first rotation.
AuthorizedCallerNonce,
/// Configuration for a developer's rate limit.
DeveloperConfig(Address),
/// Current rate limit state for a developer.
DeveloperState(Address),
}

/// Settlement contract client for crediting the global pool.
Expand Down Expand Up @@ -783,6 +790,7 @@ impl CalloraVault {
amount: i128,
request_id: Option<Symbol>,
max_fee_bps: u16,
developer: Address,
) -> Result<i128, VaultError> {
Self::require_not_paused(env.clone())?;
caller.require_auth();
Expand All @@ -798,6 +806,10 @@ impl CalloraVault {
if let Some(ref rid) = request_id {
Self::require_not_duplicate(&env, rid)?;
}

// Rate limit check
crate::rate_limit::consume_tokens(&env, &developer, amount)?;

let meta = Self::get_meta(env.clone())?;
if meta.balance < amount {
return Err(VaultError::InsufficientBalance);
Expand Down Expand Up @@ -834,7 +846,7 @@ impl CalloraVault {
&env.current_contract_address(),
&amount,
&true, // to_pool = true: credit global pool
&None, // no specific developer
&Some(developer.clone()), // developer is passed down
);

// Now that external operations succeeded, update internal state
Expand Down Expand Up @@ -919,6 +931,10 @@ impl CalloraVault {
}
seen_in_batch.push_back(rid.clone());
}

// Rate limit check
crate::rate_limit::consume_tokens(&env, &item.developer, item.amount)?;

running = running
.checked_sub(item.amount)
.ok_or(VaultError::Overflow)?;
Expand All @@ -941,7 +957,7 @@ impl CalloraVault {
&env.current_contract_address(),
&total,
&true, // to_pool = true: credit global pool
&None, // no specific developer
&None, // developers are tracked per-item, not passed for whole batch
);

// Now that external operations succeeded, update internal state
Expand Down Expand Up @@ -1685,9 +1701,28 @@ impl CalloraVault {
.get(&StorageKey::DepositorList)
.unwrap_or(Vec::new(&env))
}

pub fn set_developer_rate_limit(
env: Env,
caller: Address,
developer: Address,
capacity: i128,
refill_rate: i128,
) -> Result<(), VaultError> {
caller.require_auth();
Self::require_owner(env.clone(), caller.clone())?;

let config = crate::rate_limit::RateLimitConfig {
capacity,
refill_rate,
};
crate::rate_limit::set_config(&env, &developer, &config);
Ok(())
}
}

mod events;
pub mod rate_limit;

// ---------------------------------------------------------------------------
// Test modules
Expand Down Expand Up @@ -1719,3 +1754,6 @@ mod test_reentrancy;

#[cfg(test)]
mod test_balance_property;

#[cfg(test)]
mod test_rate_limit;
73 changes: 73 additions & 0 deletions contracts/vault/src/rate_limit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
use soroban_sdk::{contracttype, Address, Env};

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RateLimitConfig {
pub capacity: i128,
pub refill_rate: i128, // Refill amount per ledger
}

#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RateLimitState {
pub tokens: i128,
pub last_updated_ledger: u32,
}

// TTL configuration for rate limit state
pub const RATE_LIMIT_BUMP_AMOUNT: u32 = 17_280 * 30; // ~30 days
pub const RATE_LIMIT_BUMP_THRESHOLD: u32 = 17_280 * 7; // ~7 days

/// Set the rate limit config for a specific developer.
pub fn set_config(env: &Env, developer: &Address, config: &RateLimitConfig) {
env.storage().instance().set(&crate::StorageKey::DeveloperConfig(developer.clone()), config);
}

/// Get the rate limit config for a specific developer.
pub fn get_config(env: &Env, developer: &Address) -> Option<RateLimitConfig> {
env.storage().instance().get(&crate::StorageKey::DeveloperConfig(developer.clone()))
}

/// Get the current rate limit state for a developer.
pub fn get_state(env: &Env, developer: &Address) -> Option<RateLimitState> {
env.storage().persistent().get(&crate::StorageKey::DeveloperState(developer.clone()))
}

/// Consume tokens from the developer's token bucket.
/// Applies the amortized refill based on elapsed ledgers before checking the limit.
pub fn consume_tokens(env: &Env, developer: &Address, amount: i128) -> Result<(), crate::VaultError> {
let config = match get_config(env, developer) {
Some(c) => c,
None => return Ok(()), // No rate limit configured
};

let current_ledger = env.ledger().sequence();

let mut state = get_state(env, developer).unwrap_or_else(|| RateLimitState {
tokens: config.capacity,
last_updated_ledger: current_ledger,
});

if current_ledger > state.last_updated_ledger {
let elapsed = (current_ledger - state.last_updated_ledger) as i128;
if let Some(refilled) = elapsed.checked_mul(config.refill_rate) {
state.tokens = state.tokens.saturating_add(refilled);
if state.tokens > config.capacity {
state.tokens = config.capacity;
}
}
state.last_updated_ledger = current_ledger;
}

if state.tokens < amount {
return Err(crate::VaultError::RateLimited);
}

state.tokens = state.tokens.checked_sub(amount).ok_or(crate::VaultError::Overflow)?;

let state_key = crate::StorageKey::DeveloperState(developer.clone());
env.storage().persistent().set(&state_key, &state);
env.storage().persistent().extend_ttl(&state_key, RATE_LIMIT_BUMP_THRESHOLD, RATE_LIMIT_BUMP_AMOUNT);

Ok(())
}
Loading
Loading