diff --git a/contracts/chainmove-pool/src/lib.rs b/contracts/chainmove-pool/src/lib.rs index d16e37d..ca3486d 100644 --- a/contracts/chainmove-pool/src/lib.rs +++ b/contracts/chainmove-pool/src/lib.rs @@ -13,6 +13,8 @@ pub enum ContractError { PoolAlreadyExists = 2, PoolNotFound = 3, InvestorPositionNotFound = 4, + Oversubscribed = 5, + PoolInactive = 6, } #[contracttype] @@ -21,6 +23,7 @@ pub struct Pool { pub id: u64, pub owner: Address, pub asset_label: String, + pub total_units: u64, pub target_amount: i128, pub total_invested: i128, pub total_repaid: i128, @@ -50,11 +53,12 @@ impl ChainMovePoolContract { owner: Address, pool_id: u64, asset_label: String, + total_units: u64, target_amount: i128, ) -> Result { owner.require_auth(); - if pool_id == 0 || target_amount <= 0 || asset_label.is_empty() { + if pool_id == 0 || total_units == 0 || target_amount <= 0 || asset_label.is_empty() { return Err(ContractError::InvalidInput); } @@ -67,6 +71,7 @@ impl ChainMovePoolContract { id: pool_id, owner, asset_label, + total_units, target_amount, total_invested: 0, total_repaid: 0, @@ -97,6 +102,14 @@ impl ChainMovePoolContract { .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; + if !pool.active { + return Err(ContractError::PoolInactive); + } + + if pool.total_invested + amount > pool.target_amount { + return Err(ContractError::Oversubscribed); + } + pool.total_invested += amount; env.storage().persistent().set(&pool_key, &pool); @@ -154,6 +167,56 @@ impl ChainMovePoolContract { Ok(position) } + /// Returns investor share in basis points (bps): invested * 10_000 / target_amount. + pub fn get_investor_share( + env: Env, + investor: Address, + pool_id: u64, + ) -> Result { + if pool_id == 0 { + return Err(ContractError::InvalidInput); + } + + let pool: Pool = env + .storage() + .persistent() + .get(&DataKey::Pool(pool_id)) + .ok_or(ContractError::PoolNotFound)?; + + let position: InvestorPosition = env + .storage() + .persistent() + .get(&DataKey::InvestorPosition(pool_id, investor)) + .ok_or(ContractError::InvestorPositionNotFound)?; + + if pool.target_amount == 0 { + return Err(ContractError::InvalidInput); + } + + let share_bps = (position.invested * 10_000 / pool.target_amount) as u64; + Ok(share_bps) + } + + /// Marks a pool inactive so no further investments are accepted. + pub fn close_pool(env: Env, owner: Address, pool_id: u64) -> Result { + owner.require_auth(); + + if pool_id == 0 { + return Err(ContractError::InvalidInput); + } + + let key = DataKey::Pool(pool_id); + let mut pool: Pool = env + .storage() + .persistent() + .get(&key) + .ok_or(ContractError::PoolNotFound)?; + + pool.active = false; + env.storage().persistent().set(&key, &pool); + Ok(pool) + } + pub fn read_pool(env: Env, pool_id: u64) -> Result { if pool_id == 0 { return Err(ContractError::InvalidInput); diff --git a/contracts/chainmove-pool/src/test.rs b/contracts/chainmove-pool/src/test.rs index 76b03b4..6c9421a 100644 --- a/contracts/chainmove-pool/src/test.rs +++ b/contracts/chainmove-pool/src/test.rs @@ -14,13 +14,14 @@ fn setup_pool(env: &Env, client: &ChainMovePoolContractClient<'_>) -> (Address, let asset_label = String::from_str(env, "testnet-van-01"); let pool = client - .try_create_pool(&owner, &1, &asset_label, &10_000) + .try_create_pool(&owner, &1, &asset_label, &100, &10_000) .unwrap() .unwrap(); assert_eq!(pool.id, 1); assert_eq!(pool.owner, owner); assert_eq!(pool.asset_label, asset_label); + assert_eq!(pool.total_units, 100); assert_eq!(pool.target_amount, 10_000); assert_eq!(pool.total_invested, 0); assert_eq!(pool.total_repaid, 0); @@ -40,6 +41,7 @@ fn creates_pool_and_reads_pool_data() { assert_eq!(pool.id, 1); assert_eq!(pool.owner, owner); + assert_eq!(pool.total_units, 100); assert_eq!(pool.total_invested, 0); assert_eq!(pool.total_repaid, 0); } @@ -102,17 +104,109 @@ fn rejects_invalid_input() { let investor = Address::generate(&env); let asset_label = String::from_str(&env, "testnet-van-01"); - let invalid_pool = client.try_create_pool(&owner, &0, &asset_label, &10_000); + // pool_id = 0 is invalid + let invalid_pool = client.try_create_pool(&owner, &0, &asset_label, &100, &10_000); assert!(invalid_pool.is_err()); + // total_units = 0 is invalid + let invalid_units = client.try_create_pool(&owner, &1, &asset_label, &0, &10_000); + assert!(invalid_units.is_err()); + client - .try_create_pool(&owner, &1, &asset_label, &10_000) + .try_create_pool(&owner, &1, &asset_label, &100, &10_000) .unwrap() .unwrap(); + // amount = 0 is invalid let invalid_investment = client.try_record_investment(&investor, &1, &0); assert!(invalid_investment.is_err()); let missing_position_repayment = client.try_record_repayment(&owner, &1, &investor, &100); assert!(missing_position_repayment.is_err()); } + +#[test] +fn rejects_oversubscription() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_client(&env); + let (_, investor) = setup_pool(&env, &client); + + // invest 8_000 of 10_000 target — succeeds + client + .try_record_investment(&investor, &1, &8_000) + .unwrap() + .unwrap(); + + // attempt to invest 3_000 more would exceed target_amount — must fail + let oversubscribed = client.try_record_investment(&investor, &1, &3_000); + assert!(oversubscribed.is_err()); + + // investing exactly the remaining 2_000 — succeeds + client + .try_record_investment(&investor, &1, &2_000) + .unwrap() + .unwrap(); + + let pool = client.try_read_pool(&1).unwrap().unwrap(); + assert_eq!(pool.total_invested, 10_000); +} + +#[test] +fn updates_duplicate_investor_position() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_client(&env); + let (_, investor) = setup_pool(&env, &client); + + // first investment + client + .try_record_investment(&investor, &1, &1_000) + .unwrap() + .unwrap(); + + // second investment by same investor — should accumulate + let position = client + .try_record_investment(&investor, &1, &500) + .unwrap() + .unwrap(); + + assert_eq!(position.invested, 1_500); + + let pool = client.try_read_pool(&1).unwrap().unwrap(); + assert_eq!(pool.total_invested, 1_500); +} + +#[test] +fn calculates_investor_share_in_bps() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_client(&env); + let (_, investor) = setup_pool(&env, &client); + + // invest 2_500 out of 10_000 target => 25% => 2500 bps + client + .try_record_investment(&investor, &1, &2_500) + .unwrap() + .unwrap(); + + let share_bps = client + .try_get_investor_share(&investor, &1) + .unwrap() + .unwrap(); + + assert_eq!(share_bps, 2_500); +} + +#[test] +fn rejects_investment_into_closed_pool() { + let env = Env::default(); + env.mock_all_auths(); + let client = create_client(&env); + let (owner, investor) = setup_pool(&env, &client); + + client.try_close_pool(&owner, &1).unwrap().unwrap(); + + let result = client.try_record_investment(&investor, &1, &1_000); + assert!(result.is_err()); +}