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
34 changes: 13 additions & 21 deletions contracts/src/admin.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
//! Admin helpers – thin re-exports so other modules can import from a single place.
use crate::{DataKey, Error};

pub use crate::Error;


use soroban_sdk::{Address, Env, Vec};


use soroban_sdk::{symbol_short, Address, Env, Vec};

/// Default rate-limit cooldown: 0 disables per-address throttling.
const DEFAULT_RATE_LIMIT_MIN_LEDGERS: u32 = 0;
Expand All @@ -24,45 +19,42 @@ pub fn require_admin(env: &Env) -> Result<Address, Error> {
let admin = get_admin(env)?;
admin.require_auth();
Ok(admin)
//! Admin-managed configuration: rate-limit cooldowns (#236) and the
//! approved-token registry (#239).


}

/// 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)
.get(&symbol_short!("lim_ledg"))
.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);
.set(&symbol_short!("lim_ledg"), &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
/// `(symbol_short!("lst_act"), 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 key = (symbol_short!("lst_act"), caller.clone());
let current = env.ledger().sequence();

if let Some(last) = env
.storage()
.temporary()
.get::<DataKey, u32>(&key)
.get::<_, u32>(&key)
{
if current.saturating_sub(last) < min_ledgers {
return Err(Error::RateLimitExceeded);
return Err(Error::Unauthorized);
}
}

Expand All @@ -81,14 +73,14 @@ pub fn rate_limit(env: &Env, caller: &Address, min_ledgers: u32) -> Result<(), E
pub fn approved_tokens(env: &Env) -> Vec<Address> {
env.storage()
.persistent()
.get(&DataKey::ApprovedTokens)
.get(&symbol_short!("app_toks"))
.unwrap_or_else(|| Vec::new(env))
}

fn save_approved_tokens(env: &Env, tokens: &Vec<Address>) {
env.storage()
.persistent()
.set(&DataKey::ApprovedTokens, tokens);
.set(&symbol_short!("app_toks"), tokens);
}

/// Returns `true` when `token` is present in the approved-token registry.
Expand All @@ -107,7 +99,7 @@ pub fn require_token_whitelisted(env: &Env, token: &Address) -> Result<(), Error
if is_token_whitelisted(env, token) {
Ok(())
} else {
Err(Error::TokenNotWhitelisted)
Err(Error::Unauthorized)
}
}

Expand All @@ -116,7 +108,7 @@ 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);
return Err(Error::Unauthorized);
}
}
tokens.push_back(token);
Expand All @@ -138,7 +130,7 @@ pub fn remove_approved_token(env: &Env, token: Address) -> Result<(), Error> {
}
}
if !found {
return Err(Error::TokenNotInWhitelist);
return Err(Error::Unauthorized);
}
save_approved_tokens(env, &next);
Ok(())
Expand Down
18 changes: 11 additions & 7 deletions contracts/src/crypto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
//! Experts pre-sign voucher payloads off-chain so seekers can open sessions
//! without a separate on-chain expert confirmation transaction.

use soroban_sdk::{contracttype, xdr::ToXdr, Address, Bytes, BytesN, Env};
use soroban_sdk::{contracttype, symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env};

use crate::{DataKey, Error};
use crate::Error;

/// Signed session invitation issued by an expert off-chain.
#[contracttype]
Expand All @@ -21,7 +21,7 @@ pub struct SessionVoucher {
/// Canonical byte sequence signed by the expert wallet.
pub fn voucher_message(env: &Env, voucher: &SessionVoucher) -> Bytes {
let mut message = Bytes::new(env);
message.append(&voucher.expert.to_xdr(env));
message.append(&voucher.expert.clone().to_xdr(env));
message.append(&voucher.rate_per_second.to_xdr(env));
message.append(&voucher.max_duration.to_xdr(env));
message.append(&voucher.expiry.to_xdr(env));
Expand All @@ -43,25 +43,29 @@ pub fn verify_voucher_signature(
}

pub fn voucher_pubkey(env: &Env, expert: &Address) -> Option<BytesN<32>> {
let key = (symbol_short!("exp_v_pk"), expert.clone());
env.storage()
.persistent()
.get(&DataKey::ExpertVoucherPubkey(expert.clone()))
.get(&key)
}

pub fn set_voucher_pubkey(env: &Env, expert: &Address, public_key: BytesN<32>) {
let key = (symbol_short!("exp_v_pk"), expert.clone());
env.storage()
.persistent()
.set(&DataKey::ExpertVoucherPubkey(expert.clone()), &public_key);
.set(&key, &public_key);
}

pub fn is_nonce_consumed(env: &Env, expert: &Address, nonce: u64) -> bool {
let key = (symbol_short!("v_nonce"), expert.clone(), nonce);
env.storage()
.persistent()
.has(&DataKey::VoucherNonceConsumed(expert.clone(), nonce))
.has(&key)
}

pub fn consume_nonce(env: &Env, expert: &Address, nonce: u64) {
let key = (symbol_short!("v_nonce"), expert.clone(), nonce);
env.storage()
.persistent()
.set(&DataKey::VoucherNonceConsumed(expert.clone(), nonce), &true);
.set(&key, &true);
}
114 changes: 103 additions & 11 deletions contracts/src/disputes.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
//! Expert cooldown after dispute loss — Issue #240.
//!
//! When arbitration awards more to the seeker than the expert, the expert
//! enters a temporary cooldown during which they cannot accept new sessions.
//! Cooldown expiry is tracked by ledger sequence in temporary storage.
//! Expert-initiated session cancellation with partial refund (#238).

use soroban_sdk::{Address, Env};
use soroban_sdk::{symbol_short, token, Address, Env, String};

use crate::DataKey;
use crate::{
events, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW,
};

/// Stellar closes a ledger roughly every 5 seconds; seven days ≈ 120_960 ledgers.
pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12;
Expand All @@ -15,23 +14,24 @@ pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12;
pub fn cooldown_ledgers(env: &Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::ExpertCooldownLedgers)
.get(&symbol_short!("exp_cd_l"))
.unwrap_or(DEFAULT_EXPERT_COOLDOWN_LEDGERS)
}

/// Admin-only setter invoked from `lib.rs`.
pub fn set_cooldown_ledgers(env: &Env, ledgers: u32) {
env.storage()
.instance()
.set(&DataKey::ExpertCooldownLedgers, &ledgers);
.set(&symbol_short!("exp_cd_l"), &ledgers);
}

/// True when the expert still has an active post-loss cooldown.
pub fn is_expert_on_cooldown(env: &Env, expert: &Address) -> bool {
let key = (symbol_short!("exp_cd_u"), expert.clone());
if let Some(until_ledger) = env
.storage()
.temporary()
.get::<DataKey, u32>(&DataKey::ExpertCooldownUntil(expert.clone()))
.get::<_, u32>(&key)
{
return env.ledger().sequence() < until_ledger;
}
Expand All @@ -40,9 +40,10 @@ pub fn is_expert_on_cooldown(env: &Env, expert: &Address) -> bool {

/// Returns the ledger sequence after which the expert may accept sessions again.
pub fn expert_cooldown_until(env: &Env, expert: &Address) -> Option<u32> {
let key = (symbol_short!("exp_cd_u"), expert.clone());
env.storage()
.temporary()
.get(&DataKey::ExpertCooldownUntil(expert.clone()))
.get(&key)
}

/// Apply cooldown when the seeker receives a strictly larger award than the expert.
Expand All @@ -58,7 +59,98 @@ pub fn apply_cooldown_if_expert_lost(

let ledgers = cooldown_ledgers(env);
let until = env.ledger().sequence().saturating_add(ledgers);
let key = (symbol_short!("exp_cd_u"), expert.clone());
env.storage()
.temporary()
.set(&DataKey::ExpertCooldownUntil(expert.clone()), &until);
.set(&key, &until);
}

/// 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);

let key = (symbol_short!("canc_rsn"), session_id);
env.storage()
.persistent()
.set(&key, &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;
}

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_event(
env,
events::event_type::session_cancelled(),
session_id,
(expert, expert_payout, seeker_refund, reason_cid),
);

SkillSphereContract::set_reentrancy_lock(env, false);
Ok((expert_payout, seeker_refund))
}
13 changes: 2 additions & 11 deletions contracts/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,11 @@ pub enum Error {
InsufficientBalance = 34,

// #213 / #214
BurnBpsExceedsFee = 34,
StakeNotFound = 35,
NoRewardsToClaim = 36,
InsuffStakeBalance = 37,

// #194 / #195 / #196 / #197
FpAlreadyFinalised = 38,
SubNotFound = 39,
SubAlreadyCollected = 40,
SubscriptionExpired = 41,
ContractUnset = 42,
InsuffInsuranceBal = 43,

// #198 / #199 / #200
ExpertOffline = 44,
Expand All @@ -66,10 +59,8 @@ pub enum Error {
// #240 / #241 / #242
ExpertOnCooldown = 50,
SpendingLimitExceeded = 51,
VoucherExpired = 52,
VoucherNonceUsed = 53,
InvalidVoucherSignature = 54,
VoucherPubkeyNotSet = 55,
InvalidVoucher = 56,

// #257
TimelockActive = 57,
}
2 changes: 1 addition & 1 deletion contracts/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub fn publish_event<P>(
event_type,
session_id,
env.ledger().timestamp(),
payload,
payload.into_val(env),
),
);
}
Expand Down
6 changes: 4 additions & 2 deletions contracts/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,19 @@ pub fn increment_referral_session_count(env: &Env, expert: &Address) {
}
}

const DEFAULT_REFERRAL_SESSION_LIMIT: u32 = 50;

/// Returns the referral commission eligibility limit for an expert.
pub fn referral_session_limit(env: &Env) -> u32 {
env.storage()
.instance()
.get(&DataKey::ReferralSessionLimit)
.get(&soroban_sdk::symbol_short!("ref_lim"))
.unwrap_or(DEFAULT_REFERRAL_SESSION_LIMIT)
}

/// Admin configures how many referred sessions qualify for commission.
pub fn set_referral_session_limit(env: &Env, limit: u32) {
env.storage()
.instance()
.set(&DataKey::ReferralSessionLimit, &limit);
.set(&soroban_sdk::symbol_short!("ref_lim"), &limit);
}
Loading