From cac235dbccf16cb7411f3674404379b82e6e92c2 Mon Sep 17 00:00:00 2001 From: Sydhds Date: Tue, 1 Jul 2025 10:11:08 +0200 Subject: [PATCH] Add TierLimits unit tests (#12) * Add TierLimits unit tests --- prover/src/tier.rs | 492 +++++++++++++++++++++++++++--- prover/src/user_db.rs | 9 +- smart_contract/src/karma_tiers.rs | 9 + 3 files changed, 472 insertions(+), 38 deletions(-) diff --git a/prover/src/tier.rs b/prover/src/tier.rs index a62e4e6..1e56df7 100644 --- a/prover/src/tier.rs +++ b/prover/src/tier.rs @@ -1,10 +1,9 @@ use std::collections::{BTreeMap, HashSet}; -use std::ops::{ControlFlow, Deref, DerefMut}; +use std::ops::ControlFlow; // third-party use alloy::primitives::U256; -use derive_more::{From, Into}; +use derive_more::{From, Into, Deref, DerefMut}; // internal -// use crate::user_db_service::SetTierLimitsError; use smart_contract::{Tier, TierIndex}; #[derive(Debug, Clone, Copy, PartialEq, PartialOrd, From, Into)] @@ -19,20 +18,26 @@ impl From<&str> for TierName { } } -#[derive(Debug, Clone, Default, From, Into, PartialEq)] +#[derive(Debug, Clone, Default, From, Into, Deref, DerefMut, PartialEq)] pub struct TierLimits(BTreeMap); -impl Deref for TierLimits { - type Target = BTreeMap; - fn deref(&self) -> &Self::Target { - &self.0 +impl From<[(TierIndex, Tier); N]> for TierLimits { + + fn from( + value: [(TierIndex, Tier); N], + ) -> Self { + Self(BTreeMap::from(value)) } } -impl DerefMut for TierLimits { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } +#[derive(Debug, Clone, PartialEq)] +pub enum TierMatch { + /// Karma is below the lowest tier + UnderLowest, + /// Karma is above the highest tier + AboveHighest, + /// Karma is in the range of a defined tier. + Matched(TierIndex, Tier), } impl TierLimits { @@ -45,10 +50,11 @@ impl TierLimits { /// Validate tier limits (unique names, increasing min & max karma ...) pub(crate) fn validate(&self) -> Result<(), ValidateTierLimitsError> { - #[derive(Default)] + #[derive(Debug, Default)] struct Context<'a> { tier_names: HashSet, - prev_amount: Option<&'a U256>, + prev_min: Option<&'a U256>, + prev_max: Option<&'a U256>, prev_tx_per_epoch: Option<&'a u32>, prev_index: Option<&'a TierIndex>, } @@ -57,23 +63,26 @@ impl TierLimits { self.0 .iter() .try_fold(Context::default(), |mut state, (tier_index, tier)| { + if !tier.active { return Err(ValidateTierLimitsError::InactiveTier); } - - if *tier_index <= *state.prev_index.unwrap_or(&TierIndex::default()) { + if *tier_index != (*state.prev_index.unwrap_or(&TierIndex::default()) + 1) { + // Tier index must be increasing and consecutive + // cf function `_validateNoOverlap` in KarmaTiers.sol return Err(ValidateTierLimitsError::InvalidTierIndex); } - if tier.min_karma >= tier.max_karma { - return Err(ValidateTierLimitsError::InvalidMaxAmount( - tier.min_karma, - tier.max_karma, - )); + if tier.min_karma <= *state.prev_min.unwrap_or(&U256::ZERO) { + return Err(ValidateTierLimitsError::InvalidMinKarmaAmount); } - if tier.min_karma <= *state.prev_amount.unwrap_or(&U256::ZERO) { - return Err(ValidateTierLimitsError::InvalidKarmaAmount); + if tier.min_karma <= *state.prev_max.unwrap_or(&U256::ZERO) { + return Err(ValidateTierLimitsError::InvalidMinKarmaAmount); + } + + if tier.min_karma >= tier.max_karma { + return Err(ValidateTierLimitsError::InvalidMaxKarmaAmount); } if tier.tx_per_epoch <= *state.prev_tx_per_epoch.unwrap_or(&0) { @@ -84,7 +93,8 @@ impl TierLimits { return Err(ValidateTierLimitsError::NonUniqueTierName); } - state.prev_amount = Some(&tier.min_karma); + state.prev_min = Some(&tier.min_karma); + state.prev_max = Some(&tier.max_karma); state.prev_tx_per_epoch = Some(&tier.tx_per_epoch); state.tier_names.insert(tier.name.clone()); state.prev_index = Some(tier_index); @@ -94,29 +104,44 @@ impl TierLimits { Ok(()) } - /// Given some karma amount, find the matching Tier - pub(crate) fn get_tier_by_karma(&self, karma_amount: &U256) -> Option<(TierIndex, Tier)> { + /// Given some karma amount, find the matching Tier. Assume all tiers are active. + pub(crate) fn get_tier_by_karma(&self, karma_amount: &U256) -> TierMatch { + struct Context<'a> { - prev: Option<(&'a TierIndex, &'a Tier)>, + current: Option<(&'a TierIndex, &'a Tier)>, } - let ctx_initial = Context { prev: None }; + let ctx_initial = Context { current: None }; let ctx = self .0 .iter() .try_fold(ctx_initial, |mut state, (tier_index, tier)| { + + // Assume all the tier are active but checks it at dev time + debug_assert!(tier.active, "Find a non active tier"); + if karma_amount < &tier.min_karma { + // Early break - above lowest tier (< lowest_tier.min_karma) + ControlFlow::Break(state) + } else if karma_amount >= &tier.min_karma && karma_amount <= &tier.max_karma { + // Found a match - update ctx and break + state.current = Some((tier_index, tier)); ControlFlow::Break(state) } else { - state.prev = Some((tier_index, tier)); ControlFlow::Continue(state) } }); if let Some(ctx) = ctx.break_value() { - ctx.prev.map(|p| (*p.0, p.1.clone())) + // ControlFlow::Break + if let Some((tier_index, tier)) = ctx.current { + TierMatch::Matched(*tier_index, tier.clone()) + } else { + TierMatch::UnderLowest + } } else { - None + // ControlFlow::Continue + TierMatch::AboveHighest } } } @@ -124,15 +149,412 @@ impl TierLimits { #[derive(Debug, thiserror::Error)] pub enum ValidateTierLimitsError { #[error("Invalid Karma amount (must be increasing)")] - InvalidKarmaAmount, - #[error("Invalid Karma max amount (min: {0} vs max: {1})")] - InvalidMaxAmount(U256, U256), + InvalidMinKarmaAmount, + #[error("Invalid Karma max amount")] + InvalidMaxKarmaAmount, #[error("Invalid Tier limit (must be increasing)")] InvalidTierLimit, - #[error("Invalid Tier index (must be increasing)")] + #[error("Invalid Tier index (must be increasing & consecutive)")] InvalidTierIndex, #[error("Non unique Tier name")] NonUniqueTierName, #[error("Non active Tier")] InactiveTier, } + +#[cfg(test)] +mod tier_limits_tests { + use super::*; + use alloy::primitives::U256; + use claims::assert_matches; + + #[test] + fn test_filter_inactive() { + + let mut tier_limits = TierLimits::from([ + (TierIndex::from(0), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(1), + Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + (TierIndex::from(2), + Tier { + name: "Power User".to_string(), + min_karma: U256::from(500), + max_karma: U256::from(999), + tx_per_epoch: 86400, + active: false, + }), + ]); + + let filtered = tier_limits.filter_inactive(); + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_validate_fails_with_inactive_tier() { + + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: false, + }), + + ]); + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InactiveTier) + ); + } + + #[test] + fn test_validate_failed_when_karma_overlapping_between_tier() { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(100), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(150), + tx_per_epoch: 120, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidMinKarmaAmount) + ); + } + + #[test] + fn test_validate_fails_when_min_karma_equal_or_greater_max_karma() { + + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(100), + max_karma: U256::from(100), + tx_per_epoch: 6, + active: true, + }) + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidMaxKarmaAmount) + ); + + let tier_limits = TierLimits::from([ + (TierIndex::from(1), + Tier { + name: "Basic".to_string(), + min_karma: U256::from(500), + max_karma: U256::from(100), + tx_per_epoch: 6, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidMaxKarmaAmount) + ); + } + + #[test] + fn test_validate_fails_with_non_increasing_or_decreasing_min_karma() { + + // Case 1: Duplicate min_karma values + { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidMinKarmaAmount) + ); + } + + // Case 2: Decreasing min_karma values + { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 120, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidMinKarmaAmount) + ); + } + } + + #[test] + fn test_validate_fails_with_non_increasing_or_decreasing_tx_per_epoch() { + // Case 1: Duplicate tx_per_epoch values + { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 120, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidTierLimit) + ); + } + + // Case 2: Decreasing tx_per_epoch values + { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 120, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 6, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidTierLimit) + ); + } + } + + #[test] + fn test_validate_fails_with_duplicate_tier_names() { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Basic".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::NonUniqueTierName) + ); + } + + #[test] + fn test_validate_fails_tier_index() { + + // Non-consecutive tier index + { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(3), Tier { + name: "Basic".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + ]); + + assert_matches!( + tier_limits.validate(), + Err(ValidateTierLimitsError::InvalidTierIndex) + ); + } + } + + #[test] + fn test_validate_and_get_tier_by_karma_with_empty_tier_limits() { + let tier_limits = TierLimits::default(); + assert!(tier_limits.validate().is_ok()); + + // XXX: make sense to test against a empty TierLimits? + let result = tier_limits.get_tier_by_karma(&U256::ZERO); + assert_eq!(result, TierMatch::AboveHighest); + } + + #[test] + fn test_get_tier_by_karma_bounds_and_ranges() { + let tier_limits = TierLimits::from([ + (TierIndex::from(1), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: true, + }), + (TierIndex::from(2), Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + (TierIndex::from(3), Tier { + name: "Regular".to_string(), + min_karma: U256::from(100), + max_karma: U256::from(499), + tx_per_epoch: 720, + active: true, + }), + ]); + + // Case 1: Zero karma + let result = tier_limits.get_tier_by_karma(&U256::ZERO); + assert_eq!(result, TierMatch::UnderLowest); + + // Case 2: Karma below all tiers + let result = tier_limits.get_tier_by_karma(&U256::from(5)); + assert_eq!(result, TierMatch::UnderLowest); + + // Case 3: Exact match on min_karma (start of first tier) + let result = tier_limits.get_tier_by_karma(&U256::from(10)); + if let TierMatch::Matched(index, tier) = result { + assert_eq!(index, TierIndex::from(1)); + assert_eq!(tier.name, "Basic"); + } else { + panic!("Expected TierMatch::Matched, got {:?}", result); + } + + // Case 4: Exact match on a tier boundary (start of second tier) + let result = tier_limits.get_tier_by_karma(&U256::from(50)); + if let TierMatch::Matched(index, tier) = result { + assert_eq!(index, TierIndex::from(2)); + assert_eq!(tier.name, "Active"); + } else { + panic!("Expected TierMatch::Matched, got {:?}", result); + } + + // Case 5: Karma within a tier range (between third tier) + let result = tier_limits.get_tier_by_karma(&U256::from(250)); + if let TierMatch::Matched(index, tier) = result { + assert_eq!(index, TierIndex::from(3)); + assert_eq!(tier.name, "Regular"); + } else { + panic!("Expected TierMatch, got {:?}", result); + } + + // Case 6: Exact match on max_karma (end of the third tier) + let result = tier_limits.get_tier_by_karma(&U256::from(499)); + if let TierMatch::Matched(index, tier) = result { + assert_eq!(index, TierIndex::from(3)); + assert_eq!(tier.name, "Regular"); + } else { + panic!("Expected TierMatch, got {:?}", result); + } + + // Case 7: Karma above all tiers + let result = tier_limits.get_tier_by_karma(&U256::from(1000)); + assert_eq!(result, TierMatch::AboveHighest); + + } + + #[test] + #[should_panic(expected = "Find a non active tier")] + fn test_get_tier_by_karma_ignores_inactive_tiers() { + let tier_limits = TierLimits::from([ + (TierIndex::from(0), Tier { + name: "Basic".to_string(), + min_karma: U256::from(10), + max_karma: U256::from(49), + tx_per_epoch: 6, + active: false, + }), + (TierIndex::from(1), Tier { + name: "Active".to_string(), + min_karma: U256::from(50), + max_karma: U256::from(99), + tx_per_epoch: 120, + active: true, + }), + ]); + + let _result = tier_limits.get_tier_by_karma(&U256::from(25)); + } + +} \ No newline at end of file diff --git a/prover/src/user_db.rs b/prover/src/user_db.rs index 9a0c2ca..36c8ccc 100644 --- a/prover/src/user_db.rs +++ b/prover/src/user_db.rs @@ -23,7 +23,7 @@ use crate::rocksdb_operands::{ EpochCounterDeserializer, EpochCounterSerializer, EpochIncr, EpochIncrSerializer, epoch_counters_operands, u64_counter_operands, }; -use crate::tier::{TierLimit, TierLimits, TierName}; +use crate::tier::{TierLimit, TierLimits, TierMatch, TierName}; use crate::user_db_error::{ MerkleTreeIndexError, RegisterError, SetTierLimitsError, TxCounterError, UserDbOpenError, UserMerkleTreeIndexError, UserTierInfoError, @@ -563,7 +563,7 @@ impl UserDb { .map_err(|e| UserTierInfoError::Contract(e))?; let tier_limits = self.get_tier_limits()?; - let tier_info = tier_limits.get_tier_by_karma(&karma_amount); + let tier_match = tier_limits.get_tier_by_karma(&karma_amount); let user_tier_info = { let (current_epoch, current_epoch_slice) = *self.epoch_store.read(); @@ -576,10 +576,13 @@ impl UserDb { tier_name: None, tier_limit: None, }; - if let Some((_tier_index, tier)) = tier_info { + + // FIXME: Proto changes to return AboveHighest / UnderLowest + if let TierMatch::Matched(_tier_index, tier) = tier_match { t.tier_name = Some(tier.name.into()); t.tier_limit = Some(TierLimit::from(tier.tx_per_epoch)); } + t }; diff --git a/smart_contract/src/karma_tiers.rs b/smart_contract/src/karma_tiers.rs index c133271..14b8778 100644 --- a/smart_contract/src/karma_tiers.rs +++ b/smart_contract/src/karma_tiers.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::ops::Add; // third-party use alloy::{ primitives::{Address, U256}, @@ -67,6 +68,14 @@ impl From<&TierIndex> for u8 { } } +impl Add for TierIndex { + type Output = TierIndex; + + fn add(self, rhs: u8) -> Self::Output { + Self(self.0 + rhs) + } +} + #[derive(Debug, Clone, PartialEq)] pub struct Tier { pub min_karma: U256,