From 2926f80559149a08602170f10f35bfd0455abb93 Mon Sep 17 00:00:00 2001 From: testersweb0-bug Date: Sun, 31 May 2026 07:45:30 +0100 Subject: [PATCH] feat(contracts): expert cooldown, spending limits, vouchers, webhook events (#240-#243) Add dispute-loss expert cooldowns, seeker spending caps, ed25519 session vouchers, and a unified webhook event envelope with relay documentation. --- contracts/Cargo.toml | 1 + contracts/src/crypto.rs | 67 +++ contracts/src/disputes.rs | 64 +++ contracts/src/errors.rs | 9 + contracts/src/events.rs | 134 ++++++ contracts/src/lib.rs | 980 ++++++++++++++++++++++++++++++-------- docs/WEBHOOK_RELAY.md | 141 ++++++ 7 files changed, 1191 insertions(+), 205 deletions(-) create mode 100644 contracts/src/crypto.rs create mode 100644 contracts/src/disputes.rs create mode 100644 contracts/src/events.rs create mode 100644 docs/WEBHOOK_RELAY.md 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 new file mode 100644 index 0000000..272f50d --- /dev/null +++ b/contracts/src/disputes.rs @@ -0,0 +1,64 @@ +//! 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::{Address, Env}; + +use crate::DataKey; + +/// Stellar closes a ledger roughly every 5 seconds; seven days ≈ 120_960 ledgers. +pub const DEFAULT_EXPERT_COOLDOWN_LEDGERS: u32 = 7 * 24 * 60 * 12; + +/// 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) +} + +/// Admin-only setter invoked from `lib.rs`. +pub fn set_cooldown_ledgers(env: &Env, ledgers: u32) { + env.storage() + .instance() + .set(&DataKey::ExpertCooldownLedgers, &ledgers); +} + +/// 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 +} + +/// Returns the ledger sequence after which the expert may accept sessions again. +pub fn expert_cooldown_until(env: &Env, expert: &Address) -> Option { + env.storage() + .temporary() + .get(&DataKey::ExpertCooldownUntil(expert.clone())) +} + +/// 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; + } + + 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 abe187c..020d6be 100644 --- a/contracts/src/errors.rs +++ b/contracts/src/errors.rs @@ -62,4 +62,13 @@ pub enum Error { SessionFrozen = 48, SwapFailed = 49, + // #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 new file mode 100644 index 0000000..044be3d --- /dev/null +++ b/contracts/src/events.rs @@ -0,0 +1,134 @@ +//! 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, IntoVal, Symbol, Val}; + +/// Publish a webhook envelope consumed by off-chain relay services. +pub fn publish_event

( + env: &Env, + event_type: Symbol, + session_id: u64, + payload: P, +) where + P: IntoVal, +{ + env.events().publish( + (symbol_short!("webhook"),), + ( + event_type, + session_id, + 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 e42d95b..fc4badc 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -1,11 +1,15 @@ #![no_std] pub mod bridge; +mod crypto; mod dex; +mod disputes; mod errors; +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; @@ -187,6 +191,16 @@ pub enum DataKey { // call sites that opt into a "if oracle unavailable, fall back to // a static rate" policy. ExpertPriceFeed(Address), + // #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] @@ -407,6 +421,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. @@ -459,7 +477,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(()) } @@ -521,8 +539,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(()) } @@ -561,8 +583,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(()) } @@ -613,8 +639,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) } @@ -668,8 +698,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(()) } @@ -689,8 +723,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(()) } @@ -701,8 +739,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(()) } @@ -724,7 +766,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(()) } @@ -780,9 +822,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(()) } @@ -836,9 +880,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) } @@ -861,7 +907,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(()) } @@ -912,9 +958,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) } @@ -968,9 +1016,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(()) } @@ -1034,9 +1084,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(()) } @@ -1094,9 +1146,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(()) } @@ -1177,9 +1231,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) } @@ -1206,9 +1262,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) } @@ -1281,8 +1339,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(()) } @@ -1316,7 +1378,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(()) } @@ -1354,8 +1416,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(()) } @@ -1383,8 +1449,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(()) } @@ -1406,8 +1476,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(()) } @@ -1438,8 +1512,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(()) } @@ -1489,8 +1567,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(()) } @@ -1517,7 +1599,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(()) } @@ -1565,8 +1647,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(()) } @@ -1607,9 +1693,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(()) @@ -1646,9 +1734,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) @@ -1676,7 +1766,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(()) } @@ -1689,7 +1779,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(()) } @@ -1713,8 +1803,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(()) } @@ -1762,9 +1856,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(()) } @@ -1816,34 +1919,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 { @@ -1858,50 +1941,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. @@ -1958,9 +2173,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(()) @@ -2005,9 +2222,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(()) @@ -2179,9 +2398,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(()) @@ -2278,7 +2499,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(()) } @@ -2307,7 +2528,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(()) } @@ -2384,6 +2605,108 @@ impl SkillSphereContract { Ok(()) } + 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() @@ -2453,7 +2776,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() @@ -2486,8 +2809,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 @@ -2571,9 +2896,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), ); } } @@ -2682,7 +3009,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 @@ -2695,9 +3022,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)?; @@ -2714,9 +3043,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 @@ -2805,9 +3136,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); @@ -2985,15 +3318,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, ), ); @@ -3124,9 +3473,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(()) @@ -3202,9 +3553,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(()) @@ -3239,8 +3592,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(()) } @@ -3256,8 +3613,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(()) } @@ -3300,9 +3661,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(()) @@ -3356,8 +3719,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(()) } @@ -3399,8 +3766,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(()) } @@ -3463,8 +3834,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(()) } @@ -3543,8 +3918,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()), ); @@ -3613,9 +3990,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); @@ -3686,9 +4065,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); @@ -3706,7 +4087,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(()) } @@ -3753,8 +4134,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) } @@ -3792,8 +4177,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(()) } @@ -3811,8 +4200,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(()) } @@ -3855,7 +4248,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(()) } @@ -3956,10 +4349,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, @@ -4007,8 +4401,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(()) @@ -4056,8 +4452,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(()) @@ -4098,8 +4496,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(()) } @@ -4113,8 +4515,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(()) } @@ -6122,4 +6528,168 @@ mod test { // Volume counter tracks gross claimable, not net-of-fee payout. 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)); + } + + #[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); + + client.start_session(&seeker, &expert, &token, &3_000, &0, &test_cid(&env)); + } + + #[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); + } } 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) +```