diff --git a/contracts/src/dex.rs b/contracts/src/dex.rs
new file mode 100644
index 0000000..bed205d
--- /dev/null
+++ b/contracts/src/dex.rs
@@ -0,0 +1,67 @@
+//! # Cross-Contract Token Swaps (DEX Integration) — Issue #205
+//!
+//! Allows a seeker who holds `XLM` (or any asset) to start a session where
+//! the expert is paid in a *different* token (e.g. `USDC`). The contract
+//! bridges the two assets on-the-fly by calling a Stellar DEX router
+//! (Phoenix / Soroswap) through a cross-contract invocation.
+//!
+//! ## Storage keys (defined in `lib.rs` DataKey)
+//! - `DexContractAddress` — admin-set address of the DEX router contract
+//!
+//! ## Public functions added to `SkillSphereContract` (in `lib.rs`)
+//! - `set_dex_contract(env, dex_addr)` — admin-only, sets `DexContractAddress`
+//! - `start_session_with_swap(env, seeker, expert, offer_token, ask_token,
+//! path, offer_amount, metadata_cid)` — swaps then
+//! starts a streaming session denominated in `ask_token`
+//! - `get_dex_contract(env)` — reads the configured DEX address
+
+use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, Vec};
+
+/// Descriptor for a DEX swap leg passed into `start_session_with_swap`.
+///
+/// For a direct pair swap `path` is empty; multi-hop swaps list the
+/// intermediate asset addresses between offer and ask.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SwapPath {
+ /// The token the seeker is sending (e.g. XLM).
+ pub offer_asset: Address,
+ /// The token the expert will receive (e.g. USDC).
+ pub ask_asset: Address,
+ /// Optional intermediate hops (empty Vec for a direct pair).
+ pub path: Vec
,
+}
+
+/// Cross-contract call: invokes `swap` on the DEX router and returns the
+/// `ask_asset` amount received.
+///
+/// The DEX router is expected to implement a function named `"swap"` with
+/// signature `swap(offer_asset, ask_asset, path, offer_amount) -> i128`.
+///
+/// # Arguments
+/// * `env` — current contract environment
+/// * `dex_contract` — address of the DEX router
+/// * `offer_asset` — token being sold
+/// * `ask_asset` — token being bought
+/// * `path` — intermediate asset hops (may be empty)
+/// * `offer_amount` — amount of `offer_asset` to swap
+///
+/// # Returns
+/// Amount of `ask_asset` received from the swap.
+pub fn cross_contract_swap(
+ env: &Env,
+ dex_contract: &Address,
+ offer_asset: &Address,
+ ask_asset: &Address,
+ path: &Vec,
+ offer_amount: i128,
+) -> i128 {
+ let args: Vec = soroban_sdk::vec![
+ env,
+ offer_asset.into_val(env),
+ ask_asset.into_val(env),
+ path.into_val(env),
+ offer_amount.into_val(env),
+ ];
+ env.invoke_contract::(dex_contract, &symbol_short!("swap"), args)
+}
diff --git a/contracts/src/errors.rs b/contracts/src/errors.rs
index c50d31a..c91dc25 100644
--- a/contracts/src/errors.rs
+++ b/contracts/src/errors.rs
@@ -23,7 +23,7 @@ pub enum Error {
InvalidSplitBps = 17,
DisputeWindowActive = 18,
InvalidFeeConfig = 19,
- InsufficientTreasuryBalance = 20,
+ InsuffTreasuryBal = 20,
AmountBelowMinimum = 21,
ExpertNotRegistered = 22,
ExpertUnavailable = 23,
@@ -32,21 +32,33 @@ pub enum Error {
DepositTooLow = 26,
AlreadyInitialized = 27,
InvalidRating = 28,
- RatingAlreadySubmitted = 29,
+ RatingSubmitted = 29,
OracleNotTrusted = 30,
- InvalidOracleSignature = 31,
+ InvalidOracleSig = 31,
InvalidSessionState = 32,
InsufficientBalance = 33,
+
+ // #213 / #214
BurnBpsExceedsFee = 34,
StakeNotFound = 35,
NoRewardsToClaim = 36,
- StakeBalanceInsufficient = 37,
- FixedPriceSessionAlreadyFinalised = 34,
- SubscriptionNotFound = 35,
- SubscriptionAlreadyCollected = 36,
- SubscriptionExpired = 37,
- InsuranceVaultUnset = 38,
- InsufficientInsuranceBalance = 39,
- ExpertOffline = 34,
- DisputeAlreadyResolved = 35,
+ InsuffStakeBalance = 37,
+
+ // #194 / #195 / #196 / #197
+ FpAlreadyFinalised = 38,
+ SubNotFound = 39,
+ SubAlreadyCollected = 40,
+ SubscriptionExpired = 41,
+ ContractUnset = 42,
+ InsuffInsuranceBal = 43,
+
+ // #198 / #199 / #200
+ ExpertOffline = 44,
+ DisputeResolved = 45,
+
+ // #202 – Soulbound Skill Badges
+ BadgeAlreadyMinted = 46,
+ HoursThresholdNotMet = 47,
+ SessionFrozen = 48,
+ SwapFailed = 49,
}
diff --git a/contracts/src/governance.rs b/contracts/src/governance.rs
new file mode 100644
index 0000000..206878f
--- /dev/null
+++ b/contracts/src/governance.rs
@@ -0,0 +1,58 @@
+//! # Community Treasury Voting Power — Issue #204
+//!
+//! Links treasury-spending governance votes to a user's historical
+//! participation in SkillSphere sessions. Voting weight is computed as:
+//!
+//! ```text
+//! voting_power(user) = total_spent(user) + total_earned(user)
+//! ```
+//!
+//! Both counters are incremented automatically inside `internal_settle` in
+//! `lib.rs` every time a session settles.
+//!
+//! ## Storage keys (defined in `lib.rs` DataKey)
+//! - `UserTotalSpent(Address)` — cumulative tokens spent by this address as seeker
+//! - `UserTotalEarned(Address)` — cumulative tokens earned by this address as expert
+//!
+//! ## Public functions added to `SkillSphereContract` (in `lib.rs`)
+//! - `voting_power(env, user) -> i128` — returns `spent + earned`
+//! - `get_total_spent(env, user) -> i128` — reads `UserTotalSpent`
+//! - `get_total_earned(env, user) -> i128` — reads `UserTotalEarned`
+
+use soroban_sdk::{Address, Env};
+
+use crate::DataKey;
+
+/// Returns the accumulated tokens spent by `user` as a seeker.
+pub fn total_spent(env: &Env, user: &Address) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::UserTotalSpent(user.clone()))
+ .unwrap_or(0i128)
+}
+
+/// Returns the accumulated tokens earned by `user` as an expert.
+pub fn total_earned(env: &Env, user: &Address) -> i128 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::UserTotalEarned(user.clone()))
+ .unwrap_or(0i128)
+}
+
+/// Increments the seeker's `UserTotalSpent` counter by `amount`.
+/// Called from `internal_settle` in `lib.rs` on every settlement.
+pub fn accrue_spent(env: &Env, seeker: &Address, amount: i128) {
+ let prev = total_spent(env, seeker);
+ env.storage()
+ .persistent()
+ .set(&DataKey::UserTotalSpent(seeker.clone()), &prev.saturating_add(amount));
+}
+
+/// Increments the expert's `UserTotalEarned` counter by `amount`.
+/// Called from `internal_settle` in `lib.rs` on every settlement.
+pub fn accrue_earned(env: &Env, expert: &Address, amount: i128) {
+ let prev = total_earned(env, expert);
+ env.storage()
+ .persistent()
+ .set(&DataKey::UserTotalEarned(expert.clone()), &prev.saturating_add(amount));
+}
diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs
index 85d8e90..4df3a12 100644
--- a/contracts/src/lib.rs
+++ b/contracts/src/lib.rs
@@ -1,7 +1,12 @@
#![no_std]
mod errors;
+mod reputation;
+mod dex;
+mod governance;
pub use errors::Error;
+pub use reputation::BadgeRecord;
+pub use dex::SwapPath;
use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env,
@@ -59,6 +64,8 @@ const HEARTBEAT_VALIDITY_WINDOW: u64 = 60 * 60; // 1 hour
// event every Nth settled session so off-chain indexers can track total
// volume + session count without re-scanning every single event.
const PLATFORM_STATS_EMIT_INTERVAL: u64 = 100;
+// #203: seconds between mandatory seeker re-verifications for long-term escrows.
+const REVERIFY_PERIOD_SECS: u64 = 30 * 24 * 60 * 60;
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -130,6 +137,18 @@ pub enum DataKey {
// #200: running counters used by the rolled-up PlatformStats event.
TotalVolumeSettled,
TotalSessionsSettled,
+ // #202: Soulbound Skill Badges
+ SbtContractAddress,
+ ExpertBadge(Address),
+ ExpertTotalSeconds(Address),
+ // #203: Periodic Re-Verification for Long-Term Escrows
+ SessionLastVerified(u64),
+ SessionFrozenFlag(u64),
+ // #204: Community Treasury Voting Power
+ UserTotalSpent(Address),
+ UserTotalEarned(Address),
+ // #205: DEX Integration
+ DexContractAddress,
}
#[contracttype]
@@ -437,7 +456,7 @@ impl SkillSphereContract {
.get(&DataKey::StakeBalance(staker.clone()))
.unwrap_or(0i128);
if prev < amount {
- return Err(Error::StakeBalanceInsufficient);
+ return Err(Error::InsuffStakeBalance);
}
// Settle accrued rewards under the OLD balance.
Self::settle_staker_checkpoint(&env, &staker, &token);
@@ -566,6 +585,10 @@ impl SkillSphereContract {
env.events().publish(
(symbol_short!("rewardDep"),),
(from, reward_token, amount),
+ );
+ Ok(())
+ }
+
// #196 — Platform Fee Whitelist for Specific Assets
// ====================================================================
@@ -653,14 +676,14 @@ impl SkillSphereContract {
.storage()
.instance()
.get(&DataKey::InsuranceVaultAddress)
- .ok_or(Error::InsuranceVaultUnset)?;
+ .ok_or(Error::ContractUnset)?;
let mut balance: i128 = env
.storage()
.instance()
.get(&DataKey::InsuranceVaultBalance(token.clone()))
.unwrap_or(0i128);
if balance < amount {
- return Err(Error::InsufficientInsuranceBalance);
+ return Err(Error::InsuffInsuranceBal);
}
balance -= amount;
env.storage()
@@ -705,22 +728,7 @@ impl SkillSphereContract {
return Err(Error::InvalidCid);
}
- /// Refresh the expert's availability heartbeat (#199).
- ///
- /// Stamps `ExpertLastHeartbeat(expert)` with the current ledger
- /// timestamp. `start_session` rejects experts whose last heartbeat
- /// is older than `HEARTBEAT_VALIDITY_WINDOW` (1 hour). Experts who
- /// have never called `heartbeat` retain the legacy
- /// `availability_status`-only semantics so existing flows keep
- /// working until they opt in.
- pub fn heartbeat(env: Env, expert: Address) -> Result<(), Error> {
- expert.require_auth();
- let profile = Self::expert_profile(&env, expert.clone());
- if profile.rate_per_second == 0 {
- return Err(Error::ExpertNotRegistered);
- }
-
- let token_client = token::Client::new(&env, &token);
+ let token_client = token::Client::new(&env, &token);
if token_client.balance(&seeker) < amount {
return Err(Error::InsufficientBalance);
}
@@ -750,6 +758,29 @@ impl SkillSphereContract {
Ok(session_id)
}
+ /// Refresh the expert's availability heartbeat (#199).
+ ///
+ /// Stamps `ExpertLastHeartbeat(expert)` with the current ledger
+ /// timestamp. `start_session` rejects experts whose last heartbeat
+ /// is older than `HEARTBEAT_VALIDITY_WINDOW` (1 hour). Experts who
+ /// have never called `heartbeat` retain the legacy
+ /// `availability_status`-only semantics so existing flows keep
+ /// working until they opt in.
+ pub fn heartbeat(env: Env, expert: Address) -> Result<(), Error> {
+ expert.require_auth();
+ let profile = Self::expert_profile(&env, expert.clone());
+ if profile.rate_per_second == 0 {
+ return Err(Error::ExpertNotRegistered);
+ }
+ let now = env.ledger().timestamp();
+ env.storage()
+ .persistent()
+ .set(&DataKey::ExpertLastHeartbeat(expert.clone()), &now);
+ env.events()
+ .publish((symbol_short!("hb"),), (expert, now));
+ Ok(())
+ }
+
/// Seeker approves the milestone. Pays the expert `amount` minus
/// the platform fee in one shot. Triggers the same fee-routing
/// and insurance-diversion path as streaming sessions.
@@ -769,7 +800,7 @@ impl SkillSphereContract {
return Err(Error::Unauthorized);
}
if !matches!(fp.status, FixedPriceStatus::Locked) {
- return Err(Error::FixedPriceSessionAlreadyFinalised);
+ return Err(Error::FpAlreadyFinalised);
}
let amount = fp.amount;
@@ -834,7 +865,7 @@ impl SkillSphereContract {
return Err(Error::Unauthorized);
}
if !matches!(fp.status, FixedPriceStatus::Locked) {
- return Err(Error::FixedPriceSessionAlreadyFinalised);
+ return Err(Error::FpAlreadyFinalised);
}
fp.status = FixedPriceStatus::Disputed;
env.storage()
@@ -924,12 +955,7 @@ impl SkillSphereContract {
env.events().publish(
(symbol_short!("sub"), symbol_short!("started")),
(seeker, expert, monthly_fee, months, total),
- let now = env.ledger().timestamp();
- env.storage()
- .persistent()
- .set(&DataKey::ExpertLastHeartbeat(expert.clone()), &now);
- env.events()
- .publish((symbol_short!("hb"),), (expert, now));
+ );
Ok(())
}
@@ -968,7 +994,7 @@ impl SkillSphereContract {
.ok_or(Error::DisputeNotFound)?;
if dispute.resolved {
- return Err(Error::DisputeAlreadyResolved);
+ return Err(Error::DisputeResolved);
}
let session = Self::get_session_or_error(&env, session_id)?;
@@ -1010,6 +1036,8 @@ impl SkillSphereContract {
return 0;
}
Self::pending_reward_for(&env, &staker, &reward_token, stake)
+ }
+
/// Expert collects the next month's fee from a subscription.
/// Decrements `prepaid_balance` + `months_remaining`, credits
/// `expert_balance`, and routes platform fee + insurance cut
@@ -1025,14 +1053,14 @@ impl SkillSphereContract {
.storage()
.persistent()
.get(&DataKey::Subscription(seeker.clone(), expert.clone()))
- .ok_or(Error::SubscriptionNotFound)?;
+ .ok_or(Error::SubNotFound)?;
if sub.months_remaining == 0 {
return Err(Error::SubscriptionExpired);
}
let now = env.ledger().timestamp();
let elapsed = now.saturating_sub(sub.last_collected_at);
if sub.last_collected_at > 0 && elapsed < SUBSCRIPTION_PERIOD_SECS {
- return Err(Error::SubscriptionAlreadyCollected);
+ return Err(Error::SubAlreadyCollected);
}
let fee = sub.monthly_fee;
@@ -1087,7 +1115,7 @@ impl SkillSphereContract {
.storage()
.persistent()
.get(&DataKey::Subscription(seeker.clone(), expert.clone()))
- .ok_or(Error::SubscriptionNotFound)?;
+ .ok_or(Error::SubNotFound)?;
let amount = sub.expert_balance;
if amount == 0 {
return Ok(0);
@@ -1114,7 +1142,9 @@ impl SkillSphereContract {
env.storage()
.persistent()
.get(&DataKey::Subscription(seeker, expert))
- .ok_or(Error::SubscriptionNotFound)
+ .ok_or(Error::SubNotFound)
+ }
+
/// Read the rolled-up PlatformStats counters (#200).
/// Returns `(total_sessions_settled, total_volume_settled)`.
pub fn platform_stats(env: Env) -> (u64, i128) {
@@ -1483,7 +1513,7 @@ impl SkillSphereContract {
let current_balance = Self::get_treasury_balance(env.clone(), token.clone());
if current_balance < amount {
- return Err(Error::InsufficientTreasuryBalance);
+ return Err(Error::InsuffTreasuryBal);
}
let new_balance = current_balance.saturating_sub(amount);
@@ -1714,6 +1744,11 @@ impl SkillSphereContract {
.persistent()
.set(&DataKey::Session(session_id), &session);
+ // #203: stamp the initial re-verification timestamp on session creation.
+ env.storage()
+ .persistent()
+ .set(&DataKey::SessionLastVerified(session_id), &(now as u64));
+
env.events().publish(
(symbol_short!("session"), symbol_short!("started")),
(
@@ -2311,6 +2346,8 @@ impl SkillSphereContract {
(token.clone(), burn_amount, burn_bps),
);
burn_amount
+ }
+
/// Compute the platform fee, honouring the per-asset override
/// (#196) when one is configured for `token`. Falls through to
/// the global tiered config otherwise.
@@ -2361,6 +2398,7 @@ impl SkillSphereContract {
.set(&DataKey::InsuranceVaultBalance(token.clone()), &bal);
}
cut
+ }
/// Roll up volume + session counters and emit a `PlatformStats`
/// event every `PLATFORM_STATS_EMIT_INTERVAL` settled sessions
/// (#200). The counters themselves are always updated so any
@@ -2418,9 +2456,23 @@ impl SkillSphereContract {
return Err(Error::InvalidSessionState);
}
+ // #203: freeze guard — block settlement if the seeker missed the 30-day check-in.
+ let is_frozen: bool = env
+ .storage()
+ .persistent()
+ .get(&DataKey::SessionFrozenFlag(session.id))
+ .unwrap_or(false);
+ if is_frozen {
+ Self::set_reentrancy_lock(env, false);
+ return Err(Error::SessionFrozen);
+ }
+
let now = env.ledger().timestamp();
let expiry = Self::expiry_timestamp_for_session(&session);
let effective_time = Self::bounded_time(&session, now);
+ // #202: capture elapsed seconds before effects modify last_settlement_timestamp.
+ let settled_seconds =
+ effective_time.saturating_sub(session.last_settlement_timestamp as u64);
let claimable = Self::claimable_amount_for_session(&session, effective_time);
if claimable <= 0 {
@@ -2518,6 +2570,23 @@ impl SkillSphereContract {
// Increment referral session count for referral commission tracking (Issue #52)
Self::increment_referral_session_count(env, &expert);
+ // #204: accrue governance voting-power counters.
+ governance::accrue_spent(env, &session.seeker, claimable);
+ governance::accrue_earned(env, &expert, expert_payout);
+
+ // #202: increment the expert's cumulative settled-seconds counter.
+ {
+ let prev_secs: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::ExpertTotalSeconds(expert.clone()))
+ .unwrap_or(0u64);
+ env.storage().persistent().set(
+ &DataKey::ExpertTotalSeconds(expert.clone()),
+ &prev_secs.saturating_add(settled_seconds),
+ );
+ }
+
Self::set_reentrancy_lock(env, false);
Ok(expert_payout)
}
@@ -2837,7 +2906,7 @@ impl SkillSphereContract {
// Check if rating already exists for this session
if env.storage().persistent().has(&DataKey::SessionRating(session_id)) {
- return Err(Error::RatingAlreadySubmitted);
+ return Err(Error::RatingSubmitted);
}
// Store the rating
@@ -3245,7 +3314,7 @@ impl SkillSphereContract {
// Get treasury address
let treasury = env.storage().instance().get::(&DataKey::TreasuryAddress)
- .ok_or(Error::InsufficientTreasuryBalance)?;
+ .ok_or(Error::InsuffTreasuryBal)?;
// Transfer slashed tokens to treasury
let token = env.current_contract_address();
@@ -3407,6 +3476,285 @@ impl SkillSphereContract {
Self::set_reentrancy_lock(&env, false);
Ok(total_claimable)
}
+
+ // ====================================================================
+ // #202 — Soulbound Skill Badges
+ // ====================================================================
+
+ /// Admin sets the address of the external SBT contract that receives
+ /// `mint_badge` cross-contract calls.
+ pub fn set_sbt_contract(env: Env, sbt_addr: Address) -> Result<(), Error> {
+ Self::require_admin(&env)?;
+ env.storage()
+ .instance()
+ .set(&DataKey::SbtContractAddress, &sbt_addr);
+ env.events()
+ .publish((symbol_short!("sbtSet"),), sbt_addr);
+ Ok(())
+ }
+
+ /// Mints a soulbound badge for `expert` once they have accumulated ≥ 100 h
+ /// of settled session time. Reverts if already minted or threshold not met.
+ pub fn mint_badge(env: Env, expert: Address) -> Result {
+ expert.require_auth();
+
+ if env
+ .storage()
+ .persistent()
+ .has(&DataKey::ExpertBadge(expert.clone()))
+ {
+ return Err(Error::BadgeAlreadyMinted);
+ }
+
+ let total_secs: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::ExpertTotalSeconds(expert.clone()))
+ .unwrap_or(0u64);
+ if total_secs < reputation::BADGE_HOURS_THRESHOLD_SECS {
+ return Err(Error::HoursThresholdNotMet);
+ }
+
+ let sbt_contract: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::SbtContractAddress)
+ .ok_or(Error::ContractUnset)?;
+
+ let badge_id: u64 = total_secs; // deterministic: total seconds at mint
+ let now = env.ledger().timestamp();
+
+ reputation::cross_contract_mint_badge(&env, &sbt_contract, &expert, badge_id);
+
+ let record = BadgeRecord {
+ expert: expert.clone(),
+ seconds_at_mint: total_secs,
+ minted_at: now,
+ badge_token_id: badge_id,
+ };
+ env.storage()
+ .persistent()
+ .set(&DataKey::ExpertBadge(expert.clone()), &record);
+
+ env.events()
+ .publish((symbol_short!("badge"),), (expert, badge_id, now));
+ Ok(record)
+ }
+
+ /// Returns the badge record for `expert`, or `None` if not yet minted.
+ pub fn get_badge(env: Env, expert: Address) -> Option {
+ env.storage()
+ .persistent()
+ .get(&DataKey::ExpertBadge(expert))
+ }
+
+ /// Returns the total settled seconds accumulated by `expert` towards the badge.
+ pub fn get_expert_total_seconds(env: Env, expert: Address) -> u64 {
+ env.storage()
+ .persistent()
+ .get(&DataKey::ExpertTotalSeconds(expert))
+ .unwrap_or(0u64)
+ }
+
+ // ====================================================================
+ // #203 — Periodic Re-Verification for Long-Term Escrows
+ // ====================================================================
+
+ /// Seeker re-verifies an ongoing session, refreshing the 30-day clock and
+ /// clearing any freeze flag previously set by `check_and_freeze`.
+ pub fn reverify_session(env: Env, seeker: Address, session_id: u64) -> Result<(), Error> {
+ seeker.require_auth();
+ let session = Self::get_session_or_error(&env, session_id)?;
+ if seeker != session.seeker {
+ return Err(Error::Unauthorized);
+ }
+ let now = env.ledger().timestamp();
+ env.storage()
+ .persistent()
+ .set(&DataKey::SessionLastVerified(session_id), &now);
+ env.storage()
+ .persistent()
+ .set(&DataKey::SessionFrozenFlag(session_id), &false);
+ env.events()
+ .publish((symbol_short!("reverify"),), (session_id, now));
+ Ok(())
+ }
+
+ /// Permissionless: freezes a session if the seeker missed the 30-day
+ /// re-verification window. Anyone may call this to enforce the invariant.
+ pub fn check_and_freeze(env: Env, session_id: u64) -> Result<(), Error> {
+ let _session = Self::get_session_or_error(&env, session_id)?;
+ let last_verified: u64 = env
+ .storage()
+ .persistent()
+ .get(&DataKey::SessionLastVerified(session_id))
+ .unwrap_or(0u64);
+ let now = env.ledger().timestamp();
+ if now.saturating_sub(last_verified) > REVERIFY_PERIOD_SECS {
+ env.storage()
+ .persistent()
+ .set(&DataKey::SessionFrozenFlag(session_id), &true);
+ env.events()
+ .publish((symbol_short!("frozen"),), (session_id, now));
+ }
+ Ok(())
+ }
+
+ /// Returns `true` when the session has been frozen due to a missed check-in.
+ pub fn get_session_frozen(env: Env, session_id: u64) -> bool {
+ env.storage()
+ .persistent()
+ .get(&DataKey::SessionFrozenFlag(session_id))
+ .unwrap_or(false)
+ }
+
+ // ====================================================================
+ // #204 — Community Treasury Voting Power
+ // ====================================================================
+
+ /// Returns `total_spent(user) + total_earned(user)` as the user's
+ /// governance voting weight based on session volume.
+ pub fn voting_power(env: Env, user: Address) -> i128 {
+ governance::total_spent(&env, &user)
+ .saturating_add(governance::total_earned(&env, &user))
+ }
+
+ /// Returns the cumulative tokens `user` has spent as a seeker.
+ pub fn get_total_spent(env: Env, user: Address) -> i128 {
+ governance::total_spent(&env, &user)
+ }
+
+ /// Returns the cumulative tokens `user` has earned as an expert.
+ pub fn get_total_earned(env: Env, user: Address) -> i128 {
+ governance::total_earned(&env, &user)
+ }
+
+ // ====================================================================
+ // #205 — Cross-Contract Token Swaps (DEX Integration)
+ // ====================================================================
+
+ /// Admin sets the DEX router address (Phoenix / Soroswap).
+ pub fn set_dex_contract(env: Env, dex_addr: Address) -> Result<(), Error> {
+ Self::require_admin(&env)?;
+ env.storage()
+ .instance()
+ .set(&DataKey::DexContractAddress, &dex_addr);
+ env.events()
+ .publish((symbol_short!("dexSet"),), dex_addr);
+ Ok(())
+ }
+
+ /// Returns the configured DEX router address, if any.
+ pub fn get_dex_contract(env: Env) -> Option {
+ env.storage().instance().get(&DataKey::DexContractAddress)
+ }
+
+ /// Starts a streaming session where the seeker pays in `offer_token` but
+ /// the expert is paid in `ask_token`. The DEX swap happens atomically.
+ ///
+ /// # Arguments
+ /// * `seeker` — must auth; holds `offer_token`
+ /// * `expert` — registered, available expert
+ /// * `offer_token` — token the seeker sends (e.g. XLM)
+ /// * `ask_token` — token the session is denominated in (e.g. USDC)
+ /// * `path` — intermediate asset hops (empty = direct pair)
+ /// * `offer_amount` — amount of `offer_token` to spend
+ /// * `metadata_cid` — IPFS CID for session metadata
+ pub fn start_session_with_swap(
+ env: Env,
+ seeker: Address,
+ expert: Address,
+ offer_token: Address,
+ ask_token: Address,
+ path: Vec,
+ offer_amount: i128,
+ metadata_cid: String,
+ ) -> Result {
+ seeker.require_auth();
+ Self::ensure_protocol_active(&env)?;
+
+ if offer_amount <= 0 {
+ return Err(Error::InvalidAmount);
+ }
+ if !Self::is_valid_ipfs_cid(&metadata_cid) {
+ return Err(Error::InvalidCid);
+ }
+
+ 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);
+ }
+
+ let dex_contract: Address = env
+ .storage()
+ .instance()
+ .get(&DataKey::DexContractAddress)
+ .ok_or(Error::ContractUnset)?;
+
+ // Transfer offer_token from seeker into the contract escrow.
+ let offer_client = token::Client::new(&env, &offer_token);
+ if offer_client.balance(&seeker) < offer_amount {
+ return Err(Error::InsufficientBalance);
+ }
+ offer_client.transfer(&seeker, &env.current_contract_address(), &offer_amount);
+
+ // Cross-contract swap: contract sends offer_token, receives ask_token.
+ let ask_amount = dex::cross_contract_swap(
+ &env,
+ &dex_contract,
+ &offer_token,
+ &ask_token,
+ &path,
+ offer_amount,
+ );
+ if ask_amount <= 0 {
+ return Err(Error::SwapFailed);
+ }
+
+ 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: ask_token.clone(),
+ rate_per_second: profile.rate_per_second,
+ balance: ask_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);
+
+ // #203: stamp initial re-verification timestamp.
+ env.storage()
+ .persistent()
+ .set(&DataKey::SessionLastVerified(session_id), &(now as u64));
+
+ env.events().publish(
+ (symbol_short!("swap"), symbol_short!("started")),
+ (
+ session_id,
+ seeker,
+ expert,
+ offer_token,
+ ask_token,
+ offer_amount,
+ ask_amount,
+ ),
+ );
+ Ok(session_id)
+ }
}
#[cfg(test)]
@@ -4820,6 +5168,97 @@ mod test {
let (env, client, _, _, seeker, expert, token, _) = setup();
register_and_avail(&env, &client, &expert, 100);
client.set_burn_bps(&2_000u32); // 20% of the treasury share
+
+ let session_id =
+ client.start_session(&seeker, &expert, &token, &10_000, &0, &test_cid(&env));
+ env.ledger().set_timestamp(1_000 + 100);
+ client.settle_session(&session_id);
+
+ // 5% platform fee of 10_000 = 500. 20% burn of 500 = 100.
+ let burned = client.total_burned(&token);
+ assert_eq!(burned, 100);
+ // Treasury collects the remaining 400.
+ assert_eq!(client.get_treasury_balance(&token), 400);
+ }
+
+ #[test]
+ fn test_burn_zero_when_disabled() {
+ let (env, client, _, _, seeker, expert, token, _) = setup();
+ register_and_avail(&env, &client, &expert, 100);
+ // burn_bps not set; default 0.
+ let session_id =
+ client.start_session(&seeker, &expert, &token, &10_000, &0, &test_cid(&env));
+ env.ledger().set_timestamp(1_000 + 100);
+ client.settle_session(&session_id);
+ assert_eq!(client.total_burned(&token), 0);
+ assert_eq!(client.get_treasury_balance(&token), 500);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #14)")]
+ fn test_set_burn_bps_rejects_above_max() {
+ let (_env, client, _, _, _, _, _, _) = setup();
+ client.set_burn_bps(&10_001u32);
+ }
+
+ #[test]
+ fn test_stake_and_unstake_balance_tracking() {
+ let (env, client, _, _, seeker, _, token, _) = setup();
+ // Re-use seeker as the staker; they have minted balance.
+ let asset = token::Client::new(&env, &token);
+ let before = asset.balance(&seeker);
+
+ client.stake(&seeker, &token, &1_000i128);
+ assert_eq!(client.get_stake_balance(&seeker), 1_000);
+ assert_eq!(asset.balance(&seeker), before - 1_000);
+
+ client.unstake(&seeker, &token, &400i128);
+ assert_eq!(client.get_stake_balance(&seeker), 600);
+ assert_eq!(asset.balance(&seeker), before - 600);
+ }
+
+ #[test]
+ #[should_panic(expected = "Error(Contract, #37)")]
+ fn test_unstake_rejects_over_balance() {
+ let (env, client, _, _, seeker, _, token, _) = setup();
+ let _ = env;
+ client.stake(&seeker, &token, &100i128);
+ client.unstake(&seeker, &token, &500i128);
+ }
+
+ #[test]
+ fn test_stake_then_deposit_reward_then_claim() {
+ let (env, client, _, _, seeker, _, token, token_admin) = setup();
+
+ // Set up two stakers so the per-share math has something to
+ // divide by; reuse token for both stake and reward token.
+ let staker_a = Address::generate(&env);
+ let staker_b = Address::generate(&env);
+ let asset_admin = token::StellarAssetClient::new(&env, &token);
+ asset_admin.mint(&staker_a, &10_000);
+ asset_admin.mint(&staker_b, &10_000);
+ asset_admin.mint(&seeker, &10_000); // reward depositor balance
+ let _ = token_admin;
+
+ client.stake(&staker_a, &token, &1_000i128);
+ client.stake(&staker_b, &token, &3_000i128); // 25% / 75% split
+
+ // Deposit 4_000 reward; A's share should be 1_000, B's 3_000.
+ client.deposit_staking_reward(&seeker, &token, &4_000i128);
+
+ assert_eq!(client.pending_rewards(&staker_a, &token), 1_000);
+ assert_eq!(client.pending_rewards(&staker_b, &token), 3_000);
+
+ let asset = token::Client::new(&env, &token);
+ let a_before = asset.balance(&staker_a);
+ let claimed_a = client.claim_rewards(&staker_a, &token);
+ assert_eq!(claimed_a, 1_000);
+ assert_eq!(asset.balance(&staker_a) - a_before, 1_000);
+
+ // Pending after claim is zero.
+ assert_eq!(client.pending_rewards(&staker_a, &token), 0);
+ }
+
// #194 / #195 / #196 / #197 tests
// ====================================================================
@@ -4980,6 +5419,7 @@ mod test {
let (env, client, _, _, _, _, token, _) = setup();
let nonstaker = Address::generate(&env);
client.claim_rewards(&nonstaker, &token);
+ }
fn test_fixed_price_session_locks_and_releases_on_approval() {
let (env, client, _, _, seeker, expert, token, _) = setup();
register_and_avail(&env, &client, &expert, 10);
@@ -5002,7 +5442,7 @@ mod test {
}
#[test]
- #[should_panic(expected = "Error(Contract, #34)")]
+ #[should_panic(expected = "Error(Contract, #38)")]
fn test_fixed_price_double_approve_fails() {
let (env, client, _, _, seeker, expert, token, _) = setup();
register_and_avail(&env, &client, &expert, 10);
@@ -5056,7 +5496,7 @@ mod test {
}
#[test]
- #[should_panic(expected = "Error(Contract, #36)")]
+ #[should_panic(expected = "Error(Contract, #40)")]
fn test_subscription_collect_twice_in_same_period_fails() {
let (env, client, _, _, seeker, expert, token, _) = setup();
register_and_avail(&env, &client, &expert, 10);
@@ -5081,6 +5521,7 @@ mod test {
assert_eq!(net, 950);
let sub = client.get_subscription(&seeker, &expert);
assert_eq!(sub.months_remaining, 1);
+ }
// #198 / #199 / #200 tests
// ====================================================================
@@ -5113,7 +5554,7 @@ mod test {
}
#[test]
- #[should_panic(expected = "Error(Contract, #34)")]
+ #[should_panic(expected = "Error(Contract, #44)")]
fn test_start_session_fails_when_heartbeat_is_stale() {
let (env, client, _, _, seeker, expert, token, _) = setup();
register_and_avail(&env, &client, &expert, 10);
diff --git a/contracts/src/reputation.rs b/contracts/src/reputation.rs
new file mode 100644
index 0000000..b9649d8
--- /dev/null
+++ b/contracts/src/reputation.rs
@@ -0,0 +1,61 @@
+//! # Soulbound Skill Badges — Issue #202
+//!
+//! Rewards experts with a non-transferable (soulbound) badge NFT minted on a
+//! separate SBT contract once they have accumulated ≥ 100 hours of settled
+//! session time inside SkillSphere.
+//!
+//! ## Storage keys (defined in `lib.rs` DataKey)
+//! - `SbtContractAddress` — the deployed SBT contract address (admin-set)
+//! - `ExpertBadge(Address)` — `BadgeRecord` for an expert that has been minted
+//! - `ExpertTotalSeconds(Address)` — running total of settled seconds per expert
+//!
+//! ## Public functions added to `SkillSphereContract` (in `lib.rs`)
+//! - `set_sbt_contract(env, sbt_addr)` — admin-only, persists `SbtContractAddress`
+//! - `mint_badge(env, expert)` — checks threshold, cross-calls SBT contract
+//! - `get_badge(env, expert)` — reads `ExpertBadge`
+//! - `get_expert_total_seconds(env, expert)` — reads accumulated seconds
+
+#![allow(unused_imports)]
+
+use soroban_sdk::{contracttype, symbol_short, Address, Env, IntoVal, Symbol, Vec};
+
+/// Threshold: 100 hours expressed in seconds.
+pub const BADGE_HOURS_THRESHOLD_SECS: u64 = 100 * 60 * 60;
+
+/// On-chain record stored when a badge is minted for an expert.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct BadgeRecord {
+ /// The expert's address (redundant but useful for indexers).
+ pub expert: Address,
+ /// Total settled seconds the expert had accumulated at mint time.
+ pub seconds_at_mint: u64,
+ /// Ledger timestamp at the moment of minting.
+ pub minted_at: u64,
+ /// Token/badge ID returned by the SBT contract (stored for reference).
+ pub badge_token_id: u64,
+}
+
+/// Cross-contract call: invokes `mint_badge(expert, badge_id)` on the
+/// external SBT contract. The SBT contract is expected to implement a
+/// function with the symbol `"mint_bdg"` that accepts an `Address` and
+/// a `u64` badge ID and returns `()`.
+///
+/// # Arguments
+/// * `env` — current contract environment
+/// * `sbt_contract` — address of the deployed SBT contract
+/// * `expert` — recipient of the soulbound badge
+/// * `badge_id` — sequential badge token ID (caller supplies)
+pub fn cross_contract_mint_badge(
+ env: &Env,
+ sbt_contract: &Address,
+ expert: &Address,
+ badge_id: u64,
+) {
+ let args: Vec = soroban_sdk::vec![
+ env,
+ expert.into_val(env),
+ badge_id.into_val(env),
+ ];
+ env.invoke_contract::<()>(sbt_contract, &symbol_short!("mint_bdg"), args);
+}