Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 214 additions & 23 deletions contracts/analytics/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,13 +224,14 @@ mod propchain_analytics {
average_price: u128,
total_volume: u128,
properties_listed: u64,
) {
self.ensure_admin();
) -> Result<(), AnalyticsError> {
self.ensure_admin()?;
self.current_metrics = MarketMetrics {
average_price,
total_volume,
properties_listed,
};
Ok(())
}

/// Batch update multiple market metrics in a single transaction.
Expand All @@ -239,7 +240,7 @@ mod propchain_analytics {
&mut self,
updates: Vec<MetricUpdate>,
) -> Result<(), AnalyticsError> {
self.ensure_admin();
self.ensure_admin()?;
if updates.len() > MAX_BATCH_SIZE {
return Err(AnalyticsError::BatchSizeExceeded);
}
Expand All @@ -259,7 +260,7 @@ mod propchain_analytics {
/// Batch add multiple market trends in a single transaction.
#[ink(message)]
pub fn batch_add_trends(&mut self, trends: Vec<MarketTrend>) -> Result<(), AnalyticsError> {
self.ensure_admin();
self.ensure_admin()?;
if trends.len() > MAX_BATCH_SIZE {
return Err(AnalyticsError::BatchSizeExceeded);
}
Expand All @@ -273,12 +274,15 @@ mod propchain_analytics {
Ok(())
}

/// Create market trend analysis with historical data
/// Create market trend analysis with historical data.
///
/// Admin only; returns [`AnalyticsError::Unauthorized`] for any other caller.
#[ink(message)]
pub fn add_market_trend(&mut self, trend: MarketTrend) {
self.ensure_admin();
pub fn add_market_trend(&mut self, trend: MarketTrend) -> Result<(), AnalyticsError> {
self.ensure_admin()?;
self.historical_trends.insert(self.trend_count, &trend);
self.trend_count += 1;
Ok(())
}
#[ink(message)]
pub fn get_historical_trends(&self) -> Vec<MarketTrend> {
Expand Down Expand Up @@ -323,15 +327,18 @@ mod propchain_analytics {
}
}

/// Update market sentiment from prediction markets
/// Update market sentiment from prediction markets.
///
/// Admin only (or an authorized prediction-market integration once such
/// a role exists); returns [`AnalyticsError::Unauthorized`] otherwise.
#[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
) -> Result<(), AnalyticsError> {
self.ensure_admin()?; // Prediction market or admin updates this
let total_volume = bull_volume + bear_volume;
let ratio = (bull_volume * 10000)
.checked_div(total_volume)
Expand Down Expand Up @@ -364,17 +371,21 @@ mod propchain_analytics {
.checked_div(total_overall)
.map(|n| n as u32)
.unwrap_or(self.overall_sentiment.bull_bear_ratio_bips);
Ok(())
}

/// Update portfolio positions for an owner.
///
/// Admin only; returns [`AnalyticsError::Unauthorized`] for any other caller.
#[ink(message)]
pub fn set_portfolio_positions(
&mut self,
owner: AccountId,
positions: Vec<PortfolioPosition>,
) {
self.ensure_admin();
) -> Result<(), AnalyticsError> {
self.ensure_admin()?;
self.portfolio_positions.insert(owner, &positions);
Ok(())
}

/// Retrieve portfolio positions for an owner.
Expand All @@ -384,14 +395,17 @@ mod propchain_analytics {
}

/// Update property-type market trends used for portfolio rebalancing recommendations.
///
/// Admin only; returns [`AnalyticsError::Unauthorized`] for any other caller.
#[ink(message)]
pub fn update_property_type_trend(
&mut self,
property_type: propchain_traits::PropertyType,
trend: MarketTrend,
) {
self.ensure_admin();
) -> Result<(), AnalyticsError> {
self.ensure_admin()?;
self.property_type_trends.insert(property_type, &trend);
Ok(())
}

/// Get the stored market trend for a specific property type.
Expand All @@ -411,15 +425,18 @@ mod propchain_analytics {
}

/// Update the benchmark index for a property type against a basket of reference indices.
///
/// Admin only; returns [`AnalyticsError::Unauthorized`] for any other caller.
#[ink(message)]
pub fn update_benchmark_index(
&mut self,
property_type: propchain_traits::PropertyType,
performance_change_percentage: i32,
) {
self.ensure_admin();
) -> Result<(), AnalyticsError> {
self.ensure_admin()?;
self.benchmark_indices
.insert(property_type, &performance_change_percentage);
Ok(())
}

/// Get the stored benchmark index for a property type.
Expand Down Expand Up @@ -552,13 +569,15 @@ mod propchain_analytics {
self.admin
}

/// Ensure only the admin can modify metrics
fn ensure_admin(&self) {
assert_eq!(
self.env().caller(),
self.admin,
"Unauthorized: Analytics admin only"
);
/// Ensure only the admin can modify metrics.
///
/// Returns a typed [`AnalyticsError::Unauthorized`] instead of
/// panicking so integrators can distinguish authorization failures.
fn ensure_admin(&self) -> Result<(), AnalyticsError> {
if self.env().caller() != self.admin {
return Err(AnalyticsError::Unauthorized);
}
Ok(())
}

// ── Admin Key Rotation (Issue #496) ──────────────────────────────────
Expand Down Expand Up @@ -669,4 +688,176 @@ mod propchain_analytics {
self.pending_admin_rotation.clone()
}
}

#[cfg(test)]
mod tests {
use super::*;

type Environment = ink::env::DefaultEnvironment;

fn accounts() -> ink::env::test::DefaultAccounts<Environment> {
ink::env::test::default_accounts::<Environment>()
}

fn set_caller(caller: AccountId) {
ink::env::test::set_caller::<Environment>(caller);
}

fn sample_trend() -> MarketTrend {
MarketTrend {
period_start: 1_000,
period_end: 2_000,
price_change_percentage: 5,
volume_change_percentage: 10,
}
}

fn admin_contract() -> AnalyticsDashboard {
AnalyticsDashboard::new()
}

#[ink::test]
fn unauthorized_update_market_metrics_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.update_market_metrics(100, 200, 3),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn unauthorized_batch_update_metrics_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.batch_update_metrics(Vec::new()),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn unauthorized_batch_add_trends_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.batch_add_trends(Vec::new()),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn unauthorized_add_market_trend_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.add_market_trend(sample_trend()),
Err(AnalyticsError::Unauthorized)
);
assert_eq!(c.get_historical_trends().len(), 0);
}

#[ink::test]
fn unauthorized_update_market_sentiment_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.update_market_sentiment(1, 100, 100),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn unauthorized_set_portfolio_positions_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.set_portfolio_positions(accounts.alice, Vec::new()),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn unauthorized_update_property_type_trend_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.update_property_type_trend(propchain_traits::PropertyType::Residential, sample_trend()),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn unauthorized_update_benchmark_index_gets_typed_error() {
let accounts = accounts();
let mut c = admin_contract();
set_caller(accounts.bob);
assert_eq!(
c.update_benchmark_index(propchain_traits::PropertyType::Residential, 7),
Err(AnalyticsError::Unauthorized)
);
}

#[ink::test]
fn admin_succeeds_on_all_gated_messages() {
let accounts = accounts();
let mut c = admin_contract(); // constructor caller is admin
assert_eq!(c.update_market_metrics(150, 300, 4), Ok(()));
assert_eq!(c.get_market_metrics().average_price, 150);

assert_eq!(c.add_market_trend(sample_trend()), Ok(()));
assert_eq!(c.get_historical_trends().len(), 1);

assert_eq!(
c.batch_update_metrics(vec![MetricUpdate {
average_price: 160,
total_volume: 320,
properties_listed: 5,
}]),
Ok(())
);

assert_eq!(c.batch_add_trends(vec![sample_trend()]), Ok(()));
assert_eq!(c.get_historical_trends().len(), 2);

assert_eq!(c.update_market_sentiment(1, 400, 100), Ok(()));
assert_eq!(c.overall_sentiment.bull_volume.saturating_sub(0), 400);

let positions = vec![PortfolioPosition {
property_type: propchain_traits::PropertyType::Residential,
value: 1_000,
}];
assert_eq!(c.set_portfolio_positions(accounts.alice, positions), Ok(()));
assert_eq!(c.get_portfolio_positions(accounts.alice).len(), 1);

assert_eq!(
c.update_property_type_trend(
propchain_traits::PropertyType::Commercial,
sample_trend()
),
Ok(())
);
assert_eq!(
c.get_property_type_trend(propchain_traits::PropertyType::Commercial)
.price_change_percentage,
5
);

assert_eq!(
c.update_benchmark_index(propchain_traits::PropertyType::Commercial, 9),
Ok(())
);
assert_eq!(
c.get_benchmark_index(propchain_traits::PropertyType::Commercial),
9
);
}
}
}
Loading
Loading