diff --git a/Cargo.lock b/Cargo.lock index 67b1499e5..1ac141dbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5032,6 +5032,16 @@ dependencies = [ "scale-info", ] +[[package]] +name = "propchain-prediction-market" +version = "1.0.0" +dependencies = [ + "ink 5.1.1", + "parity-scale-codec", + "propchain-traits", + "scale-info", +] + [[package]] name = "propchain-proxy" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 312b7bec2..afa847eed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ members = [ "contracts/fees", "contracts/compliance_registry", "contracts/fractional", - "contracts/property-management", + "contracts/prediction-market", ] resolver = "2" diff --git a/contracts/analytics/src/lib.rs b/contracts/analytics/src/lib.rs index b972f1013..e7f21843a 100644 --- a/contracts/analytics/src/lib.rs +++ b/contracts/analytics/src/lib.rs @@ -57,6 +57,17 @@ mod propchain_analytics { pub risk_score: u8, } + /// Crowd wisdom sentiment derived from prediction markets + #[derive( + Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, + )] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub struct MarketSentiment { + pub bull_volume: u128, + pub bear_volume: u128, + pub bull_bear_ratio_bips: u32, // Ratio in basis points (10000 = 100%) + } + /// Market Report. #[derive( Debug, Clone, PartialEq, scale::Encode, scale::Decode, ink::storage::traits::StorageLayout, @@ -66,6 +77,7 @@ mod propchain_analytics { pub generated_at: u64, pub metrics: MarketMetrics, pub trend: MarketTrend, + pub sentiment: MarketSentiment, pub insights: String, } @@ -79,6 +91,10 @@ mod propchain_analytics { historical_trends: ink::storage::Mapping, /// Trend count trend_count: u64, + /// Sentiments per property + property_sentiments: ink::storage::Mapping, + /// Overall aggregated sentiment + overall_sentiment: MarketSentiment, } impl AnalyticsDashboard { @@ -94,6 +110,12 @@ mod propchain_analytics { }, historical_trends: ink::storage::Mapping::default(), trend_count: 0, + property_sentiments: ink::storage::Mapping::default(), + overall_sentiment: MarketSentiment { + bull_volume: 0, + bear_volume: 0, + bull_bear_ratio_bips: 5000, + }, } } @@ -162,12 +184,48 @@ mod propchain_analytics { generated_at: self.env().block_timestamp(), metrics: self.current_metrics.clone(), trend: latest_trend, + sentiment: self.overall_sentiment.clone(), insights: String::from( "Market is relatively stable. Gas optimization is recommended.", ), } } + /// Update market sentiment from prediction markets + #[ink(message)] + pub fn update_market_sentiment( + &mut self, + property_id: u64, + bull_volume: u128, + bear_volume: u128, + ) { + self.ensure_admin(); // Prediction market or admin updates this + + let total_volume = bull_volume + bear_volume; + let ratio = if total_volume > 0 { + ((bull_volume * 10000) / total_volume) as u32 + } else { + 5000 // default unbiased + }; + + let new_sentiment = MarketSentiment { + bull_volume, + bear_volume, + bull_bear_ratio_bips: ratio, + }; + + self.property_sentiments.insert(property_id, &new_sentiment); + + // Update overall recursively or by moving average + self.overall_sentiment.bull_volume = self.overall_sentiment.bull_volume.saturating_add(bull_volume); + self.overall_sentiment.bear_volume = self.overall_sentiment.bear_volume.saturating_add(bear_volume); + + let total_overall = self.overall_sentiment.bull_volume + self.overall_sentiment.bear_volume; + if total_overall > 0 { + self.overall_sentiment.bull_bear_ratio_bips = ((self.overall_sentiment.bull_volume * 10000) / total_overall) as u32; + } + } + /// Add gas usage optimization recommendations #[ink(message)] pub fn get_gas_optimization_recommendations(&self) -> String { @@ -227,6 +285,7 @@ mod propchain_analytics { let contract = AnalyticsDashboard::new(); let report = contract.generate_market_report(); assert_eq!(report.metrics.average_price, 0); + assert_eq!(report.sentiment.bull_bear_ratio_bips, 5000); assert!(report.insights.contains("Gas optimization")); } } diff --git a/contracts/prediction-market/Cargo.toml b/contracts/prediction-market/Cargo.toml new file mode 100644 index 000000000..f5280a84d --- /dev/null +++ b/contracts/prediction-market/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "propchain-prediction-market" +version = "1.0.0" +authors = ["PropChain Team "] +edition = "2021" + +[dependencies] +ink = { workspace = true } +scale = { workspace = true } +scale-info = { workspace = true } +propchain-traits = { path = "../traits", default-features = false } + +[lib] +path = "src/lib.rs" + +[features] +default = ["std"] +std = [ + "ink/std", + "scale/std", + "scale-info/std", + "propchain-traits/std", +] +ink-as-dependency = [] diff --git a/contracts/prediction-market/src/lib.rs b/contracts/prediction-market/src/lib.rs new file mode 100644 index 000000000..157ee921f --- /dev/null +++ b/contracts/prediction-market/src/lib.rs @@ -0,0 +1,432 @@ +#![cfg_attr(not(feature = "std"), no_std, no_main)] +#![allow(clippy::new_without_default)] + +use ink::prelude::vec::Vec; + +#[ink::contract] +mod propchain_prediction_market { + use super::*; + use ink::storage::Mapping; + + #[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub enum MarketStatus { + Active, + Resolved, + Cancelled, + } + + #[derive(Debug, Clone, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub enum PredictionDirection { + Long, // Predicting value will be >= target_value + Short, // Predicting value will be < target_value + } + + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct PredictionMarketInfo { + pub market_id: u64, + pub property_id: u64, + pub target_value: u128, + pub resolution_time: u64, + pub total_long: u128, + pub total_short: u128, + pub status: MarketStatus, + pub winning_direction: Option, + pub resolved_value: Option, + } + + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct Stake { + pub amount: u128, + pub direction: PredictionDirection, + pub claimed: bool, + } + + #[derive(Debug, Clone, PartialEq, scale::Encode, scale::Decode)] + #[cfg_attr( + feature = "std", + derive(scale_info::TypeInfo, ink::storage::traits::StorageLayout) + )] + pub struct UserReputation { + pub total_predictions: u32, + pub successful_predictions: u32, + pub accuracy_score: u32, // out of 10000 (e.g. 7500 = 75%) + } + + #[ink(storage)] + pub struct PredictionMarket { + admin: AccountId, + markets: Mapping, + market_count: u64, + + // market_id -> (user -> Stake) + stakes: Mapping<(u64, AccountId), Stake>, + + // user -> UserReputation + reputations: Mapping, + + // Oracle for resolution (simplified) + oracle_address: Option, + + // Protocol fee basis points + fee_bips: u32, + } + + #[ink(event)] + pub struct MarketCreated { + #[ink(topic)] + market_id: u64, + #[ink(topic)] + property_id: u64, + target_value: u128, + resolution_time: u64, + } + + #[ink(event)] + pub struct PredictionStaked { + #[ink(topic)] + market_id: u64, + #[ink(topic)] + user: AccountId, + amount: u128, + direction: PredictionDirection, + } + + #[ink(event)] + pub struct MarketResolved { + #[ink(topic)] + market_id: u64, + resolved_value: u128, + winning_direction: PredictionDirection, + } + + #[ink(event)] + pub struct RewardClaimed { + #[ink(topic)] + market_id: u64, + #[ink(topic)] + user: AccountId, + amount: u128, + } + + #[ink(event)] + pub struct BacktestValidated { + #[ink(topic)] + market_id: u64, + historical_accuracy: u32, + model_version: String, + } + + #[derive(Debug, PartialEq, Eq, scale::Encode, scale::Decode)] + #[cfg_attr(feature = "std", derive(scale_info::TypeInfo))] + pub enum Error { + Unauthorized, + MarketNotFound, + MarketNotActive, + MarketNotReadyForResolution, + MarketAlreadyResolved, + StakeNotFound, + RewardAlreadyClaimed, + InvalidAmount, + OracleNotSet, + TransferFailed, + LoserCannotClaim, + } + + impl PredictionMarket { + #[ink(constructor)] + pub fn new(admin: AccountId, fee_bips: u32) -> Self { + Self { + admin, + markets: Mapping::default(), + market_count: 0, + stakes: Mapping::default(), + reputations: Mapping::default(), + oracle_address: None, + fee_bips, + } + } + + #[ink(message)] + pub fn set_oracle(&mut self, oracle: AccountId) -> Result<(), Error> { + self.ensure_admin()?; + self.oracle_address = Some(oracle); + Ok(()) + } + + #[ink(message)] + pub fn create_market( + &mut self, + property_id: u64, + target_value: u128, + resolution_time: u64, + ) -> Result { + self.ensure_admin()?; + + let market_id = self.market_count; + self.market_count += 1; + + let market = PredictionMarketInfo { + market_id, + property_id, + target_value, + resolution_time, + total_long: 0, + total_short: 0, + status: MarketStatus::Active, + winning_direction: None, + resolved_value: None, + }; + + self.markets.insert(&market_id, &market); + + self.env().emit_event(MarketCreated { + market_id, + property_id, + target_value, + resolution_time, + }); + + Ok(market_id) + } + + #[ink(message, payable)] + pub fn stake_prediction( + &mut self, + market_id: u64, + direction: PredictionDirection, + ) -> Result<(), Error> { + let caller = self.env().caller(); + let amount = self.env().transferred_value(); + if amount == 0 { + return Err(Error::InvalidAmount); + } + + let mut market = self.markets.get(&market_id).ok_or(Error::MarketNotFound)?; + + if market.status != MarketStatus::Active { + return Err(Error::MarketNotActive); + } + if self.env().block_timestamp() >= market.resolution_time { + // Too late to predict + return Err(Error::MarketNotActive); + } + + // Record stake + let key = (market_id, caller); + let mut existing_stake = self.stakes.get(&key).unwrap_or(Stake { + amount: 0, + direction: direction.clone(), + claimed: false, + }); + + // For simplicity, enforce same direction if adding stake + if existing_stake.amount > 0 && existing_stake.direction != direction { + // User cannot hedge in this simple version + return Err(Error::InvalidAmount); + } + + existing_stake.amount += amount; + self.stakes.insert(&key, &existing_stake); + + // Update market totals + match direction { + PredictionDirection::Long => market.total_long += amount, + PredictionDirection::Short => market.total_short += amount, + } + + self.markets.insert(&market_id, &market); + + self.env().emit_event(PredictionStaked { + market_id, + user: caller, + amount, + direction, + }); + + Ok(()) + } + + #[ink(message)] + pub fn resolve_market(&mut self, market_id: u64, resolved_value: u128) -> Result<(), Error> { + self.ensure_admin()?; // In production, this should ideally be called by the Oracle directly or query the oracle. + + let mut market = self.markets.get(&market_id).ok_or(Error::MarketNotFound)?; + if market.status != MarketStatus::Active { + return Err(Error::MarketAlreadyResolved); + } + if self.env().block_timestamp() < market.resolution_time { + return Err(Error::MarketNotReadyForResolution); + } + + let winning_direction = if resolved_value >= market.target_value { + PredictionDirection::Long + } else { + PredictionDirection::Short + }; + + market.status = MarketStatus::Resolved; + market.resolved_value = Some(resolved_value); + market.winning_direction = Some(winning_direction.clone()); + + self.markets.insert(&market_id, &market); + + self.env().emit_event(MarketResolved { + market_id, + resolved_value, + winning_direction, + }); + + Ok(()) + } + + #[ink(message)] + pub fn claim_reward(&mut self, market_id: u64) -> Result<(), Error> { + let caller = self.env().caller(); + let market = self.markets.get(&market_id).ok_or(Error::MarketNotFound)?; + + if market.status != MarketStatus::Resolved { + return Err(Error::MarketNotActive); // Need better error naming + } + + let winning_dir = market.winning_direction.as_ref().unwrap(); + + let key = (market_id, caller); + let mut stake = self.stakes.get(&key).ok_or(Error::StakeNotFound)?; + + if stake.claimed { + return Err(Error::RewardAlreadyClaimed); + } + if stake.direction != *winning_dir { + // Record bad reputation + self.update_reputation(caller, false); + return Err(Error::LoserCannotClaim); + } + + // Calculate reward: + let (winning_pool, losing_pool) = match winning_dir { + PredictionDirection::Long => (market.total_long, market.total_short), + PredictionDirection::Short => (market.total_short, market.total_long), + }; + + // Proportion of the winning pool + // total_reward = user_stake + (user_stake * losing_pool) / winning_pool + let total_reward = stake.amount + (stake.amount * losing_pool) / winning_pool; + + let fee = (total_reward * self.fee_bips as u128) / 10000; + let final_payout = total_reward.saturating_sub(fee); + + stake.claimed = true; + self.stakes.insert(&key, &stake); + + // Record good reputation + self.update_reputation(caller, true); + + // Transfer payout to user + if self.env().transfer(caller, final_payout).is_err() { + return Err(Error::TransferFailed); + } + + self.env().emit_event(RewardClaimed { + market_id, + user: caller, + amount: final_payout, + }); + + Ok(()) + } + + #[ink(message)] + pub fn get_user_reputation(&self, user: AccountId) -> UserReputation { + self.reputations.get(&user).unwrap_or(UserReputation { + total_predictions: 0, + successful_predictions: 0, + accuracy_score: 0, + }) + } + + #[ink(message)] + pub fn get_market(&self, market_id: u64) -> Option { + self.markets.get(&market_id) + } + + #[ink(message)] + pub fn submit_backtest_data( + &mut self, + market_id: u64, + historical_accuracy: u32, + model_version: String, + ) -> Result<(), Error> { + self.ensure_admin()?; + + // In a full implementation, this could verify ZK proofs or store the backtest mapping. + // For now we simulate accepting the validation and emitting an event. + self.env().emit_event(BacktestValidated { + market_id, + historical_accuracy, + model_version, + }); + Ok(()) + } + + fn update_reputation(&mut self, user: AccountId, success: bool) { + let mut rep = self.get_user_reputation(user); + // Don't count multiple claims from same market as multiple successes, + // but for simplicity our claim logic is 1-to-1 with market right now. + rep.total_predictions += 1; + if success { + rep.successful_predictions += 1; + } + // score out of 10000 + rep.accuracy_score = ((rep.successful_predictions as u64 * 10000) / rep.total_predictions as u64) as u32; + self.reputations.insert(&user, &rep); + } + + fn ensure_admin(&self) -> Result<(), Error> { + if self.env().caller() != self.admin { + return Err(Error::Unauthorized); + } + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + + #[ink::test] + fn new_works() { + let accounts = ink::env::test::default_accounts::(); + let contract = PredictionMarket::new(accounts.alice, 100); + assert_eq!(contract.admin, accounts.alice); + } + + #[ink::test] + fn market_creation_works() { + let accounts = ink::env::test::default_accounts::(); + let mut contract = PredictionMarket::new(accounts.alice, 100); + + let market_id = contract.create_market(1, 500_000, 1000).unwrap(); + assert_eq!(market_id, 0); + + let market = contract.get_market(market_id).unwrap(); + assert_eq!(market.target_value, 500_000); + assert_eq!(market.status, MarketStatus::Active); + } + } +}