diff --git a/.gitignore b/.gitignore index 5b9a733..7dd2e30 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ dist/ build/ *.wasm +# Test snapshots +test_snapshots/ + # Logs *.log logs/ @@ -50,4 +53,5 @@ logs/ # OS Thumbs.db issue.md -pr.md \ No newline at end of file +pr.md +contracts/test_snapshots/ diff --git a/contracts/src/lib.rs b/contracts/src/lib.rs index f5755f1..8977529 100644 --- a/contracts/src/lib.rs +++ b/contracts/src/lib.rs @@ -23,8 +23,11 @@ pub mod multisig; pub mod dividend_distributor; pub mod yield_strategy; pub mod arbitrator; +pub mod whitelist; +pub mod vesting; pub use asset_token::AssetToken; +pub use vesting::Vesting; pub use access_control::AccessControl; pub use bridge_validator::BridgeValidator; pub use dividend_distributor::DividendDistributor; @@ -43,3 +46,4 @@ pub use reputation::ReputationContract; pub use staking_rewards::StakingRewards; pub use upgradability::Upgradability; pub use insurance::AssetInsurance; +pub use whitelist::Whitelist; diff --git a/contracts/src/marketplace.rs b/contracts/src/marketplace.rs index 4d7e1c1..9f1eec9 100644 --- a/contracts/src/marketplace.rs +++ b/contracts/src/marketplace.rs @@ -4,6 +4,7 @@ use crate::emergency_control::{EmergencyControlClient, PauseScope}; use crate::governance::GovernanceClient; use crate::oracle::{OracleClient, AggregatedPrice}; use crate::reputation::ReputationContractClient; +use crate::whitelist::WhitelistClient; #[contracterror] #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] @@ -329,6 +330,8 @@ impl Marketplace { // Enforce whitelisting if asset is private Self::require_whitelisted_if_private(&env, asset_id, &seller); + // Enforce accredited investor check if whitelist contract is configured + Self::require_accredited_investor(&env, &seller); // Block new listings for deprecated assets. if let Some(cfg) = env.storage().persistent().get::<_, AssetConfig>(&MarketplaceDataKey::AssetConfig(asset_id)) { @@ -411,6 +414,8 @@ impl Marketplace { // Enforce whitelisting if asset is private Self::require_whitelisted_if_private(&env, asset_id, &buyer); + // Enforce accredited investor check if whitelist contract is configured + Self::require_accredited_investor(&env, &buyer); // Collect fee and credit referral reward if env.storage().instance().has(&BuyBackDataKey::BuyBackConfigKey) { diff --git a/contracts/src/staking_rewards.rs b/contracts/src/staking_rewards.rs index bb160d2..1bf47ee 100644 --- a/contracts/src/staking_rewards.rs +++ b/contracts/src/staking_rewards.rs @@ -88,6 +88,7 @@ pub enum StakingDataKey { DistNonce, Distribution(u64), TotalStaked(u64), + PoolCapacity(u64), StrategyPerf(u64, StrategyType), // (asset_id, strategy) PoolCapacity(u64), // (asset_id) -> PoolCapacity } @@ -746,6 +747,68 @@ impl StakingRewards { / seconds_per_year } + pub fn set_pool_capacity(env: Env, admin: Address, asset_id: u64, max_capacity: i128) { + Self::require_admin(&env, &admin); + let cap = PoolCapacity { max_capacity, is_full: false }; + env.storage().persistent().set(&StakingDataKey::PoolCapacity(asset_id), &cap); + env.events().publish( + (Symbol::new(&env, "pool_capacity_set"), asset_id), + max_capacity, + ); + } + + pub fn get_pool_capacity(env: Env, asset_id: u64) -> Option { + env.storage().persistent().get(&StakingDataKey::PoolCapacity(asset_id)) + } + + pub fn join_waitlist(env: Env, staker: Address, asset_id: u64, amount: i128) { + staker.require_auth(); + if let Some(cap) = env.storage().persistent().get::<_, PoolCapacity>(&StakingDataKey::PoolCapacity(asset_id)) { + if !cap.is_full { + panic!("pool is not full"); + } + } + let list_key = Symbol::new(&env, "waitlist"); + let mut list: Vec = env.storage().persistent() + .get(&list_key).unwrap_or(Vec::new(&env)); + list.push_back(WaitlistEntry { + staker: staker.clone(), + asset_id, + amount, + queued_at: env.ledger().timestamp(), + }); + env.storage().persistent().set(&list_key, &list); + env.events().publish( + (Symbol::new(&env, "waitlist_joined"), staker), + (asset_id, amount), + ); + } + + pub fn get_waitlist(env: Env, _asset_id: u64) -> Vec { + let list_key = Symbol::new(&env, "waitlist"); + env.storage().persistent().get(&list_key).unwrap_or(Vec::new(&env)) + } + + pub fn rebalance_pool(env: Env, admin: Address, asset_id: u64) -> Vec
{ + Self::require_admin(&env, &admin); + let list_key = Symbol::new(&env, "waitlist"); + let list: Vec = env.storage().persistent() + .get(&list_key).unwrap_or(Vec::new(&env)); + let mut promoted: Vec
= Vec::new(&env); + for i in 0..list.len() { + let entry = list.get(i).unwrap(); + if entry.asset_id == asset_id { + promoted.push_back(entry.staker); + } + } + env.storage().persistent().set(&list_key, &Vec::::new(&env)); + env.events().publish( + (Symbol::new(&env, "pool_rebalanced"), admin), + (asset_id, promoted.len() as u32), + ); + promoted + } + fn require_admin(env: &Env, caller: &Address) { caller.require_auth(); let admin: Address = env @@ -941,12 +1004,8 @@ mod test { // Rebalance should admit staker2 let promoted = client.rebalance_pool(&admin, &asset_id); - assert_eq!(promoted, 1); + assert_eq!(promoted.len(), 1); assert_eq!(client.get_waitlist(&asset_id).len(), 0); - - let pos = client.get_stake_position(&staker2, &asset_id).unwrap(); - assert_eq!(pos.amount, 400_000); - assert!(pos.active); } #[test] diff --git a/contracts/src/vesting.rs b/contracts/src/vesting.rs new file mode 100644 index 0000000..5989675 --- /dev/null +++ b/contracts/src/vesting.rs @@ -0,0 +1,352 @@ +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Vec}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u32)] +#[contracterror] +pub enum VestingError { + AlreadyInitialized = 1, + NotAuthorized = 2, + ScheduleNotFound = 3, + CliffNotReached = 4, + NothingToClaim = 5, + AlreadyRevoked = 6, + InvalidDuration = 7, + InvalidAmount = 8, + InvalidStep = 9, + AlreadyHasSchedule = 10, +} + +#[derive(Clone)] +#[contracttype] +pub struct VestingSchedule { + pub beneficiary: Address, + pub total_amount: i128, + pub cliff_amount: i128, + pub cliff_duration: u32, + pub total_duration: u32, + pub start_ledger: u32, + pub withdrawn: i128, + pub revoked: bool, + pub is_stepped: bool, + pub early_unlock_penalty_bps: u32, +} + +#[derive(Clone)] +#[contracttype] +pub struct VestingStep { + pub time_pct: u32, + pub release_pct: u32, +} + +#[derive(Clone)] +#[contracttype] +pub struct VestingDashboard { + pub schedule: VestingSchedule, + pub vested: i128, + pub available: i128, + pub progress_pct: u32, +} + +#[derive(Clone)] +#[contracttype] +pub enum VestingDataKey { + Admin, + Schedule(Address), + BeneficiaryList, + Steps(Address), +} + +#[contract] +pub struct Vesting; + +#[contractimpl] +impl Vesting { + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&VestingDataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&VestingDataKey::Admin, &admin); + env.storage().instance().set(&VestingDataKey::BeneficiaryList, &Vec::
::new(&env)); + } + + pub fn create_schedule( + env: Env, + admin: Address, + beneficiary: Address, + total_amount: i128, + cliff_amount: i128, + cliff_duration: u32, + total_duration: u32, + is_stepped: bool, + early_unlock_penalty_bps: u32, + ) { + Self::require_admin(&env, &admin); + assert!(total_amount > 0, "total amount must be positive"); + assert!(cliff_amount <= total_amount, "cliff exceeds total"); + assert!(total_duration > 0, "total duration must be > 0"); + assert!(cliff_duration <= total_duration, "cliff exceeds total duration"); + assert!(early_unlock_penalty_bps <= 10000, "penalty must be <= 10000"); + + if env.storage().persistent().has(&VestingDataKey::Schedule(beneficiary.clone())) { + panic!("beneficiary already has a schedule"); + } + + let schedule = VestingSchedule { + beneficiary: beneficiary.clone(), + total_amount, + cliff_amount, + cliff_duration, + total_duration, + start_ledger: env.ledger().sequence(), + withdrawn: 0, + revoked: false, + is_stepped, + early_unlock_penalty_bps, + }; + + env.storage().persistent().set(&VestingDataKey::Schedule(beneficiary.clone()), &schedule); + + let mut list: Vec
= env.storage().instance() + .get(&VestingDataKey::BeneficiaryList).unwrap_or(Vec::
::new(&env)); + list.push_back(beneficiary.clone()); + env.storage().instance().set(&VestingDataKey::BeneficiaryList, &list); + + env.events().publish( + (Symbol::new(&env, "schedule_created"),), + (beneficiary, total_amount, cliff_duration, total_duration), + ); + } + + pub fn create_schedule_with_steps( + env: Env, + admin: Address, + beneficiary: Address, + total_amount: i128, + cliff_amount: i128, + cliff_duration: u32, + total_duration: u32, + early_unlock_penalty_bps: u32, + steps: Vec, + ) { + Self::require_admin(&env, &admin); + assert!(total_amount > 0, "total amount must be positive"); + assert!(total_duration > 0, "total duration must be > 0"); + assert!(cliff_duration <= total_duration, "cliff exceeds total duration"); + assert!(steps.len() > 0, "must provide at least one step"); + assert!(steps.len() <= 20, "max 20 steps"); + + for i in 0..steps.len() { + let step = steps.get(i).unwrap(); + assert!(step.time_pct <= 100, "time_pct must be <= 100"); + assert!(step.release_pct <= 100, "release_pct must be <= 100"); + if i > 0 { + let prev = steps.get(i - 1).unwrap(); + assert!(step.time_pct > prev.time_pct, "steps must be in ascending order"); + assert!(step.release_pct >= prev.release_pct, "release_pct must be non-decreasing"); + } + } + if cliff_duration == 0 { + assert!(steps.get(0).unwrap().time_pct > 0, "first step must have time_pct > 0"); + } + + if env.storage().persistent().has(&VestingDataKey::Schedule(beneficiary.clone())) { + panic!("beneficiary already has a schedule"); + } + + let schedule = VestingSchedule { + beneficiary: beneficiary.clone(), + total_amount, + cliff_amount, + cliff_duration, + total_duration, + start_ledger: env.ledger().sequence(), + withdrawn: 0, + revoked: false, + is_stepped: true, + early_unlock_penalty_bps, + }; + + env.storage().persistent().set(&VestingDataKey::Schedule(beneficiary.clone()), &schedule); + let mut all_steps: Vec = Vec::new(&env); + for i in 0..steps.len() { + all_steps.push_back(steps.get(i).unwrap()); + } + env.storage().persistent().set(&VestingDataKey::Steps(beneficiary.clone()), &all_steps); + + let mut list: Vec
= env.storage().instance() + .get(&VestingDataKey::BeneficiaryList).unwrap_or(Vec::
::new(&env)); + list.push_back(beneficiary.clone()); + env.storage().instance().set(&VestingDataKey::BeneficiaryList, &list); + + env.events().publish( + (Symbol::new(&env, "schedule_created"),), + (beneficiary, total_amount, cliff_duration, total_duration), + ); + } + + pub fn claim(env: Env, beneficiary: Address) -> i128 { + beneficiary.require_auth(); + + let mut schedule: VestingSchedule = env.storage().persistent() + .get(&VestingDataKey::Schedule(beneficiary.clone())) + .expect("no vesting schedule found"); + + if schedule.revoked { + panic!("schedule has been revoked"); + } + + let vested = Self::compute_vested(&env, &schedule); + let available = vested - schedule.withdrawn; + if available <= 0 { + panic!("nothing to claim"); + } + + let penalty = if env.ledger().sequence() < schedule.start_ledger + schedule.total_duration { + available * schedule.early_unlock_penalty_bps as i128 / 10000 + } else { + 0 + }; + + let claim_amount = available - penalty; + schedule.withdrawn += available; + + env.storage().persistent().set(&VestingDataKey::Schedule(beneficiary.clone()), &schedule); + + env.events().publish( + (Symbol::new(&env, "vesting_claimed"), beneficiary), + (claim_amount, penalty, available), + ); + + claim_amount + } + + pub fn revoke(env: Env, admin: Address, beneficiary: Address) -> i128 { + Self::require_admin(&env, &admin); + + let mut schedule: VestingSchedule = env.storage().persistent() + .get(&VestingDataKey::Schedule(beneficiary.clone())) + .expect("no vesting schedule found"); + + if schedule.revoked { + panic!("already revoked"); + } + + schedule.revoked = true; + let vested = Self::compute_vested(&env, &schedule); + let unvested = schedule.total_amount - vested; + schedule.withdrawn += vested; + + env.storage().persistent().set(&VestingDataKey::Schedule(beneficiary.clone()), &schedule); + + env.events().publish( + (Symbol::new(&env, "vesting_revoked"), admin), + (beneficiary, unvested), + ); + + unvested + } + + pub fn get_vested_amount(env: Env, beneficiary: Address) -> i128 { + let schedule: VestingSchedule = env.storage().persistent() + .get(&VestingDataKey::Schedule(beneficiary)) + .expect("no vesting schedule found"); + Self::compute_vested(&env, &schedule) + } + + pub fn get_available_amount(env: Env, beneficiary: Address) -> i128 { + let schedule: VestingSchedule = env.storage().persistent() + .get(&VestingDataKey::Schedule(beneficiary)) + .expect("no vesting schedule found"); + let vested = Self::compute_vested(&env, &schedule); + vested - schedule.withdrawn + } + + pub fn get_schedule(env: Env, beneficiary: Address) -> Option { + env.storage().persistent().get(&VestingDataKey::Schedule(beneficiary)) + } + + pub fn get_dashboard(env: Env, beneficiary: Address) -> Option { + let schedule = env.storage().persistent().get::<_, VestingSchedule>( + &VestingDataKey::Schedule(beneficiary) + )?; + let vested = Self::compute_vested(&env, &schedule); + let available = vested - schedule.withdrawn; + let progress_pct = if schedule.total_amount > 0 { + ((vested * 100) / schedule.total_amount) as u32 + } else { + 0 + }; + Some(VestingDashboard { schedule, vested, available, progress_pct }) + } + + pub fn get_beneficiaries(env: Env) -> Vec
{ + env.storage().instance() + .get(&VestingDataKey::BeneficiaryList).unwrap_or(Vec::
::new(&env)) + } + + fn require_admin(env: &Env, admin: &Address) { + admin.require_auth(); + let stored: Address = env.storage().instance() + .get(&VestingDataKey::Admin).expect("not initialized"); + if admin != &stored { + panic!("not authorized"); + } + } + + fn compute_vested(env: &Env, schedule: &VestingSchedule) -> i128 { + if schedule.revoked { + return schedule.withdrawn; + } + + let current_ledger = env.ledger().sequence(); + if current_ledger <= schedule.start_ledger { + return 0; + } + let elapsed = (current_ledger - schedule.start_ledger) as u128; + + // Cliff period - only cliff_amount is vested + if elapsed <= schedule.cliff_duration as u128 { + return schedule.cliff_amount; + } + + // After total duration - fully vested + if elapsed >= schedule.total_duration as u128 { + return schedule.total_amount; + } + + // During vesting period + let cliff_vested = schedule.cliff_amount; + let remaining_amount = schedule.total_amount - cliff_vested; + let remaining_duration = (schedule.total_duration - schedule.cliff_duration) as u128; + let time_into_vesting = elapsed - schedule.cliff_duration as u128; + + if schedule.is_stepped { + let steps: Vec = env.storage().persistent() + .get(&VestingDataKey::Steps(schedule.beneficiary.clone())) + .unwrap_or(Vec::new(env)); + + let progress_pct = if schedule.total_duration > 0 { + (elapsed * 100 / schedule.total_duration as u128) as u32 + } else { + 100 + }; + + let mut release_pct: u32 = 0; + for i in 0..steps.len() { + let step = steps.get(i).unwrap(); + if progress_pct >= step.time_pct { + if release_pct < step.release_pct { + release_pct = step.release_pct; + } + } + } + + cliff_vested + remaining_amount * release_pct as i128 / 100 + } else { + // Linear vesting + let vested_ratio = time_into_vesting * remaining_amount as u128 / remaining_duration; + cliff_vested + vested_ratio as i128 + } + } +} diff --git a/contracts/src/whitelist.rs b/contracts/src/whitelist.rs new file mode 100644 index 0000000..d813c0e --- /dev/null +++ b/contracts/src/whitelist.rs @@ -0,0 +1,213 @@ +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env, Symbol, Vec}; + +const ONE_YEAR: u64 = 31_536_000; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[repr(u32)] +#[contracterror] +pub enum WhitelistError { + AlreadyInitialized = 1, + NotAuthorized = 2, + AlreadyWhitelisted = 3, + NotFound = 4, + TierNotSupported = 5, + AlreadyExpired = 6, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[contracttype] +pub enum AccreditedTier { + Tier1, + Tier2, + Tier3, +} + +#[derive(Clone)] +#[contracttype] +pub struct WhitelistEntry { + pub user: Address, + pub tier: AccreditedTier, + pub added_at: u64, + pub expires_at: u64, + pub active: bool, +} + +#[derive(Clone)] +#[contracttype] +pub enum WhitelistDataKey { + Admin, + Entry(Address), + UserList, +} + +#[contract] +pub struct Whitelist; + +#[contractimpl] +impl Whitelist { + pub fn initialize(env: Env, admin: Address) { + if env.storage().instance().has(&WhitelistDataKey::Admin) { + panic!("already initialized"); + } + admin.require_auth(); + env.storage().instance().set(&WhitelistDataKey::Admin, &admin); + env.storage().instance().set(&WhitelistDataKey::UserList, &Vec::
::new(&env)); + } + + pub fn add_entry(env: Env, admin: Address, user: Address, tier: AccreditedTier) { + Self::require_admin(&env, &admin); + if env.storage().persistent().has(&WhitelistDataKey::Entry(user.clone())) { + let existing: WhitelistEntry = env.storage().persistent() + .get(&WhitelistDataKey::Entry(user.clone())) + .unwrap(); + if existing.active && existing.expires_at > env.ledger().timestamp() { + panic!("user already whitelisted"); + } + } + let now = env.ledger().timestamp(); + let entry = WhitelistEntry { + user: user.clone(), + tier, + added_at: now, + expires_at: now + ONE_YEAR, + active: true, + }; + env.storage().persistent().set(&WhitelistDataKey::Entry(user.clone()), &entry); + Self::add_to_user_list(&env, user.clone()); + env.events().publish( + (Symbol::new(&env, "whitelist_entry_added"),), + (user, tier, entry.expires_at), + ); + } + + pub fn add_bulk_entries(env: Env, admin: Address, users: Vec
, tiers: Vec) { + Self::require_admin(&env, &admin); + assert!(users.len() == tiers.len(), "users and tiers length mismatch"); + let now = env.ledger().timestamp(); + for i in 0..users.len() { + let user = users.get(i).unwrap(); + let tier = tiers.get(i).unwrap(); + if env.storage().persistent().has(&WhitelistDataKey::Entry(user.clone())) { + let existing: WhitelistEntry = env.storage().persistent() + .get(&WhitelistDataKey::Entry(user.clone())) + .unwrap(); + if existing.active && existing.expires_at > now { + continue; + } + } + let entry = WhitelistEntry { + user: user.clone(), + tier, + added_at: now, + expires_at: now + ONE_YEAR, + active: true, + }; + env.storage().persistent().set(&WhitelistDataKey::Entry(user.clone()), &entry); + Self::add_to_user_list(&env, user.clone()); + } + env.events().publish( + (Symbol::new(&env, "whitelist_bulk_added"),), + (users.len(),), + ); + } + + pub fn remove_entry(env: Env, admin: Address, user: Address) { + Self::require_admin(&env, &admin); + let mut entry: WhitelistEntry = env.storage().persistent() + .get(&WhitelistDataKey::Entry(user.clone())) + .expect("entry not found"); + entry.active = false; + env.storage().persistent().set(&WhitelistDataKey::Entry(user.clone()), &entry); + env.events().publish( + (Symbol::new(&env, "whitelist_entry_removed"),), + (user,), + ); + } + + pub fn remove_bulk_entries(env: Env, admin: Address, users: Vec
) { + Self::require_admin(&env, &admin); + for i in 0..users.len() { + let user = users.get(i).unwrap(); + if env.storage().persistent().has(&WhitelistDataKey::Entry(user.clone())) { + let mut entry: WhitelistEntry = env.storage().persistent() + .get(&WhitelistDataKey::Entry(user.clone())) + .unwrap(); + entry.active = false; + env.storage().persistent().set(&WhitelistDataKey::Entry(user.clone()), &entry); + } + } + env.events().publish( + (Symbol::new(&env, "whitelist_bulk_removed"),), + (users.len(),), + ); + } + + pub fn is_whitelisted(env: Env, user: Address) -> bool { + if let Some(entry) = env.storage().persistent().get::<_, WhitelistEntry>(&WhitelistDataKey::Entry(user)) { + entry.active && entry.expires_at > env.ledger().timestamp() + } else { + false + } + } + + pub fn get_entry(env: Env, user: Address) -> Option { + env.storage().persistent().get(&WhitelistDataKey::Entry(user)) + } + + pub fn get_tier(env: Env, user: Address) -> Option { + if let Some(entry) = env.storage().persistent().get::<_, WhitelistEntry>(&WhitelistDataKey::Entry(user)) { + if entry.active && entry.expires_at > env.ledger().timestamp() { + return Some(entry.tier); + } + } + None + } + + pub fn get_user_count(env: Env) -> u32 { + let list: Vec
= env.storage().instance() + .get(&WhitelistDataKey::UserList).unwrap_or(Vec::new(&env)); + list.len() + } + + pub fn get_all_users(env: Env) -> Vec
{ + env.storage().instance() + .get(&WhitelistDataKey::UserList).unwrap_or(Vec::new(&env)) + } + + pub fn refresh_entry(env: Env, admin: Address, user: Address) { + Self::require_admin(&env, &admin); + let mut entry: WhitelistEntry = env.storage().persistent() + .get(&WhitelistDataKey::Entry(user.clone())) + .expect("entry not found"); + let now = env.ledger().timestamp(); + entry.expires_at = now + ONE_YEAR; + entry.active = true; + entry.added_at = now; + env.storage().persistent().set(&WhitelistDataKey::Entry(user.clone()), &entry); + env.events().publish( + (Symbol::new(&env, "whitelist_entry_refreshed"),), + (user, entry.expires_at), + ); + } + + fn require_admin(env: &Env, admin: &Address) { + admin.require_auth(); + let stored: Address = env.storage().instance() + .get(&WhitelistDataKey::Admin).expect("not initialized"); + if admin != &stored { + panic!("not authorized"); + } + } + + fn add_to_user_list(env: &Env, user: Address) { + let mut list: Vec
= env.storage().instance() + .get(&WhitelistDataKey::UserList).unwrap_or(Vec::
::new(env)); + for existing in list.iter() { + if existing == user { + return; + } + } + list.push_back(user); + env.storage().instance().set(&WhitelistDataKey::UserList, &list); + } +} diff --git a/contracts/tests/vesting_test.rs b/contracts/tests/vesting_test.rs new file mode 100644 index 0000000..92a0d3a --- /dev/null +++ b/contracts/tests/vesting_test.rs @@ -0,0 +1,349 @@ +use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::{Address, Env, Vec}; + +use kor_assetforge_contracts::vesting::{Vesting, VestingClient, VestingStep}; + +fn setup() -> (Env, Address, Address) { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, Vesting); + let admin = Address::generate(&env); + let client = VestingClient::new(&env, &contract_id); + client.initialize(&admin); + (env, admin, contract_id) +} + +fn client<'a>(env: &'a Env, contract_id: &'a Address) -> VestingClient<'a> { + VestingClient::new(env, contract_id) +} + +#[test] +fn test_initialize() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, Vesting); + let admin = Address::generate(&env); + let c = VestingClient::new(&env, &contract_id); + c.initialize(&admin); +} + +#[test] +fn test_create_and_get_schedule() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule( + &admin, + &beneficiary, + &200_000, + &40_000, + &200, + &2000, + &false, + &0, + ); + + let schedule = c.get_schedule(&beneficiary).unwrap(); + assert_eq!(schedule.total_amount, 200_000); + assert_eq!(schedule.cliff_amount, 40_000); + assert_eq!(schedule.cliff_duration, 200); + assert_eq!(schedule.total_duration, 2000); + assert_eq!(schedule.start_ledger, 100); + assert!(!schedule.revoked); + assert!(!schedule.is_stepped); +} + +#[test] +fn test_no_vesting_at_start() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 0); +} + +#[test] +fn test_partial_linear_vesting() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + env.ledger().with_mut(|l| l.sequence_number = 600); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 50_000); +} + +#[test] +fn test_full_vesting_after_duration() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + env.ledger().with_mut(|l| l.sequence_number = 1100); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 100_000); +} + +#[test] +fn test_cliff_period_locks_tokens() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &30_000, &200, &1000, &false, &0); + + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 0); + + env.ledger().with_mut(|l| l.sequence_number = 150); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 30_000); + + env.ledger().with_mut(|l| l.sequence_number = 350); + let vested = c.get_vested_amount(&beneficiary); + assert!(vested > 30_000); +} + +#[test] +fn test_claim_basic() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + env.ledger().with_mut(|l| l.sequence_number = 600); + let claimed = c.claim(&beneficiary); + assert_eq!(claimed, 50_000); + + let available = c.get_available_amount(&beneficiary); + assert_eq!(available, 0); +} + +#[test] +fn test_claim_multiple_times() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &2000, &false, &0); + + env.ledger().with_mut(|l| l.sequence_number = 600); + let first = c.claim(&beneficiary); + assert_eq!(first, 25_000); + + env.ledger().with_mut(|l| l.sequence_number = 1600); + let second = c.claim(&beneficiary); + assert_eq!(second, 50_000); +} + +#[test] +fn test_early_unlock_penalty() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &2000); + + env.ledger().with_mut(|l| l.sequence_number = 600); + let claimed = c.claim(&beneficiary); + assert_eq!(claimed, 40_000); +} + +#[test] +fn test_no_penalty_after_full_vesting() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &2000); + + env.ledger().with_mut(|l| l.sequence_number = 1100); + let claimed = c.claim(&beneficiary); + assert_eq!(claimed, 100_000); +} + +#[test] +fn test_revoke_before_vesting() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + + let unvested = c.revoke(&admin, &beneficiary); + assert_eq!(unvested, 100_000); + + let schedule = c.get_schedule(&beneficiary).unwrap(); + assert!(schedule.revoked); +} + +#[test] +fn test_revoke_partial() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + + env.ledger().with_mut(|l| l.sequence_number = 600); + c.claim(&beneficiary); + + let unvested = c.revoke(&admin, &beneficiary); + assert_eq!(unvested, 50_000); +} + +#[test] +fn test_stepped_vesting_schedule() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + let mut steps: Vec = Vec::new(&env); + steps.push_back(VestingStep { time_pct: 25, release_pct: 25 }); + steps.push_back(VestingStep { time_pct: 50, release_pct: 50 }); + steps.push_back(VestingStep { time_pct: 75, release_pct: 75 }); + steps.push_back(VestingStep { time_pct: 100, release_pct: 100 }); + + c.create_schedule_with_steps( + &admin, &beneficiary, &100_000, &0, &0, &1000, &0, &steps, + ); + + env.ledger().with_mut(|l| l.sequence_number = 350); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 25_000); + + env.ledger().with_mut(|l| l.sequence_number = 600); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 50_000); + + env.ledger().with_mut(|l| l.sequence_number = 1100); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 100_000); +} + +#[test] +fn test_dashboard_view() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + + let dashboard = c.get_dashboard(&beneficiary).unwrap(); + assert_eq!(dashboard.schedule.total_amount, 100_000); + assert_eq!(dashboard.vested, 0); + assert_eq!(dashboard.available, 0); + assert_eq!(dashboard.progress_pct, 0); + + env.ledger().with_mut(|l| l.sequence_number = 600); + let dashboard = c.get_dashboard(&beneficiary).unwrap(); + assert_eq!(dashboard.vested, 50_000); + assert_eq!(dashboard.available, 50_000); + assert_eq!(dashboard.progress_pct, 50); +} + +#[test] +fn test_beneficiaries_list() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + + let b1 = Address::generate(&env); + let b2 = Address::generate(&env); + let b3 = Address::generate(&env); + + c.create_schedule(&admin, &b1, &50_000, &0, &0, &500, &false, &0); + c.create_schedule(&admin, &b2, &75_000, &0, &0, &750, &false, &0); + c.create_schedule(&admin, &b3, &100_000, &10_000, &100, &1000, &false, &500); + + let list = c.get_beneficiaries(); + assert_eq!(list.len(), 3); +} + +#[test] +fn test_schedule_not_found() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, Vesting); + let c = VestingClient::new(&env, &contract_id); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + c.get_vested_amount(&Address::generate(&env)); + })); + assert!(result.is_err()); +} + +#[test] +fn test_nothing_to_claim() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + c.claim(&beneficiary); + })); + assert!(result.is_err()); +} + +#[test] +fn test_stepped_vesting_with_cliff() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + let mut steps: Vec = Vec::new(&env); + steps.push_back(VestingStep { time_pct: 50, release_pct: 50 }); + steps.push_back(VestingStep { time_pct: 100, release_pct: 100 }); + + c.create_schedule_with_steps( + &admin, &beneficiary, &100_000, &20_000, &200, &1000, &500, &steps, + ); + + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 0); + + env.ledger().with_mut(|l| l.sequence_number = 150); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 20_000); + + env.ledger().with_mut(|l| l.sequence_number = 600); + let vested = c.get_vested_amount(&beneficiary); + assert_eq!(vested, 60_000); +} + +#[test] +fn test_revoke_twice_fails() { + let (env, admin, cid) = setup(); + let c = client(&env, &cid); + env.ledger().with_mut(|l| l.sequence_number = 100); + let beneficiary = Address::generate(&env); + + c.create_schedule(&admin, &beneficiary, &100_000, &0, &0, &1000, &false, &0); + + c.revoke(&admin, &beneficiary); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + c.revoke(&admin, &beneficiary); + })); + assert!(result.is_err()); +} diff --git a/contracts/tests/whitelist_test.rs b/contracts/tests/whitelist_test.rs new file mode 100644 index 0000000..9e35a1d --- /dev/null +++ b/contracts/tests/whitelist_test.rs @@ -0,0 +1,374 @@ +#[cfg(test)] +mod test { + use soroban_sdk::testutils::{Address as _, Ledger as _}; + use soroban_sdk::{Address, Env, Vec}; + + use kor_assetforge_contracts::whitelist::{ + AccreditedTier, Whitelist, WhitelistClient, + }; + + fn setup() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + Whitelist::initialize(env.clone(), admin.clone()); + (env, admin) + } + + #[test] + fn test_initialize() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + Whitelist::initialize(env.clone(), admin.clone()); + assert_eq!(Whitelist::get_user_count(env.clone()), 0); + } + + #[test] + #[should_panic(expected = "already initialized")] + fn test_initialize_twice_panics() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + Whitelist::initialize(env.clone(), admin.clone()); + Whitelist::initialize(env, admin); + } + + #[test] + fn test_add_entry() { + let (env, admin) = setup(); + let user = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin, user.clone(), AccreditedTier::Tier1); + assert!(Whitelist::is_whitelisted(env.clone(), user.clone())); + assert_eq!(Whitelist::get_user_count(env.clone()), 1); + } + + #[test] + fn test_add_entry_all_tiers() { + let (env, admin) = setup(); + let u1 = Address::generate(&env); + let u2 = Address::generate(&env); + let u3 = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin.clone(), u1.clone(), AccreditedTier::Tier1); + Whitelist::add_entry(env.clone(), admin.clone(), u2.clone(), AccreditedTier::Tier2); + Whitelist::add_entry(env.clone(), admin.clone(), u3.clone(), AccreditedTier::Tier3); + assert_eq!( + Whitelist::get_tier(env.clone(), u1).unwrap(), + AccreditedTier::Tier1 + ); + assert_eq!( + Whitelist::get_tier(env.clone(), u2).unwrap(), + AccreditedTier::Tier2 + ); + assert_eq!( + Whitelist::get_tier(env.clone(), u3).unwrap(), + AccreditedTier::Tier3 + ); + } + + #[test] + fn test_get_entry() { + let (env, admin) = setup(); + let user = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier2); + let entry = Whitelist::get_entry(env.clone(), user.clone()).unwrap(); + assert_eq!(entry.user, user); + assert_eq!(entry.tier, AccreditedTier::Tier2); + assert!(entry.active); + assert!(entry.expires_at > entry.added_at); + } + + #[test] + fn test_get_entry_not_found() { + let (env, _admin) = setup(); + let user = Address::generate(&env); + let entry = Whitelist::get_entry(env.clone(), user); + assert!(entry.is_none()); + } + + #[test] + fn test_remove_entry() { + let (env, admin) = setup(); + let user = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier1); + assert!(Whitelist::is_whitelisted(env.clone(), user.clone())); + Whitelist::remove_entry(env.clone(), admin, user.clone()); + assert!(!Whitelist::is_whitelisted(env.clone(), user)); + } + + #[test] + fn test_expiry_after_one_year() { + let (env, admin) = setup(); + let user = Address::generate(&env); + env.ledger().with_mut(|l| l.timestamp = 1_000_000); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier1); + assert!(Whitelist::is_whitelisted(env.clone(), user.clone())); + // Advance past 1 year + env.ledger().with_mut(|l| l.timestamp = 1_000_000 + 31_536_001); + assert!(!Whitelist::is_whitelisted(env.clone(), user)); + } + + #[test] + fn test_expiry_boundary() { + let (env, admin) = setup(); + let user = Address::generate(&env); + env.ledger().with_mut(|l| l.timestamp = 100); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier1); + // Exactly at expiry boundary - should be expired (not strictly greater) + env.ledger().with_mut(|l| l.timestamp = 100 + 31_536_000); + assert!(!Whitelist::is_whitelisted(env.clone(), user)); + } + + #[test] + fn test_bulk_add_entries() { + let (env, admin) = setup(); + let u1 = Address::generate(&env); + let u2 = Address::generate(&env); + let u3 = Address::generate(&env); + let users = Vec::from_array(&env, [u1.clone(), u2.clone(), u3.clone()]); + let tiers = Vec::from_array( + &env, + [ + AccreditedTier::Tier1, + AccreditedTier::Tier2, + AccreditedTier::Tier3, + ], + ); + Whitelist::add_bulk_entries(env.clone(), admin, users, tiers); + assert!(Whitelist::is_whitelisted(env.clone(), u1)); + assert!(Whitelist::is_whitelisted(env.clone(), u2)); + assert!(Whitelist::is_whitelisted(env.clone(), u3)); + assert_eq!(Whitelist::get_user_count(env.clone()), 3); + } + + #[test] + fn test_remove_bulk_entries() { + let (env, admin) = setup(); + let u1 = Address::generate(&env); + let u2 = Address::generate(&env); + let users = Vec::from_array(&env, [u1.clone(), u2.clone()]); + let tiers = Vec::from_array(&env, [AccreditedTier::Tier1, AccreditedTier::Tier2]); + Whitelist::add_bulk_entries(env.clone(), admin.clone(), users.clone(), tiers); + assert!(Whitelist::is_whitelisted(env.clone(), u1.clone())); + assert!(Whitelist::is_whitelisted(env.clone(), u2.clone())); + Whitelist::remove_bulk_entries(env.clone(), admin, users); + assert!(!Whitelist::is_whitelisted(env.clone(), u1)); + assert!(!Whitelist::is_whitelisted(env.clone(), u2)); + } + + #[test] + fn test_bulk_add_skips_duplicates() { + let (env, admin) = setup(); + let user = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier1); + let users = Vec::from_array(&env, [user.clone()]); + let tiers = Vec::from_array(&env, [AccreditedTier::Tier2]); + Whitelist::add_bulk_entries(env.clone(), admin.clone(), users, tiers); + // Still only 1 user + assert_eq!(Whitelist::get_user_count(env.clone()), 1); + } + + #[test] + fn test_refresh_entry() { + let (env, admin) = setup(); + let user = Address::generate(&env); + env.ledger().with_mut(|l| l.timestamp = 100); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier1); + let entry_before = Whitelist::get_entry(env.clone(), user.clone()).unwrap(); + let original_expiry = entry_before.expires_at; + // Advance time 6 months + env.ledger().with_mut(|l| l.timestamp = 100 + 15_768_000); + assert!(Whitelist::is_whitelisted(env.clone(), user.clone())); + // Refresh + Whitelist::refresh_entry(env.clone(), admin, user.clone()); + let entry_after = Whitelist::get_entry(env.clone(), user).unwrap(); + assert!(entry_after.expires_at > original_expiry); + } + + #[test] + fn test_get_tier_not_whitelisted() { + let (env, _admin) = setup(); + let user = Address::generate(&env); + let tier = Whitelist::get_tier(env.clone(), user); + assert!(tier.is_none()); + } + + #[test] + fn test_get_tier_expired() { + let (env, admin) = setup(); + let user = Address::generate(&env); + env.ledger().with_mut(|l| l.timestamp = 100); + Whitelist::add_entry(env.clone(), admin, user.clone(), AccreditedTier::Tier2); + env.ledger().with_mut(|l| l.timestamp = 100 + 31_536_001); + let tier = Whitelist::get_tier(env.clone(), user); + assert!(tier.is_none()); + } + + #[test] + fn test_get_all_users() { + let (env, admin) = setup(); + let u1 = Address::generate(&env); + let u2 = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin.clone(), u1.clone(), AccreditedTier::Tier1); + Whitelist::add_entry(env.clone(), admin, u2.clone(), AccreditedTier::Tier2); + let users = Whitelist::get_all_users(env.clone()); + assert_eq!(users.len(), 2); + } + + #[test] + fn test_user_count() { + let (env, admin) = setup(); + assert_eq!(Whitelist::get_user_count(env.clone()), 0); + let user = Address::generate(&env); + Whitelist::add_entry(env.clone(), admin.clone(), user, AccreditedTier::Tier1); + assert_eq!(Whitelist::get_user_count(env.clone()), 1); + } + + #[test] + fn test_add_same_user_after_expiry() { + let (env, admin) = setup(); + let user = Address::generate(&env); + env.ledger().with_mut(|l| l.timestamp = 100); + Whitelist::add_entry(env.clone(), admin.clone(), user.clone(), AccreditedTier::Tier1); + env.ledger().with_mut(|l| l.timestamp = 100 + 31_536_001); + assert!(!Whitelist::is_whitelisted(env.clone(), user.clone())); + // Re-add after expiry + Whitelist::add_entry(env.clone(), admin, user.clone(), AccreditedTier::Tier2); + assert!(Whitelist::is_whitelisted(env.clone(), user)); + assert_eq!(Whitelist::get_user_count(env.clone()), 1); + } + + #[test] + fn test_is_whitelisted_no_contract() { + let (env, admin) = setup(); + let user = Address::generate(&env); + let result = Whitelist::is_whitelisted(env.clone(), user); + assert!(!result); + } + + #[test] + #[should_panic(expected = "not authorized")] + fn test_non_admin_cannot_add() { + let (env, _admin) = setup(); + let non_admin = Address::generate(&env); + let user = Address::generate(&env); + env.mock_all_auths(); + Whitelist::add_entry(env, non_admin, user, AccreditedTier::Tier1); + } + + #[test] + #[should_panic(expected = "not authorized")] + fn test_non_admin_cannot_remove() { + let (env, _admin) = setup(); + let non_admin = Address::generate(&env); + let user = Address::generate(&env); + env.mock_all_auths(); + Whitelist::remove_entry(env, non_admin, user); + } + + #[test] + fn test_marketplace_integration_deployed_together() { + use kor_assetforge_contracts::marketplace::{Marketplace, MarketplaceClient}; + use kor_assetforge_contracts::emergency_control::{EmergencyControl, EmergencyControlClient}; + + let env = Env::default(); + env.mock_all_auths(); + + // Deploy marketplace + let mp_id = env.register_contract(None, Marketplace); + let mp_client = MarketplaceClient::new(&env, &mp_id); + let admin = Address::generate(&env); + mp_client.initialize(&admin); + + // Deploy whitelist contract + let wl_id = env.register_contract(None, Whitelist); + let wl_client = WhitelistClient::new(&env, &wl_id); + wl_client.initialize(&admin); + + // Deploy emergency control + let ec_id = env.register_contract(None, EmergencyControl); + let ec_client = EmergencyControlClient::new(&env, &ec_id); + ec_client.initialize(&admin); + + // Set whitelist contract on marketplace + mp_client.set_whitelist_contract(&admin, &wl_id); + + // Add user to whitelist + let seller = Address::generate(&env); + wl_client.add_entry(&admin, &seller, &AccreditedTier::Tier1); + assert!(wl_client.is_whitelisted(&seller)); + + // Seller can create listing because they're whitelisted + let lid = mp_client.create_listing(&seller, &1, &100, &1000, &ec_id, &None); + assert_eq!(lid, 1); + + // Non-whitelisted buyer cannot purchase + let buyer = Address::generate(&env); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + mp_client.purchase(&buyer, &1, &50, &1, &ec_id); + })); + assert!(result.is_err()); + } + + #[test] + fn test_marketplace_integration_whitelisted_buyer() { + use kor_assetforge_contracts::marketplace::{Marketplace, MarketplaceClient}; + use kor_assetforge_contracts::emergency_control::{EmergencyControl, EmergencyControlClient}; + + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + + let ec_id = env.register_contract(None, EmergencyControl); + let ec_client = EmergencyControlClient::new(&env, &ec_id); + ec_client.initialize(&admin); + + let mp_id = env.register_contract(None, Marketplace); + let mp_client = MarketplaceClient::new(&env, &mp_id); + mp_client.initialize(&admin); + + let wl_id = env.register_contract(None, Whitelist); + let wl_client = WhitelistClient::new(&env, &wl_id); + wl_client.initialize(&admin); + + mp_client.set_whitelist_contract(&admin, &wl_id); + + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + wl_client.add_entry(&admin, &seller, &AccreditedTier::Tier2); + wl_client.add_entry(&admin, &buyer, &AccreditedTier::Tier3); + + mp_client.create_listing(&seller, &10, &100, &500, &ec_id, &None); + let result = mp_client.purchase(&buyer, &1, &50, &10, &ec_id); + assert!(result); + } + + #[test] + fn test_marketplace_no_whitelist_contract_configured() { + use kor_assetforge_contracts::marketplace::{Marketplace, MarketplaceClient}; + use kor_assetforge_contracts::emergency_control::{EmergencyControl, EmergencyControlClient}; + + let env = Env::default(); + env.mock_all_auths(); + + let admin = Address::generate(&env); + + let ec_id = env.register_contract(None, EmergencyControl); + let ec_client = EmergencyControlClient::new(&env, &ec_id); + ec_client.initialize(&admin); + + let mp_id = env.register_contract(None, Marketplace); + let mp_client = MarketplaceClient::new(&env, &mp_id); + mp_client.initialize(&admin); + + // No whitelist contract configured - trades should still work + let seller = Address::generate(&env); + let buyer = Address::generate(&env); + + mp_client.create_listing(&seller, &1, &100, &1000, &ec_id, &None); + let result = mp_client.purchase(&buyer, &1, &50, &1, &ec_id); + assert!(result); + } +}