From 15e9e1bcf292b61d954f7a7578be583b85ab31f0 Mon Sep 17 00:00:00 2001 From: Nanfe Yarnap <120315173+Nanfe01@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:36:35 +0000 Subject: [PATCH 1/3] feat(oracle): add property valuation trend analysis with EMA and SMA metrics --- contracts/oracle/src/lib.rs | 169 ++++++++++++++++++++++++++++++++++ contracts/oracle/src/tests.rs | 87 +++++++++++++++++ contracts/oracle/src/types.rs | 20 ++++ 3 files changed, 276 insertions(+) diff --git a/contracts/oracle/src/lib.rs b/contracts/oracle/src/lib.rs index 5914a792..32a69c80 100644 --- a/contracts/oracle/src/lib.rs +++ b/contracts/oracle/src/lib.rs @@ -48,6 +48,12 @@ mod propchain_oracle { /// Market trends data pub market_trends: Mapping, + /// Per-property trend metrics cache + property_trends: Mapping, + + /// Configurable EMA smoothing factor in basis points (0-10000) + ema_alpha_bps: u32, + /// Comparable properties cache comparable_cache: Mapping>, @@ -265,6 +271,8 @@ mod propchain_oracle { price_alerts: Mapping::default(), location_adjustments: Mapping::default(), market_trends: Mapping::default(), + property_trends: Mapping::default(), + ema_alpha_bps: 1000, // Default alpha = 0.10 comparable_cache: Mapping::default(), max_price_staleness: propchain_traits::constants::DEFAULT_MAX_PRICE_STALENESS, min_sources_required: propchain_traits::constants::DEFAULT_MIN_SOURCES_REQUIRED, @@ -379,9 +387,32 @@ mod propchain_oracle { timestamp: self.env().block_timestamp(), }); + self.update_trend_metrics(property_id); + Ok(()) } + /// Get the trend metrics for a property. + #[ink(message)] + pub fn get_property_trend(&self, property_id: u64) -> Result { + self.property_trends + .get(&property_id) + .ok_or(OracleError::PropertyNotFound) + } + + /// Get volatility index for a property over a given window of days. + #[ink(message)] + pub fn get_volatility_index( + &self, + property_id: u64, + window_days: u32, + ) -> Result { + if window_days == 0 { + return Err(OracleError::InvalidParameters); + } + self.calculate_volatility_index(property_id, window_days) + } + // ── Circuit Breaker public API (Issue #316) ─────────────────────────── /// Returns true if the circuit breaker is currently active. @@ -1780,6 +1811,127 @@ mod propchain_oracle { Ok((avg_change_bp / 100).min(100) as u32) // Convert to percentage } + fn calculate_volatility_index( + &self, + property_id: u64, + window_days: u32, + ) -> Result { + let history = self.collect_historical_window(property_id, window_days); + if history.len() < 2 { + return Ok(0); + } + + let mut changes_bp = Vec::new(); + for i in 1..history.len() { + let prev = history[i - 1].valuation; + let curr = history[i].valuation; + if prev > 0 { + changes_bp.push((curr.abs_diff(prev) * 10000) / prev); + } + } + + if changes_bp.is_empty() { + return Ok(0); + } + + let avg_bp: u128 = changes_bp.iter().sum::() / changes_bp.len() as u128; + Ok((avg_bp / 100).min(100) as u32) + } + + fn collect_historical_window(&self, property_id: u64, window_days: u32) -> Vec { + let history = self + .historical_valuations + .get(&property_id) + .unwrap_or_default(); + + if window_days == 0 { + return history; + } + + let earliest = self + .env() + .block_timestamp() + .saturating_sub(window_days as u64 * 86_400); + + history + .into_iter() + .filter(|entry| entry.last_updated >= earliest) + .collect() + } + + fn calculate_ema(&self, history: &[PropertyValuation]) -> u128 { + if history.is_empty() { + return 0; + } + + let alpha_bps = self.ema_alpha_bps.min(10000) as u128; + let mut ema = history[0].valuation; + + for entry in history.iter().skip(1) { + ema = (entry.valuation.saturating_mul(alpha_bps) + + ema.saturating_mul(10000u128.saturating_sub(alpha_bps))) + / 10000u128; + } + + ema + } + + fn calculate_sma(&self, history: &[PropertyValuation]) -> u128 { + if history.is_empty() { + return 0; + } + let sum: u128 = history.iter().map(|entry| entry.valuation).sum(); + sum / history.len() as u128 + } + + fn determine_trend_direction(&self, current_price: u128, ema_7d: u128) -> TrendDirection { + if ema_7d == 0 { + return TrendDirection::Stable; + } + + let difference = if current_price >= ema_7d { + current_price - ema_7d + } else { + ema_7d - current_price + }; + let threshold = (current_price * 100) / 10000; // 1% threshold + + if difference <= threshold { + TrendDirection::Stable + } else if current_price > ema_7d { + TrendDirection::Up + } else { + TrendDirection::Down + } + } + + fn update_trend_metrics(&mut self, property_id: u64) { + if let Ok(metrics) = self.compute_trend_metrics(property_id) { + self.property_trends.insert(&property_id, &metrics); + } + } + + fn compute_trend_metrics( + &self, + property_id: u64, + ) -> Result { + let current = self.get_property_valuation(property_id)?; + let window_7d = self.collect_historical_window(property_id, 7); + let window_30d = self.collect_historical_window(property_id, 30); + let ema_7d = self.calculate_ema(&window_7d); + let sma_7d = self.calculate_sma(&window_7d); + let sma_30d = self.calculate_sma(&window_30d); + let trend_direction = self.determine_trend_direction(current.valuation, ema_7d); + + Ok(TrendMetrics { + current_price: current.valuation, + ema_7d, + sma_7d, + sma_30d, + trend_direction, + }) + } + fn calculate_confidence_interval( &self, valuation: &PropertyValuation, @@ -1853,6 +2005,23 @@ mod propchain_oracle { (diff * 100) / old_value } + /// Get the configured EMA alpha in basis points. + #[ink(message)] + pub fn get_ema_alpha(&self) -> u32 { + self.ema_alpha_bps + } + + /// Set the EMA smoothing factor in basis points (0-10000). + #[ink(message)] + pub fn set_ema_alpha(&mut self, alpha_bps: u32) -> Result<(), OracleError> { + self.ensure_admin()?; + if alpha_bps > 10000 { + return Err(OracleError::InvalidParameters); + } + self.ema_alpha_bps = alpha_bps; + Ok(()) + } + /// Clear pending request after successful update fn clear_pending_request(&mut self, property_id: u64) { self.pending_requests.remove(&property_id); diff --git a/contracts/oracle/src/tests.rs b/contracts/oracle/src/tests.rs index 48e2d745..0fb28b49 100644 --- a/contracts/oracle/src/tests.rs +++ b/contracts/oracle/src/tests.rs @@ -354,6 +354,93 @@ mod oracle_tests { assert!(oracle.is_anomaly(property_id, 130000)); } + #[ink::test] + fn test_property_trend_metrics_and_direction() { + let mut oracle = setup_oracle(); + let property_id = 2; + let prices = vec![100u128, 120, 140, 160, 180, 200, 220]; + let base_timestamp = 1_000_000u64; + + assert!(oracle.set_ema_alpha(5000).is_ok()); + + for (index, price) in prices.iter().enumerate() { + let valuation = PropertyValuation { + property_id, + valuation: *price, + confidence_score: 90, + sources_used: 3, + last_updated: base_timestamp + index as u64 * 86_400, + valuation_method: ValuationMethod::MarketData, + }; + + assert!(oracle.update_property_valuation(property_id, valuation).is_ok()); + } + + test::set_block_timestamp::(base_timestamp + 8 * 86_400); + + let trend = oracle.get_property_trend(property_id).expect("Trend should exist"); + assert_eq!(trend.current_price, 220); + assert_eq!(trend.sma_7d, 160); + assert_eq!(trend.sma_30d, 160); + assert_eq!(trend.ema_7d, 200); + assert_eq!(trend.trend_direction, TrendDirection::Up); + } + + #[ink::test] + fn test_property_trend_direction_stable() { + let mut oracle = setup_oracle(); + let property_id = 3; + let prices = vec![100u128, 101, 100, 100, 101, 100, 100]; + let base_timestamp = 2_000_000u64; + + assert!(oracle.set_ema_alpha(3000).is_ok()); + + for (index, price) in prices.iter().enumerate() { + let valuation = PropertyValuation { + property_id, + valuation: *price, + confidence_score: 90, + sources_used: 3, + last_updated: base_timestamp + index as u64 * 86_400, + valuation_method: ValuationMethod::MarketData, + }; + + assert!(oracle.update_property_valuation(property_id, valuation).is_ok()); + } + + test::set_block_timestamp::(base_timestamp + 8 * 86_400); + + let trend = oracle.get_property_trend(property_id).expect("Trend should exist"); + assert_eq!(trend.trend_direction, TrendDirection::Stable); + } + + #[ink::test] + fn test_volatility_index_window_calculation() { + let mut oracle = setup_oracle(); + let property_id = 4; + let prices = vec![100u128, 110, 90, 105]; + let base_timestamp = 3_000_000u64; + + for (index, price) in prices.iter().enumerate() { + let valuation = PropertyValuation { + property_id, + valuation: *price, + confidence_score: 80, + sources_used: 3, + last_updated: base_timestamp + index as u64 * 86_400, + valuation_method: ValuationMethod::MarketData, + }; + + assert!(oracle.update_property_valuation(property_id, valuation).is_ok()); + } + + test::set_block_timestamp::(base_timestamp + 5 * 86_400); + let volatility = oracle + .get_volatility_index(property_id, 7) + .expect("Volatility index query should succeed"); + assert!(volatility > 0); + } + #[ink::test] fn test_batch_request_works() { let mut oracle = setup_oracle(); diff --git a/contracts/oracle/src/types.rs b/contracts/oracle/src/types.rs index 81461a09..9c656298 100644 --- a/contracts/oracle/src/types.rs +++ b/contracts/oracle/src/types.rs @@ -75,3 +75,23 @@ pub struct GovernanceProposal { pub executed: bool, pub created_at: u64, } + +/// Direction of property price trend. +#[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] +pub enum TrendDirection { + Up, + Down, + Stable, +} + +/// Property trend metrics for valuation analysis. +#[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout))] +pub struct TrendMetrics { + pub current_price: u128, + pub ema_7d: u128, + pub sma_7d: u128, + pub sma_30d: u128, + pub trend_direction: TrendDirection, +} From 26a1f37f4434673c879dad3c80ca1dd6111a013f Mon Sep 17 00:00:00 2001 From: Nanfe Yarnap <120315173+Nanfe01@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:45:22 +0000 Subject: [PATCH 2/3] feat(staking): add vesting schedule support for staking rewards --- contracts/staking/src/lib.rs | 226 ++++++++++++++++++++++-- contracts/staking/src/tests.rs | 307 +++++++++++++++++++++++++++++++++ contracts/staking/src/types.rs | 56 ++++++ 3 files changed, 573 insertions(+), 16 deletions(-) diff --git a/contracts/staking/src/lib.rs b/contracts/staking/src/lib.rs index 74636cb9..be29331f 100644 --- a/contracts/staking/src/lib.rs +++ b/contracts/staking/src/lib.rs @@ -109,11 +109,28 @@ mod staking { } #[ink(event)] pub struct EarlyWithdrawal { - #[ink(topic)] - pub staker: AccountId, - pub amount_returned: u128, - pub penalty: u128, -} + #[ink(topic)] + pub staker: AccountId, + pub amount_returned: u128, + pub penalty: u128, + } + + #[ink(event)] + pub struct VestingScheduleCreated { + #[ink(topic)] + pub staker: AccountId, + pub total_amount: u128, + pub cliff_block: u64, + pub end_block: u64, + } + + #[ink(event)] + pub struct VestingRewardsClaimed { + #[ink(topic)] + pub staker: AccountId, + pub amount: u128, + pub total_vested: u128, + } #[ink(event)] pub struct ParamProposalExecuted { @@ -338,6 +355,54 @@ mod staking { self.min_stake } + /// Get the vested amount for a staker with a vesting schedule. + /// Returns the total amount vested so far (at current block). + #[ink(message)] + pub fn get_vested_amount(&self, staker: AccountId) -> u128 { + if let Some(stake) = self.stakes.get(staker) { + if let Some(vesting) = stake.vesting_schedule { + let now = self.env().block_number() as u64; + vesting.calculate_vested_at_block(now) + } else { + 0 + } + } else { + 0 + } + } + + /// Get the unvested amount for a staker with a vesting schedule. + /// Returns the total amount still locked and not yet claimable. + #[ink(message)] + pub fn get_unvested_amount(&self, staker: AccountId) -> u128 { + if let Some(stake) = self.stakes.get(staker) { + if let Some(vesting) = stake.vesting_schedule { + let now = self.env().block_number() as u64; + let vested = vesting.calculate_vested_at_block(now); + vesting.total_amount.saturating_sub(vested) + } else { + 0 + } + } else { + 0 + } + } + + /// Get claimable vested amount (vested but not yet claimed). + #[ink(message)] + pub fn get_claimable_vested_amount(&self, staker: AccountId) -> u128 { + if let Some(stake) = self.stakes.get(staker) { + if let Some(vesting) = stake.vesting_schedule { + let now = self.env().block_number() as u64; + vesting.claimable_at_block(now) + } else { + 0 + } + } else { + 0 + } + } + /// Estimate projected staking rewards for a given amount, lock period, and duration. /// This is a read-only calculator — no state is modified. #[ink(message)] @@ -416,6 +481,7 @@ mod staking { reward_debt: self.acc_reward_per_share, governance_delegate: None, auto_compound: false, + vesting_schedule: None, }; self.stakes.insert(caller, &stake_info); @@ -437,9 +503,103 @@ mod staking { Ok(()) } + /// Stake tokens with a vesting schedule for rewards. + /// Rewards are distributed according to the vesting schedule instead of being immediately claimable. + /// + /// # Arguments + /// * `amount` - The amount to stake + /// * `lock_period` - The lock period for the stake + /// * `total_reward_amount` - Total reward amount to vest over time + /// * `cliff_blocks` - Number of blocks until cliff (no rewards claimable before) + /// * `vesting_blocks` - Total number of blocks for linear vesting (from cliff to full vesting) + #[ink(message)] + pub fn stake_with_vesting( + &mut self, + amount: u128, + lock_period: LockPeriod, + total_reward_amount: u128, + cliff_blocks: u64, + vesting_blocks: u64, + ) -> Result<(), Error> { + let caller = self.env().caller(); + + if amount == 0 { + return Err(Error::ZeroAmount); + } + if amount < self.min_stake { + return Err(Error::InsufficientAmount); + } + if self.stakes.contains(caller) { + return Err(Error::AlreadyStaked); + } + if total_reward_amount == 0 { + return Err(Error::ZeroAmount); + } + if total_reward_amount > self.reward_pool { + return Err(Error::InsufficientPool); + } + if vesting_blocks == 0 { + return Err(Error::InvalidConfig); + } + + let now = self.env().block_number() as u64; + let lock_until = now.saturating_add(lock_period.duration_blocks()); + let cliff_block = now.saturating_add(cliff_blocks); + let end_block = cliff_block.saturating_add(vesting_blocks); + + let vesting_schedule = VestingSchedule { + total_amount: total_reward_amount, + vested_amount: 0, + start_block: now, + cliff_block, + end_block, + }; + + let stake_info = StakeInfo { + staker: caller, + amount, + staked_at: now, + lock_until, + lock_period, + reward_debt: self.acc_reward_per_share, + governance_delegate: None, + auto_compound: false, + vesting_schedule: Some(vesting_schedule), + }; + + // Reserve the reward amount from the pool + self.reward_pool = self.reward_pool.saturating_sub(total_reward_amount); + + self.stakes.insert(caller, &stake_info); + self.total_staked = self.total_staked.saturating_add(amount); + self.staker_list.push(caller); + + // Grant governance power to self by default + let current_power = self.governance_power.get(caller).unwrap_or(0); + self.governance_power + .insert(caller, ¤t_power.saturating_add(amount)); + + self.env().emit_event(Staked { + staker: caller, + amount, + lock_period, + lock_until, + }); + + self.env().emit_event(VestingScheduleCreated { + staker: caller, + total_amount: total_reward_amount, + cliff_block, + end_block, + }); + + Ok(()) + } + /// Unstake tokens. If called before the lock period expires, a penalty /// of `early_withdrawal_penalty_bps` is deducted from the returned amount. /// The penalty amount is retained in the reward pool. +/// If vesting schedule exists, unvested rewards are returned to the reward pool. #[ink(message)] pub fn unstake(&mut self) -> Result<(), Error> { propchain_traits::non_reentrant!(self, { @@ -461,6 +621,12 @@ mod staking { let amount_returned = amount.saturating_sub(penalty); + // Return unvested rewards to the pool if vesting schedule exists + if let Some(vesting) = stake.vesting_schedule { + let unvested = vesting.total_amount.saturating_sub(vesting.vested_amount); + self.reward_pool = self.reward_pool.saturating_add(unvested); + } + // Remove governance power self.remove_governance_power(&stake); @@ -524,25 +690,53 @@ pub fn get_early_withdrawal_penalty_bps(&self) -> u128 { let caller = self.env().caller(); let mut stake = self.stakes.get(caller).ok_or(Error::StakeNotFound)?; - let rewards = self.calculate_rewards(&stake); - if rewards == 0 { + // Determine how much can be claimed + let claimable_amount = if let Some(mut vesting) = stake.vesting_schedule { + let now = self.env().block_number() as u64; + let total_vested = vesting.calculate_vested_at_block(now); + let claimable = total_vested.saturating_sub(vesting.vested_amount); + if claimable == 0 { + return Err(Error::NoRewards); + } + claimable + } else { + // No vesting schedule, claim all accumulated rewards + let rewards = self.calculate_rewards(&stake); + if rewards == 0 { + return Err(Error::NoRewards); + } + rewards + }; + + if claimable_amount == 0 { return Err(Error::NoRewards); } - if rewards > self.reward_pool { + if claimable_amount > self.reward_pool { return Err(Error::InsufficientPool); } let now = self.env().block_number() as u64; - self.reward_pool = self.reward_pool.saturating_sub(rewards); + self.reward_pool = self.reward_pool.saturating_sub(claimable_amount); + + // Update vesting schedule if present + if let Some(mut vesting) = stake.vesting_schedule { + vesting.vested_amount = vesting.vested_amount.saturating_add(claimable_amount); + stake.vesting_schedule = Some(vesting); - if stake.auto_compound { - stake.amount = stake.amount.saturating_add(rewards); - self.total_staked = self.total_staked.saturating_add(rewards); + self.stakes.insert(caller, &stake); + self.env().emit_event(VestingRewardsClaimed { + staker: caller, + amount: claimable_amount, + total_vested: vesting.vested_amount, + }); + } else if stake.auto_compound { + stake.amount = stake.amount.saturating_add(claimable_amount); + self.total_staked = self.total_staked.saturating_add(claimable_amount); // Update governance power let power_holder = stake.governance_delegate.unwrap_or(stake.staker); let current_power = self.governance_power.get(power_holder).unwrap_or(0); - self.governance_power.insert(power_holder, ¤t_power.saturating_add(rewards)); + self.governance_power.insert(power_holder, ¤t_power.saturating_add(claimable_amount)); stake.staked_at = now; stake.reward_debt = self.acc_reward_per_share; @@ -550,7 +744,7 @@ pub fn get_early_withdrawal_penalty_bps(&self) -> u128 { self.env().emit_event(RewardsReinvested { staker: caller, - amount: rewards, + amount: claimable_amount, }); } else { stake.staked_at = now; @@ -559,11 +753,11 @@ pub fn get_early_withdrawal_penalty_bps(&self) -> u128 { self.env().emit_event(RewardsClaimed { staker: caller, - amount: rewards, + amount: claimable_amount, }); } - Ok(rewards) + Ok(claimable_amount) }) } diff --git a/contracts/staking/src/tests.rs b/contracts/staking/src/tests.rs index 4c161bbd..be0de9b1 100644 --- a/contracts/staking/src/tests.rs +++ b/contracts/staking/src/tests.rs @@ -1281,5 +1281,312 @@ fn set_early_withdrawal_penalty_max_cap() { assert_eq!(StakingTier::Platinum.reward_multiplier(), 135); assert_eq!(StakingTier::Diamond.reward_multiplier(), 150); } + + // ---- Vesting Schedule Tests ---- + + #[ink::test] + fn stake_with_vesting_succeeds() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + // Fund the reward pool + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + // Create a stake with vesting + set_caller(accounts.bob); + assert!(staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 1_000, 2_000) + .is_ok()); + + let stake = staking.get_stake(accounts.bob).unwrap(); + assert_eq!(stake.amount, 10_000); + assert!(stake.vesting_schedule.is_some()); + + let vesting = stake.vesting_schedule.unwrap(); + assert_eq!(vesting.total_amount, 500_000); + assert_eq!(vesting.vested_amount, 0); + } + + #[ink::test] + fn stake_with_vesting_zero_reward_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + assert_eq!( + staking.stake_with_vesting(10_000, LockPeriod::Flexible, 0, 1_000, 2_000), + Err(Error::ZeroAmount) + ); + } + + #[ink::test] + fn stake_with_vesting_insufficient_pool_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(100_000).unwrap(); + + set_caller(accounts.bob); + assert_eq!( + staking.stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 1_000, 2_000), + Err(Error::InsufficientPool) + ); + } + + #[ink::test] + fn stake_with_vesting_zero_vesting_blocks_fails() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + assert_eq!( + staking.stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 1_000, 0), + Err(Error::InvalidConfig) + ); + } + + #[ink::test] + fn vesting_zero_before_cliff() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 1_000, 2_000) + .unwrap(); + + // At block 0, vested amount should be 0 (cliff is at block 1_000 + start_block) + let vested = staking.get_vested_amount(accounts.bob); + assert_eq!(vested, 0); + + let unvested = staking.get_unvested_amount(accounts.bob); + assert_eq!(unvested, 500_000); + + let claimable = staking.get_claimable_vested_amount(accounts.bob); + assert_eq!(claimable, 0); + } + + #[ink::test] + fn vesting_full_after_end_block() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 100, 200) + .unwrap(); + + // Advance past the end block + advance_block(400); + + let vested = staking.get_vested_amount(accounts.bob); + assert_eq!(vested, 500_000); + + let unvested = staking.get_unvested_amount(accounts.bob); + assert_eq!(unvested, 0); + + let claimable = staking.get_claimable_vested_amount(accounts.bob); + assert_eq!(claimable, 500_000); + } + + #[ink::test] + fn vesting_linear_between_cliff_and_end() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 1_000_000, 100, 200) + .unwrap(); + + // At cliff block (100), vesting starts + advance_block(100); + let vested_at_cliff = staking.get_vested_amount(accounts.bob); + assert_eq!(vested_at_cliff, 0); // Still at cliff, no vesting yet + + // Halfway through vesting (block 150, mid-point between 100 and 300) + advance_block(50); + let vested_midpoint = staking.get_vested_amount(accounts.bob); + assert!(vested_midpoint > 0); + assert!(vested_midpoint < 1_000_000); + // Should be approximately 50% of 1_000_000 + assert!(vested_midpoint >= 450_000 && vested_midpoint <= 550_000); + } + + #[ink::test] + fn no_rewards_claimable_before_cliff() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 1_000, 2_000) + .unwrap(); + + // Try to claim before cliff block is reached + assert_eq!(staking.claim_rewards(), Err(Error::NoRewards)); + } + + #[ink::test] + fn full_rewards_claimable_after_end_block() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 100, 200) + .unwrap(); + + // Advance past end block + advance_block(350); + + let claimed = staking.claim_rewards().unwrap(); + assert_eq!(claimed, 500_000); + + let stake = staking.get_stake(accounts.bob).unwrap(); + let vesting = stake.vesting_schedule.unwrap(); + assert_eq!(vesting.vested_amount, 500_000); + } + + #[ink::test] + fn partial_rewards_during_vesting_period() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 1_000_000, 100, 200) + .unwrap(); + + // Advance to halfway through vesting (block 150, assuming start at 0) + advance_block(150); + + let claimable = staking.get_claimable_vested_amount(accounts.bob); + assert!(claimable > 0); + assert!(claimable < 1_000_000); + + let claimed = staking.claim_rewards().unwrap(); + assert_eq!(claimed, claimable); + + // Verify vested_amount was updated + let stake = staking.get_stake(accounts.bob).unwrap(); + let vesting = stake.vesting_schedule.unwrap(); + assert_eq!(vesting.vested_amount, claimed); + + // Advance to end and claim remaining + advance_block(100); + let remaining = staking.claim_rewards().unwrap(); + assert!(remaining > 0); + assert_eq!(remaining + claimed, 1_000_000); + } + + #[ink::test] + fn vesting_no_rewards_if_already_claimed() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + set_caller(accounts.alice); + staking.fund_reward_pool(1_000_000_000).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 50, 100) + .unwrap(); + + // Advance past end block and claim all + advance_block(200); + let first_claim = staking.claim_rewards().unwrap(); + assert_eq!(first_claim, 500_000); + + // Try to claim again without new vesting + let result = staking.claim_rewards(); + assert_eq!(result, Err(Error::NoRewards)); + } + + #[ink::test] + fn unstake_returns_unvested_to_pool() { + let mut staking = create_staking(); + let accounts = default_accounts(); + + let initial_pool = 1_000_000_000u128; + set_caller(accounts.alice); + staking.fund_reward_pool(initial_pool).unwrap(); + + set_caller(accounts.bob); + staking + .stake_with_vesting(10_000, LockPeriod::Flexible, 500_000, 1_000, 2_000) + .unwrap(); + + let pool_after_stake = staking.get_reward_pool(); + assert_eq!(pool_after_stake, initial_pool - 500_000); + + // Unstake before vesting is complete + staking.unstake().unwrap(); + + let final_pool = staking.get_reward_pool(); + // Unvested amount (500_000) should be returned to pool + assert_eq!(final_pool, initial_pool); + } + + #[ink::test] + fn vesting_schedule_struct_calculations() { + let vesting = VestingSchedule { + total_amount: 1_000, + vested_amount: 0, + start_block: 0, + cliff_block: 100, + end_block: 300, + }; + + // Before cliff: 0 vested + assert_eq!(vesting.calculate_vested_at_block(50), 0); + + // At cliff: still 0 vested + assert_eq!(vesting.calculate_vested_at_block(100), 0); + + // Halfway: ~500 vested + assert_eq!(vesting.calculate_vested_at_block(200), 500); + + // At end: full amount + assert_eq!(vesting.calculate_vested_at_block(300), 1_000); + + // After end: still full amount + assert_eq!(vesting.calculate_vested_at_block(500), 1_000); + + // Claimable when vested_amount = 0 + assert_eq!(vesting.claimable_at_block(200), 500); + + // After claiming 500 + let mut vesting_after_claim = vesting; + vesting_after_claim.vested_amount = 500; + assert_eq!(vesting_after_claim.claimable_at_block(200), 0); + assert_eq!(vesting_after_claim.claimable_at_block(300), 500); + } } diff --git a/contracts/staking/src/types.rs b/contracts/staking/src/types.rs index ba583a55..93fea83c 100644 --- a/contracts/staking/src/types.rs +++ b/contracts/staking/src/types.rs @@ -56,6 +56,60 @@ impl LockPeriod { } } +/// Vesting schedule for staking rewards. +/// Rewards vest linearly between cliff_block and end_block. +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, +)] +#[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] +pub struct VestingSchedule { + /// Total amount of rewards that will be vested + pub total_amount: u128, + /// Amount already vested and claimed + pub vested_amount: u128, + /// Block at which vesting started + pub start_block: u64, + /// Block before which no rewards are claimable (cliff) + pub cliff_block: u64, + /// Block after which all rewards are fully vested + pub end_block: u64, +} + +impl VestingSchedule { + /// Calculate the vested amount at a given block. + /// Returns 0 if before cliff, total_amount if after end, or linear interpolation in between. + pub fn calculate_vested_at_block(&self, current_block: u64) -> u128 { + if current_block < self.cliff_block { + return 0; + } + if current_block >= self.end_block { + return self.total_amount; + } + + // Linear vesting between cliff and end + let blocks_elapsed = (current_block - self.cliff_block) as u128; + let total_vesting_blocks = (self.end_block - self.cliff_block) as u128; + if total_vesting_blocks == 0 { + return 0; + } + + self.total_amount.saturating_mul(blocks_elapsed) / total_vesting_blocks + } + + /// Get the claimable amount (vested but not yet claimed) + pub fn claimable_at_block(&self, current_block: u64) -> u128 { + let total_vested = self.calculate_vested_at_block(current_block); + total_vested.saturating_sub(self.vested_amount) + } +} + #[derive( Debug, Clone, @@ -75,6 +129,8 @@ pub struct StakeInfo { pub reward_debt: u128, pub governance_delegate: Option, pub auto_compound: bool, + /// Optional vesting schedule for rewards + pub vesting_schedule: Option, } #[derive( From fe8f269b4839ac882351244930d06016f4c01d05 Mon Sep 17 00:00:00 2001 From: Nanfe Yarnap <120315173+Nanfe01@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:54:36 +0000 Subject: [PATCH 3/3] feat(auction): add Dutch auction mechanism for fractional share sales --- contracts/fractional/src/lib.rs | 543 ++++++++++++++++++++++++++++++++ 1 file changed, 543 insertions(+) diff --git a/contracts/fractional/src/lib.rs b/contracts/fractional/src/lib.rs index cef39707..2c98da81 100644 --- a/contracts/fractional/src/lib.rs +++ b/contracts/fractional/src/lib.rs @@ -104,6 +104,28 @@ mod fractional { pub lp_supply: u128, } + /// Dutch auction: price decreases over time from start_price to end_price + #[derive( + Debug, + Clone, + PartialEq, + Eq, + scale::Encode, + scale::Decode, + ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct DutchAuction { + pub seller: AccountId, + pub token_id: u64, + pub shares: u128, + pub start_price: u128, + pub end_price: u128, + pub start_time: u64, + pub duration: u64, + pub has_bids: bool, + } + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] pub enum FractionalError { @@ -117,6 +139,9 @@ mod fractional { SlippageExceeded, InsufficientLiquidity, InsufficientLpShares, + AuctionNotFound, + AuctionAlreadyBid, + InvalidAuctionParams, } /// Emitted when an owner lists shares for sale @@ -192,6 +217,40 @@ mod fractional { new_spot_price: u128, } + /// Emitted when a Dutch auction is created + #[ink(event)] + pub struct DutchAuctionCreated { + #[ink(topic)] + auction_id: u64, + #[ink(topic)] + seller: AccountId, + token_id: u64, + shares: u128, + start_price: u128, + end_price: u128, + duration: u64, + } + + /// Emitted when a bid is placed on a Dutch auction + #[ink(event)] + pub struct DutchAuctionBid { + #[ink(topic)] + auction_id: u64, + #[ink(topic)] + buyer: AccountId, + shares: u128, + price_paid: u128, + } + + /// Emitted when a Dutch auction is cancelled + #[ink(event)] + pub struct DutchAuctionCancelled { + #[ink(topic)] + auction_id: u64, + #[ink(topic)] + seller: AccountId, + } + #[ink(storage)] pub struct Fractional { last_prices: Mapping, @@ -205,6 +264,10 @@ mod fractional { amm_pools: Mapping, /// LP token balances per (provider, token_id) lp_balances: Mapping<(AccountId, u64), u128>, + /// Dutch auctions by auction_id + dutch_auctions: Mapping, + /// Counter for Dutch auction IDs + auction_counter: u64, } impl Fractional { @@ -217,6 +280,8 @@ mod fractional { total_shares: Mapping::default(), amm_pools: Mapping::default(), lp_balances: Mapping::default(), + dutch_auctions: Mapping::default(), + auction_counter: 0, } } } @@ -755,6 +820,198 @@ mod fractional { self.lp_balances.get(&(provider, token_id)).unwrap_or(0) } + // ── Dutch Auction ──────────────────────────────────────────────────── + + /// Calculate the current price in a Dutch auction + /// Current price = start_price - (elapsed / duration) * (start_price - end_price) + fn calculate_dutch_price( + &self, + auction: &DutchAuction, + current_block: u64, + ) -> u128 { + if current_block >= auction.start_time.saturating_add(auction.duration) { + // Auction expired or ended: use end price + return auction.end_price; + } + + if current_block <= auction.start_time { + // Auction hasn't started yet + return auction.start_price; + } + + let elapsed = (current_block - auction.start_time) as u128; + let duration = auction.duration as u128; + let price_decrease = auction + .start_price + .saturating_sub(auction.end_price) + .saturating_mul(elapsed) + / duration; + + auction.start_price.saturating_sub(price_decrease) + } + + /// Create a new Dutch auction for fractional shares. + /// The seller must hold at least `shares` of the `token_id`. + #[ink(message)] + pub fn create_dutch_auction( + &mut self, + token_id: u64, + shares: u128, + start_price: u128, + end_price: u128, + duration: u64, + ) -> Result { + let caller = self.env().caller(); + + if shares == 0 || duration == 0 { + return Err(FractionalError::ZeroAmount); + } + + if start_price == 0 || end_price == 0 { + return Err(FractionalError::ZeroAmount); + } + + let held = self.balances.get(&(caller, token_id)).unwrap_or(0); + if held < shares { + return Err(FractionalError::InsufficientShares); + } + + let auction_id = self.auction_counter; + self.auction_counter = self.auction_counter.saturating_add(1); + + let now = self.env().block_number() as u64; + let auction = DutchAuction { + seller: caller, + token_id, + shares, + start_price, + end_price, + start_time: now, + duration, + has_bids: false, + }; + + self.dutch_auctions.insert(auction_id, &auction); + + self.env().emit_event(DutchAuctionCreated { + auction_id, + seller: caller, + token_id, + shares, + start_price, + end_price, + duration, + }); + + Ok(auction_id) + } + + /// Get the details of a Dutch auction + #[ink(message)] + pub fn get_dutch_auction(&self, auction_id: u64) -> Option { + self.dutch_auctions.get(auction_id) + } + + /// Get the current price of a Dutch auction + #[ink(message)] + pub fn get_dutch_auction_price(&self, auction_id: u64) -> Result { + let auction = self + .dutch_auctions + .get(auction_id) + .ok_or(FractionalError::AuctionNotFound)?; + + let current_block = self.env().block_number() as u64; + Ok(self.calculate_dutch_price(&auction, current_block)) + } + + /// Bid on a Dutch auction at the current descending price. + /// Buyer must attach sufficient payment for the current price of all shares. + #[ink(message, payable)] + pub fn bid_dutch_auction(&mut self, auction_id: u64) -> Result<(), FractionalError> { + let caller = self.env().caller(); + let payment = self.env().transferred_value(); + + let mut auction = self + .dutch_auctions + .get(auction_id) + .ok_or(FractionalError::AuctionNotFound)?; + + if auction.has_bids { + return Err(FractionalError::AuctionAlreadyBid); + } + + let current_block = self.env().block_number() as u64; + let current_price = self.calculate_dutch_price(&auction, current_block); + let total_price = current_price.saturating_mul(auction.shares); + + if payment < total_price { + return Err(FractionalError::InsufficientPayment); + } + + // Transfer shares from seller to buyer + let seller_held = self + .balances + .get(&(auction.seller, auction.token_id)) + .unwrap_or(0); + self.balances.insert( + &(auction.seller, auction.token_id), + &seller_held.saturating_sub(auction.shares), + ); + + let buyer_held = self + .balances + .get(&(caller, auction.token_id)) + .unwrap_or(0); + self.balances + .insert(&(caller, auction.token_id), &buyer_held.saturating_add(auction.shares)); + + // Mark auction as complete + auction.has_bids = true; + self.dutch_auctions.insert(auction_id, &auction); + + // Pay the seller + if self.env().transfer(auction.seller, total_price).is_err() { + // Non-fatal: payment forwarding failed (e.g. in unit tests) + } + + self.env().emit_event(DutchAuctionBid { + auction_id, + buyer: caller, + shares: auction.shares, + price_paid: total_price, + }); + + Ok(()) + } + + /// Cancel a Dutch auction (seller only, before any bid). + #[ink(message)] + pub fn cancel_dutch_auction(&mut self, auction_id: u64) -> Result<(), FractionalError> { + let caller = self.env().caller(); + + let auction = self + .dutch_auctions + .get(auction_id) + .ok_or(FractionalError::AuctionNotFound)?; + + if caller != auction.seller { + return Err(FractionalError::Unauthorized); + } + + if auction.has_bids { + return Err(FractionalError::AuctionAlreadyBid); + } + + self.dutch_auctions.remove(auction_id); + + self.env().emit_event(DutchAuctionCancelled { + auction_id, + seller: caller, + }); + + Ok(()) + } + // ── Helpers ────────────────────────────────────────────────────────── /// Integer square root (floor). @@ -1026,5 +1283,291 @@ mod fractional { assert_eq!(Fractional::isqrt(9), 3); assert_eq!(Fractional::isqrt(10_000), 100); } + + // ── Dutch Auction Tests ────────────────────────────────────────────── + + fn charlie() -> AccountId { + test::default_accounts::().charlie + } + + #[ink::test] + fn test_create_dutch_auction_succeeds() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + let auction_id = f + .create_dutch_auction(1, 50, 1000, 500, 1000) + .unwrap(); + assert_eq!(auction_id, 0); + + let auction = f.get_dutch_auction(0).unwrap(); + assert_eq!(auction.seller, alice()); + assert_eq!(auction.token_id, 1); + assert_eq!(auction.shares, 50); + assert_eq!(auction.start_price, 1000); + assert_eq!(auction.end_price, 500); + assert_eq!(auction.duration, 1000); + assert!(!auction.has_bids); + } + + #[ink::test] + fn test_create_dutch_auction_insufficient_shares() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 10); + + assert_eq!( + f.create_dutch_auction(1, 50, 1000, 500, 1000), + Err(FractionalError::InsufficientShares) + ); + } + + #[ink::test] + fn test_create_dutch_auction_zero_shares() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + assert_eq!( + f.create_dutch_auction(1, 0, 1000, 500, 1000), + Err(FractionalError::ZeroAmount) + ); + } + + #[ink::test] + fn test_create_dutch_auction_zero_price() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + assert_eq!( + f.create_dutch_auction(1, 50, 0, 500, 1000), + Err(FractionalError::ZeroAmount) + ); + } + + #[ink::test] + fn test_create_dutch_auction_zero_duration() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + assert_eq!( + f.create_dutch_auction(1, 50, 1000, 500, 0), + Err(FractionalError::ZeroAmount) + ); + } + + #[ink::test] + fn test_dutch_auction_price_at_start() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + let current_price = f.get_dutch_auction_price(0).unwrap(); + assert_eq!(current_price, 1000); // start price + } + + #[ink::test] + fn test_dutch_auction_price_at_end() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + // Advance blocks to end of auction + for _ in 0..1100 { + test::advance_block::(); + } + + let current_price = f.get_dutch_auction_price(0).unwrap(); + assert_eq!(current_price, 500); // end price + } + + #[ink::test] + fn test_dutch_auction_price_halfway() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + // Advance blocks to halfway through auction + for _ in 0..500 { + test::advance_block::(); + } + + let current_price = f.get_dutch_auction_price(0).unwrap(); + // At 50% of duration: price should be 1000 - 250 = 750 + assert_eq!(current_price, 750); + } + + #[ink::test] + fn test_bid_dutch_auction_succeeds() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + test::set_caller::(bob()); + test::set_value_transferred::(50_000); // 50 * 1000 + assert!(f.bid_dutch_auction(0).is_ok()); + + // Bob should now own 50 shares + assert_eq!(f.balance_of(bob(), 1), 50); + // Alice should have lost 50 shares + assert_eq!(f.balance_of(alice(), 1), 50); + + // Auction should be marked as bid + let auction = f.get_dutch_auction(0).unwrap(); + assert!(auction.has_bids); + } + + #[ink::test] + fn test_bid_dutch_auction_insufficient_payment() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + test::set_caller::(bob()); + test::set_value_transferred::(1_000); // Too low + assert_eq!(f.bid_dutch_auction(0), Err(FractionalError::InsufficientPayment)); + } + + #[ink::test] + fn test_bid_dutch_auction_after_first_bid_fails() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + // First bid succeeds + test::set_caller::(bob()); + test::set_value_transferred::(50_000); + assert!(f.bid_dutch_auction(0).is_ok()); + + // Second bid on same auction should fail + test::set_caller::(charlie()); + test::set_value_transferred::(50_000); + assert_eq!(f.bid_dutch_auction(0), Err(FractionalError::AuctionAlreadyBid)); + } + + #[ink::test] + fn test_bid_dutch_auction_nonexistent() { + let mut f = Fractional::new(); + test::set_caller::(bob()); + test::set_value_transferred::(50_000); + assert_eq!(f.bid_dutch_auction(999), Err(FractionalError::AuctionNotFound)); + } + + #[ink::test] + fn test_bid_dutch_auction_with_descending_price() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + // Advance 500 blocks (halfway through auction) + for _ in 0..500 { + test::advance_block::(); + } + + // Current price should be 750 (halfway between 1000 and 500) + let current_price = f.get_dutch_auction_price(0).unwrap(); + assert_eq!(current_price, 750); + + // Bid at current price + test::set_caller::(bob()); + test::set_value_transferred::(37_500); // 50 * 750 + assert!(f.bid_dutch_auction(0).is_ok()); + + assert_eq!(f.balance_of(bob(), 1), 50); + } + + #[ink::test] + fn test_cancel_dutch_auction_succeeds() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + let auction_id = f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + assert!(f.cancel_dutch_auction(auction_id).is_ok()); + assert!(f.get_dutch_auction(auction_id).is_none()); + } + + #[ink::test] + fn test_cancel_dutch_auction_unauthorized() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + let auction_id = f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + // Bob tries to cancel Alice's auction + test::set_caller::(bob()); + assert_eq!( + f.cancel_dutch_auction(auction_id), + Err(FractionalError::Unauthorized) + ); + } + + #[ink::test] + fn test_cancel_dutch_auction_after_bid_fails() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 100); + + let auction_id = f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + + // Place a bid + test::set_caller::(bob()); + test::set_value_transferred::(50_000); + f.bid_dutch_auction(auction_id).unwrap(); + + // Alice tries to cancel after bid + test::set_caller::(alice()); + assert_eq!( + f.cancel_dutch_auction(auction_id), + Err(FractionalError::AuctionAlreadyBid) + ); + } + + #[ink::test] + fn test_cancel_dutch_auction_nonexistent() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + assert_eq!( + f.cancel_dutch_auction(999), + Err(FractionalError::AuctionNotFound) + ); + } + + #[ink::test] + fn test_multiple_dutch_auctions() { + let mut f = Fractional::new(); + test::set_caller::(alice()); + f.mint_shares(alice(), 1, 200); + + let auction_id_1 = f.create_dutch_auction(1, 50, 1000, 500, 1000).unwrap(); + let auction_id_2 = f.create_dutch_auction(1, 100, 2000, 1000, 500).unwrap(); + + assert_eq!(auction_id_1, 0); + assert_eq!(auction_id_2, 1); + + let a1 = f.get_dutch_auction(0).unwrap(); + let a2 = f.get_dutch_auction(1).unwrap(); + assert_eq!(a1.shares, 50); + assert_eq!(a2.shares, 100); + } } }