Skip to content

feat(contracts): add rate limits, token whitelist, top-up, and expert cancel#244

Merged
Luluameh merged 1 commit into
LightForgeHub:mainfrom
jahrulezfrancis:feat/rate-limit-whitelist-topup-cancel
May 31, 2026
Merged

feat(contracts): add rate limits, token whitelist, top-up, and expert cancel#244
Luluameh merged 1 commit into
LightForgeHub:mainfrom
jahrulezfrancis:feat/rate-limit-whitelist-topup-cancel

Conversation

@jahrulezfrancis
Copy link
Copy Markdown
Contributor

@jahrulezfrancis jahrulezfrancis commented May 30, 2026

Summary

Implements four session lifecycle and security hardening features for the SkillSphere Soroban contract:

  • Rate-Limit Guard Per Address #236 Rate-Limit Guard Per Address — Per-address ledger cooldown using DataKey::LastAction(Address) in temporary storage. Admin-configurable via set_rate_limit_min_ledgers. Enforced on start_session and heartbeat.
  • Whitelisted Token Registry #239 Whitelisted Token Registry — Admin-managed Vec<Address> of approved payment tokens. start_session / start_session_with_swap reject unlisted tokens with Error::TokenNotWhitelisted.
  • Expert-Initiated Session Cancellation with Partial Refund #238 Expert-Initiated Session Cancellation — Experts can call cancel_session(session_id, reason_cid) on their own Active/Paused sessions. Accrued earnings go to the expert, remainder is refunded to the seeker, reason CID is stored, status becomes CancelledByExpert.
  • Session Renewal / Top-Up Function #237 Session Top-Up — Seekers can call top_up_session(session_id, amount) on Active sessions to add escrow funds without reopening the session. Emits TopUpEvent (session/topup).

New / changed files

File Purpose
contracts/src/admin.rs Rate-limit helper + approved-token registry
contracts/src/disputes.rs Expert cancellation settlement logic
contracts/src/events.rs Top-up and expert-cancel event publishers
contracts/src/lib.rs Public API wiring, SessionStatus::CancelledByExpert, tests
contracts/src/errors.rs RateLimitExceeded, TokenNotWhitelisted, etc.

New public API

Function Description
set_rate_limit_min_ledgers(min_ledgers) Admin: minimum ledger gap between rate-limited calls (0 = off)
get_rate_limit_min_ledgers() Read current rate-limit config
add_approved_token(token) Admin: whitelist a payment token
remove_approved_token(token) Admin: remove a payment token
is_token_approved(token) Check whitelist membership
get_approved_tokens() Return full whitelist
cancel_session(expert, session_id, reason_cid) Expert cancels session with partial refund
get_session_cancel_reason(session_id) Read stored cancellation reason CID
top_up_session(seeker, session_id, amount) Seeker tops up active session escrow

New errors

Code Variant
50 RateLimitExceeded
51 TokenNotWhitelisted
52 TokenAlreadyWhitelisted
53 TokenNotInWhitelist

Migration notes

  • Token whitelist is enforced immediately. Existing deployments must call add_approved_token for each accepted payment asset before new sessions can start.
  • Rate limiting defaults to disabled (min_ledgers = 0). Enable via admin when ready.
  • CancelledByExpert is treated as a terminal session state alongside Completed, Disputed, and Resolved.

Test plan

  • cargo test in contracts/ — all existing + new tests pass
  • Rate limit: rapid start_session / heartbeat blocked within cooldown; allowed after ledger gap
  • Token whitelist: start_session fails for non-whitelisted token; succeeds after add_approved_token
  • Top-up: balance increases, settlement works on topped-up escrow
  • Expert cancel: expert receives accrued amount, seeker receives remainder, status is CancelledByExpert, reason CID stored
  • Expert cancel rejected for seeker / non-participant
  • Multi-token tests still pass with explicit whitelisting of secondary tokens

Closes #236
Closes #237
Closes #238
Closes #239

Summary by CodeRabbit

Release Notes

  • New Features
    • Rate limit configuration: Admins can set action cooldown periods
    • Token whitelist system: Admins can manage approved payment tokens
    • Expert session cancellation: Experts can cancel sessions and provide a reason
    • Session top-up: Seekers can add additional funds to active sessions
    • Session event tracking: New lifecycle change events

Review Change Stack

… cancel

Implements LightForgeHub#236LightForgeHub#239: per-address ledger cooldowns, admin-managed payment
token registry, seeker session top-ups, and expert-initiated cancellation
with partial refund and stored reason CID.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 30, 2026

📝 Walkthrough

Walkthrough

This PR introduces admin-managed rate limiting and token whitelisting, expert session cancellation with partial refunds, and seeker top-up capability. New error types, event publishers, and contract methods integrate these features into the session lifecycle with comprehensive test coverage.

Changes

Admin Controls and Session Features

Layer / File(s) Summary
Error Types and Event Publishing
contracts/src/errors.rs, contracts/src/events.rs
New error variants for rate limiting and token-whitelist operations; event-publishing helpers for top-up and expert-cancellation events.
Rate Limit Configuration and Enforcement
contracts/src/admin.rs (lines 1–57)
Per-address ledger-based cooldown using temporary storage with configurable threshold, persistent min-ledger setting, and automatic TTL extension.
Token Whitelist Registry
contracts/src/admin.rs (lines 59–124)
Persistent approved-token list with add/remove/query operations, duplicate prevention, and whitelist-membership guard function.
Expert Session Cancellation Logic
contracts/src/disputes.rs
Implements expert-only cancellation with authorization, CID validation, claimable vs. remaining escrow computation, status transition, metadata persistence, token transfers, and event publishing.
Schema Extensions and Helper Visibility
contracts/src/lib.rs (DataKey, SessionStatus, constants, helpers)
Extends storage schema for rate limiting, token registry, and cancellation metadata; adds CancelledByExpert terminal status; widens helper-function visibility for cross-module reuse.
Admin API Endpoints
contracts/src/lib.rs (lines 1410–1460)
Six new contract methods for rate-limit and token-whitelist configuration and querying.
Session Lifecycle Rate-Limit and Whitelist Enforcement
contracts/src/lib.rs (heartbeat, start_session, start_session_with_swap)
Integrates rate-limit and token-whitelist checks into session-control flows with failure handling.
New Session Lifecycle Features
contracts/src/lib.rs (lines 2171–2237)
Expert cancellation, seeker top-up, and cancellation-reason querying endpoints.
Terminal State Recognition in Settlement and Closure
contracts/src/lib.rs (settlement, session closing)
Recognizes CancelledByExpert as terminal state to allow proper settlement and closure.
Test Suite Updates
contracts/src/lib.rs (test helpers, setup, multi-token, settlement, acceptance, treasury, new tests)
Whitelist-token helper, setup integration, and comprehensive tests for rate limiting, token whitelist, top-up, and expert cancellation flows.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • LightForgeHub/SkillSphere-Dapp#111: Both PRs modify session lifecycle and terminal-state guards in contracts/src/lib.rs by adding the CancelledByExpert status alongside streaming escrow settlement logic.
  • LightForgeHub/SkillSphere-Dapp#124: Both PRs address session dispute resolution and token-transfer reentrancy handling; the main PR adds expert cancellation and events while the retrieved PR adds dispute flagging and resolution flows.
  • LightForgeHub/SkillSphere-Dapp#224: Both PRs extend start_session gating in contracts/src/lib.rs by adding control checks (main: rate limiting and token whitelisting; retrieved: heartbeat freshness).

Poem

🐰 Hop, hop, the experts now can flee,
With whitelists and rate-limits—woohoo, spree!
Top up thy sessions, cancel with grace,
Admin controls rule this smart-contract space!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes all four main features added in the PR: rate limits, token whitelist, top-up functionality, and expert cancellation.
Linked Issues check ✅ Passed All four linked issues (#236-#239) have their primary objectives met: rate-limit guards with configurable cooldowns applied to start_session/heartbeat, token whitelist enforcement, expert session cancellation with partial refunds, and session top-up functionality.
Out of Scope Changes check ✅ Passed All code changes align with the four linked issues. Changes include new admin.rs, disputes.rs, and events.rs modules for requested features, plus lib.rs updates wiring the features and updating tests, with no unrelated modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
contracts/src/lib.rs (1)

4021-4067: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Whitelist offer_token in the swap-backed start path too.

Only ask_token is checked right now. The contract still calls transfer on offer_token and hands it to the DEX, so this route still accepts an arbitrary token contract despite the new whitelist feature.

Suggested fix
         if let Err(e) = admin::require_token_whitelisted(&env, &ask_token) {
             return Err(e);
         }
+        if let Err(e) = admin::require_token_whitelisted(&env, &offer_token) {
+            return Err(e);
+        }
 
         if offer_amount <= 0 {
             return Err(Error::InvalidAmount);
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/lib.rs` around lines 4021 - 4067, In start_session_with_swap
ensure the offer_token is validated by calling the same whitelist check used for
ask_token (i.e., invoke admin::require_token_whitelisted(&env, &offer_token) and
propagate Err(e) on failure) before performing the balance check and
offer_client.transfer; update the logic in start_session_with_swap so the
offer_token whitelist check occurs prior to creating offer_client/transfer to
prevent unwhitelisted tokens from being accepted.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/src/disputes.rs`:
- Around line 63-70: The current logic zeroes both expert_payout and
seeker_refund when each is below MIN_SESSION_ESCROW, risking locked funds
because session.balance was already set to 0; update the payout logic (around
expert_payout, seeker_refund, MIN_SESSION_ESCROW and session.balance) to handle
the “both below threshold” case explicitly — e.g., if expert_payout <
MIN_SESSION_ESCROW and seeker_refund < MIN_SESSION_ESCROW and session.balance >
0 then route the leftover (session.balance or sum of claimable+remaining) to a
recovery destination (award full balance to one party or transfer to a
treasury/burn address) instead of leaving both zero, or alternatively only apply
the threshold to an amount that is non-zero so at least one party receives the
funds. Ensure the chosen branch updates session.balance and emits the
appropriate transfer to avoid locked funds.
- Around line 31-36: The reentrancy lock may remain set if
SkillSphereContract::get_session_or_error(env, session_id) returns an error
because the `?` returns before calling
SkillSphereContract::set_reentrancy_lock(env, false); change the flow to ensure
the lock is always cleared on early return: call get_session_or_error and handle
its Result explicitly (e.g., match or map_err) so that on Err you first call
SkillSphereContract::set_reentrancy_lock(env, false) and then return the error;
alternatively implement a small RAII guard that calls set_reentrancy_lock(env,
false) in Drop and use it around this function, referencing
get_session_or_error, set_reentrancy_lock, SkillSphereContract, session_id and
expert to locate the code.

In `@contracts/src/lib.rs`:
- Around line 2203-2235: top_up_session currently performs token_client.transfer
before enforcing the protocol pause and token-whitelist guards; move or add
guard checks immediately after loading the session and before any balance
transfer so top-ups are blocked when the protocol is paused or the session.token
is not approved. Concretely: in top_up_session (after let mut session =
Self::get_session_or_error...) call the contract's pause guard and token
whitelist guard (the same helper used elsewhere, e.g.
ensure_not_paused/require_not_paused and
ensure_token_whitelisted/require_token_whitelisted or their actual names) and
only then perform token_client.transfer and update session.balance, save and
emit events.

---

Outside diff comments:
In `@contracts/src/lib.rs`:
- Around line 4021-4067: In start_session_with_swap ensure the offer_token is
validated by calling the same whitelist check used for ask_token (i.e., invoke
admin::require_token_whitelisted(&env, &offer_token) and propagate Err(e) on
failure) before performing the balance check and offer_client.transfer; update
the logic in start_session_with_swap so the offer_token whitelist check occurs
prior to creating offer_client/transfer to prevent unwhitelisted tokens from
being accepted.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1e280fad-cda4-40f1-8b4c-f2d9ae8bc607

📥 Commits

Reviewing files that changed from the base of the PR and between a746b14 and 600a175.

📒 Files selected for processing (5)
  • contracts/src/admin.rs
  • contracts/src/disputes.rs
  • contracts/src/errors.rs
  • contracts/src/events.rs
  • contracts/src/lib.rs

Comment thread contracts/src/disputes.rs
Comment on lines +31 to +36
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Reentrancy lock not released when get_session_or_error fails.

If get_session_or_error returns an error, the ? operator propagates immediately without calling set_reentrancy_lock(env, false). This leaves the contract in a locked state, blocking all subsequent operations that check reentrancy.

🐛 Proposed fix
-    let mut session = SkillSphereContract::get_session_or_error(env, session_id)?;
+    let mut session = match SkillSphereContract::get_session_or_error(env, session_id) {
+        Ok(s) => s,
+        Err(e) => {
+            SkillSphereContract::set_reentrancy_lock(env, false);
+            return Err(e);
+        }
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
let mut session = match SkillSphereContract::get_session_or_error(env, session_id) {
Ok(s) => s,
Err(e) => {
SkillSphereContract::set_reentrancy_lock(env, false);
return Err(e);
}
};
if expert != session.expert {
SkillSphereContract::set_reentrancy_lock(env, false);
return Err(Error::Unauthorized);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 31 - 36, The reentrancy lock may
remain set if SkillSphereContract::get_session_or_error(env, session_id) returns
an error because the `?` returns before calling
SkillSphereContract::set_reentrancy_lock(env, false); change the flow to ensure
the lock is always cleared on early return: call get_session_or_error and handle
its Result explicitly (e.g., match or map_err) so that on Err you first call
SkillSphereContract::set_reentrancy_lock(env, false) and then return the error;
alternatively implement a small RAII guard that calls set_reentrancy_lock(env,
false) in Drop and use it around this function, referencing
get_session_or_error, set_reentrancy_lock, SkillSphereContract, session_id and
expert to locate the code.

Comment thread contracts/src/disputes.rs
Comment on lines +63 to +70
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;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Potential fund lock when both payouts fall below MIN_SESSION_ESCROW.

If both claimable and remaining are below MIN_SESSION_ESCROW, both payouts are zeroed but session.balance was already set to 0 on line 51. These funds become permanently locked in the contract with no recovery path.

Consider either:

  1. Transferring dust amounts to a treasury/burn address, or
  2. Awarding the full balance to one party when both are below minimum, or
  3. Only applying the threshold when the individual amount is non-zero but below minimum
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/disputes.rs` around lines 63 - 70, The current logic zeroes
both expert_payout and seeker_refund when each is below MIN_SESSION_ESCROW,
risking locked funds because session.balance was already set to 0; update the
payout logic (around expert_payout, seeker_refund, MIN_SESSION_ESCROW and
session.balance) to handle the “both below threshold” case explicitly — e.g., if
expert_payout < MIN_SESSION_ESCROW and seeker_refund < MIN_SESSION_ESCROW and
session.balance > 0 then route the leftover (session.balance or sum of
claimable+remaining) to a recovery destination (award full balance to one party
or transfer to a treasury/burn address) instead of leaving both zero, or
alternatively only apply the threshold to an amount that is non-zero so at least
one party receives the funds. Ensure the chosen branch updates session.balance
and emits the appropriate transfer to avoid locked funds.

Comment thread contracts/src/lib.rs
Comment on lines +2203 to +2235
pub fn top_up_session(
env: Env,
seeker: Address,
session_id: u64,
amount: i128,
) -> Result<i128, Error> {
seeker.require_auth();

if amount <= 0 {
return Err(Error::InvalidAmount);
}

let mut session = Self::get_session_or_error(&env, session_id)?;

if seeker != session.seeker {
return Err(Error::Unauthorized);
}
if session.status != SessionStatus::Active {
return Err(Error::SessionNotActive);
}

let token_client = token::Client::new(&env, &session.token);
if token_client.balance(&seeker) < amount {
return Err(Error::InsufficientBalance);
}
token_client.transfer(&seeker, &env.current_contract_address(), &amount);

session.balance = session.balance.saturating_add(amount);
let new_balance = session.balance;
Self::save_session(&env, &session);

events::publish_top_up(&env, session_id, amount, new_balance);
Ok(new_balance)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply the new pause/whitelist guards before accepting a top-up.

Line 2224 transfers fresh escrow without checking whether the protocol is paused or whether session.token is still approved. That lets callers keep funding sessions during an emergency pause and keep using a token after the admin has removed it from the whitelist.

Suggested fix
     pub fn top_up_session(
         env: Env,
         seeker: Address,
         session_id: u64,
         amount: i128,
     ) -> Result<i128, Error> {
+        Self::ensure_protocol_active(&env)?;
         seeker.require_auth();
 
         if amount <= 0 {
             return Err(Error::InvalidAmount);
         }
 
         let mut session = Self::get_session_or_error(&env, session_id)?;
+        admin::require_token_whitelisted(&env, &session.token)?;
 
         if seeker != session.seeker {
             return Err(Error::Unauthorized);
         }
         if session.status != SessionStatus::Active {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn top_up_session(
env: Env,
seeker: Address,
session_id: u64,
amount: i128,
) -> Result<i128, Error> {
seeker.require_auth();
if amount <= 0 {
return Err(Error::InvalidAmount);
}
let mut session = Self::get_session_or_error(&env, session_id)?;
if seeker != session.seeker {
return Err(Error::Unauthorized);
}
if session.status != SessionStatus::Active {
return Err(Error::SessionNotActive);
}
let token_client = token::Client::new(&env, &session.token);
if token_client.balance(&seeker) < amount {
return Err(Error::InsufficientBalance);
}
token_client.transfer(&seeker, &env.current_contract_address(), &amount);
session.balance = session.balance.saturating_add(amount);
let new_balance = session.balance;
Self::save_session(&env, &session);
events::publish_top_up(&env, session_id, amount, new_balance);
Ok(new_balance)
pub fn top_up_session(
env: Env,
seeker: Address,
session_id: u64,
amount: i128,
) -> Result<i128, Error> {
Self::ensure_protocol_active(&env)?;
seeker.require_auth();
if amount <= 0 {
return Err(Error::InvalidAmount);
}
let mut session = Self::get_session_or_error(&env, session_id)?;
admin::require_token_whitelisted(&env, &session.token)?;
if seeker != session.seeker {
return Err(Error::Unauthorized);
}
if session.status != SessionStatus::Active {
return Err(Error::SessionNotActive);
}
let token_client = token::Client::new(&env, &session.token);
if token_client.balance(&seeker) < amount {
return Err(Error::InsufficientBalance);
}
token_client.transfer(&seeker, &env.current_contract_address(), &amount);
session.balance = session.balance.saturating_add(amount);
let new_balance = session.balance;
Self::save_session(&env, &session);
events::publish_top_up(&env, session_id, amount, new_balance);
Ok(new_balance)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/src/lib.rs` around lines 2203 - 2235, top_up_session currently
performs token_client.transfer before enforcing the protocol pause and
token-whitelist guards; move or add guard checks immediately after loading the
session and before any balance transfer so top-ups are blocked when the protocol
is paused or the session.token is not approved. Concretely: in top_up_session
(after let mut session = Self::get_session_or_error...) call the contract's
pause guard and token whitelist guard (the same helper used elsewhere, e.g.
ensure_not_paused/require_not_paused and
ensure_token_whitelisted/require_token_whitelisted or their actual names) and
only then perform token_client.transfer and update session.balance, save and
emit events.

@Luluameh Luluameh merged commit f913a19 into LightForgeHub:main May 31, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants