-
Notifications
You must be signed in to change notification settings - Fork 37
feat(contracts): add rate limits, token whitelist, top-up, and expert cancel #244
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| //! Admin-managed configuration: rate-limit cooldowns (#236) and the | ||
| //! approved-token registry (#239). | ||
|
|
||
| use soroban_sdk::{Address, Env, Vec}; | ||
|
|
||
| use crate::{DataKey, Error}; | ||
|
|
||
| /// Default rate-limit cooldown: 0 disables per-address throttling. | ||
| const DEFAULT_RATE_LIMIT_MIN_LEDGERS: u32 = 0; | ||
|
|
||
| /// Reads the configured minimum ledger gap between rate-limited calls. | ||
| pub fn rate_limit_min_ledgers(env: &Env) -> u32 { | ||
| env.storage() | ||
| .instance() | ||
| .get(&DataKey::RateLimitMinLedgers) | ||
| .unwrap_or(DEFAULT_RATE_LIMIT_MIN_LEDGERS) | ||
| } | ||
|
|
||
| /// Persists the minimum ledger gap between rate-limited calls. | ||
| pub fn set_rate_limit_min_ledgers(env: &Env, min_ledgers: u32) { | ||
| env.storage() | ||
| .instance() | ||
| .set(&DataKey::RateLimitMinLedgers, &min_ledgers); | ||
| } | ||
|
|
||
| /// Enforces a per-address cooldown of at least `min_ledgers` ledgers | ||
| /// between successive calls. The last action ledger is stored under | ||
| /// `DataKey::LastAction(address)` in **temporary** storage so it | ||
| /// auto-expires and does not accumulate rent. | ||
| pub fn rate_limit(env: &Env, caller: &Address, min_ledgers: u32) -> Result<(), Error> { | ||
| if min_ledgers == 0 { | ||
| return Ok(()); | ||
| } | ||
|
|
||
| let key = DataKey::LastAction(caller.clone()); | ||
| let current = env.ledger().sequence(); | ||
|
|
||
| if let Some(last) = env | ||
| .storage() | ||
| .temporary() | ||
| .get::<DataKey, u32>(&key) | ||
| { | ||
| if current.saturating_sub(last) < min_ledgers { | ||
| return Err(Error::RateLimitExceeded); | ||
| } | ||
| } | ||
|
|
||
| env.storage().temporary().set(&key, ¤t); | ||
|
|
||
| // Keep the tombstone alive through the cooldown window. | ||
| let extend_to = min_ledgers.saturating_add(10); | ||
| env.storage() | ||
| .temporary() | ||
| .extend_ttl(&key, min_ledgers, extend_to); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| /// Returns the admin-maintained list of approved payment tokens. | ||
| pub fn approved_tokens(env: &Env) -> Vec<Address> { | ||
| env.storage() | ||
| .persistent() | ||
| .get(&DataKey::ApprovedTokens) | ||
| .unwrap_or_else(|| Vec::new(env)) | ||
| } | ||
|
|
||
| fn save_approved_tokens(env: &Env, tokens: &Vec<Address>) { | ||
| env.storage() | ||
| .persistent() | ||
| .set(&DataKey::ApprovedTokens, tokens); | ||
| } | ||
|
|
||
| /// Returns `true` when `token` is present in the approved-token registry. | ||
| pub fn is_token_whitelisted(env: &Env, token: &Address) -> bool { | ||
| let tokens = approved_tokens(env); | ||
| for i in 0..tokens.len() { | ||
| if tokens.get(i).unwrap() == *token { | ||
| return true; | ||
| } | ||
| } | ||
| false | ||
| } | ||
|
|
||
| /// Rejects `token` unless it appears in the approved-token registry. | ||
| pub fn require_token_whitelisted(env: &Env, token: &Address) -> Result<(), Error> { | ||
| if is_token_whitelisted(env, token) { | ||
| Ok(()) | ||
| } else { | ||
| Err(Error::TokenNotWhitelisted) | ||
| } | ||
| } | ||
|
|
||
| /// Appends `token` to the approved-token registry. | ||
| pub fn add_approved_token(env: &Env, token: Address) -> Result<(), Error> { | ||
| let mut tokens = approved_tokens(env); | ||
| for i in 0..tokens.len() { | ||
| if tokens.get(i).unwrap() == token { | ||
| return Err(Error::TokenAlreadyWhitelisted); | ||
| } | ||
| } | ||
| tokens.push_back(token); | ||
| save_approved_tokens(env, &tokens); | ||
| Ok(()) | ||
| } | ||
|
|
||
| /// Removes `token` from the approved-token registry. | ||
| pub fn remove_approved_token(env: &Env, token: Address) -> Result<(), Error> { | ||
| let tokens = approved_tokens(env); | ||
| let mut next = Vec::new(env); | ||
| let mut found = false; | ||
| for i in 0..tokens.len() { | ||
| let entry = tokens.get(i).unwrap(); | ||
| if entry == token { | ||
| found = true; | ||
| } else { | ||
| next.push_back(entry); | ||
| } | ||
| } | ||
| if !found { | ||
| return Err(Error::TokenNotInWhitelist); | ||
| } | ||
| save_approved_tokens(env, &next); | ||
| Ok(()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| //! Expert-initiated session cancellation with partial refund (#238). | ||
|
|
||
| use soroban_sdk::{token, Address, Env, String}; | ||
|
|
||
| use crate::{ | ||
| events, DataKey, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW, | ||
| }; | ||
|
|
||
| /// Cancels an active or paused session on behalf of the expert. | ||
| /// | ||
| /// Accrued (claimable) earnings are paid to the expert; the remaining | ||
| /// escrow balance is refunded to the seeker. The cancellation reason | ||
| /// CID is stored for transparency and the session status becomes | ||
| /// `CancelledByExpert`. | ||
| pub fn cancel_session_by_expert( | ||
| env: &Env, | ||
| expert: Address, | ||
| session_id: u64, | ||
| reason_cid: String, | ||
| ) -> Result<(i128, i128), Error> { | ||
| SkillSphereContract::assert_not_locked(env)?; | ||
| SkillSphereContract::set_reentrancy_lock(env, true); | ||
|
|
||
| expert.require_auth(); | ||
|
|
||
| if !SkillSphereContract::is_valid_ipfs_cid(&reason_cid) { | ||
| SkillSphereContract::set_reentrancy_lock(env, false); | ||
| return Err(Error::InvalidCid); | ||
| } | ||
|
|
||
| let mut session = SkillSphereContract::get_session_or_error(env, session_id)?; | ||
|
|
||
| if expert != session.expert { | ||
| SkillSphereContract::set_reentrancy_lock(env, false); | ||
| return Err(Error::Unauthorized); | ||
| } | ||
|
|
||
| if !matches!( | ||
| session.status, | ||
| SessionStatus::Active | SessionStatus::Paused | ||
| ) { | ||
| SkillSphereContract::set_reentrancy_lock(env, false); | ||
| return Err(Error::InvalidSessionState); | ||
| } | ||
|
|
||
| let now = env.ledger().timestamp(); | ||
| let effective_time = SkillSphereContract::bounded_time(&session, now); | ||
| let claimable = SkillSphereContract::claimable_amount_for_session(&session, effective_time); | ||
| let remaining = session.balance.saturating_sub(claimable); | ||
|
|
||
| session.balance = 0; | ||
| session.accrued_amount = 0; | ||
| session.last_settlement_timestamp = effective_time as u32; | ||
| session.status = SessionStatus::CancelledByExpert; | ||
| SkillSphereContract::save_session(env, &session); | ||
|
|
||
| env.storage() | ||
| .persistent() | ||
| .set(&DataKey::SessionCancelReason(session_id), &reason_cid); | ||
|
|
||
| let token_client = token::Client::new(env, &session.token); | ||
|
|
||
| let mut expert_payout = claimable; | ||
| let mut seeker_refund = remaining; | ||
| if expert_payout < MIN_SESSION_ESCROW { | ||
| expert_payout = 0; | ||
| } | ||
| if seeker_refund < MIN_SESSION_ESCROW { | ||
| seeker_refund = 0; | ||
| } | ||
|
Comment on lines
+63
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Potential fund lock when both payouts fall below If both Consider either:
🤖 Prompt for AI Agents |
||
|
|
||
| if expert_payout > 0 { | ||
| token_client.transfer( | ||
| &env.current_contract_address(), | ||
| &session.expert, | ||
| &expert_payout, | ||
| ); | ||
| } | ||
| if seeker_refund > 0 { | ||
| token_client.transfer( | ||
| &env.current_contract_address(), | ||
| &session.seeker, | ||
| &seeker_refund, | ||
| ); | ||
| } | ||
|
|
||
| events::publish_expert_cancel( | ||
| env, | ||
| session_id, | ||
| expert, | ||
| expert_payout, | ||
| seeker_refund, | ||
| reason_cid, | ||
| ); | ||
|
|
||
| SkillSphereContract::set_reentrancy_lock(env, false); | ||
| Ok((expert_payout, seeker_refund)) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| //! Centralised event publishing helpers for session lifecycle events. | ||
|
|
||
| use soroban_sdk::{symbol_short, Env}; | ||
|
|
||
| /// Emitted when a seeker tops up an active session escrow (#237). | ||
| pub fn publish_top_up(env: &Env, session_id: u64, amount: i128, new_balance: i128) { | ||
| env.events().publish( | ||
| (symbol_short!("session"), symbol_short!("topup")), | ||
| (session_id, amount, new_balance), | ||
| ); | ||
| } | ||
|
|
||
| /// Emitted when an expert cancels their session (#238). | ||
| pub fn publish_expert_cancel( | ||
| env: &Env, | ||
| session_id: u64, | ||
| expert: soroban_sdk::Address, | ||
| expert_payout: i128, | ||
| seeker_refund: i128, | ||
| reason_cid: soroban_sdk::String, | ||
| ) { | ||
| env.events().publish( | ||
| (symbol_short!("session"), symbol_short!("expcncl")), | ||
| ( | ||
| session_id, | ||
| expert, | ||
| expert_payout, | ||
| seeker_refund, | ||
| reason_cid, | ||
| ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reentrancy lock not released when
get_session_or_errorfails.If
get_session_or_errorreturns an error, the?operator propagates immediately without callingset_reentrancy_lock(env, false). This leaves the contract in a locked state, blocking all subsequent operations that check reentrancy.🐛 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents