Skip to content

feat: implement core smart contract features#167

Merged
Luluameh merged 1 commit into
LightForgeHub:mainfrom
jacktruedream:feat/skillsphere-contracts-162-163-164-165
Apr 27, 2026
Merged

feat: implement core smart contract features#167
Luluameh merged 1 commit into
LightForgeHub:mainfrom
jacktruedream:feat/skillsphere-contracts-162-163-164-165

Conversation

@jacktruedream
Copy link
Copy Markdown
Contributor

@jacktruedream jacktruedream commented Apr 27, 2026

Summary

Implement four critical smart contract features for the SkillSphere expert consultation platform built on Soroban. These implementations enable partial withdrawals for long sessions, expert staking for visibility, multi-signature arbitration governance, and penalty mechanisms for malicious experts.

Changes Implemented

1. Partial Withdrawals for Long Sessions (#162)

  • Function: withdraw_accrued(session_id)
  • Purpose: Allow experts to claim earned funds mid-session without closing the consultation
  • Details:
    • Calculates claimable amount based on time elapsed and expert hourly rate
    • Transfers tokens to expert without modifying session status
    • Updates last_settlement_timestamp to track payment intervals
    • Returns amount withdrawn

2. Staking Mechanism for Top Experts (#163)

  • Functions: stake_tokens(amount), unstake_tokens(amount)
  • Purpose: Enable experts to boost profile visibility through token staking
  • Details:
    • Transfers tokens into contract staking vault
    • Maintains expert stake tier for fee reductions (Tier 1: 1k, Tier 2: 5k, Tier 3: 10k)
    • Emits events for frontend indexer to update rankings
    • Supports full unstaking with token return

3. Multi-Signature Arbitration Panel (#164)

  • Functions: initialize_arbitration_committee(member1, member2, member3), propose_resolution(session_id, seeker_award_bps)
  • Purpose: Implement 2-of-3 multisig governance for dispute resolution
  • Details:
    • Stores committee addresses in persistent state
    • Only admin can initialize committee
    • Committee members can propose resolutions
    • Prevents single-admin control of dispute outcomes

4. Escrow Slashing for Malicious Experts (#165)

  • Function: slash_expert(expert_id, amount, reason)
  • Purpose: Allow arbitrators to penalize non-performing experts
  • Details:
    • Deducts tokens from expert's staked balance
    • Transfers slashed tokens to community treasury
    • Requires admin or arbitration committee authorization
    • Emits audit events for compliance tracking

Technical Details

All implementations include:

  • Proper authorization checks (expert/admin authentication)
  • Balance and amount validation
  • Error handling with specific error codes
  • Event emission for blockchain indexing
  • Persistent state management via Soroban SDK
  • Integration with existing Session and ExpertProfile structures

Data Structure Updates:

  • Added ArbitrationCommittee to DataKey enum
  • Leverages existing Session, ExpertProfile types
  • Compatible with Soroban's token transfer interface

Security Considerations

  • Partial Withdrawals: Only callable by session expert, verifies active status
  • Staking: Requires expert auth, validates token amounts
  • Arbitration: Restricted to admin/committee, prevents unauthorized dispute resolution
  • Slashing: Requires admin auth, validates expert registration, tracks treasury balance

Integration Points

  • Token Interface: Uses Soroban SDK token::Client for XLM/USDC transfers
  • Event Emission: Emits events for frontend indexing and audit logging
  • Storage Persistence: Utilizes Soroban's instance and persistent storage layers
  • Existing Contracts: Integrates seamlessly with existing dispute and session systems

Testing Recommendations

  1. Test partial withdrawals on various session durations
  2. Verify staking tier calculations and fee reductions
  3. Test arbitration committee initialization and proposal flows
  4. Test slash operations with edge cases (insufficient balance, invalid amounts)
  5. Verify treasury balance tracking after slashing operations
  6. Test authorization checks across all functions

Next Steps

  1. Add comprehensive unit tests for each function
  2. Add integration tests for cross-function workflows
  3. Test with actual Soroban test harness
  4. Verify gas efficiency of storage operations
  5. Add documentation for protocol governance procedures
  6. Deploy to testnet for E2E testing

Closes #162
Closes #163
Closes #164
Closes #165

Summary by CodeRabbit

  • New Features
    • Experts can now withdraw accrued session earnings without pausing participation
    • Token staking and unstaking capabilities now available
    • Arbitration committee initialization and management features added
    • Dispute resolution proposal system for session disputes
    • Expert penalty mechanism implemented for governance and security

Add four essential smart contract functions for the expert consultation platform:

- Partial Withdrawals (LightForgeHub#162): withdraw_accrued() allows experts to claim accrued
  funds mid-session without closing it, updating session state and tracking claimable
  amounts based on elapsed time and expert rate.

- Staking Mechanism (LightForgeHub#163): stake_tokens() and unstake_tokens() enable experts to
  stake XLM/USDC for visibility boost, with events emitted for frontend indexer
  to update expert rankings based on stake tier.

- Multi-Sig Arbitration (LightForgeHub#164): initialize_arbitration_committee() and
  propose_resolution() implement 2-of-3 multisig governance for dispute resolution,
  with committee member storage and proposal tracking.

- Escrow Slashing (LightForgeHub#165): slash_expert() allows arbitration committee to penalize
  malicious experts by transferring staked tokens to treasury, with audit logging
  and balance tracking.

All implementations include proper authorization checks, error handling, event
emission for indexing, and state persistence via Soroban SDK storage.

Refs LightForgeHub#162 LightForgeHub#163 LightForgeHub#164 LightForgeHub#165
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

📝 Walkthrough

Walkthrough

This pull request introduces four new on-chain features to the smart contract: a mid-session withdrawal mechanism allowing experts to collect accrued proceeds while maintaining active sessions, token staking and unstaking capabilities for experts managed through the contract vault, a persistent three-member arbitration committee structure, and expert token slashing functionality that redirects slashed amounts to a treasury while tracking state changes.

Changes

Cohort / File(s) Summary
Session Withdrawal
contracts/src/lib.rs
Adds withdraw_accrued method enabling experts to retrieve time-accrued session proceeds mid-session (when SessionStatus::Active), transferring accrued amounts while updating session balance and settlement timestamps.
Staking Infrastructure
contracts/src/lib.rs
Introduces stake_tokens and unstake_tokens methods that manage expert token deposits/withdrawals, mutating ExpertProfile.staked_balance and emitting staking events for frontend indexing.
Arbitration Committee Setup
contracts/src/lib.rs
Adds ArbitrationCommittee storage entry (enum variant) and initialize_arbitration_committee admin-only method that persists a 3-member committee vector for multi-sig dispute resolution.
Dispute Resolution & Slashing
contracts/src/lib.rs
Implements propose_resolution to emit dispute resolution proposals with seeker_award_bps validation, and slash_expert to transfer slashed tokens to treasury, decrement expert staked balance, and emit audit events.

Sequence Diagram

sequenceDiagram
    participant Expert
    participant Contract
    participant Committee
    participant Treasury

    Expert->>Contract: stake_tokens(amount)
    Contract->>Contract: Transfer tokens to vault
    Contract->>Contract: Increment staked_balance

    Note over Expert,Contract: Session Active

    Expert->>Contract: withdraw_accrued(session_id)
    Contract->>Contract: Calculate accrued amount
    Contract->>Expert: Transfer accrued + newly_accrued
    Contract->>Contract: Update last_settlement_timestamp

    Note over Committee,Contract: If Dispute Occurs

    Committee->>Contract: initialize_arbitration_committee(3 members)
    Contract->>Contract: Store committee addresses

    Committee->>Contract: propose_resolution(session_id, seeker_award_bps)
    Contract->>Contract: Validate & emit proposal event

    alt Expert Misbehavior
        Committee->>Contract: slash_expert(expert_id, amount, reason)
        Contract->>Treasury: Transfer slashed tokens
        Contract->>Contract: Decrement staked_balance
        Contract->>Contract: Emit audit event
    end

    Expert->>Contract: unstake_tokens(amount)
    Contract->>Expert: Transfer remaining staked tokens
    Contract->>Contract: Decrement staked_balance
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 A rabbit hops through contracts new,
With stakes and votes and arbiters true!
Mid-session coins flow through the vault,
And slashing serves when experts default,
A burrow of trust, three-fold and bright!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive The title is overly broad and does not specify the main change—it uses a vague term 'core features' that doesn't clarify which specific features are being implemented. Consider a more specific title such as 'feat: implement withdrawal, staking, arbitration, and slashing features' to clearly convey the main changes.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed The implementation addresses all four linked issues: withdraw_accrued for #162, stake/unstake functions for #163, initialize_arbitration_committee and propose_resolution for #164, and slash_expert for #165.
Out of Scope Changes check ✅ Passed All changes appear directly related to the four linked issues. The addition of ArbitrationCommittee storage, six new public methods, and related functionality align with requirements for withdrawals, staking, arbitration, and slashing.
Docstring Coverage ✅ Passed Docstring coverage is 85.71% 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: 5

🧹 Nitpick comments (2)
contracts/src/lib.rs (2)

1520-1522: Nit: prefer is_empty() over len() == 0.

Idiomatic Rust and matches the style used elsewhere (e.g., Line 778: if reason.is_empty()).

-        if reason.len() == 0 {
+        if reason.is_empty() {
             return Err(Error::EmptyDisputeReason);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1520 - 1522, Replace the length check
using reason.len() == 0 with the idiomatic is_empty() call to match project
style: change the condition to if reason.is_empty() so the branch that returns
Err(Error::EmptyDisputeReason) keeps the same behavior but uses the preferred
Rust API for the variable reason.

1355-1563: Recommended: add tests for the four new features.

The test module below already covers staking-tier fee math, treasury flows, sessions, and disputes, but there are zero tests for withdraw_accrued, stake_tokens/unstake_tokens, initialize_arbitration_committee, propose_resolution, or slash_expert. Given the bugs flagged above (token misuse, storage-key divergence, missing multisig), any of these will silently regress without coverage. At minimum, add:

  • withdraw_accrued: happy path, non-expert caller (Unauthorized), inactive session (InvalidSessionState), insufficient balance, repeated calls update timestamp.
  • stake_tokens / unstake_tokens: tier transitions verified via get_expert_fee_bps, full unstake returns tokens, partial unstake retains tier.
  • initialize_arbitration_committee: re-init rejected, non-admin rejected.
  • slash_expert: stake decreases, treasury balance increases as observed by get_treasury_balance, non-admin rejected.

I can scaffold these tests once the underlying bugs above are addressed — let me know.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1355 - 1563, Add comprehensive unit tests
for the new features: write tests for withdraw_accrued covering happy path,
Unauthorized (non-expert caller), InvalidSessionState (inactive session),
InsufficientBalance, and repeated calls updating last_settlement_timestamp;
tests should exercise the withdraw_accrued function and inspect session fields
(balance, accrued_amount, last_settlement_timestamp) stored under
DataKey::Session. Add stake_tokens and unstake_tokens tests that assert token
transfers occur and that get_expert_fee_bps reflects tier transitions for
full/partial unstake, and that full unstake returns tokens to expert; reference
stake_tokens, unstake_tokens, and get_expert_fee_bps. Add tests for
initialize_arbitration_committee to assert admin-only access and that
re-initialization is rejected and that committee is persisted under
DataKey::ArbitrationCommittee. Add propose_resolution test to ensure a proposal
event is emitted and invalid bps is rejected. Add slash_expert tests validating
admin/committee authorization (non-admin rejected), staked_balance decreases for
the expert, treasury balance increases as observed via get_treasury_balance or
DataKey::TreasuryBalance, and that events are emitted; reference slash_expert,
get_treasury_address, and DataKey::TreasuryBalance. Ensure tests set up token
balances and use token::Client mocks or test ledger to verify transfers.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@contracts/src/lib.rs`:
- Around line 1484-1503: propose_resolution currently only logs an event and
must be converted into the first step of a 2-of-3 multisig: verify caller is a
member of ArbitrationCommittee (use the existing ArbitrationCommittee
storage/lookup), fetch the dispute via get_dispute and assert it is not already
resolved (rename the _dispute binding to dispute), persist a Proposal struct
containing session_id, seeker_award_bps and approvals: Vec<Address> (initially
containing the proposer), and do NOT call resolve_dispute_with_split yet;
implement a companion approve_resolution function that: validates caller is a
distinct ArbitrationCommittee member, loads the Proposal, adds the caller to
approvals if not already present, persists the updated Proposal, and when
approvals.len() >= 2 calls resolve_dispute_with_split(session_id,
seeker_award_bps) and clears the Proposal entry. Ensure uniqueness of approvers
and proper storage key for Proposal so both functions operate on the same
proposal record.
- Around line 1461-1481: initialize_arbitration_committee currently allows
silent re-initialization, accepts duplicate members, and emits no event; update
initialize_arbitration_committee to first check
env.storage().persistent().has(&DataKey::ArbitrationCommittee) and return an
error (reuse Error::Unauthorized or add Error::AlreadyInitialized) to prevent
re-initialization, validate that member1, member2, member3 are pairwise distinct
(return Error::InvalidInput or similar if duplicates), and write an on-chain
event/log after successful set so tooling (e.g., propose_resolution indexing)
can detect committee changes; alternatively split into a protected
rotate_committee function that requires existing committee approval if rotation
is desired.
- Around line 1355-1399: This function mixes u64/u32 timestamps, bypasses
fees/referrer/treasury logic, and omits the protocol-active guard—fix by (1)
calling Self::ensure_protocol_active(&env) at the top, (2) compute claimable
using the existing helper (e.g., call or mirror claimable_amount_for_session /
bounded_time so time_elapsed is correctly bounded) instead of manual math, (3)
reuse the internal_settle logic (or its sub-steps: calculate_platform_fee,
pay_referrer, route treasury) so platform fee/referrer/dust cleanup are applied
rather than transferring full gross amount, and (4) normalize types by casting
env.ledger().timestamp() to u32 where you store it (or cast
session.last_settlement_timestamp to u64 for math) and update
session.last_settlement_timestamp using the correct u32 cast; ensure all
references to session.last_settlement_timestamp and now use consistent types.
- Around line 1403-1457: stake_tokens and unstake_tokens use
env.current_contract_address() as the token client (wrong token) and update only
ExpertProfile.staked_balance while get_expert_fee_bps reads
DataKey::ExpertStakedBalance, causing state divergence and broken fee tiers; fix
by resolving the actual staking token source (use the existing
StakingContract/DataKey or the same token parameter pattern used in
start_session or the contract address stored by set_staking_contract) when
constructing token::Client (replace token = env.current_contract_address() with
the correct staking token reference), and atomically synchronize updates to both
storage locations (set DataKey::ExpertStakedBalance and
ExpertProfile.staked_balance together inside stake_tokens and unstake_tokens, or
consolidate to a single canonical storage key used by get_expert_fee_bps); also
change event topics to the existing symbol style (e.g., symbol_short!("staked")
/ symbol_short!("unstaked")) to match the rest of the contract.
- Around line 1507-1563: slash_expert has five bugs: use the staking token,
persist treasury accounting in the same storage, update the separate
expert-stake key, allow arbitration committee members, and return a clear error
when the treasury address is missing. Fix it by 1) using
Self::get_staking_contract() as the token address when constructing
token::Client and calling transfer (instead of env.current_contract_address());
2) update treasury bookkeeping through the same persistent storage and/or
existing helpers (get_treasury_balance, deposit_to_treasury, withdraw_treasury)
and write DataKey::TreasuryBalance into persistent storage (not instance
storage) so other methods see slashed funds; 3) decrement the expert stake both
on the ExpertProfile (profile.staked_balance) and the
DataKey::ExpertStakedBalance(expert_id) persistent key so
get_expert_staked_balance and fee-tier logic reflect the slash; 4) expand
authorization to allow either admin OR a member of the arbitration committee
stored by initialize_arbitration_committee (check the committee membership
storage/set before returning Unauthorized); and 5) add and return a clear error
variant (e.g., Error::TreasuryNotConfigured) when get_treasury_address(&env)
returns None, and use that instead of InsufficientTreasuryBalance.

---

Nitpick comments:
In `@contracts/src/lib.rs`:
- Around line 1520-1522: Replace the length check using reason.len() == 0 with
the idiomatic is_empty() call to match project style: change the condition to if
reason.is_empty() so the branch that returns Err(Error::EmptyDisputeReason)
keeps the same behavior but uses the preferred Rust API for the variable reason.
- Around line 1355-1563: Add comprehensive unit tests for the new features:
write tests for withdraw_accrued covering happy path, Unauthorized (non-expert
caller), InvalidSessionState (inactive session), InsufficientBalance, and
repeated calls updating last_settlement_timestamp; tests should exercise the
withdraw_accrued function and inspect session fields (balance, accrued_amount,
last_settlement_timestamp) stored under DataKey::Session. Add stake_tokens and
unstake_tokens tests that assert token transfers occur and that
get_expert_fee_bps reflects tier transitions for full/partial unstake, and that
full unstake returns tokens to expert; reference stake_tokens, unstake_tokens,
and get_expert_fee_bps. Add tests for initialize_arbitration_committee to assert
admin-only access and that re-initialization is rejected and that committee is
persisted under DataKey::ArbitrationCommittee. Add propose_resolution test to
ensure a proposal event is emitted and invalid bps is rejected. Add slash_expert
tests validating admin/committee authorization (non-admin rejected),
staked_balance decreases for the expert, treasury balance increases as observed
via get_treasury_balance or DataKey::TreasuryBalance, and that events are
emitted; reference slash_expert, get_treasury_address, and
DataKey::TreasuryBalance. Ensure tests set up token balances and use
token::Client mocks or test ledger to verify transfers.
🪄 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: 7a9afe77-835e-4b5d-aee7-1d494d7c4954

📥 Commits

Reviewing files that changed from the base of the PR and between 808617c and 9d739f4.

📒 Files selected for processing (1)
  • contracts/src/lib.rs

Comment thread contracts/src/lib.rs
Comment on lines +1355 to +1399
// ===== Issue #162: Partial Withdrawals for Long Sessions =====
/// Allow experts to withdraw accrued funds mid-session without closing it
pub fn withdraw_accrued(env: Env, caller: Address, session_id: u64) -> Result<i128, Error> {
caller.require_auth();
let mut session = Self::get_session_or_error(&env, session_id)?;

// Verify caller is the expert
if session.expert != caller {
return Err(Error::Unauthorized);
}

// Verify session is active
if session.status != SessionStatus::Active {
return Err(Error::InvalidSessionState);
}

// Calculate currently claimable amount based on time elapsed
let now = env.ledger().timestamp();
let time_elapsed = now.saturating_sub(session.last_settlement_timestamp);
let newly_accrued = session.rate_per_second.saturating_mul(time_elapsed as i128);

// Total claimable is accrued + newly accrued
let total_claimable = session.accrued_amount.saturating_add(newly_accrued);

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

// Verify session has sufficient balance
if session.balance < total_claimable {
return Err(Error::InsufficientBalance);
}

// Transfer tokens to expert
let token_client = token::Client::new(&env, &session.token);
token_client.transfer(&env.current_contract_address(), &caller, &total_claimable);

// Update session state
session.balance = session.balance.saturating_sub(total_claimable);
session.last_settlement_timestamp = now;
session.accrued_amount = 0;
env.storage().persistent().set(&DataKey::Session(session_id), &session);

Ok(total_claimable)
}
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

Critical: type mismatches and bypassed platform fees in withdraw_accrued.

Several blocking issues in this function:

  1. Won't compile (type mismatch). session.last_settlement_timestamp is u32, but now = env.ledger().timestamp() is u64. Both Line 1373 (now.saturating_sub(session.last_settlement_timestamp)) and Line 1394 (session.last_settlement_timestamp = now;) mix u64 and u32 directly. The rest of the codebase consistently casts (e.g., Lines 580, 645).
  2. Bypasses platform fee, referrer reward, and dust cleanup. internal_settle (Lines 998–1084) deducts calculate_platform_fee, pays the referrer, and routes to treasury. withdraw_accrued transfers the full claimable amount directly to the expert, opening an obvious fee-avoidance path: experts can call withdraw_accrued repeatedly instead of settle_session. Per Issue #162, mid-session withdrawals should still respect fee economics.
  3. Missing protocol-pause guard. Other settlement-style operations (resume_session, settle_session, batch_settle) call Self::ensure_protocol_active(&env) first; this one does not, so withdrawals can drain the contract while the protocol is paused.
  4. No expiry bounding. Compare with bounded_time / claimable_amount_for_session. The local balance < total_claimable check below saves you from over-paying, but time_elapsed will keep growing past expiry, masking the correct settlement timestamp on the session record.
🛠️ Suggested refactor — reuse settlement helpers
-    pub fn withdraw_accrued(env: Env, caller: Address, session_id: u64) -> Result<i128, Error> {
-        caller.require_auth();
-        let mut session = Self::get_session_or_error(&env, session_id)?;
-
-        // Verify caller is the expert
-        if session.expert != caller {
-            return Err(Error::Unauthorized);
-        }
-
-        // Verify session is active
-        if session.status != SessionStatus::Active {
-            return Err(Error::InvalidSessionState);
-        }
-
-        // Calculate currently claimable amount based on time elapsed
-        let now = env.ledger().timestamp();
-        let time_elapsed = now.saturating_sub(session.last_settlement_timestamp);
-        let newly_accrued = session.rate_per_second.saturating_mul(time_elapsed as i128);
-
-        // Total claimable is accrued + newly accrued
-        let total_claimable = session.accrued_amount.saturating_add(newly_accrued);
-
-        if total_claimable <= 0 {
-            return Err(Error::InvalidAmount);
-        }
-
-        // Verify session has sufficient balance
-        if session.balance < total_claimable {
-            return Err(Error::InsufficientBalance);
-        }
-
-        // Transfer tokens to expert
-        let token_client = token::Client::new(&env, &session.token);
-        token_client.transfer(&env.current_contract_address(), &caller, &total_claimable);
-
-        // Update session state
-        session.balance = session.balance.saturating_sub(total_claimable);
-        session.last_settlement_timestamp = now;
-        session.accrued_amount = 0;
-        env.storage().persistent().set(&DataKey::Session(session_id), &session);
-
-        Ok(total_claimable)
-    }
+    pub fn withdraw_accrued(env: Env, caller: Address, session_id: u64) -> Result<i128, Error> {
+        Self::ensure_protocol_active(&env)?;
+        caller.require_auth();
+        let session = Self::get_session_or_error(&env, session_id)?;
+        if session.expert != caller {
+            return Err(Error::Unauthorized);
+        }
+        if session.status != SessionStatus::Active {
+            return Err(Error::InvalidSessionState);
+        }
+        // Reuse the audited settlement path so platform fee, referrer reward,
+        // dust cleanup, and timestamp bounding all apply identically.
+        Self::internal_settle(&env, session)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1355 - 1399, This function mixes u64/u32
timestamps, bypasses fees/referrer/treasury logic, and omits the protocol-active
guard—fix by (1) calling Self::ensure_protocol_active(&env) at the top, (2)
compute claimable using the existing helper (e.g., call or mirror
claimable_amount_for_session / bounded_time so time_elapsed is correctly
bounded) instead of manual math, (3) reuse the internal_settle logic (or its
sub-steps: calculate_platform_fee, pay_referrer, route treasury) so platform
fee/referrer/dust cleanup are applied rather than transferring full gross
amount, and (4) normalize types by casting env.ledger().timestamp() to u32 where
you store it (or cast session.last_settlement_timestamp to u64 for math) and
update session.last_settlement_timestamp using the correct u32 cast; ensure all
references to session.last_settlement_timestamp and now use consistent types.

Comment thread contracts/src/lib.rs
Comment on lines +1403 to +1457
pub fn stake_tokens(env: Env, expert: Address, amount: i128) -> Result<(), Error> {
expert.require_auth();

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

// Get expert profile
let mut profile = Self::expert_profile(&env, expert.clone());

// Transfer tokens from expert to contract
let token = env.current_contract_address(); // Using contract address as staking vault
let token_client = token::Client::new(&env, &token);
token_client.transfer(&expert, &env.current_contract_address(), &amount);

// Update staked balance
profile.staked_balance = profile.staked_balance.saturating_add(amount);
env.storage().persistent().set(&DataKey::ExpertProfile(expert.clone()), &profile);

// Emit event for frontend indexer
env.events().publish(("expert", "staked"), (&expert, &amount));

Ok(())
}

/// Allows experts to withdraw staked tokens
pub fn unstake_tokens(env: Env, expert: Address, amount: i128) -> Result<(), Error> {
expert.require_auth();

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

// Get expert profile
let mut profile = Self::expert_profile(&env, expert.clone());

// Verify expert has sufficient staked balance
if profile.staked_balance < amount {
return Err(Error::InsufficientBalance);
}

// Transfer tokens back to expert
let token = env.current_contract_address();
let token_client = token::Client::new(&env, &token);
token_client.transfer(&env.current_contract_address(), &expert, &amount);

// Update staked balance
profile.staked_balance = profile.staked_balance.saturating_sub(amount);
env.storage().persistent().set(&DataKey::ExpertProfile(expert.clone()), &profile);

// Emit event for frontend indexer
env.events().publish(("expert", "unstaked"), (&expert, &amount));

Ok(())
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm there is no separate staking-token configuration we missed,
# and that ExpertStakedBalance is the only key consulted for fee tiers.
rg -nP --type=rust -C2 '\bExpertStakedBalance\b'
rg -nP --type=rust -C2 '\bStakingContract\b'
rg -nP --type=rust -C2 'staked_balance'

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 10603


🏁 Script executed:

# Get the full implementation of stake_tokens and unstake_tokens, plus surrounding context
sed -n '1403,1457p' contracts/src/lib.rs | cat -n

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 2494


🏁 Script executed:

# Check if there's any code that synchronizes the two staked_balance locations
rg -n 'profile\.staked_balance' contracts/src/lib.rs -A2 -B2

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 1172


🏁 Script executed:

# Check if ExpertProfile.staked_balance is used anywhere for fee calculations or tier checking
rg -n 'profile\.staked_balance' contracts/src/lib.rs | head -30

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 429


🏁 Script executed:

# Check what token address is actually being used in the functions (search for the exact lines)
sed -n '1408,1420p' contracts/src/lib.rs
sed -n '1441,1453p' contracts/src/lib.rs

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 1229


🏁 Script executed:

# Check all places that write to ExpertStakedBalance to confirm only set_expert_staked_balance does it
rg -n 'ExpertStakedBalance' contracts/src/lib.rs | grep -v '//'

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 234


🏁 Script executed:

# Verify the token parameter usage in other functions like start_session (mentioned in review)
sed -n '570,590p' contracts/src/lib.rs | cat -n

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 995


🏁 Script executed:

# Check the ExpertProfile struct definition
sed -n '110,125p' contracts/src/lib.rs | cat -n

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 615


Critical: staking is broken — wrong token address and separate staked balance tracking.

Two independent bugs prevent these functions from working correctly:

  1. env.current_contract_address() is not a token. Lines 1412 and 1443 set let token = env.current_contract_address(); and construct token::Client::new(&env, &token). This dispatches SAC calls to this contract, which does not implement the token transfer interface. The staking token must come from a parameter (as in start_session at line 573), a StakingToken DataKey, or the StakingContract configured by set_staking_contract (line 298).

  2. State divergence: stake updates do not affect fee tiers. stake_tokens mutates profile.staked_balance in the ExpertProfile storage key (line 1419), but get_expert_fee_bps (line 339) reads from DataKey::ExpertStakedBalance, which is only populated by set_expert_staked_balance. An expert using stake_tokens will not receive Tier 1/2/3 fee reductions. Either consolidate to a single storage source or synchronize both atomically. Same applies to unstake_tokens.

Consider using a separate "staking" event topic (e.g., symbol_short!("staked")) for consistency with the rest of the file rather than the string-tuple form.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1403 - 1457, stake_tokens and
unstake_tokens use env.current_contract_address() as the token client (wrong
token) and update only ExpertProfile.staked_balance while get_expert_fee_bps
reads DataKey::ExpertStakedBalance, causing state divergence and broken fee
tiers; fix by resolving the actual staking token source (use the existing
StakingContract/DataKey or the same token parameter pattern used in
start_session or the contract address stored by set_staking_contract) when
constructing token::Client (replace token = env.current_contract_address() with
the correct staking token reference), and atomically synchronize updates to both
storage locations (set DataKey::ExpertStakedBalance and
ExpertProfile.staked_balance together inside stake_tokens and unstake_tokens, or
consolidate to a single canonical storage key used by get_expert_fee_bps); also
change event topics to the existing symbol style (e.g., symbol_short!("staked")
/ symbol_short!("unstaked")) to match the rest of the contract.

Comment thread contracts/src/lib.rs
Comment on lines +1461 to +1481
pub fn initialize_arbitration_committee(
env: Env,
member1: Address,
member2: Address,
member3: Address,
) -> Result<(), Error> {
// Only admin can initialize
let admin = Self::get_admin(&env)?;
admin.require_auth();

// Store committee members in persistent state
// Using a vector to store the committee members
let mut committee: Vec<Address> = Vec::new(&env);
committee.push_back(member1);
committee.push_back(member2);
committee.push_back(member3);

env.storage().persistent().set(&DataKey::ArbitrationCommittee, &committee);

Ok(())
}
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

Major: committee can be silently re-initialized; no duplicate-member or event guard.

  • No "already initialized" check. Compare with initialize (Lines 152–154). An admin (or compromised admin) can replace the committee at any time, defeating the multisig safety property. Add if env.storage().persistent().has(&DataKey::ArbitrationCommittee) { return Err(Error::Unauthorized); } (or a dedicated error variant) and/or expose a separate rotate_committee flow that requires existing committee approval.
  • No check that member1, member2, member3 are distinct. If two (or three) addresses are equal, "2-of-3" collapses to "1-of-1" / "1-of-2".
  • No event emitted; propose_resolution and downstream tooling can't index committee changes.
🛠️ Suggested fix
     pub fn initialize_arbitration_committee(
         env: Env,
         member1: Address,
         member2: Address,
         member3: Address,
     ) -> Result<(), Error> {
-        // Only admin can initialize
         let admin = Self::get_admin(&env)?;
         admin.require_auth();
+
+        if env.storage().persistent().has(&DataKey::ArbitrationCommittee) {
+            return Err(Error::InvalidSessionState); // or a dedicated error
+        }
+        if member1 == member2 || member1 == member3 || member2 == member3 {
+            return Err(Error::InvalidReferrer); // or a dedicated error
+        }

-        // Store committee members in persistent state
-        // Using a vector to store the committee members
         let mut committee: Vec<Address> = Vec::new(&env);
         committee.push_back(member1);
         committee.push_back(member2);
         committee.push_back(member3);

         env.storage().persistent().set(&DataKey::ArbitrationCommittee, &committee);
+        env.events().publish((symbol_short!("arbInit"),), committee);

         Ok(())
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1461 - 1481,
initialize_arbitration_committee currently allows silent re-initialization,
accepts duplicate members, and emits no event; update
initialize_arbitration_committee to first check
env.storage().persistent().has(&DataKey::ArbitrationCommittee) and return an
error (reuse Error::Unauthorized or add Error::AlreadyInitialized) to prevent
re-initialization, validate that member1, member2, member3 are pairwise distinct
(return Error::InvalidInput or similar if duplicates), and write an on-chain
event/log after successful set so tooling (e.g., propose_resolution indexing)
can detect committee changes; alternatively split into a protected
rotate_committee function that requires existing committee approval if rotation
is desired.

Comment thread contracts/src/lib.rs
Comment on lines +1484 to +1503
pub fn propose_resolution(
env: Env,
caller: Address,
session_id: u64,
seeker_award_bps: u32,
) -> Result<(), Error> {
caller.require_auth();

if seeker_award_bps > MAX_BPS {
return Err(Error::InvalidSplitBps);
}

// Verify dispute exists
let _dispute = Self::get_dispute(&env, session_id)?;

// In a real implementation, this would store the proposal for other members to approve
env.events().publish(("resolution", "proposed"), (&session_id, &seeker_award_bps));

Ok(())
}
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

Critical: propose_resolution does not implement the 2-of-3 multisig described in #164.

The function only authenticates the caller and emits an event — there is no committee membership check, no proposal record persisted, and no companion approve_resolution flow that counts approvals and triggers resolve_dispute_with_split. As written, any address can call this and nothing changes on-chain. Per the PR objectives this should: (1) verify caller is in ArbitrationCommittee, (2) persist a Proposal { session_id, seeker_award_bps, approvals: Vec<Address> }, and (3) once a second distinct committee member calls approve_resolution, invoke the existing resolve_dispute_with_split helper.

Also: the _dispute binding on Line 1497 is fetched only to validate existence; consider renaming or asserting !dispute.resolved to avoid acting on already-resolved disputes.

Want me to draft the full proposal/approval state machine (storage key, propose_resolution, approve_resolution, integration with resolve_dispute_with_split)? Happy to open it as a follow-up issue.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1484 - 1503, propose_resolution currently
only logs an event and must be converted into the first step of a 2-of-3
multisig: verify caller is a member of ArbitrationCommittee (use the existing
ArbitrationCommittee storage/lookup), fetch the dispute via get_dispute and
assert it is not already resolved (rename the _dispute binding to dispute),
persist a Proposal struct containing session_id, seeker_award_bps and approvals:
Vec<Address> (initially containing the proposer), and do NOT call
resolve_dispute_with_split yet; implement a companion approve_resolution
function that: validates caller is a distinct ArbitrationCommittee member, loads
the Proposal, adds the caller to approvals if not already present, persists the
updated Proposal, and when approvals.len() >= 2 calls
resolve_dispute_with_split(session_id, seeker_award_bps) and clears the Proposal
entry. Ensure uniqueness of approvers and proper storage key for Proposal so
both functions operate on the same proposal record.

Comment thread contracts/src/lib.rs
Comment on lines +1507 to +1563
pub fn slash_expert(
env: Env,
caller: Address,
expert_id: Address,
amount: i128,
reason: String,
) -> Result<(), Error> {
caller.require_auth();

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

if reason.len() == 0 {
return Err(Error::EmptyDisputeReason);
}

// Verify caller is admin or arbitration committee member
let admin = Self::get_admin(&env)?;
if caller != admin {
return Err(Error::Unauthorized);
}

// Get expert profile
let mut profile = Self::expert_profile(&env, expert_id.clone());

// Verify expert has sufficient staked balance
if profile.staked_balance < amount {
return Err(Error::InsufficientBalance);
}

// Get treasury address
let treasury = Self::get_treasury_address(&env)
.ok_or(Error::InsufficientTreasuryBalance)?;

// Transfer slashed tokens to treasury
let token = env.current_contract_address();
let token_client = token::Client::new(&env, &token);
token_client.transfer(&env.current_contract_address(), &treasury, &amount);

// Deduct from expert's staked balance
profile.staked_balance = profile.staked_balance.saturating_sub(amount);
env.storage().persistent().set(&DataKey::ExpertProfile(expert_id.clone()), &profile);

// Update treasury balance tracking
let treasury_key = DataKey::TreasuryBalance(token);
let mut treasury_balance: i128 = env.storage().instance()
.get(&treasury_key)
.unwrap_or(0);
treasury_balance = treasury_balance.saturating_add(amount);
env.storage().instance().set(&treasury_key, &treasury_balance);

// Emit event for auditing
env.events().publish(("expert", "slashed"), (&expert_id, &amount, &reason));

Ok(())
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify treasury read path uses persistent storage exclusively,
# so the instance-storage write in slash_expert is provably invisible.
rg -nP --type=rust -C2 'DataKey::TreasuryBalance'

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 1560


🏁 Script executed:

# Check what the actual staking token contract should be
rg -nP --type=rust -C3 'staking.*token|stake.*token' | head -60

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 1406


🏁 Script executed:

# Verify the ExpertStakedBalance key usage and fee-tier calculation
rg -nP --type=rust 'ExpertStakedBalance|get_expert_staked_balance' -A2 -B2

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 2415


🏁 Script executed:

# Check admin authorization logic and see if committee is implemented
rg -nP --type=rust 'arbitration|committee|admin.*auth' -C3 | head -80

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 4125


🏁 Script executed:

# Examine the get_treasury_address function and error handling
rg -nP --type=rust 'get_treasury_address|TreasuryNotConfigured' -A5 -B2

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 2147


🏁 Script executed:

# Find Error enum definition
rg -nP --type=rust 'enum Error' -A30

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 1587


🏁 Script executed:

# Check how staking token should be initialized or stored
rg -nP --type=rust 'StakingContract|staking.*contract|STAKING' -B2 -A2

Repository: LightForgeHub/SkillSphere-Dapp

Length of output: 2191


Critical: slash_expert has five serious bugs.

  1. Wrong token (line 1543). Uses env.current_contract_address() instead of get_staking_contract(). The transfer won't debit the actual staking token.

  2. Treasury accounting in the wrong storage (lines 1552–1557). Updates DataKey::TreasuryBalance in instance storage, but get_treasury_balance, deposit_to_treasury, and withdraw_treasury read/write persistent storage. Slashed funds are silently invisible to the rest of the contract.

  3. Stake bookkeeping divergence (line 1548). Decrements profile.staked_balance, but fee-tier calculation and get_expert_staked_balance (lines 330–334) read the separate DataKey::ExpertStakedBalance(addr) key. Slashed experts retain their fee-tier discount.

  4. Authorization incomplete (lines 1524–1527). Comment claims "admin or arbitration committee member", but only the admin check is implemented. Committee members are never authorized despite initialize_arbitration_committee storing them (lines 1461–1481).

  5. Wrong error type (lines 1539–1540). Returns InsufficientTreasuryBalance when the treasury address is unset. That error semantically means balance is low, not that the treasury isn't configured. No TreasuryNotConfigured variant exists, but the current usage is misleading.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/lib.rs` around lines 1507 - 1563, slash_expert has five bugs:
use the staking token, persist treasury accounting in the same storage, update
the separate expert-stake key, allow arbitration committee members, and return a
clear error when the treasury address is missing. Fix it by 1) using
Self::get_staking_contract() as the token address when constructing
token::Client and calling transfer (instead of env.current_contract_address());
2) update treasury bookkeeping through the same persistent storage and/or
existing helpers (get_treasury_balance, deposit_to_treasury, withdraw_treasury)
and write DataKey::TreasuryBalance into persistent storage (not instance
storage) so other methods see slashed funds; 3) decrement the expert stake both
on the ExpertProfile (profile.staked_balance) and the
DataKey::ExpertStakedBalance(expert_id) persistent key so
get_expert_staked_balance and fee-tier logic reflect the slash; 4) expand
authorization to allow either admin OR a member of the arbitration committee
stored by initialize_arbitration_committee (check the committee membership
storage/set before returning Unauthorized); and 5) add and return a clear error
variant (e.g., Error::TreasuryNotConfigured) when get_treasury_address(&env)
returns None, and use that instead of InsufficientTreasuryBalance.

@Luluameh Luluameh merged commit a59b13a into LightForgeHub:main Apr 27, 2026
1 check 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

Development

Successfully merging this pull request may close these issues.

Escrow Slashing for Malicious Experts Multi-Sig Arbitration Panel Staking Mechanism for Top Experts Partial Withdrawals for Long Sessions

2 participants