Add TierLimits unit tests (#12)

* Add TierLimits unit tests
This commit is contained in:
Sydhds
2025-07-01 10:11:08 +02:00
committed by GitHub
parent 9f4027ed2b
commit cac235dbcc
3 changed files with 472 additions and 38 deletions

View File

@@ -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<TierIndex, Tier>);
impl Deref for TierLimits {
type Target = BTreeMap<TierIndex, Tier>;
fn deref(&self) -> &Self::Target {
&self.0
impl<const N: usize> 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<String>,
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));
}
}

View File

@@ -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
};

View File

@@ -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<u8> 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,