diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 36c5469..23a01fa 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -13,6 +13,7 @@ soroban-sdk = "21.0.0" [dev-dependencies] soroban-sdk = { version = "21.0.0", features = ["testutils"] } proptest = { version = "1.4.0", default-features = false, features = ["std"] } +ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } [[test]] name = "fuzz_claimable" diff --git a/contracts/src/crypto.rs b/contracts/src/crypto.rs new file mode 100644 index 0000000..2a7ca70 --- /dev/null +++ b/contracts/src/crypto.rs @@ -0,0 +1,67 @@ +//! Off-chain signed session invitations — Issue #242. +//! +//! 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 crate::{DataKey, Error}; + +/// Signed session invitation issued by an expert off-chain. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SessionVoucher { + pub expert: Address, + pub rate_per_second: i128, + pub max_duration: u64, + pub expiry: u64, + pub nonce: u64, +} + +/// 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.rate_per_second.to_xdr(env)); + message.append(&voucher.max_duration.to_xdr(env)); + message.append(&voucher.expiry.to_xdr(env)); + message.append(&voucher.nonce.to_xdr(env)); + message +} + +/// Verify an ed25519 signature over the canonical voucher message. +pub fn verify_voucher_signature( + env: &Env, + voucher: &SessionVoucher, + public_key: &BytesN<32>, + signature: &BytesN<64>, +) -> Result<(), Error> { + let message = voucher_message(env, voucher); + env.crypto() + .ed25519_verify(public_key, &message, signature); + Ok(()) +} + +pub fn voucher_pubkey(env: &Env, expert: &Address) -> Option> { + env.storage() + .persistent() + .get(&DataKey::ExpertVoucherPubkey(expert.clone())) +} + +pub fn set_voucher_pubkey(env: &Env, expert: &Address, public_key: BytesN<32>) { + env.storage() + .persistent() + .set(&DataKey::ExpertVoucherPubkey(expert.clone()), &public_key); +} + +pub fn is_nonce_consumed(env: &Env, expert: &Address, nonce: u64) -> bool { + env.storage() + .persistent() + .has(&DataKey::VoucherNonceConsumed(expert.clone(), nonce)) +} + +pub fn consume_nonce(env: &Env, expert: &Address, nonce: u64) { + env.storage() + .persistent() + .set(&DataKey::VoucherNonceConsumed(expert.clone(), nonce), &true); +} diff --git a/contracts/src/disputes.rs b/contracts/src/disputes.rs index 05326ea..272f50d 100644 --- a/contracts/src/disputes.rs +++ b/contracts/src/disputes.rs @@ -1,98 +1,64 @@ -//! Expert-initiated session cancellation with partial refund (#238). +//! 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. -use soroban_sdk::{token, Address, Env, String}; +use soroban_sdk::{Address, Env}; -use crate::{ - events, DataKey, Error, SessionStatus, SkillSphereContract, MIN_SESSION_ESCROW, -}; +use crate::DataKey; -/// 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); - } +/// Stellar closes a ledger roughly every 5 seconds; seven days ≈ 120_960 ledgers. +pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12; - let mut session = SkillSphereContract::get_session_or_error(env, session_id)?; +/// Returns the configured cooldown length in ledgers (admin-set, default 7 days). +pub fn cooldown_ledgers(env: &Env) -> u32 { + env.storage() + .instance() + .get(&DataKey::ExpertCooldownLedgers) + .unwrap_or(DEFAULT_EXPERT_COOLDOWN_LEDGERS) +} - if expert != session.expert { - SkillSphereContract::set_reentrancy_lock(env, false); - return Err(Error::Unauthorized); - } +/// Admin-only setter invoked from `lib.rs`. +pub fn set_cooldown_ledgers(env: &Env, ledgers: u32) { + env.storage() + .instance() + .set(&DataKey::ExpertCooldownLedgers, &ledgers); +} - if !matches!( - session.status, - SessionStatus::Active | SessionStatus::Paused - ) { - SkillSphereContract::set_reentrancy_lock(env, false); - return Err(Error::InvalidSessionState); +/// True when the expert still has an active post-loss cooldown. +pub fn is_expert_on_cooldown(env: &Env, expert: &Address) -> bool { + if let Some(until_ledger) = env + .storage() + .temporary() + .get::(&DataKey::ExpertCooldownUntil(expert.clone())) + { + return env.ledger().sequence() < until_ledger; } + false +} - 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); - +/// Returns the ledger sequence after which the expert may accept sessions again. +pub fn expert_cooldown_until(env: &Env, expert: &Address) -> Option { 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; - } + .temporary() + .get(&DataKey::ExpertCooldownUntil(expert.clone())) +} - 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, - ); +/// Apply cooldown when the seeker receives a strictly larger award than the expert. +pub fn apply_cooldown_if_expert_lost( + env: &Env, + expert: &Address, + seeker_award_bps: u32, + expert_award_bps: u32, +) { + if seeker_award_bps <= expert_award_bps { + return; } - 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)) + let ledgers = cooldown_ledgers(env); + let until = env.ledger().sequence().saturating_add(ledgers); + env.storage() + .temporary() + .set(&DataKey::ExpertCooldownUntil(expert.clone()), &until); } diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs index 1083476..020d6be 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -62,10 +62,13 @@ pub enum Error { SessionFrozen = 48, SwapFailed = 49, - // #236 / #239 / #237 / #238 - RateLimitExceeded = 50, - TokenNotWhitelisted = 51, - TokenAlreadyWhitelisted = 52, - TokenNotInWhitelist = 53, + // #240 / #241 / #242 + ExpertOnCooldown = 50, + SpendingLimitExceeded = 51, + VoucherExpired = 52, + VoucherNonceUsed = 53, + InvalidVoucherSignature = 54, + VoucherPubkeyNotSet = 55, + InvalidVoucher = 56, } diff --git a/contracts/src/events.rs b/contracts/src/events.rs index 8cd5346..044be3d 100644 --- a/contracts/src/events.rs +++ b/contracts/src/events.rs @@ -1,32 +1,134 @@ -//! Centralised event publishing helpers for session lifecycle events. +//! Standardized webhook event schema — Issue #243. +//! +//! Every contract event is published as a four-field envelope: +//! `{ event_type, session_id, timestamp, payload }` under the `webhook` +//! topic so off-chain relay daemons can parse a single shape. -use soroban_sdk::{symbol_short, Env}; +use soroban_sdk::{symbol_short, Env, IntoVal, Symbol, Val}; -/// 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( +/// Publish a webhook envelope consumed by off-chain relay services. +pub fn publish_event

( env: &Env, + event_type: Symbol, session_id: u64, - expert: soroban_sdk::Address, - expert_payout: i128, - seeker_refund: i128, - reason_cid: soroban_sdk::String, -) { + payload: P, +) where + P: IntoVal, +{ env.events().publish( - (symbol_short!("session"), symbol_short!("expcncl")), + (symbol_short!("webhook"),), ( + event_type, session_id, - expert, - expert_payout, - seeker_refund, - reason_cid, + env.ledger().timestamp(), + payload, ), ); } + +/// Session lifecycle events. +pub mod event_type { + use soroban_sdk::symbol_short; + + use super::Symbol; + + pub fn session_started() -> Symbol { + symbol_short!("sessStart") + } + pub fn session_paused() -> Symbol { + symbol_short!("sessPause") + } + pub fn session_resumed() -> Symbol { + symbol_short!("sessResum") + } + pub fn session_settled() -> Symbol { + symbol_short!("sessSettl") + } + pub fn session_finished() -> Symbol { + symbol_short!("sessFinsh") + } + pub fn session_refund() -> Symbol { + symbol_short!("sessRefnd") + } + pub fn session_commit() -> Symbol { + symbol_short!("sessComit") + } + pub fn session_reveal() -> Symbol { + symbol_short!("sessRevl") + } + pub fn session_voucher() -> Symbol { + symbol_short!("sessVouch") + } + + pub fn dispute_flagged() -> Symbol { + symbol_short!("dispFlag") + } + pub fn dispute_evidence() -> Symbol { + symbol_short!("dispEvid") + } + pub fn dispute_resolved() -> Symbol { + symbol_short!("dispResl") + } + + pub fn expert_cooldown() -> Symbol { + symbol_short!("expCooldn") + } + pub fn spending_limit() -> Symbol { + symbol_short!("spndLim") + } + + pub fn admin_config() -> Symbol { + symbol_short!("adminCfg") + } + pub fn platform_stats() -> Symbol { + symbol_short!("platStat") + } + pub fn fee_burn() -> Symbol { + symbol_short!("feeBurn") + } + pub fn staking() -> Symbol { + symbol_short!("staking") + } + pub fn subscription() -> Symbol { + symbol_short!("subscrip") + } + pub fn fixed_price() -> Symbol { + symbol_short!("fixPrice") + } + pub fn expert_profile() -> Symbol { + symbol_short!("expert") + } + pub fn rating() -> Symbol { + symbol_short!("rating") + } + pub fn swap() -> Symbol { + symbol_short!("swap") + } + pub fn governance() -> Symbol { + symbol_short!("gov") + } + pub fn insurance() -> Symbol { + symbol_short!("insuranc") + } + pub fn upgrade() -> Symbol { + symbol_short!("upgrade") + } + pub fn integration() -> Symbol { + symbol_short!("integr") + } + pub fn heartbeat() -> Symbol { + symbol_short!("heartbt") + } + pub fn slashing() -> Symbol { + symbol_short!("slash") + } + pub fn reverify() -> Symbol { + symbol_short!("reverify") + } + pub fn frozen() -> Symbol { + symbol_short!("frozen") + } + pub fn badge() -> Symbol { + symbol_short!("badge") + } +} diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index fbf5808..e166b69 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,7 +1,7 @@ #![no_std] pub mod bridge; -mod admin; +mod crypto; mod dex; mod disputes; mod errors; @@ -9,6 +9,7 @@ mod events; mod governance; mod reputation; pub use bridge::BridgeError; +pub use crypto::SessionVoucher; pub use dex::SwapPath; pub use errors::Error; pub use reputation::BadgeRecord; @@ -190,14 +191,16 @@ pub enum DataKey { // call sites that opt into a "if oracle unavailable, fall back to // a static rate" policy. ExpertPriceFeed(Address), - // #236: per-address rate-limit tombstone (temporary storage). - LastAction(Address), - // #236: admin-configured minimum ledger gap between rate-limited calls. - RateLimitMinLedgers, - // #239: admin-managed whitelist of approved payment tokens. - ApprovedTokens, - // #238: expert cancellation reason stored separately for transparency. - SessionCancelReason(u64), + // #240: admin-configured cooldown length (ledgers) after dispute loss. + ExpertCooldownLedgers, + // #240: temporary storage — ledger sequence until expert may accept sessions. + ExpertCooldownUntil(Address), + // #241: optional per-seeker max deposit per session. + SeekerSpendingLimit(Address), + // #242: ed25519 public key used to verify session vouchers. + ExpertVoucherPubkey(Address), + // #242: tombstone for consumed voucher nonces (replay protection). + VoucherNonceConsumed(Address, u64), } #[contracttype] @@ -419,6 +422,10 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::ReentrancyLock, &false); + env.storage().instance().set( + &DataKey::ExpertCooldownLedgers, + &disputes::DEFAULT_EXPERT_COOLDOWN_LEDGERS, + ); } /// Registers or updates an expert's profile details. @@ -471,7 +478,7 @@ impl SkillSphereContract { return Err(Error::InvalidFeeBps); } env.storage().instance().set(&DataKey::BurnBps, &burn_bps); - env.events().publish((symbol_short!("burnBps"),), burn_bps); + events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("burnBps"), burn_bps)); Ok(()) } @@ -533,8 +540,12 @@ impl SkillSphereContract { .instance() .set(&DataKey::StakingTotalStaked, &total.saturating_add(amount)); - env.events() - .publish((symbol_short!("stake"),), (staker, token, amount)); + events::publish_event( + &env, + events::event_type::staking(), + 0, + (symbol_short!("stake"), staker, token, amount), + ); Ok(()) } @@ -573,8 +584,12 @@ impl SkillSphereContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&env.current_contract_address(), &staker, &amount); - env.events() - .publish((symbol_short!("unstake"),), (staker, token, amount)); + events::publish_event( + &env, + events::event_type::staking(), + 0, + (symbol_short!("unstake"), staker, token, amount), + ); Ok(()) } @@ -625,8 +640,12 @@ impl SkillSphereContract { let token_client = token::Client::new(&env, &reward_token); token_client.transfer(&env.current_contract_address(), &staker, &owed); - env.events() - .publish((symbol_short!("claim"),), (staker, reward_token, owed)); + events::publish_event( + &env, + events::event_type::staking(), + 0, + (symbol_short!("claim"), staker, reward_token, owed), + ); Ok(owed) } @@ -680,8 +699,12 @@ impl SkillSphereContract { &acc.saturating_add(delta), ); - env.events() - .publish((symbol_short!("rewardDep"),), (from, reward_token, amount)); + events::publish_event( + &env, + events::event_type::staking(), + 0, + (symbol_short!("rewardDep"), from, reward_token, amount), + ); Ok(()) } @@ -701,8 +724,12 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::AssetFeeBps(asset.clone()), &fee_bps); - env.events() - .publish((symbol_short!("assetFee"),), (asset, fee_bps)); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("assetFee"), asset, fee_bps), + ); Ok(()) } @@ -713,8 +740,12 @@ impl SkillSphereContract { env.storage() .instance() .remove(&DataKey::AssetFeeBps(asset.clone())); - env.events() - .publish((symbol_short!("assetFee"),), (asset, 0u32)); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("assetFee"), asset, 0u32), + ); Ok(()) } @@ -736,7 +767,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::InsuranceVaultAddress, &vault); - env.events().publish((symbol_short!("insVault"),), vault); + events::publish_event(&env, events::event_type::insurance(), 0, (symbol_short!("insVault"), vault)); Ok(()) } @@ -792,9 +823,11 @@ impl SkillSphereContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&env.current_contract_address(), &recipient, &amount); - env.events().publish( - (symbol_short!("insWithdr"),), - (vault, token, recipient, amount), + events::publish_event( + &env, + events::event_type::insurance(), + 0, + (symbol_short!("insWithdr"), vault, token, recipient, amount), ); Ok(()) } @@ -848,9 +881,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::FixedPriceSession(session_id), &session); - env.events().publish( - (symbol_short!("fp"), symbol_short!("started")), - (session_id, seeker, expert, token, amount), + events::publish_event( + &env, + events::event_type::fixed_price(), + session_id, + (symbol_short!("started"), seeker, expert, token, amount), ); Ok(session_id) } @@ -874,7 +909,7 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::ExpertLastHeartbeat(expert.clone()), &now); - env.events().publish((symbol_short!("hb"),), (expert, now)); + events::publish_event(&env, events::event_type::heartbeat(), 0, (expert, now)); Ok(()) } @@ -925,9 +960,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::FixedPriceSession(session_id), &fp); - env.events().publish( - (symbol_short!("fp"), symbol_short!("approved")), - (session_id, expert_payout, platform_fee, insurance_cut), + events::publish_event( + &env, + events::event_type::fixed_price(), + session_id, + (symbol_short!("approved"), expert_payout, platform_fee, insurance_cut), ); Ok(expert_payout) } @@ -981,9 +1018,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::Dispute(session_id), &dispute); - env.events().publish( - (symbol_short!("fp"), symbol_short!("disputed")), - (session_id, seeker, evidence_cid), + events::publish_event( + &env, + events::event_type::fixed_price(), + session_id, + (symbol_short!("disputed"), seeker, evidence_cid), ); Ok(()) } @@ -1047,9 +1086,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::Subscription(seeker.clone(), expert.clone()), &sub); - env.events().publish( - (symbol_short!("sub"), symbol_short!("started")), - (seeker, expert, monthly_fee, months, total), + events::publish_event( + &env, + events::event_type::subscription(), + 0, + (symbol_short!("started"), seeker, expert, monthly_fee, months, total), ); Ok(()) } @@ -1107,9 +1148,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::Dispute(session_id), &dispute); - env.events().publish( - (symbol_short!("dispute"), symbol_short!("evidence")), - (session_id, caller, cid), + events::publish_event( + &env, + events::event_type::dispute_evidence(), + session_id, + (caller, cid), ); Ok(()) } @@ -1190,9 +1233,11 @@ impl SkillSphereContract { } } - env.events().publish( - (symbol_short!("sub"), symbol_short!("collect")), - (seeker, expert, net, platform_fee, insurance_cut), + events::publish_event( + &env, + events::event_type::subscription(), + 0, + (symbol_short!("collect"), seeker, expert, net, platform_fee, insurance_cut), ); Ok(net) } @@ -1219,9 +1264,11 @@ impl SkillSphereContract { .set(&DataKey::Subscription(seeker.clone(), expert.clone()), &sub); let token_client = token::Client::new(&env, &sub.token); token_client.transfer(&env.current_contract_address(), &expert, &amount); - env.events().publish( - (symbol_short!("sub"), symbol_short!("claim")), - (seeker, expert, amount), + events::publish_event( + &env, + events::event_type::subscription(), + 0, + (symbol_short!("claim"), seeker, expert, amount), ); Ok(amount) } @@ -1294,8 +1341,12 @@ impl SkillSphereContract { new_admin.require_auth(); env.storage().instance().set(&DataKey::Admin, &new_admin); - env.events() - .publish((symbol_short!("setAdmin"),), new_admin); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("setAdmin"), new_admin), + ); Ok(()) } @@ -1329,7 +1380,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::PlatformFeeConfig, &config); - env.events().publish((symbol_short!("setFee"),), fee_bps); + events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("setFee"), fee_bps)); Ok(()) } @@ -1367,8 +1418,12 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::PlatformFeeConfig, &config); - env.events() - .publish((symbol_short!("feeCfg"),), config.clone()); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("feeCfg"), config.clone()), + ); Ok(()) } @@ -1396,8 +1451,12 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::MinimumSessionDeposit, &min_deposit); - env.events() - .publish((symbol_short!("setMinDep"),), min_deposit); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("setMinDep"), min_deposit), + ); Ok(()) } @@ -1470,8 +1529,12 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::StakingContract, &staking_contract); - env.events() - .publish((symbol_short!("setStake"),), staking_contract); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("setStake"), staking_contract), + ); Ok(()) } @@ -1502,8 +1565,12 @@ impl SkillSphereContract { &DataKey::ExpertStakedBalance(expert.clone()), &staked_balance, ); - env.events() - .publish((symbol_short!("setStBal"),), (expert, staked_balance)); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("setStBal"), expert, staked_balance), + ); Ok(()) } @@ -1553,8 +1620,12 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::ExpertProfile(expert.clone()), &profile); - env.events() - .publish((symbol_short!("setRefrr"),), (expert, referrer)); + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("setRefrr"), expert, referrer), + ); Ok(()) } @@ -1581,7 +1652,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::TreasuryAddress, &treasury); - env.events().publish((symbol_short!("setTreas"),), treasury); + events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("setTreas"), treasury)); Ok(()) } @@ -1629,8 +1700,12 @@ impl SkillSphereContract { .persistent() .set(&DataKey::TreasuryBalance(token.clone()), &new_balance); - env.events() - .publish((symbol_short!("feeCollct"),), (session_id, token, amount)); + events::publish_event( + &env, + events::event_type::admin_config(), + session_id, + (symbol_short!("feeCollct"), token, amount), + ); Ok(()) } @@ -1671,9 +1746,11 @@ impl SkillSphereContract { let token_client = token::Client::new(&env, &token); token_client.transfer(&env.current_contract_address(), &recipient, &amount); - env.events().publish( - (symbol_short!("treasWdrw"),), - (token.clone(), amount, recipient.clone()), + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("treasWdrw"), token.clone(), amount, recipient.clone()), ); Ok(()) @@ -1710,9 +1787,11 @@ impl SkillSphereContract { ¤t_balance, ); - env.events().publish( - (symbol_short!("treasWdrw"),), - (token.clone(), current_balance, recipient.clone()), + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("treasWdrw"), token.clone(), current_balance, recipient.clone()), ); Ok(current_balance) @@ -1740,7 +1819,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::ProtocolPaused, &true); - env.events().publish((symbol_short!("protPause"),), true); + events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("protPause"), true)); Ok(()) } @@ -1753,7 +1832,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::ProtocolPaused, &false); - env.events().publish((symbol_short!("protPause"),), false); + events::publish_event(&env, events::event_type::admin_config(), 0, (symbol_short!("protPause"), false)); Ok(()) } @@ -1777,8 +1856,12 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::ExpertProfile(expert.clone()), &profile); - env.events() - .publish((symbol_short!("setReput"),), (expert, reputation)); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("setReput"), expert, reputation), + ); Ok(()) } @@ -1826,9 +1909,18 @@ impl SkillSphereContract { .persistent() .set(&DataKey::ExpertProfile(expert.clone()), &profile); - env.events().publish( - (symbol_short!("xchain"), symbol_short!("reput")), - (oracle, expert, chain, score, profile.cross_chain_reputation), + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + ( + symbol_short!("xchain"), + oracle, + expert, + chain, + score, + profile.cross_chain_reputation, + ), ); Ok(()) } @@ -1886,34 +1978,14 @@ impl SkillSphereContract { panic_with_error!(&env, Error::InvalidCid); } - let profile = Self::expert_profile(&env, expert.clone()); - if profile.rate_per_second == 0 { - panic_with_error!(&env, Error::ExpertNotRegistered); - } - if !profile.availability_status { - panic_with_error!(&env, Error::ExpertUnavailable); - } - - // #199: heartbeat freshness check. - // Backward-compat: experts who have never called `heartbeat()` - // (no LastHeartbeat key) keep the legacy "online via - // availability_status only" semantics so existing flows don't - // break. Once an expert has called heartbeat at least once, the - // 1-hour window is enforced on every new session. - if let Some(last_hb) = env - .storage() - .persistent() - .get::(&DataKey::ExpertLastHeartbeat(expert.clone())) - { - let now_secs = env.ledger().timestamp(); - if now_secs.saturating_sub(last_hb) > HEARTBEAT_VALIDITY_WINDOW { - panic_with_error!(&env, Error::ExpertOffline); - } + if let Err(err) = Self::enforce_seeker_spending_limit(&env, &seeker, amount) { + panic_with_error!(&env, err); } - if Self::effective_reputation(&profile) < min_reputation { - panic_with_error!(&env, Error::ReputationTooLow); - } + let profile = match Self::assert_expert_can_accept_session(&env, expert.clone(), min_reputation) { + Ok(p) => p, + Err(err) => panic_with_error!(&env, err), + }; let min_deposit = Self::min_session_deposit(&env); if amount < min_deposit { @@ -1928,50 +2000,182 @@ impl SkillSphereContract { if token_client.balance(&seeker) < amount { panic_with_error!(&env, Error::InsufficientBalance); } - token_client.transfer(&seeker, &env.current_contract_address(), &amount); - let session_id = Self::next_session_id(&env); - let now = env.ledger().timestamp() as u32; + Self::create_active_session( + &env, + seeker, + expert, + token, + profile.rate_per_second, + amount, + metadata_cid, + ) + } - let session = Session { - id: session_id, - seeker: seeker.clone(), - expert: expert.clone(), - token: token.clone(), - rate_per_second: profile.rate_per_second, - balance: amount, - last_settlement_timestamp: now, - start_timestamp: now, - accrued_amount: 0, - status: SessionStatus::Active, - metadata_cid: metadata_cid.clone(), - encrypted_notes_hash: None, - paused_at: None, - }; + /// Seeker-imposed cap on the deposit amount for each new session (#241). + pub fn set_spending_limit(env: Env, seeker: Address, max_per_session: i128) -> Result<(), Error> { + seeker.require_auth(); + if max_per_session <= 0 { + return Err(Error::InvalidAmount); + } + env.storage() + .persistent() + .set(&DataKey::SeekerSpendingLimit(seeker.clone()), &max_per_session); + events::publish_event( + &env, + events::event_type::spending_limit(), + 0, + (seeker, max_per_session), + ); + Ok(()) + } + /// Clear a previously configured spending limit (#241). + pub fn clear_spending_limit(env: Env, seeker: Address) -> Result<(), Error> { + seeker.require_auth(); env.storage() .persistent() - .set(&DataKey::Session(session_id), &session); + .remove(&DataKey::SeekerSpendingLimit(seeker.clone())); + events::publish_event( + &env, + events::event_type::spending_limit(), + 0, + (seeker, 0i128), + ); + Ok(()) + } - // #203: stamp the initial re-verification timestamp on session creation. + pub fn get_spending_limit(env: Env, seeker: Address) -> Option { env.storage() .persistent() - .set(&DataKey::SessionLastVerified(session_id), &(now as u64)); + .get(&DataKey::SeekerSpendingLimit(seeker)) + } - env.events().publish( - (symbol_short!("session"), symbol_short!("started")), - ( - session_id, - seeker.clone(), - expert.clone(), - profile.rate_per_second, - amount, - now, - metadata_cid, - ), + /// Admin: configure expert cooldown length in ledgers after dispute loss (#240). + pub fn set_expert_cooldown_ledgers(env: Env, ledgers: u32) -> Result<(), Error> { + Self::require_admin(&env)?; + if ledgers == 0 { + return Err(Error::InvalidAmount); + } + disputes::set_cooldown_ledgers(&env, ledgers); + events::publish_event( + &env, + events::event_type::admin_config(), + 0, + (symbol_short!("expCool"), ledgers), ); + Ok(()) + } - session_id + pub fn get_expert_cooldown_ledgers(env: Env) -> u32 { + disputes::cooldown_ledgers(&env) + } + + pub fn get_expert_cooldown_until(env: Env, expert: Address) -> Option { + disputes::expert_cooldown_until(&env, &expert) + } + + /// Expert registers the ed25519 public key used to verify session vouchers (#242). + pub fn set_voucher_signing_key( + env: Env, + expert: Address, + public_key: BytesN<32>, + ) -> Result<(), Error> { + expert.require_auth(); + crypto::set_voucher_pubkey(&env, &expert, public_key.clone()); + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (expert, public_key), + ); + Ok(()) + } + + pub fn get_voucher_signing_key(env: Env, expert: Address) -> Option> { + crypto::voucher_pubkey(&env, &expert) + } + + /// Start a session using an expert-signed off-chain voucher (#242). + pub fn start_session_with_voucher( + env: Env, + seeker: Address, + token: Address, + amount: i128, + min_reputation: u32, + metadata_cid: String, + voucher: SessionVoucher, + expert_signature: BytesN<64>, + ) -> Result { + seeker.require_auth(); + Self::ensure_protocol_active(&env)?; + if !Self::is_valid_ipfs_cid(&metadata_cid) { + return Err(Error::InvalidCid); + } + Self::enforce_seeker_spending_limit(&env, &seeker, amount)?; + + if voucher.rate_per_second <= 0 || voucher.max_duration == 0 { + return Err(Error::InvalidVoucher); + } + if env.ledger().timestamp() > voucher.expiry { + return Err(Error::VoucherExpired); + } + if crypto::is_nonce_consumed(&env, &voucher.expert, voucher.nonce) { + return Err(Error::VoucherNonceUsed); + } + + let public_key = crypto::voucher_pubkey(&env, &voucher.expert) + .ok_or(Error::VoucherPubkeyNotSet)?; + crypto::verify_voucher_signature(&env, &voucher, &public_key, &expert_signature)?; + + let profile = + Self::assert_expert_can_accept_session(&env, voucher.expert.clone(), min_reputation)?; + + if profile.rate_per_second != voucher.rate_per_second { + return Err(Error::InvalidVoucher); + } + + let max_escrow = voucher + .rate_per_second + .saturating_mul(voucher.max_duration as i128); + if amount > max_escrow { + return Err(Error::InvalidAmount); + } + + let min_deposit = Self::min_session_deposit(&env); + if amount < min_deposit { + return Err(Error::AmountBelowMinimum); + } + let min_escrow = voucher.rate_per_second.saturating_mul(300); + if amount < min_escrow { + return Err(Error::DepositTooLow); + } + + let token_client = token::Client::new(&env, &token); + if token_client.balance(&seeker) < amount { + return Err(Error::InsufficientBalance); + } + + crypto::consume_nonce(&env, &voucher.expert, voucher.nonce); + + let session_id = Self::create_active_session( + &env, + seeker, + voucher.expert.clone(), + token, + voucher.rate_per_second, + amount, + metadata_cid, + ); + + events::publish_event( + &env, + events::event_type::session_voucher(), + session_id, + (voucher.expert, voucher.nonce), + ); + + Ok(session_id) } /// Calculates the amount claimable from a session at a given time. @@ -2028,9 +2232,11 @@ impl SkillSphereContract { session.paused_at = Some(now); Self::save_session(&env, &session); - env.events().publish( - (symbol_short!("session"), symbol_short!("paused")), - (session_id, now), + events::publish_event( + &env, + events::event_type::session_paused(), + session_id, + now, ); Ok(()) @@ -2075,9 +2281,11 @@ impl SkillSphereContract { session.paused_at = None; Self::save_session(&env, &session); - env.events().publish( - (symbol_short!("session"), symbol_short!("resumed")), - (session_id, now), + events::publish_event( + &env, + events::event_type::session_resumed(), + session_id, + now, ); Ok(()) @@ -2316,9 +2524,11 @@ impl SkillSphereContract { .set(&DataKey::Dispute(session_id), &dispute); let created_at = dispute.created_at; - env.events().publish( - (symbol_short!("dispute"), symbol_short!("flagged")), - (session_id, seeker, evidence_cid, created_at), + events::publish_event( + &env, + events::event_type::dispute_flagged(), + session_id, + (seeker, evidence_cid, created_at), ); Ok(()) @@ -2415,7 +2625,7 @@ impl SkillSphereContract { .instance() .set(&DataKey::UpgradeTimelock, &timelock); - env.events().publish((symbol_short!("upgInit"),), now); + events::publish_event(&env, events::event_type::upgrade(), 0, (symbol_short!("upgInit"), now)); Ok(()) } @@ -2444,7 +2654,7 @@ impl SkillSphereContract { env.deployer() .update_current_contract_wasm(timelock.new_wasm_hash); - env.events().publish((symbol_short!("upgExec"),), now); + events::publish_event(&env, events::event_type::upgrade(), 0, (symbol_short!("upgExec"), now)); Ok(()) } @@ -2521,7 +2731,109 @@ impl SkillSphereContract { Ok(()) } - pub(crate) fn get_session_or_error(env: &Env, session_id: u64) -> Result { + fn enforce_seeker_spending_limit(env: &Env, seeker: &Address, amount: i128) -> Result<(), Error> { + if let Some(limit) = env + .storage() + .persistent() + .get::(&DataKey::SeekerSpendingLimit(seeker.clone())) + { + if amount > limit { + return Err(Error::SpendingLimitExceeded); + } + } + Ok(()) + } + + fn assert_expert_can_accept_session( + env: &Env, + expert: Address, + min_reputation: u32, + ) -> Result { + if disputes::is_expert_on_cooldown(env, &expert) { + return Err(Error::ExpertOnCooldown); + } + + let profile = Self::expert_profile(env, expert.clone()); + if profile.rate_per_second == 0 { + return Err(Error::ExpertNotRegistered); + } + if !profile.availability_status { + return Err(Error::ExpertUnavailable); + } + + if let Some(last_hb) = env + .storage() + .persistent() + .get::(&DataKey::ExpertLastHeartbeat(expert.clone())) + { + let now_secs = env.ledger().timestamp(); + if now_secs.saturating_sub(last_hb) > HEARTBEAT_VALIDITY_WINDOW { + return Err(Error::ExpertOffline); + } + } + + if Self::effective_reputation(&profile) < min_reputation { + return Err(Error::ReputationTooLow); + } + + Ok(profile) + } + + fn create_active_session( + env: &Env, + seeker: Address, + expert: Address, + token: Address, + rate_per_second: i128, + amount: i128, + metadata_cid: String, + ) -> u64 { + let token_client = token::Client::new(env, &token); + token_client.transfer(&seeker, &env.current_contract_address(), &amount); + + let session_id = Self::next_session_id(env); + let now = env.ledger().timestamp() as u32; + + let session = Session { + id: session_id, + seeker: seeker.clone(), + expert: expert.clone(), + token: token.clone(), + rate_per_second, + balance: amount, + last_settlement_timestamp: now, + start_timestamp: now, + accrued_amount: 0, + status: SessionStatus::Active, + metadata_cid: metadata_cid.clone(), + encrypted_notes_hash: None, + paused_at: None, + }; + + env.storage() + .persistent() + .set(&DataKey::Session(session_id), &session); + env.storage() + .persistent() + .set(&DataKey::SessionLastVerified(session_id), &(now as u64)); + + events::publish_event( + env, + events::event_type::session_started(), + session_id, + ( + seeker.clone(), + expert.clone(), + rate_per_second, + amount, + metadata_cid, + ), + ); + + session_id + } + + fn get_session_or_error(env: &Env, session_id: u64) -> Result { env.storage() .persistent() .get(&DataKey::Session(session_id)) @@ -2590,7 +2902,7 @@ impl SkillSphereContract { /// in Stellar deployments), and bump the per-token TotalBurned /// counter. Returns the burned amount so the caller can subtract /// it from the treasury transfer. - fn apply_burn(env: &Env, token: &Address, treasury_share: i128) -> i128 { + fn apply_burn(env: &Env, session_id: u64, token: &Address, treasury_share: i128) -> i128 { let burn_bps: u32 = env .storage() .instance() @@ -2623,8 +2935,10 @@ impl SkillSphereContract { &DataKey::TotalBurned(token.clone()), &prev.saturating_add(burn_amount), ); - env.events().publish( - (symbol_short!("burn"),), + events::publish_event( + env, + events::event_type::fee_burn(), + session_id, (token.clone(), burn_amount, burn_bps), ); burn_amount @@ -2708,9 +3022,11 @@ impl SkillSphereContract { .set(&DataKey::TotalVolumeSettled, &total_volume); if total_sessions % PLATFORM_STATS_EMIT_INTERVAL == 0 { - env.events().publish( - (symbol_short!("plat_stat"),), - (total_sessions, total_volume, env.ledger().timestamp()), + events::publish_event( + env, + events::event_type::platform_stats(), + 0, + (total_sessions, total_volume), ); } } @@ -2822,7 +3138,7 @@ impl SkillSphereContract { // `total_burned(token)` counter let off-chain bookkeeping // (or a follow-up admin call to the token contract's // burn function) clear them. - let burned = Self::apply_burn(env, &token, treasury_fee); + let burned = Self::apply_burn(env, session_id, &token, treasury_fee); let treasury_payout = treasury_fee.saturating_sub(burned); if treasury_payout > 0 { if let Some(treasury) = env @@ -2835,9 +3151,11 @@ impl SkillSphereContract { &treasury, &treasury_payout, ); - env.events().publish( - (symbol_short!("feeRoute"),), - (session_id, token.clone(), treasury_payout), + events::publish_event( + env, + events::event_type::admin_config(), + session_id, + (symbol_short!("feeRoute"), token.clone(), treasury_payout), ); } else { Self::collect_fee(env.clone(), session_id, token.clone(), treasury_payout)?; @@ -2854,9 +3172,11 @@ impl SkillSphereContract { token_client.transfer(&env.current_contract_address(), &expert, &expert_payout); } - env.events().publish( - (symbol_short!("session"), symbol_short!("settled")), - (session_id, expert_payout, now), + events::publish_event( + env, + events::event_type::session_settled(), + session_id, + (expert_payout, now), ); // #200: roll-up volume + session counters and emit a @@ -2948,9 +3268,11 @@ impl SkillSphereContract { } let finished_at = env.ledger().timestamp(); - env.events().publish( - (symbol_short!("session"), symbol_short!("finished")), - (session.id, final_claimable, final_remaining, finished_at), + events::publish_event( + env, + events::event_type::session_finished(), + session.id, + (final_claimable, final_remaining, finished_at), ); Self::set_reentrancy_lock(env, false); @@ -3128,15 +3450,31 @@ impl SkillSphereContract { ); } - let resolved_at = env.ledger().timestamp(); - env.events().publish( - (symbol_short!("dispute"), symbol_short!("resolved")), + disputes::apply_cooldown_if_expert_lost( + env, + &session.expert, + seeker_award_bps, + expert_award_bps, + ); + if disputes::is_expert_on_cooldown(env, &session.expert) { + if let Some(until) = disputes::expert_cooldown_until(env, &session.expert) { + events::publish_event( + env, + events::event_type::expert_cooldown(), + session.id, + (session.expert.clone(), until), + ); + } + } + + events::publish_event( + env, + events::event_type::dispute_resolved(), + session.id, ( - session.id, seeker_amount, expert_amount, auto_resolved, - resolved_at, ), ); @@ -3267,9 +3605,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::ExpertRatingCount(expert.clone()), &new_count); - env.events().publish( - (symbol_short!("rating"), symbol_short!("submitted")), - (session_id, expert, rating, new_avg), + events::publish_event( + &env, + events::event_type::rating(), + session_id, + (expert, rating, new_avg), ); Ok(()) @@ -3345,9 +3685,11 @@ impl SkillSphereContract { .set(&DataKey::ReferralSessionCount(expert.clone()), &0u32); } - env.events().publish( - (symbol_short!("expert"), symbol_short!("regist")), - (expert, referrer_id), + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("regist"), expert, referrer_id), ); Ok(()) @@ -3382,8 +3724,12 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::TrustedOracle(oracle.clone()), &true); - env.events() - .publish((symbol_short!("oracle"), symbol_short!("regist")), oracle); + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("oracleReg"), oracle), + ); Ok(()) } @@ -3399,8 +3745,12 @@ impl SkillSphereContract { env.storage() .persistent() .remove(&DataKey::TrustedOracle(oracle.clone())); - env.events() - .publish((symbol_short!("oracle"), symbol_short!("removed")), oracle); + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("oracleRm"), oracle), + ); Ok(()) } @@ -3443,9 +3793,11 @@ impl SkillSphereContract { &verification, ); - env.events().publish( - (symbol_short!("expert"), symbol_short!("verified")), - (expert, oracle_source), + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("verified"), expert, oracle_source), ); Ok(()) @@ -3499,8 +3851,12 @@ impl SkillSphereContract { .set(&DataKey::ExpertProfile(expert.clone()), &profile); // Emit event for frontend indexer - env.events() - .publish((symbol_short!("staked"),), (expert.clone(), amount)); + events::publish_event( + &env, + events::event_type::staking(), + 0, + (symbol_short!("staked"), expert.clone(), amount), + ); Ok(()) } @@ -3542,8 +3898,12 @@ impl SkillSphereContract { .set(&DataKey::ExpertProfile(expert.clone()), &profile); // Emit event for frontend indexer - env.events() - .publish((symbol_short!("unstaked"),), (expert.clone(), amount)); + events::publish_event( + &env, + events::event_type::staking(), + 0, + (symbol_short!("unstaked"), expert.clone(), amount), + ); Ok(()) } @@ -3606,8 +3966,12 @@ impl SkillSphereContract { // Verify dispute exists let _dispute = Self::get_session_or_error(&env, session_id)?; - env.events() - .publish((symbol_short!("resProp"),), (session_id, seeker_award_bps)); + events::publish_event( + &env, + events::event_type::governance(), + session_id, + (symbol_short!("resProp"), seeker_award_bps), + ); Ok(()) } @@ -3686,8 +4050,10 @@ impl SkillSphereContract { .set(&treasury_key, &treasury_balance); // Emit event for auditing - env.events().publish( - (symbol_short!("slashed"),), + events::publish_event( + &env, + events::event_type::slashing(), + 0, (expert_id.clone(), amount, reason.clone()), ); @@ -3756,9 +4122,11 @@ impl SkillSphereContract { &refund_amount, ); - env.events().publish( - (symbol_short!("session"), symbol_short!("refund")), - (session_id, refund_amount, now), + events::publish_event( + &env, + events::event_type::session_refund(), + session_id, + (refund_amount, now), ); Self::set_reentrancy_lock(&env, false); @@ -3829,9 +4197,11 @@ impl SkillSphereContract { &total_claimable, ); - env.events().publish( - (symbol_short!("withdraw"), symbol_short!("accrued")), - (session_id, total_claimable, now), + events::publish_event( + &env, + events::event_type::session_settled(), + session_id, + (symbol_short!("withdraw"), total_claimable, now), ); Self::set_reentrancy_lock(&env, false); @@ -3849,7 +4219,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::SbtContractAddress, &sbt_addr); - env.events().publish((symbol_short!("sbtSet"),), sbt_addr); + events::publish_event(&env, events::event_type::integration(), 0, (symbol_short!("sbtSet"), sbt_addr)); Ok(()) } @@ -3896,8 +4266,12 @@ impl SkillSphereContract { .persistent() .set(&DataKey::ExpertBadge(expert.clone()), &record); - env.events() - .publish((symbol_short!("badge"),), (expert, badge_id, now)); + events::publish_event( + &env, + events::event_type::badge(), + 0, + (expert, badge_id, now), + ); Ok(record) } @@ -3935,8 +4309,12 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::SessionFrozenFlag(session_id), &false); - env.events() - .publish((symbol_short!("reverify"),), (session_id, now)); + events::publish_event( + &env, + events::event_type::reverify(), + session_id, + now, + ); Ok(()) } @@ -3954,8 +4332,12 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::SessionFrozenFlag(session_id), &true); - env.events() - .publish((symbol_short!("frozen"),), (session_id, now)); + events::publish_event( + &env, + events::event_type::frozen(), + session_id, + now, + ); } Ok(()) } @@ -3998,7 +4380,7 @@ impl SkillSphereContract { env.storage() .instance() .set(&DataKey::DexContractAddress, &dex_addr); - env.events().publish((symbol_short!("dexSet"),), dex_addr); + events::publish_event(&env, events::event_type::integration(), 0, (symbol_short!("dexSet"), dex_addr)); Ok(()) } @@ -4106,10 +4488,11 @@ impl SkillSphereContract { .persistent() .set(&DataKey::SessionLastVerified(session_id), &(now as u64)); - env.events().publish( - (symbol_short!("swap"), symbol_short!("started")), + events::publish_event( + &env, + events::event_type::swap(), + session_id, ( - session_id, seeker, expert, offer_token, @@ -4157,8 +4540,10 @@ impl SkillSphereContract { created_at: env.ledger().timestamp() as u32, }; env.storage().temporary().set(&key, &record); - env.events().publish( - (symbol_short!("session"), symbol_short!("commit")), + events::publish_event( + &env, + events::event_type::session_commit(), + 0, (commitment, committer), ); Ok(()) @@ -4206,8 +4591,10 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::SessionCommitConsumed(computed.into()), &true); - env.events().publish( - (symbol_short!("session"), symbol_short!("reveal")), + events::publish_event( + &env, + events::event_type::session_reveal(), + 0, (committer, seeker, expert), ); Ok(()) @@ -4248,8 +4635,12 @@ impl SkillSphereContract { env.storage() .persistent() .set(&DataKey::ExpertPriceFeed(expert.clone()), &config); - env.events() - .publish((symbol_short!("expert"), symbol_short!("feedset")), expert); + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("feedset"), expert), + ); Ok(()) } @@ -4263,8 +4654,12 @@ impl SkillSphereContract { env.storage() .persistent() .remove(&DataKey::ExpertPriceFeed(expert.clone())); - env.events() - .publish((symbol_short!("expert"), symbol_short!("feedrm")), expert); + events::publish_event( + &env, + events::event_type::expert_profile(), + 0, + (symbol_short!("feedrm"), expert), + ); Ok(()) } @@ -6290,6 +6685,43 @@ mod test { assert!(v1 >= claimable); } + #[test] + fn test_expert_cooldown_after_dispute_loss() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + + client.flag_dispute( + &session_id, + &seeker, + &String::from_str(&env, "Expert no-show"), + &test_cid(&env), + ); + client.resolve_dispute(&session_id, &8_000); + + let until = client.get_expert_cooldown_until(&expert); + assert!(until.is_some()); + assert!(until.unwrap() > env.ledger().sequence()); + } + + #[test] + #[should_panic(expected = "Error(Contract, #50)")] + fn test_start_session_rejects_expert_on_cooldown() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + + client.flag_dispute( + &session_id, + &seeker, + &String::from_str(&env, "Seeker wins"), + &test_cid(&env), + ); + client.resolve_dispute(&session_id, &10_000); + + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); // #236 / #237 / #238 / #239 tests // ==================================================================== @@ -6335,6 +6767,11 @@ mod test { #[test] #[should_panic(expected = "Error(Contract, #51)")] + fn test_seeker_spending_limit_enforced() { + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + client.set_spending_limit(&seeker, &2_000); + fn test_start_session_rejects_non_whitelisted_token() { let (env, client, _, admin, seeker, expert, token, _) = setup(); register_and_avail(&env, &client, &expert, 10); @@ -6343,6 +6780,118 @@ mod test { } #[test] + fn test_start_session_with_voucher() { + use ed25519_dalek::{Signer, SigningKey}; + use soroban_sdk::BytesN; + + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + + let seed = [7u8; 32]; + let signing_key = SigningKey::from_bytes(&seed); + let verifying_key = signing_key.verifying_key(); + let mut pk_arr = [0u8; 32]; + pk_arr.copy_from_slice(verifying_key.as_bytes()); + let public_key = BytesN::from_array(&env, &pk_arr); + client.set_voucher_signing_key(&expert, &public_key); + + let voucher = SessionVoucher { + expert: expert.clone(), + rate_per_second: 10, + max_duration: 600, + expiry: env.ledger().timestamp() + 3_600, + nonce: 1, + }; + + let mut msg = soroban_sdk::Bytes::new(&env); + msg.append(&voucher.expert.to_xdr(&env)); + msg.append(&voucher.rate_per_second.to_xdr(&env)); + msg.append(&voucher.max_duration.to_xdr(&env)); + msg.append(&voucher.expiry.to_xdr(&env)); + msg.append(&voucher.nonce.to_xdr(&env)); + let mut msg_vec = std::vec![0u8; msg.len() as usize]; + msg.copy_into_slice(&mut msg_vec); + let sig_bytes = signing_key.sign(&msg_vec); + let mut sig_arr = [0u8; 64]; + sig_arr.copy_from_slice(sig_bytes.as_bytes()); + let signature = BytesN::from_array(&env, &sig_arr); + + let session_id = client + .start_session_with_voucher( + &seeker, + &token, + &3_000, + &0, + &test_cid(&env), + &voucher, + &signature, + ) + .unwrap(); + assert_eq!(session_id, 1); + + let replay = client.try_start_session_with_voucher( + &seeker, + &token, + &3_000, + &0, + &test_cid(&env), + &voucher, + &signature, + ); + assert_eq!(replay, Err(Ok(Error::VoucherNonceUsed))); + } + + #[test] + fn test_webhook_relay_emits_standard_envelope() { + use soroban_sdk::testutils::Events; + use soroban_sdk::{symbol_short, Symbol}; + + let (env, client, _, _, seeker, expert, token, _) = setup(); + register_and_avail(&env, &client, &expert, 10); + + let session_id = + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + client.set_spending_limit(&seeker, &5_000); + + client.flag_dispute( + &session_id, + &seeker, + &String::from_str(&env, "Relay test"), + &test_cid(&env), + ); + + let all_events = env.events().all(); + assert!(!all_events.is_empty()); + + let webhook_topic = symbol_short!("webhook"); + let mut saw_session_start = false; + let mut saw_dispute = false; + let mut saw_spending_limit = false; + + for (_contract, topics, data) in all_events { + assert!(!topics.is_empty()); + let topic0: Symbol = topics[0].try_into_val(&env).unwrap(); + if topic0 != webhook_topic { + continue; + } + let (event_type, sid, _ts, _payload): (Symbol, u64, u64, soroban_sdk::Val) = + data.try_into_val(&env).unwrap(); + if event_type == crate::events::event_type::session_started() { + saw_session_start = true; + assert_eq!(sid, session_id); + } + if event_type == crate::events::event_type::dispute_flagged() { + saw_dispute = true; + assert_eq!(sid, session_id); + } + if event_type == crate::events::event_type::spending_limit() { + saw_spending_limit = true; + } + } + + assert!(saw_session_start); + assert!(saw_dispute); + assert!(saw_spending_limit); fn test_admin_token_whitelist_add_and_remove() { let (env, client, _, admin, _, _, token, _) = setup(); let extra = Address::generate(&env); diff --git a/docs/WEBHOOK_RELAY.md b/docs/WEBHOOK_RELAY.md new file mode 100644 index 0000000..8ac9abc --- /dev/null +++ b/docs/WEBHOOK_RELAY.md @@ -0,0 +1,141 @@ +# Webhook Relay Service + +Off-chain relay daemons subscribe to SkillSphere contract events and forward +normalized notifications to registered webhook URLs. Webhook URLs and relay +configuration are stored **off-chain**; the contract only emits a standardized +event envelope. + +## Event envelope schema + +Every contract event is published under the `webhook` topic with a four-field +data tuple: + +```text +(event_type, session_id, timestamp, payload) +``` + +| Field | Type | Description | +|--------------|----------|-------------| +| `event_type` | `Symbol` | Stable identifier for the event kind (see table below) | +| `session_id` | `u64` | Related session id, or `0` when not session-scoped | +| `timestamp` | `u64` | Ledger timestamp (`env.ledger().timestamp()`) at emit time | +| `payload` | tuple | Event-specific fields documented per type | + +### Decoding in a relay + +1. Subscribe to contract events where topic[0] == `"webhook"`. +2. Parse the data SCVal as a four-tuple. +3. Route on `event_type` to the appropriate payload decoder. +4. POST a JSON body to each registered webhook URL for that event type. + +## Event types to listen for + +### Session lifecycle + +| `event_type` | When emitted | Payload fields | +|--------------|--------------|----------------| +| `sessStart` | `start_session` / voucher start | `(seeker, expert, rate, amount, metadata_cid)` | +| `sessPause` | `pause_session` | `(paused_at)` | +| `sessResum` | `resume_session` | `(resumed_at)` | +| `sessSettl` | `settle_session`, partial withdraw | `(expert_payout_or_label, ts_or_amount, …)` | +| `sessFinsh` | `end_session` | `(claimable, remaining, finished_at)` | +| `sessRefnd` | no-show refund | `(refund_amount, ts)` | +| `sessComit` | commit-reveal handshake commit | `(commitment_hash, committer)` | +| `sessRevl` | commit-reveal reveal | `(committer, seeker, expert)` | +| `sessVouch` | voucher session started | `(expert, nonce)` | + +### Disputes + +| `event_type` | When emitted | Payload fields | +|--------------|--------------|----------------| +| `dispFlag` | `flag_dispute` | `(seeker, evidence_cid, created_at)` | +| `dispEvid` | `add_dispute_evidence` | `(caller, cid)` | +| `dispResl` | dispute resolved | `(seeker_amount, expert_amount, auto_resolved)` | +| `expCooldn` | expert enters post-loss cooldown | `(expert, cooldown_until_ledger)` | + +### Seeker limits + +| `event_type` | When emitted | Payload fields | +|--------------|--------------|----------------| +| `spndLim` | `set_spending_limit` / `clear_spending_limit` | `(seeker, max_per_session_or_0)` | + +### Platform / admin / other + +| `event_type` | Examples | +|--------------|----------| +| `adminCfg` | fee changes, treasury, pause, asset fees | +| `platStat` | rolled-up volume every 100 settlements | +| `feeBurn` | fee burn on settlement | +| `staking` | stake / unstake / claim / reward deposit | +| `subscrip` | subscription started / collect / claim | +| `fixPrice` | fixed-price escrow lifecycle | +| `expert` | registration, verification, price feeds | +| `rating` | session rating submitted | +| `swap` | DEX-backed session start | +| `gov` | arbitration proposal | +| `insuranc` | insurance vault config / withdraw | +| `upgrade` | WASM upgrade timelock | +| `integr` | SBT / DEX contract pointers | +| `heartbt` | expert heartbeat | +| `slash` | expert slashing | +| `reverify` | session re-verification | +| `frozen` | session frozen for missed check-in | +| `badge` | soulbound badge minted | + +## Webhook POST format + +The relay should POST JSON to each subscriber URL: + +```json +{ + "event_type": "sessStart", + "session_id": 42, + "timestamp": 1700000000, + "contract_id": "C…", + "ledger": 12345678, + "tx_hash": "…", + "payload": { + "seeker": "G…", + "expert": "G…", + "rate_per_second": "10", + "amount": "3000", + "metadata_cid": "Qm…" + } +} +``` + +Field names inside `payload` are chosen by the relay implementation; the +contract only guarantees the on-chain tuple ordering documented above. + +### Recommended relay behaviour + +- **Idempotency**: dedupe on `(tx_hash, event_type, session_id)`. +- **Retries**: exponential backoff on HTTP 5xx / network errors. +- **Auth**: sign outbound requests (HMAC-SHA256 shared secret per webhook). +- **Filtering**: clients register for subsets of `event_type` values off-chain. + +## Local verification + +Contract integration tests in `contracts/src/lib.rs` (`test_webhook_relay_emits_standard_envelope`) +assert that session, dispute, and config flows emit `webhook`-topic events whose +data tuple has the expected four-field shape. + +Run: + +```bash +cd contracts && cargo test test_webhook_relay +``` + +## Example minimal relay (pseudocode) + +```python +for event in soroban_stream(contract_id): + if event.topics[0] != "webhook": + continue + event_type, session_id, timestamp, payload = decode(event.data) + urls = db.webhooks_for(event_type) + body = {"event_type": event_type, "session_id": session_id, + "timestamp": timestamp, "payload": payload_to_json(payload)} + for url in urls: + requests.post(url, json=body, headers=sign(body), timeout=5) +```