diff --git a/contracts/amm/src/anti_mev.rs b/contracts/amm/src/anti_mev.rs new file mode 100644 index 00000000..1668bf0d --- /dev/null +++ b/contracts/amm/src/anti_mev.rs @@ -0,0 +1,46 @@ +//! Anti-MEV sandwich protection module. +//! +//! Provides defense mechanisms against frontrunning and sandwich attacks on DEX liquidity pools: +//! - User-specified deadline validation (reverts if ledger timestamp exceeds designated deadline). +//! - Minimum output amount checks (strict slippage enforcement). +//! - Same-block bidirectional trade restriction (reverting immediate round-trip arbitrage from the same origin address). + +use soroban_sdk::{panic_with_error, Address, Env}; +use crate::{storage::DataKey, AmmError}; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SwapDirection { + AtoB = 1, + BtoA = 2, +} + +/// Validates that transaction execution does not exceed the user's deadline. +pub fn check_deadline(env: &Env, deadline: u64) { + if deadline > 0 && env.ledger().timestamp() > deadline { + panic_with_error!(env, AmmError::DeadlineExceeded); + } +} + +/// Enforces that output amount is at least the user's minimum expected output. +pub fn check_min_output(env: &Env, amount_out: i128, min_amount_out: i128) { + if amount_out < min_amount_out { + panic_with_error!(env, AmmError::SlippageExceeded); + } +} + +/// Prevents the same origin address from executing bidirectional round-trip trades +/// (A -> B followed by B -> A or vice-versa) in the same block/ledger sequence. +pub fn check_and_record_trade(env: &Env, user: &Address, direction: SwapDirection) { + let current_ledger = env.ledger().sequence(); + let key = DataKey::TradeRecord(user.clone()); + + let record: Option<(u32, u32)> = env.storage().temporary().get(&key); + if let Some((recorded_ledger, recorded_direction)) = record { + if recorded_ledger == current_ledger && recorded_direction != (direction as u32) { + panic_with_error!(env, AmmError::SameBlockBidirectionalTrade); + } + } + + env.storage().temporary().set(&key, &(current_ledger, direction as u32)); + env.storage().temporary().extend_ttl(&key, 100, 1000); +} diff --git a/contracts/amm/src/lib.rs b/contracts/amm/src/lib.rs index f05fd9f7..1913c863 100644 --- a/contracts/amm/src/lib.rs +++ b/contracts/amm/src/lib.rs @@ -40,6 +40,13 @@ pub enum AmmError { SlippageExceeded = 403, /// `404` — Contract has not been initialised. NotInitialized = 404, + /// `405` — Deadline expired for trade execution. + DeadlineExceeded = 405, + /// `406` — Same-block bidirectional trades from the same origin address are prohibited. + SameBlockBidirectionalTrade = 406, +} + +pub mod anti_mev; /// `405` — AMM operations are paused by the circuit breaker. ContractPaused = 405, /// `406` — Caller is not authorized to manage the circuit breaker. @@ -314,6 +321,7 @@ impl AMMContract { if amount_in <= 0 { panic_with_error!(&env, AmmError::InvalidAmount); } + anti_mev::check_and_record_trade(&env, &user, anti_mev::SwapDirection::AtoB); let token_a: Address = env.storage().instance().get(&DataKey::TokenA).unwrap(); let token_b: Address = env.storage().instance().get(&DataKey::TokenB).unwrap(); @@ -380,6 +388,18 @@ impl AMMContract { amount_out } + /// Swaps token A for token B with user-specified deadline and slippage protection. + pub fn swap_a_for_b_with_deadline( + env: Env, + user: Address, + amount_in: i128, + min_amount_out: i128, + deadline: u64, + ) -> i128 { + anti_mev::check_deadline(&env, deadline); + Self::swap_a_for_b(env, user, amount_in, min_amount_out) + } + /// Swaps token B for token A with dynamic fee based on price impact. pub fn swap_b_for_a(env: Env, user: Address, amount_in: i128, min_amount_out: i128) -> i128 { user.require_auth(); @@ -387,6 +407,7 @@ impl AMMContract { if amount_in <= 0 { panic_with_error!(&env, AmmError::InvalidAmount); } + anti_mev::check_and_record_trade(&env, &user, anti_mev::SwapDirection::BtoA); let token_a: Address = env.storage().instance().get(&DataKey::TokenA).unwrap(); let token_b: Address = env.storage().instance().get(&DataKey::TokenB).unwrap(); @@ -453,6 +474,18 @@ impl AMMContract { amount_out } + /// Swaps token B for token A with user-specified deadline and slippage protection. + pub fn swap_b_for_a_with_deadline( + env: Env, + user: Address, + amount_in: i128, + min_amount_out: i128, + deadline: u64, + ) -> i128 { + anti_mev::check_deadline(&env, deadline); + Self::swap_b_for_a(env, user, amount_in, min_amount_out) + } + /// Returns current pool state for calculations and display. pub fn get_pool_state(env: Env) -> (i128, i128, i128) { let balance_a: i128 = env diff --git a/contracts/amm/src/storage.rs b/contracts/amm/src/storage.rs index 8147e138..3f0ce094 100644 --- a/contracts/amm/src/storage.rs +++ b/contracts/amm/src/storage.rs @@ -34,4 +34,6 @@ pub enum DataKey { LastBlockTimestamp, SnapshotCount, Snapshot(u32), + // ── Anti-MEV Storage Keys ───────────────────────────────────────── + TradeRecord(soroban_sdk::Address), } diff --git a/contracts/amm/src/test.rs b/contracts/amm/src/test.rs index 27def3c9..da0f1a63 100644 --- a/contracts/amm/src/test.rs +++ b/contracts/amm/src/test.rs @@ -283,6 +283,79 @@ fn test_twap_resistant_to_single_block_flash_manipulation() { } #[test] +fn test_swap_with_deadline_succeeds_before_deadline() { + let env = Env::default(); + let (admin, client, token_a, _, _) = setup(&env); + client.add_liquidity(&admin, &100_000, &100_000, &0); + + let user = Address::generate(&env); + let token_a_client = token::StellarAssetClient::new(&env, &token_a); + token_a_client.mint(&user, &1000); + + env.ledger().with_mut(|li| li.timestamp = 100); + let out = client.swap_a_for_b_with_deadline(&user, &1000, &0, &200); + assert!(out > 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #405)")] +fn test_swap_with_deadline_reverts_if_expired() { + let env = Env::default(); + let (admin, client, token_a, _, _) = setup(&env); + client.add_liquidity(&admin, &100_000, &100_000, &0); + + let user = Address::generate(&env); + let token_a_client = token::StellarAssetClient::new(&env, &token_a); + token_a_client.mint(&user, &1000); + + env.ledger().with_mut(|li| li.timestamp = 201); + // User deadline is 200, current timestamp is 201 -> should panic with 405 + client.swap_a_for_b_with_deadline(&user, &1000, &0, &200); +} + +#[test] +#[should_panic(expected = "Error(Contract, #406)")] +fn test_same_block_bidirectional_trade_rejected() { + let env = Env::default(); + let (admin, client, token_a, token_b, _) = setup(&env); + client.add_liquidity(&admin, &100_000, &100_000, &0); + + let attacker = Address::generate(&env); + let token_a_client = token::StellarAssetClient::new(&env, &token_a); + let token_b_client = token::StellarAssetClient::new(&env, &token_b); + token_a_client.mint(&attacker, &5000); + token_b_client.mint(&attacker, &5000); + + // Swap A -> B + client.swap_a_for_b(&attacker, &1000, &0); + + // In the exact same block/sequence, attempt reverse swap B -> A (sandwich backrun) -> should panic with 406 + client.swap_b_for_a(&attacker, &500, &0); +} + +#[test] +fn test_bidirectional_trade_allowed_in_different_blocks() { + let env = Env::default(); + let (admin, client, token_a, token_b, _) = setup(&env); + client.add_liquidity(&admin, &100_000, &100_000, &0); + + let user = Address::generate(&env); + let token_a_client = token::StellarAssetClient::new(&env, &token_a); + let token_b_client = token::StellarAssetClient::new(&env, &token_b); + token_a_client.mint(&user, &5000); + token_b_client.mint(&user, &5000); + + // Block 1: Swap A -> B + env.ledger().with_mut(|li| li.sequence_number = 10); + let out1 = client.swap_a_for_b(&user, &1000, &0); + assert!(out1 > 0); + + // Block 2: Advance block sequence -> reverse swap B -> A is allowed + env.ledger().with_mut(|li| li.sequence_number = 11); + let out2 = client.swap_b_for_a(&user, &500, &0); + assert!(out2 > 0); +} + fn test_guardian_pause_blocks_amm_operations() { let env = Env::default(); let (admin, client, token_a, _, _) = setup(&env); diff --git a/contracts/amm/tests/fuzz_amm.rs b/contracts/amm/tests/fuzz_amm.rs index 06749ed4..ba2b5765 100644 --- a/contracts/amm/tests/fuzz_amm.rs +++ b/contracts/amm/tests/fuzz_amm.rs @@ -8,7 +8,7 @@ use amm::{AMMContract, AMMContractClient}; use proptest::prelude::*; use soroban_sdk::{ - testutils::Address as _, + testutils::{Address as _, Ledger as _}, token, Address, Env, }; @@ -84,6 +84,7 @@ fn test_fuzz_amm_k_invariant_one_million_continuous_actions() { // 1. In-contract trades to verify live state consistency and event emission for _ in 0..100 { + env.ledger().with_mut(|li| li.sequence_number += 1); let trade_a_to_b = (next_rand() % 2) == 0; let amount_in = ((next_rand() % 50_000) + 1_000) as i128; diff --git a/contracts/content_management_system/src/drm.rs b/contracts/content_management_system/src/drm.rs new file mode 100644 index 00000000..4d9ab322 --- /dev/null +++ b/contracts/content_management_system/src/drm.rs @@ -0,0 +1,59 @@ +use crate::errors::Error; +use crate::storage::{get_content, is_instructor}; +use crate::types::StorageKey; +use soroban_sdk::{contracttype, Address, BytesN, Env, symbol_short}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CopyrightRecord { + pub content_id: u64, + pub author: Address, + pub copyright_hash: BytesN<32>, + pub registered_at: u64, +} + +pub fn register_copyright( + env: &Env, + author: Address, + content_id: u64, + copyright_hash: BytesN<32>, +) -> Result { + author.require_auth(); + + if !is_instructor(env, &author) { + return Err(Error::NotInstructor); + } + + let content = get_content(env, content_id)?; + if content.instructor != author { + return Err(Error::Unauthorized); + } + + let key = StorageKey::Copyright(content_id); + if env.storage().persistent().has(&key) { + return Err(Error::CopyrightAlreadyRegistered); + } + + let record = CopyrightRecord { + content_id, + author: author.clone(), + copyright_hash, + registered_at: env.ledger().timestamp(), + }; + + env.storage().persistent().set(&key, &record); + env.events().publish( + (symbol_short!("copyright"), symbol_short!("register")), + (content_id, author, record.registered_at), + ); + + Ok(record) +} + +pub fn get_copyright(env: &Env, content_id: u64) -> Result { + let key = StorageKey::Copyright(content_id); + env.storage() + .persistent() + .get(&key) + .ok_or(Error::ContentNotFound) +} diff --git a/contracts/content_management_system/src/errors.rs b/contracts/content_management_system/src/errors.rs index 6224446a..c10597bc 100644 --- a/contracts/content_management_system/src/errors.rs +++ b/contracts/content_management_system/src/errors.rs @@ -21,4 +21,11 @@ pub enum Error { EnrollmentNotAllowed = 15, AlreadyEnrolled = 16, NotEnrolled = 17, + CopyrightAlreadyRegistered = 18, + LicenseNotFound = 19, + LicenseExpired = 20, + LicenseTermsNotSet = 21, + InvalidLicenseDuration = 22, + InvalidLicensePrice = 23, + LicenseAlreadyRevoked = 24, } diff --git a/contracts/content_management_system/src/lib.rs b/contracts/content_management_system/src/lib.rs index df5c3bd6..3db9126f 100644 --- a/contracts/content_management_system/src/lib.rs +++ b/contracts/content_management_system/src/lib.rs @@ -1,7 +1,9 @@ #![no_std] +pub mod drm; mod errors; mod events; +pub mod licensing; mod storage; mod types; @@ -10,7 +12,7 @@ mod test; use errors::Error; use events::*; -use soroban_sdk::{contract, contractimpl, Address, Env, String, Vec}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Vec}; use storage::*; use types::{AccessPolicy, ContentItem, ContentMetadata, ContentStatus}; @@ -391,4 +393,61 @@ impl ContentManagementSystem { pub fn is_enrolled(env: Env, content_id: u64, student: Address) -> bool { is_enrolled(&env, content_id, &student) } + + /// Register curriculum copyright hash for a content item + pub fn register_copyright( + env: Env, + author: Address, + content_id: u64, + copyright_hash: BytesN<32>, + ) -> Result { + drm::register_copyright(&env, author, content_id, copyright_hash) + } + + /// Get registered curriculum copyright record + pub fn get_copyright(env: Env, content_id: u64) -> Result { + drm::get_copyright(&env, content_id) + } + + /// Set commercial licensing terms for a content item + pub fn set_license_terms( + env: Env, + author: Address, + content_id: u64, + payment_token: Address, + price: i128, + duration_seconds: u64, + ) -> Result { + licensing::set_license_terms(&env, author, content_id, payment_token, price, duration_seconds) + } + + /// Get licensing terms for a content item + pub fn get_license_terms(env: Env, content_id: u64) -> Result { + licensing::get_license_terms(&env, content_id) + } + + /// Purchase a time-limited commercial license voucher, forwarding royalties to course author + pub fn purchase_license( + env: Env, + institution: Address, + content_id: u64, + signature: BytesN<64>, + ) -> Result { + licensing::purchase_license(&env, institution, content_id, signature) + } + + /// Assert whether an institution holds an active valid license + pub fn is_license_valid(env: Env, institution: Address, content_id: u64) -> bool { + licensing::is_license_valid(&env, institution, content_id) + } + + /// Get license voucher details + pub fn get_license(env: Env, license_id: u64) -> Result { + licensing::get_license(&env, license_id) + } + + /// Revoke an active license voucher (author only) + pub fn revoke_license(env: Env, author: Address, license_id: u64) -> Result<(), Error> { + licensing::revoke_license(&env, author, license_id) + } } diff --git a/contracts/content_management_system/src/licensing.rs b/contracts/content_management_system/src/licensing.rs new file mode 100644 index 00000000..720b8026 --- /dev/null +++ b/contracts/content_management_system/src/licensing.rs @@ -0,0 +1,181 @@ +use crate::errors::Error; +use crate::storage::{get_content, is_instructor}; +use crate::types::StorageKey; +use soroban_sdk::{contracttype, token, symbol_short, Address, BytesN, Env}; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LicenseTerms { + pub content_id: u64, + pub author: Address, + pub payment_token: Address, + pub price: i128, + pub duration_seconds: u64, + pub is_active: bool, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LicenseVoucher { + pub license_id: u64, + pub content_id: u64, + pub licensee: Address, + pub author: Address, + pub valid_from: u64, + pub valid_to: u64, + pub signature: BytesN<64>, + pub fee_paid: i128, + pub is_revoked: bool, +} + +pub fn set_license_terms( + env: &Env, + author: Address, + content_id: u64, + payment_token: Address, + price: i128, + duration_seconds: u64, +) -> Result { + author.require_auth(); + + if !is_instructor(env, &author) { + return Err(Error::NotInstructor); + } + + let content = get_content(env, content_id)?; + if content.instructor != author { + return Err(Error::Unauthorized); + } + + if price < 0 { + return Err(Error::InvalidLicensePrice); + } + + if duration_seconds == 0 { + return Err(Error::InvalidLicenseDuration); + } + + let terms = LicenseTerms { + content_id, + author: author.clone(), + payment_token, + price, + duration_seconds, + is_active: true, + }; + + let key = StorageKey::LicenseTerms(content_id); + env.storage().persistent().set(&key, &terms); + + env.events().publish( + (symbol_short!("lic_terms"), symbol_short!("set")), + (content_id, author, price, duration_seconds), + ); + + Ok(terms) +} + +pub fn get_license_terms(env: &Env, content_id: u64) -> Result { + let key = StorageKey::LicenseTerms(content_id); + env.storage() + .persistent() + .get(&key) + .ok_or(Error::LicenseTermsNotSet) +} + +pub fn purchase_license( + env: &Env, + institution: Address, + content_id: u64, + signature: BytesN<64>, +) -> Result { + institution.require_auth(); + + let terms = get_license_terms(env, content_id)?; + if !terms.is_active { + return Err(Error::LicenseTermsNotSet); + } + + // Direct royalty payment forwarding to the original course creator + if terms.price > 0 { + let token_client = token::Client::new(env, &terms.payment_token); + token_client.transfer(&institution, &terms.author, &terms.price); + } + + let next_id_key = StorageKey::NextLicenseId; + let license_id: u64 = env.storage().instance().get(&next_id_key).unwrap_or(1); + env.storage().instance().set(&next_id_key, &(license_id + 1)); + + let now = env.ledger().timestamp(); + let valid_to = now.checked_add(terms.duration_seconds).unwrap_or(u64::MAX); + + let voucher = LicenseVoucher { + license_id, + content_id, + licensee: institution.clone(), + author: terms.author.clone(), + valid_from: now, + valid_to, + signature, + fee_paid: terms.price, + is_revoked: false, + }; + + let license_key = StorageKey::License(license_id); + env.storage().persistent().set(&license_key, &voucher); + + let active_key = StorageKey::ActiveLicense(content_id, institution.clone()); + env.storage().persistent().set(&active_key, &license_id); + + env.events().publish( + (symbol_short!("lic_mint"), symbol_short!("voucher")), + (license_id, content_id, institution, valid_to), + ); + + Ok(license_id) +} + +pub fn is_license_valid(env: &Env, institution: Address, content_id: u64) -> bool { + let active_key = StorageKey::ActiveLicense(content_id, institution); + let license_id_opt: Option = env.storage().persistent().get(&active_key); + + if let Some(license_id) = license_id_opt { + let license_key = StorageKey::License(license_id); + if let Some(voucher) = env.storage().persistent().get::<_, LicenseVoucher>(&license_key) { + let now = env.ledger().timestamp(); + return !voucher.is_revoked && now >= voucher.valid_from && now <= voucher.valid_to; + } + } + false +} + +pub fn get_license(env: &Env, license_id: u64) -> Result { + let license_key = StorageKey::License(license_id); + env.storage() + .persistent() + .get(&license_key) + .ok_or(Error::LicenseNotFound) +} + +pub fn revoke_license(env: &Env, author: Address, license_id: u64) -> Result<(), Error> { + author.require_auth(); + + let mut voucher = get_license(env, license_id)?; + if voucher.author != author { + return Err(Error::Unauthorized); + } + if voucher.is_revoked { + return Err(Error::LicenseAlreadyRevoked); + } + + voucher.is_revoked = true; + let license_key = StorageKey::License(license_id); + env.storage().persistent().set(&license_key, &voucher); + + env.events().publish( + (symbol_short!("lic_rev"), symbol_short!("revoked")), + (license_id, author), + ); + + Ok(()) +} diff --git a/contracts/content_management_system/src/test.rs b/contracts/content_management_system/src/test.rs index 4b5c9cda..8246ceba 100644 --- a/contracts/content_management_system/src/test.rs +++ b/contracts/content_management_system/src/test.rs @@ -566,3 +566,144 @@ fn test_content_versioning() { let v2_hash = client.get_content_version(&content_id, &2, &instructor); assert_eq!(v2_hash, String::from_str(&env, "QmHashV2")); } + +#[test] +fn test_drm_copyright_registration() { + let env = create_test_env(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let author = Address::generate(&env); + client.add_instructor(&author); + + let content_id = client.create_content( + &author, + &String::from_str(&env, "Advanced Cryptography"), + &String::from_str(&env, "Curriculum details"), + &String::from_str(&env, "QmContentHash123"), + &String::from_str(&env, "course"), + &vec![&env], + &AccessPolicy::Public, + ); + + let copyright_hash = BytesN::from_array(&env, &[42u8; 32]); + let record = client.register_copyright(&author, &content_id, ©right_hash); + assert_eq!(record.content_id, content_id); + assert_eq!(record.author, author); + assert_eq!(record.copyright_hash, copyright_hash); + + let fetched = client.get_copyright(&content_id); + assert_eq!(fetched.copyright_hash, copyright_hash); +} + +#[test] +fn test_dynamic_licensing_purchase_and_validation() { + let env = create_test_env(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let author = Address::generate(&env); + client.add_instructor(&author); + + let content_id = client.create_content( + &author, + &String::from_str(&env, "Blockchain Engineering 101"), + &String::from_str(&env, "Comprehensive bootcamp curriculum"), + &String::from_str(&env, "QmCurriculumHash"), + &String::from_str(&env, "course"), + &vec![&env], + &AccessPolicy::Restricted, + ); + + // Setup payment token + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_client = soroban_sdk::token::Client::new(&env, &token_contract.address()); + let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_contract.address()); + + let institution = Address::generate(&env); + token_admin_client.mint(&institution, &5000); + assert_eq!(token_client.balance(&institution), 5000); + assert_eq!(token_client.balance(&author), 0); + + // Set licensing terms: 1500 tokens for 3600 seconds (1 hour) + let terms = client.set_license_terms( + &author, + &content_id, + &token_contract.address(), + &1500, + &3600, + ); + assert_eq!(terms.price, 1500); + assert_eq!(terms.duration_seconds, 3600); + + // Initially institution does not hold a valid license + assert!(!client.is_license_valid(&institution, &content_id)); + + // Institution purchases license voucher + let voucher_sig = BytesN::from_array(&env, &[1u8; 64]); + let license_id = client.purchase_license(&institution, &content_id, &voucher_sig); + + // Check royalty payment forwarded directly to author + assert_eq!(token_client.balance(&institution), 3500); + assert_eq!(token_client.balance(&author), 1500); + + // Voucher validation is active + assert!(client.is_license_valid(&institution, &content_id)); + + let voucher = client.get_license(&license_id); + assert_eq!(voucher.licensee, institution); + assert_eq!(voucher.author, author); + assert_eq!(voucher.signature, voucher_sig); + + // Advance time past expiration (3601 seconds) + env.ledger().with_mut(|li| { + li.timestamp += 3601; + }); + + // License is no longer valid after expiration + assert!(!client.is_license_valid(&institution, &content_id)); +} + +#[test] +fn test_license_revocation() { + let env = create_test_env(); + env.mock_all_auths(); + + let (_, client) = setup_contract(&env); + let author = Address::generate(&env); + client.add_instructor(&author); + + let content_id = client.create_content( + &author, + &String::from_str(&env, "Web3 Security"), + &String::from_str(&env, "Security curriculum"), + &String::from_str(&env, "QmSecHash"), + &String::from_str(&env, "course"), + &vec![&env], + &AccessPolicy::Restricted, + ); + + let token_admin = Address::generate(&env); + let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone()); + let token_admin_client = soroban_sdk::token::StellarAssetClient::new(&env, &token_contract.address()); + + let institution = Address::generate(&env); + token_admin_client.mint(&institution, &1000); + + client.set_license_terms( + &author, + &content_id, + &token_contract.address(), + &500, + &10000, + ); + + let sig = BytesN::from_array(&env, &[2u8; 64]); + let license_id = client.purchase_license(&institution, &content_id, &sig); + assert!(client.is_license_valid(&institution, &content_id)); + + // Author revokes license early + client.revoke_license(&author, &license_id); + assert!(!client.is_license_valid(&institution, &content_id)); +} diff --git a/contracts/content_management_system/src/types.rs b/contracts/content_management_system/src/types.rs index 81afe2a9..fcca51b0 100644 --- a/contracts/content_management_system/src/types.rs +++ b/contracts/content_management_system/src/types.rs @@ -48,4 +48,9 @@ pub enum StorageKey { ContentVersion(u64, u32), Instructor(Address), Enrollment(u64, Address), + Copyright(u64), + LicenseTerms(u64), + License(u64), + ActiveLicense(u64, Address), + NextLicenseId, } diff --git a/contracts/freelance-platform/src/bounty.rs b/contracts/freelance-platform/src/bounty.rs new file mode 100644 index 00000000..141bbf49 --- /dev/null +++ b/contracts/freelance-platform/src/bounty.rs @@ -0,0 +1,251 @@ +//! Decentralized Bounty Board with milestone claim validation, auto-refund, and collaborative split payouts. + +use soroban_sdk::{ + contracttype, panic_with_error, symbol_short, token, Address, BytesN, Env, Symbol, Vec, +}; +use crate::{DataKey, FreelanceError}; + +const BPS_DENOMINATOR: u32 = 10_000; + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BountyStatus { + Open, + Claimed, + Completed, + Refunded, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Bounty { + pub id: u64, + pub funder: Address, + pub title: Symbol, + pub amount: i128, + pub expiration: u64, + pub status: BountyStatus, + pub evidence_hash: Option>, + pub claimants: Vec
, + pub split_bps: Vec, +} + +/// Creates and funds a new open-source bounty, locking collateral securely in escrow. +pub fn create_bounty( + env: &Env, + funder: Address, + title: Symbol, + amount: i128, + expiration: u64, +) -> u64 { + funder.require_auth(); + if amount <= 0 { + panic_with_error!(env, FreelanceError::BudgetMismatch); + } + if expiration <= env.ledger().timestamp() { + panic_with_error!(env, FreelanceError::BountyExpired); + } + + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::NotInitialized)); + + let token_client = token::Client::new(env, &token_address); + let contract_addr = env.current_contract_address(); + token_client.transfer(&funder, &contract_addr, &amount); + + let bounty_id: u64 = env + .storage() + .instance() + .get(&DataKey::NextBountyId) + .unwrap_or(1); + env.storage() + .instance() + .set(&DataKey::NextBountyId, &(bounty_id + 1)); + + let bounty = Bounty { + id: bounty_id, + funder: funder.clone(), + title, + amount, + expiration, + status: BountyStatus::Open, + evidence_hash: None, + claimants: Vec::new(env), + split_bps: Vec::new(env), + }; + + env.storage().instance().set(&DataKey::Bounty(bounty_id), &bounty); + env.events().publish((symbol_short!("bounty_c"), funder), (bounty_id, amount)); + + bounty_id +} + +/// Submits work evidence hash for single contributor claim. +pub fn claim_bounty( + env: &Env, + claimant: Address, + bounty_id: u64, + evidence_hash: BytesN<32>, +) { + claimant.require_auth(); + let mut bounty: Bounty = env + .storage() + .instance() + .get(&DataKey::Bounty(bounty_id)) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::BountyNotFound)); + + if bounty.status != BountyStatus::Open { + panic_with_error!(env, FreelanceError::BountyNotOpen); + } + if env.ledger().timestamp() > bounty.expiration { + panic_with_error!(env, FreelanceError::BountyExpired); + } + + let mut claimants = Vec::new(env); + claimants.push_back(claimant.clone()); + + let mut split_bps = Vec::new(env); + split_bps.push_back(BPS_DENOMINATOR); + + bounty.status = BountyStatus::Claimed; + bounty.evidence_hash = Some(evidence_hash.clone()); + bounty.claimants = claimants; + bounty.split_bps = split_bps; + + env.storage().instance().set(&DataKey::Bounty(bounty_id), &bounty); + env.events().publish((symbol_short!("bounty_m"), claimant), bounty_id); +} + +/// Submits collaborative work evidence with proportional split basis points. +pub fn claim_bounty_collaborative( + env: &Env, + submitter: Address, + bounty_id: u64, + claimants: Vec
, + split_bps: Vec, + evidence_hash: BytesN<32>, +) { + submitter.require_auth(); + let mut bounty: Bounty = env + .storage() + .instance() + .get(&DataKey::Bounty(bounty_id)) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::BountyNotFound)); + + if bounty.status != BountyStatus::Open { + panic_with_error!(env, FreelanceError::BountyNotOpen); + } + if env.ledger().timestamp() > bounty.expiration { + panic_with_error!(env, FreelanceError::BountyExpired); + } + + if claimants.is_empty() || claimants.len() != split_bps.len() { + panic_with_error!(env, FreelanceError::InvalidSplit); + } + + let mut total_bps: u32 = 0; + for bps in split_bps.iter() { + if bps == 0 { + panic_with_error!(env, FreelanceError::InvalidSplit); + } + total_bps = total_bps.checked_add(bps).unwrap(); + } + if total_bps != BPS_DENOMINATOR { + panic_with_error!(env, FreelanceError::InvalidSplit); + } + + bounty.status = BountyStatus::Claimed; + bounty.evidence_hash = Some(evidence_hash); + bounty.claimants = claimants; + bounty.split_bps = split_bps; + + env.storage().instance().set(&DataKey::Bounty(bounty_id), &bounty); + env.events().publish((symbol_short!("bounty_cl"), submitter), bounty_id); +} + +/// Verifies submitted pull request evidence and releases payouts (single or split). +pub fn verify_and_payout(env: &Env, funder: Address, bounty_id: u64) { + funder.require_auth(); + let mut bounty: Bounty = env + .storage() + .instance() + .get(&DataKey::Bounty(bounty_id)) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::BountyNotFound)); + + if bounty.funder != funder { + panic_with_error!(env, FreelanceError::Unauthorized); + } + if bounty.status != BountyStatus::Claimed { + panic_with_error!(env, FreelanceError::NoEvidenceSubmitted); + } + + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::NotInitialized)); + + let token_client = token::Client::new(env, &token_address); + let contract_addr = env.current_contract_address(); + + let count = bounty.claimants.len(); + let mut distributed: i128 = 0; + + for i in 0..count { + let claimant = bounty.claimants.get(i).unwrap(); + let bps = bounty.split_bps.get(i).unwrap(); + let payout = if i == count - 1 { + bounty.amount - distributed + } else { + (bounty.amount * (bps as i128)) / (BPS_DENOMINATOR as i128) + }; + distributed += payout; + token_client.transfer(&contract_addr, &claimant, &payout); + } + + bounty.status = BountyStatus::Completed; + env.storage().instance().set(&DataKey::Bounty(bounty_id), &bounty); + env.events().publish((symbol_short!("bounty_p"), funder), bounty_id); +} + +/// Allows funder to claim a 100% refund once expiration has elapsed without valid completion. +pub fn claim_refund(env: &Env, funder: Address, bounty_id: u64) { + funder.require_auth(); + let mut bounty: Bounty = env + .storage() + .instance() + .get(&DataKey::Bounty(bounty_id)) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::BountyNotFound)); + + if bounty.funder != funder { + panic_with_error!(env, FreelanceError::Unauthorized); + } + if bounty.status == BountyStatus::Completed || bounty.status == BountyStatus::Refunded { + panic_with_error!(env, FreelanceError::BountyAlreadyCompleted); + } + if env.ledger().timestamp() <= bounty.expiration { + panic_with_error!(env, FreelanceError::BountyNotExpired); + } + + let token_address: Address = env + .storage() + .instance() + .get(&DataKey::Token) + .unwrap_or_else(|| panic_with_error!(env, FreelanceError::NotInitialized)); + + let token_client = token::Client::new(env, &token_address); + let contract_addr = env.current_contract_address(); + token_client.transfer(&contract_addr, &funder, &bounty.amount); + + bounty.status = BountyStatus::Refunded; + env.storage().instance().set(&DataKey::Bounty(bounty_id), &bounty); + env.events().publish((symbol_short!("bounty_r"), funder), bounty_id); +} + +/// Retrieves bounty details by ID. +pub fn get_bounty(env: &Env, bounty_id: u64) -> Option { + env.storage().instance().get(&DataKey::Bounty(bounty_id)) +} diff --git a/contracts/freelance-platform/src/lib.rs b/contracts/freelance-platform/src/lib.rs index fcb223be..14785d88 100644 --- a/contracts/freelance-platform/src/lib.rs +++ b/contracts/freelance-platform/src/lib.rs @@ -23,6 +23,13 @@ pub enum FreelanceError { AutoReleaseNotReady = 14, NotArbiter = 15, DisputeAlreadyResolved = 16, + BountyNotFound = 17, + BountyNotOpen = 18, + BountyExpired = 19, + BountyNotExpired = 20, + InvalidSplit = 21, + NoEvidenceSubmitted = 22, + BountyAlreadyCompleted = 23, } #[contracttype] @@ -55,6 +62,8 @@ pub enum DataKey { Job(u64), Worker(Address), Applications(u64), + NextBountyId, + Bounty(u64), } #[contract] @@ -557,9 +566,58 @@ impl FreelancePlatform { pub fn get_dispute(env: Env, job_id: u64, milestone_index: u32) -> Option { arbitration::ArbitrationManager::get_dispute(&env, job_id, milestone_index) } + + /// Create a bounty board offering with locked escrow + pub fn create_bounty( + env: Env, + funder: Address, + title: Symbol, + amount: i128, + expiration: u64, + ) -> u64 { + bounty::create_bounty(&env, funder, title, amount, expiration) + } + + /// Claim a bounty with proof evidence hash + pub fn claim_bounty( + env: Env, + claimant: Address, + bounty_id: u64, + evidence_hash: BytesN<32>, + ) { + bounty::claim_bounty(&env, claimant, bounty_id, evidence_hash) + } + + /// Claim a bounty collaboratively with multiple contributors and split bps + pub fn claim_bounty_collaborative( + env: Env, + submitter: Address, + bounty_id: u64, + claimants: Vec
, + split_bps: Vec, + evidence_hash: BytesN<32>, + ) { + bounty::claim_bounty_collaborative(&env, submitter, bounty_id, claimants, split_bps, evidence_hash) + } + + /// Funder verifies PR evidence and triggers release of funds (single or split) + pub fn verify_and_payout(env: Env, funder: Address, bounty_id: u64) { + bounty::verify_and_payout(&env, funder, bounty_id) + } + + /// Funder claims 100% refund if bounty expired + pub fn claim_refund(env: Env, funder: Address, bounty_id: u64) { + bounty::claim_refund(&env, funder, bounty_id) + } + + /// Query bounty details + pub fn get_bounty(env: Env, bounty_id: u64) -> Option { + bounty::get_bounty(&env, bounty_id) + } } pub mod arbitration; +pub mod bounty; pub mod escrow; pub mod reputation_system; diff --git a/contracts/freelance-platform/src/test.rs b/contracts/freelance-platform/src/test.rs index ba6f0d32..650728f5 100644 --- a/contracts/freelance-platform/src/test.rs +++ b/contracts/freelance-platform/src/test.rs @@ -307,3 +307,117 @@ fn test_arbiter_registration_and_binding_multisig_arbitration() { assert_eq!(token_client.balance(&freelancer), 1000); assert_eq!(token_client.balance(&employer), 0); } + +#[test] +fn test_bounty_creation_and_single_claim_payout() { + let (env, _, token_client, token_admin, contract_addr, client) = setup(); + + let funder = Address::generate(&env); + let contributor = Address::generate(&env); + + token_admin.mint(&funder, &2000); + assert_eq!(token_client.balance(&funder), 2000); + + let expiration = env.ledger().timestamp() + 1000; + let bounty_id = client.create_bounty( + &funder, + &Symbol::new(&env, "Fix_Issue_123"), + &1500, + &expiration, + ); + assert_eq!(bounty_id, 1); + assert_eq!(token_client.balance(&contract_addr), 1500); + assert_eq!(token_client.balance(&funder), 500); + + let bounty = client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.amount, 1500); + assert_eq!(bounty.status, bounty::BountyStatus::Open); + + // Contributor submits pull request proof hash + let evidence_hash = BytesN::from_array(&env, &[7u8; 32]); + client.claim_bounty(&contributor, &bounty_id, &evidence_hash); + + let bounty = client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.status, bounty::BountyStatus::Claimed); + assert_eq!(bounty.evidence_hash, Some(evidence_hash)); + + // Funder verifies PR and approves payout + client.verify_and_payout(&funder, &bounty_id); + + let bounty = client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.status, bounty::BountyStatus::Completed); + assert_eq!(token_client.balance(&contributor), 1500); + assert_eq!(token_client.balance(&contract_addr), 0); +} + +#[test] +fn test_bounty_collaborative_split_payout() { + let (env, _, token_client, token_admin, contract_addr, client) = setup(); + + let funder = Address::generate(&env); + let dev1 = Address::generate(&env); + let dev2 = Address::generate(&env); + let dev3 = Address::generate(&env); + + token_admin.mint(&funder, &10000); + let expiration = env.ledger().timestamp() + 500; + let bounty_id = client.create_bounty( + &funder, + &Symbol::new(&env, "Multi_Dev_Feature"), + &10000, + &expiration, + ); + + let evidence_hash = BytesN::from_array(&env, &[9u8; 32]); + let claimants = vec![&env, dev1.clone(), dev2.clone(), dev3.clone()]; + // 50% (5000 bps), 30% (3000 bps), 20% (2000 bps) + let split_bps = vec![&env, 5000u32, 3000u32, 2000u32]; + + client.claim_bounty_collaborative( + &dev1, + &bounty_id, + &claimants, + &split_bps, + &evidence_hash, + ); + + client.verify_and_payout(&funder, &bounty_id); + + assert_eq!(token_client.balance(&dev1), 5000); + assert_eq!(token_client.balance(&dev2), 3000); + assert_eq!(token_client.balance(&dev3), 2000); + assert_eq!(token_client.balance(&contract_addr), 0); +} + +#[test] +fn test_bounty_expired_refund() { + let (env, _, token_client, token_admin, contract_addr, client) = setup(); + + let funder = Address::generate(&env); + token_admin.mint(&funder, &3000); + + let expiration = env.ledger().timestamp() + 100; + let bounty_id = client.create_bounty( + &funder, + &Symbol::new(&env, "Unfinished_Task"), + &3000, + &expiration, + ); + + // Attempting refund before expiration should fail + let refund_early = client.try_claim_refund(&funder, &bounty_id); + assert!(refund_early.is_err()); + + // Advance ledger timestamp beyond expiration + env.ledger().with_mut(|li| { + li.timestamp += 200; + }); + + // Now refund succeeds 100% + client.claim_refund(&funder, &bounty_id); + assert_eq!(token_client.balance(&funder), 3000); + assert_eq!(token_client.balance(&contract_addr), 0); + + let bounty = client.get_bounty(&bounty_id).unwrap(); + assert_eq!(bounty.status, bounty::BountyStatus::Refunded); +} diff --git a/contracts/sybil_resistance/src/decay.rs b/contracts/sybil_resistance/src/decay.rs new file mode 100644 index 00000000..221ac618 --- /dev/null +++ b/contracts/sybil_resistance/src/decay.rs @@ -0,0 +1,48 @@ +//! Time-decay engine implementing exponential decay without floating point arithmetic. +//! Formula: R_t = R_0 * 2^(-t / T) + +/// Default 6-month epoch half-life (180 days in seconds) +pub const DEFAULT_HALF_LIFE_SECONDS: u64 = 180 * 24 * 3600; // 15,552,000s + +/// Maximum reputation score cap to prevent entrenched monopoly power +pub const MAX_REPUTATION_SCORE: u64 = 10_000; + +/// Computes R_t = R_0 * 2^(-t / T) using pure integer arithmetic with scaled basis points. +pub fn apply_exponential_decay( + initial_score: u64, + elapsed_seconds: u64, + half_life_seconds: u64, +) -> u64 { + if initial_score == 0 || elapsed_seconds == 0 || half_life_seconds == 0 { + return initial_score; + } + + let whole_epochs = elapsed_seconds / half_life_seconds; + if whole_epochs >= 64 { + return 0; + } + + // Integer halving for full epochs: R_0 >> whole_epochs + let base_score = initial_score >> whole_epochs; + if base_score == 0 { + return 0; + } + + let remainder_seconds = elapsed_seconds % half_life_seconds; + if remainder_seconds == 0 { + return base_score; + } + + // Fractional decay for remaining seconds: 2^(-r / T) + // Approximate 2^(-u) on [0, 1] via quadratic integer polynomial: + // f(u) = 10000 - 6931 * u + 1931 * u^2 (where u = remainder / T in 10,000 bps) + let u = (remainder_seconds as u128 * 10_000) / (half_life_seconds as u128); + let term1: u128 = 10_000; + let term2: u128 = (6931 * u) / 10_000; + let term3: u128 = (1931 * u * u) / 100_000_000; + + let decay_bps = term1.saturating_sub(term2).saturating_add(term3); + let decayed = (base_score as u128 * decay_bps) / 10_000; + + decayed as u64 +} diff --git a/contracts/sybil_resistance/src/lib.rs b/contracts/sybil_resistance/src/lib.rs index 7cb5be11..f3adc790 100644 --- a/contracts/sybil_resistance/src/lib.rs +++ b/contracts/sybil_resistance/src/lib.rs @@ -31,6 +31,7 @@ pub enum SybilError { pub enum DataKey { Admin, VerifiedUser(Address), + Reputation(Address), } #[contract] @@ -93,7 +94,43 @@ impl SybilResistanceContract { panic_with_error!(&env, SybilError::NotVerified); } } + + /// Retrieves current reputation score after applying exponential time decay. + pub fn get_reputation(env: Env, user: Address) -> u64 { + reputation::get_reputation(&env, &user) + } + + /// Adjusts user reputation score according to an action, capping at MAX_REPUTATION_SCORE + /// and emitting a transparent adjustment event with a reasoning code. + pub fn adjust_reputation( + env: Env, + user: Address, + delta: i64, + reason: reputation::ReputationReason, + ) -> u64 { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .unwrap_or_else(|| panic_with_error!(&env, SybilError::NotInitialized)); + admin.require_auth(); + + reputation::adjust_reputation(&env, user, delta, reason) + } + + /// Applies time decay and commits the settled reputation score on-chain. + pub fn settle_decay(env: Env, user: Address) -> u64 { + reputation::settle_decay(&env, user) + } + + /// Retrieves raw reputation record without auto-decay settlement. + pub fn get_reputation_record(env: Env, user: Address) -> Option { + reputation::get_reputation_record(&env, &user) + } } +pub mod decay; +pub mod reputation; + #[cfg(test)] mod test; diff --git a/contracts/sybil_resistance/src/reputation.rs b/contracts/sybil_resistance/src/reputation.rs new file mode 100644 index 00000000..709aa175 --- /dev/null +++ b/contracts/sybil_resistance/src/reputation.rs @@ -0,0 +1,103 @@ +//! Reputation scoring system with decaying trust metrics and score caps. + +use crate::decay::{apply_exponential_decay, DEFAULT_HALF_LIFE_SECONDS, MAX_REPUTATION_SCORE}; +use crate::DataKey; +use soroban_sdk::{contracttype, symbol_short, Address, Env}; + +#[contracttype] +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ReputationReason { + CourseCompletion = 1, + PeerReview = 2, + HackathonWin = 3, + TimeDecay = 4, + AdminAdjustment = 5, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReputationRecord { + pub user: Address, + pub score: u64, + pub last_updated: u64, +} + +/// Retrieves current reputation score after applying exponential time decay. +pub fn get_reputation(env: &Env, user: &Address) -> u64 { + let key = DataKey::Reputation(user.clone()); + if let Some(record) = env.storage().persistent().get::<_, ReputationRecord>(&key) { + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(record.last_updated); + apply_exponential_decay(record.score, elapsed, DEFAULT_HALF_LIFE_SECONDS) + } else { + 0 + } +} + +/// Retrieves the raw reputation record without auto-decay settlement. +pub fn get_reputation_record(env: &Env, user: &Address) -> Option { + let key = DataKey::Reputation(user.clone()); + env.storage().persistent().get(&key) +} + +/// Adjusts user reputation score according to an action, capping at MAX_REPUTATION_SCORE +/// and emitting a transparent adjustment event with a reasoning code. +pub fn adjust_reputation( + env: &Env, + user: Address, + delta: i64, + reason: ReputationReason, +) -> u64 { + let key = DataKey::Reputation(user.clone()); + let now = env.ledger().timestamp(); + + let old_score = get_reputation(env, &user); + let new_score = if delta >= 0 { + let added = old_score.saturating_add(delta as u64); + added.min(MAX_REPUTATION_SCORE) + } else { + old_score.saturating_sub((-delta) as u64) + }; + + let record = ReputationRecord { + user: user.clone(), + score: new_score, + last_updated: now, + }; + env.storage().persistent().set(&key, &record); + + env.events().publish( + (symbol_short!("rep_adj"), user), + (old_score, new_score, reason as u32, now), + ); + + new_score +} + +/// Applies time decay and commits the settled reputation score on-chain. +pub fn settle_decay(env: &Env, user: Address) -> u64 { + let old_record = get_reputation_record(env, &user); + if let Some(record) = old_record { + let now = env.ledger().timestamp(); + let elapsed = now.saturating_sub(record.last_updated); + let decayed = apply_exponential_decay(record.score, elapsed, DEFAULT_HALF_LIFE_SECONDS); + + let key = DataKey::Reputation(user.clone()); + let updated = ReputationRecord { + user: user.clone(), + score: decayed, + last_updated: now, + }; + env.storage().persistent().set(&key, &updated); + + env.events().publish( + (symbol_short!("rep_adj"), user), + (record.score, decayed, ReputationReason::TimeDecay as u32, now), + ); + + decayed + } else { + 0 + } +} diff --git a/contracts/sybil_resistance/src/test.rs b/contracts/sybil_resistance/src/test.rs index f6154cc7..fb2b7ecb 100644 --- a/contracts/sybil_resistance/src/test.rs +++ b/contracts/sybil_resistance/src/test.rs @@ -50,3 +50,93 @@ fn double_initialize_panics() { let admin2 = Address::generate(&env); client.initialize(&admin2); } + +#[test] +fn test_reputation_awarding_and_cap() { + let env = Env::default(); + env.mock_all_auths(); + let (client, _, user) = setup(&env); + + assert_eq!(client.get_reputation(&user), 0); + + // Award points for course completion (+500) + client.adjust_reputation( + &user, + &500, + &reputation::ReputationReason::CourseCompletion, + ); + assert_eq!(client.get_reputation(&user), 500); + + // Award points for peer review (+200) + client.adjust_reputation( + &user, + &200, + &reputation::ReputationReason::PeerReview, + ); + assert_eq!(client.get_reputation(&user), 700); + + // Award massive points for winning hackathon (+15000), should cap at MAX_REPUTATION_SCORE (10,000) + client.adjust_reputation( + &user, + &15000, + &reputation::ReputationReason::HackathonWin, + ); + assert_eq!(client.get_reputation(&user), 10_000); +} + +#[test] +fn test_exponential_time_decay_halving() { + let env = Env::default(); + env.mock_all_auths(); + use soroban_sdk::testutils::Ledger as _; + + env.ledger().with_mut(|li| { + li.timestamp = 1_000_000; + }); + + let (client, _, user) = setup(&env); + + // Give user 8000 points + client.adjust_reputation( + &user, + &8000, + &reputation::ReputationReason::HackathonWin, + ); + assert_eq!(client.get_reputation(&user), 8000); + + // Advance ledger by 1 epoch (180 days = 15,552,000s) + let half_life = decay::DEFAULT_HALF_LIFE_SECONDS; + env.ledger().with_mut(|li| { + li.timestamp += half_life; + }); + + // Score should decay by half: 8000 * 2^(-1) = 4000 + let decayed_score = client.get_reputation(&user); + assert_eq!(decayed_score, 4000); + + // Advance ledger by another 1 epoch (total 2 epochs = 360 days) + env.ledger().with_mut(|li| { + li.timestamp += half_life; + }); + + // Score should decay by half again: 4000 * 2^(-1) = 2000 + assert_eq!(client.get_reputation(&user), 2000); + + // Settle decay on-chain + let settled = client.settle_decay(&user); + assert_eq!(settled, 2000); + + let record = client.get_reputation_record(&user).unwrap(); + assert_eq!(record.score, 2000); +} + +#[test] +fn test_decay_pure_math() { + // Exact halving + assert_eq!(decay::apply_exponential_decay(1000, 100, 100), 500); + assert_eq!(decay::apply_exponential_decay(1000, 200, 100), 250); + assert_eq!(decay::apply_exponential_decay(1000, 300, 100), 125); + // Partial decay (t = 50, T = 100 -> ~70.7% = 701) + let half_decay = decay::apply_exponential_decay(1000, 50, 100); + assert!(half_decay >= 700 && half_decay <= 710); +}