use crate::error::{Result, TokenError};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tenzro_storage::{KvStore, WriteOp, CF_TOKENS};
use tenzro_types::primitives::{BlockHeight, Timestamp};
use tracing::{debug, info};
pub const BURN_RATE_CONFIG_KEY: &[u8] = b"burn_rate:current";
pub const SUPPLY_TARGETS_KEY: &[u8] = b"burn_targets:current";
pub const SUPPLY_METRICS_KEY: &[u8] = b"burn_metrics:latest";
pub const DEFAULT_BASE_FEE_BURN_BPS: u16 = 10_000;
pub const DEFAULT_LOCAL_FEE_BURN_BPS: u16 = 10_000;
pub const DEFAULT_PAYMASTER_BURN_BPS: u16 = 10_000;
pub const DEFAULT_NEUTRAL_BAND_BPS: u16 = 5;
pub const DEFAULT_ROLLING_WINDOW_EPOCHS: u32 = 90;
pub const DEFAULT_INFLATION_ALARM_BPS: u16 = 500;
pub const DEFAULT_DEFLATION_ALARM_BPS: u16 = 500;
pub const DEFAULT_TARGET_ANNUAL_SUPPLY_BPS: i32 = 50;
pub const DEFAULT_GAIN_BPS_PER_PCT: u16 = 50;
pub const DEFAULT_MAGNITUDE_CAP_NORMAL_BPS: u16 = 200;
pub const DEFAULT_MAGNITUDE_CAP_ALARM_BPS: u16 = 100;
pub const DEFAULT_AUTO_PROPOSAL_MIN_MAGNITUDE_BPS: u16 = 25;
pub const DEFAULT_ALARM_FAST_TRACK_ENABLED: bool = true;
pub const DEFAULT_ALARM_TIMELOCK_HOURS: u32 = 6;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct BurnRateConfig {
pub base_fee_burn_bps: u16,
pub local_fee_burn_bps: u16,
pub paymaster_burn_bps: u16,
}
impl Default for BurnRateConfig {
fn default() -> Self {
Self {
base_fee_burn_bps: DEFAULT_BASE_FEE_BURN_BPS,
local_fee_burn_bps: DEFAULT_LOCAL_FEE_BURN_BPS,
paymaster_burn_bps: DEFAULT_PAYMASTER_BURN_BPS,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FeeSplit {
pub burn: u128,
pub treasury: u128,
}
impl FeeSplit {
pub fn total(&self) -> u128 {
self.burn.saturating_add(self.treasury)
}
}
fn split_amount(amount: u128, burn_bps: u16) -> FeeSplit {
let bps = burn_bps.min(10_000) as u128;
let burn = amount.saturating_mul(bps) / 10_000;
FeeSplit {
burn,
treasury: amount.saturating_sub(burn),
}
}
impl BurnRateConfig {
pub fn split_base_fee(&self, amount: u128) -> FeeSplit {
split_amount(amount, self.base_fee_burn_bps)
}
pub fn split_local_fee(&self, amount: u128) -> FeeSplit {
split_amount(amount, self.local_fee_burn_bps)
}
pub fn split_paymaster(&self, amount: u128) -> FeeSplit {
split_amount(amount, self.paymaster_burn_bps)
}
pub fn base_fee_treasury_bps(&self) -> u16 {
10_000_u16.saturating_sub(self.base_fee_burn_bps)
}
pub fn local_fee_treasury_bps(&self) -> u16 {
10_000_u16.saturating_sub(self.local_fee_burn_bps)
}
pub fn paymaster_treasury_bps(&self) -> u16 {
10_000_u16.saturating_sub(self.paymaster_burn_bps)
}
pub fn with_base_fee_burn_delta(self, delta_bps: i32) -> Self {
let new = (self.base_fee_burn_bps as i32 + delta_bps).clamp(0, 10_000) as u16;
Self {
base_fee_burn_bps: new,
..self
}
}
pub fn validate(&self) -> Result<()> {
for (name, bps) in [
("base_fee_burn_bps", self.base_fee_burn_bps),
("local_fee_burn_bps", self.local_fee_burn_bps),
("paymaster_burn_bps", self.paymaster_burn_bps),
] {
if bps > 10_000 {
return Err(TokenError::InvalidParameter(format!(
"{} {} > 10000",
name, bps
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct SupplyTargets {
pub enabled: bool,
pub epoch_neutral_band_bps: u16,
pub rolling_window_epochs: u32,
pub inflation_alarm_bps: u16,
pub deflation_alarm_bps: u16,
pub target_annual_supply_bps: i32,
pub gain_bps_per_pct: u16,
pub magnitude_cap_normal_bps: u16,
pub magnitude_cap_alarm_bps: u16,
pub auto_proposal_min_magnitude_bps: u16,
pub alarm_fast_track_enabled: bool,
pub alarm_timelock_hours: u32,
}
impl Default for SupplyTargets {
fn default() -> Self {
Self {
enabled: true,
epoch_neutral_band_bps: DEFAULT_NEUTRAL_BAND_BPS,
rolling_window_epochs: DEFAULT_ROLLING_WINDOW_EPOCHS,
inflation_alarm_bps: DEFAULT_INFLATION_ALARM_BPS,
deflation_alarm_bps: DEFAULT_DEFLATION_ALARM_BPS,
target_annual_supply_bps: DEFAULT_TARGET_ANNUAL_SUPPLY_BPS,
gain_bps_per_pct: DEFAULT_GAIN_BPS_PER_PCT,
magnitude_cap_normal_bps: DEFAULT_MAGNITUDE_CAP_NORMAL_BPS,
magnitude_cap_alarm_bps: DEFAULT_MAGNITUDE_CAP_ALARM_BPS,
auto_proposal_min_magnitude_bps: DEFAULT_AUTO_PROPOSAL_MIN_MAGNITUDE_BPS,
alarm_fast_track_enabled: DEFAULT_ALARM_FAST_TRACK_ENABLED,
alarm_timelock_hours: DEFAULT_ALARM_TIMELOCK_HOURS,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct BurnBreakdown {
pub base_fee: u128,
pub local_fee: u128,
pub paymaster: u128,
pub slash: u128,
}
impl BurnBreakdown {
pub fn total(&self) -> u128 {
self.base_fee
.saturating_add(self.local_fee)
.saturating_add(self.paymaster)
.saturating_add(self.slash)
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EmissionBreakdown {
pub staking_rewards: u128,
pub treasury_emissions: u128,
}
impl EmissionBreakdown {
pub fn total(&self) -> u128 {
self.staking_rewards
.saturating_add(self.treasury_emissions)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SupplyMetricsSnapshot {
pub block_height: BlockHeight,
pub captured_at: Timestamp,
pub circulating_supply: u128,
pub epoch_supply_delta: i128,
pub rolling_window_supply_delta_bps: i32,
pub burn_breakdown: BurnBreakdown,
pub emission_breakdown: EmissionBreakdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecommendationAction {
Disabled,
NoChange,
IncreaseBurnPct,
DecreaseBurnPct,
AlarmHighInflation,
AlarmHighDeflation,
}
impl RecommendationAction {
pub fn as_str(&self) -> &'static str {
match self {
RecommendationAction::Disabled => "disabled",
RecommendationAction::NoChange => "no_change",
RecommendationAction::IncreaseBurnPct => "increase_burn_pct",
RecommendationAction::DecreaseBurnPct => "decrease_burn_pct",
RecommendationAction::AlarmHighInflation => "alarm_high_inflation",
RecommendationAction::AlarmHighDeflation => "alarm_high_deflation",
}
}
pub fn is_alarm(&self) -> bool {
matches!(
self,
RecommendationAction::AlarmHighInflation
| RecommendationAction::AlarmHighDeflation
)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BurnRateRecommendation {
pub action: RecommendationAction,
pub magnitude_bps: i32,
pub above_proposal_floor: bool,
pub deviation_bps: i32,
}
pub fn compute_recommendation(
metrics: &SupplyMetricsSnapshot,
targets: &SupplyTargets,
) -> BurnRateRecommendation {
if !targets.enabled {
return BurnRateRecommendation {
action: RecommendationAction::Disabled,
magnitude_bps: 0,
above_proposal_floor: false,
deviation_bps: 0,
};
}
let rolling = metrics.rolling_window_supply_delta_bps;
let target = targets.target_annual_supply_bps;
let deviation = rolling.saturating_sub(target);
let extreme_inflation = (targets.inflation_alarm_bps as i32).saturating_mul(2);
let extreme_deflation = (targets.deflation_alarm_bps as i32).saturating_mul(2);
if rolling > extreme_inflation {
return BurnRateRecommendation {
action: RecommendationAction::AlarmHighInflation,
magnitude_bps: 0,
above_proposal_floor: true,
deviation_bps: deviation,
};
}
if rolling < -extreme_deflation {
return BurnRateRecommendation {
action: RecommendationAction::AlarmHighDeflation,
magnitude_bps: 0,
above_proposal_floor: true,
deviation_bps: deviation,
};
}
let window = targets.rolling_window_epochs.max(1) as i32;
let per_epoch_deviation = deviation / window;
let band = targets.epoch_neutral_band_bps as i32;
if per_epoch_deviation.abs() < band {
return BurnRateRecommendation {
action: RecommendationAction::NoChange,
magnitude_bps: 0,
above_proposal_floor: false,
deviation_bps: deviation,
};
}
if rolling > targets.inflation_alarm_bps as i32 {
return BurnRateRecommendation {
action: RecommendationAction::AlarmHighInflation,
magnitude_bps: 0,
above_proposal_floor: true,
deviation_bps: deviation,
};
}
if rolling < -(targets.deflation_alarm_bps as i32) {
return BurnRateRecommendation {
action: RecommendationAction::AlarmHighDeflation,
magnitude_bps: 0,
above_proposal_floor: true,
deviation_bps: deviation,
};
}
let abs_dev = deviation.unsigned_abs();
let raw_magnitude =
(abs_dev as u64 * targets.gain_bps_per_pct as u64 / 100) as i32;
let cap = targets.magnitude_cap_normal_bps as i32;
let capped = raw_magnitude.min(cap);
let (action, magnitude) = if deviation > 0 {
(RecommendationAction::IncreaseBurnPct, capped)
} else {
(RecommendationAction::DecreaseBurnPct, -capped)
};
let above_floor =
magnitude.unsigned_abs() >= targets.auto_proposal_min_magnitude_bps as u32;
BurnRateRecommendation {
action,
magnitude_bps: magnitude,
above_proposal_floor: above_floor,
deviation_bps: deviation,
}
}
pub struct BurnRateConfigManager {
config: parking_lot::RwLock<BurnRateConfig>,
targets: parking_lot::RwLock<SupplyTargets>,
metrics: parking_lot::RwLock<SupplyMetricsSnapshot>,
storage: Option<Arc<dyn KvStore>>,
}
impl Default for BurnRateConfigManager {
fn default() -> Self {
Self::new()
}
}
impl BurnRateConfigManager {
pub fn new() -> Self {
Self {
config: parking_lot::RwLock::new(BurnRateConfig::default()),
targets: parking_lot::RwLock::new(SupplyTargets::default()),
metrics: parking_lot::RwLock::new(SupplyMetricsSnapshot::default()),
storage: None,
}
}
pub fn with_storage(storage: Arc<dyn KvStore>) -> Result<Self> {
let mgr = Self {
config: parking_lot::RwLock::new(BurnRateConfig::default()),
targets: parking_lot::RwLock::new(SupplyTargets::default()),
metrics: parking_lot::RwLock::new(SupplyMetricsSnapshot::default()),
storage: Some(storage),
};
mgr.hydrate_from_storage()?;
Ok(mgr)
}
pub fn config(&self) -> BurnRateConfig {
*self.config.read()
}
pub fn targets(&self) -> SupplyTargets {
*self.targets.read()
}
pub fn latest_metrics(&self) -> SupplyMetricsSnapshot {
self.metrics.read().clone()
}
pub fn apply_config(&self, new_config: BurnRateConfig) -> Result<()> {
new_config.validate()?;
if new_config.paymaster_burn_bps != DEFAULT_PAYMASTER_BURN_BPS {
return Err(TokenError::InvalidParameter(format!(
"paymaster_burn_bps is locked at {} (got {})",
DEFAULT_PAYMASTER_BURN_BPS, new_config.paymaster_burn_bps
)));
}
*self.config.write() = new_config;
self.persist_config(&new_config)?;
info!(
base_fee_burn_bps = new_config.base_fee_burn_bps,
local_fee_burn_bps = new_config.local_fee_burn_bps,
paymaster_burn_bps = new_config.paymaster_burn_bps,
"BurnRateConfig updated"
);
Ok(())
}
pub fn apply_targets(&self, new_targets: SupplyTargets) -> Result<()> {
if new_targets.epoch_neutral_band_bps > 10_000
|| new_targets.gain_bps_per_pct > 10_000
{
return Err(TokenError::InvalidParameter(
"epoch_neutral_band_bps / gain_bps_per_pct must be <= 10000".into(),
));
}
*self.targets.write() = new_targets;
self.persist_targets(&new_targets)?;
info!("SupplyTargets updated");
Ok(())
}
pub fn record_metrics(&self, snapshot: SupplyMetricsSnapshot) -> Result<()> {
*self.metrics.write() = snapshot.clone();
self.persist_metrics(&snapshot)?;
debug!(
block_height = snapshot.block_height.0,
epoch_supply_delta = snapshot.epoch_supply_delta,
rolling_bps = snapshot.rolling_window_supply_delta_bps,
"SupplyMetricsSnapshot recorded"
);
Ok(())
}
pub fn current_recommendation(&self) -> BurnRateRecommendation {
let metrics = self.metrics.read().clone();
let targets = *self.targets.read();
compute_recommendation(&metrics, &targets)
}
fn hydrate_from_storage(&self) -> Result<()> {
let storage = match &self.storage {
Some(s) => s.clone(),
None => return Ok(()),
};
match storage
.get(CF_TOKENS, BURN_RATE_CONFIG_KEY)
.map_err(|e| TokenError::StorageError(format!("get burn rate config: {}", e)))?
{
Some(value) => {
let cfg: BurnRateConfig = serde_json::from_slice(&value).map_err(|e| {
TokenError::StorageError(format!("decode burn rate config: {}", e))
})?;
cfg.validate()?;
*self.config.write() = cfg;
info!(
base_fee_burn_bps = cfg.base_fee_burn_bps,
"BurnRateConfig hydrated from storage"
);
}
None => {
let genesis = *self.config.read();
self.persist_config(&genesis)?;
info!("BurnRateConfig initialized from genesis defaults");
}
}
match storage
.get(CF_TOKENS, SUPPLY_TARGETS_KEY)
.map_err(|e| TokenError::StorageError(format!("get supply targets: {}", e)))?
{
Some(value) => {
let t: SupplyTargets = serde_json::from_slice(&value).map_err(|e| {
TokenError::StorageError(format!("decode supply targets: {}", e))
})?;
*self.targets.write() = t;
info!("SupplyTargets hydrated from storage");
}
None => {
let genesis = *self.targets.read();
self.persist_targets(&genesis)?;
info!("SupplyTargets initialized from genesis defaults");
}
}
if let Some(value) = storage
.get(CF_TOKENS, SUPPLY_METRICS_KEY)
.map_err(|e| TokenError::StorageError(format!("get supply metrics: {}", e)))?
{
let m: SupplyMetricsSnapshot = serde_json::from_slice(&value).map_err(|e| {
TokenError::StorageError(format!("decode supply metrics: {}", e))
})?;
*self.metrics.write() = m;
info!("SupplyMetricsSnapshot hydrated from storage");
}
Ok(())
}
fn persist_config(&self, cfg: &BurnRateConfig) -> Result<()> {
if let Some(storage) = &self.storage {
let value = serde_json::to_vec(cfg).map_err(|e| {
TokenError::StorageError(format!("encode burn rate config: {}", e))
})?;
storage
.write_batch_sync(vec![WriteOp::Put {
cf: CF_TOKENS.to_string(),
key: BURN_RATE_CONFIG_KEY.to_vec(),
value,
}])
.map_err(|e| {
TokenError::StorageError(format!("persist burn rate config: {}", e))
})?;
}
Ok(())
}
fn persist_targets(&self, t: &SupplyTargets) -> Result<()> {
if let Some(storage) = &self.storage {
let value = serde_json::to_vec(t).map_err(|e| {
TokenError::StorageError(format!("encode supply targets: {}", e))
})?;
storage
.write_batch_sync(vec![WriteOp::Put {
cf: CF_TOKENS.to_string(),
key: SUPPLY_TARGETS_KEY.to_vec(),
value,
}])
.map_err(|e| {
TokenError::StorageError(format!("persist supply targets: {}", e))
})?;
}
Ok(())
}
fn persist_metrics(&self, m: &SupplyMetricsSnapshot) -> Result<()> {
if let Some(storage) = &self.storage {
let value = serde_json::to_vec(m).map_err(|e| {
TokenError::StorageError(format!("encode supply metrics: {}", e))
})?;
storage
.write_batch_sync(vec![WriteOp::Put {
cf: CF_TOKENS.to_string(),
key: SUPPLY_METRICS_KEY.to_vec(),
value,
}])
.map_err(|e| {
TokenError::StorageError(format!("persist supply metrics: {}", e))
})?;
}
Ok(())
}
}
pub const DEFAULT_AUTO_PROPOSAL_POLL_INTERVAL_SECS: u64 = 8 * 60 * 60;
pub const DEFAULT_AUTO_PROPOSAL_DEBOUNCE_SECS: u64 = 24 * 60 * 60;
pub const DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS: u32 = 24;
#[derive(Debug, Clone, Copy)]
pub struct AutoProposalGeneratorConfig {
pub poll_interval_secs: u64,
pub debounce_secs: u64,
pub normal_voting_hours: u32,
}
impl Default for AutoProposalGeneratorConfig {
fn default() -> Self {
Self {
poll_interval_secs: DEFAULT_AUTO_PROPOSAL_POLL_INTERVAL_SECS,
debounce_secs: DEFAULT_AUTO_PROPOSAL_DEBOUNCE_SECS,
normal_voting_hours: DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS,
}
}
}
pub struct AutoProposalGenerator {
manager: Arc<BurnRateConfigManager>,
governance: Arc<crate::governance::GovernanceEngine>,
config: AutoProposalGeneratorConfig,
last_issued_at: parking_lot::Mutex<Option<std::time::Instant>>,
}
impl AutoProposalGenerator {
pub fn new(
manager: Arc<BurnRateConfigManager>,
governance: Arc<crate::governance::GovernanceEngine>,
) -> Self {
Self::with_config(manager, governance, AutoProposalGeneratorConfig::default())
}
pub fn with_config(
manager: Arc<BurnRateConfigManager>,
governance: Arc<crate::governance::GovernanceEngine>,
config: AutoProposalGeneratorConfig,
) -> Self {
Self {
manager,
governance,
config,
last_issued_at: parking_lot::Mutex::new(None),
}
}
pub fn spawn(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
let poll = std::time::Duration::from_secs(self.config.poll_interval_secs);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(poll);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = self.tick_once() {
tracing::warn!(error = %e, "AutoProposalGenerator: tick_once failed");
}
}
})
}
pub fn tick_once(&self) -> Result<Option<String>> {
let recommendation = self.manager.current_recommendation();
if !self.should_propose(&recommendation) {
return Ok(None);
}
{
let last = self.last_issued_at.lock();
if let Some(t) = *last {
let elapsed = t.elapsed();
if elapsed.as_secs() < self.config.debounce_secs {
debug!(
elapsed_secs = elapsed.as_secs(),
debounce_secs = self.config.debounce_secs,
"AutoProposalGenerator: within debounce window, skipping"
);
return Ok(None);
}
}
}
let targets = self.manager.targets();
let current = self.manager.config();
let proposed = self.compute_proposed_config(¤t, &targets, &recommendation);
proposed.validate()?;
let voting_duration_ms = self.voting_duration_ms(&recommendation, &targets);
let title = format!(
"Adaptive burn: {} (deviation {} bps)",
recommendation.action.as_str(),
recommendation.deviation_bps,
);
let description = format!(
"Auto-issued by AutoProposalGenerator.\n\
Action: {action}\n\
Magnitude: {magnitude} bps\n\
Deviation: {deviation} bps\n\
Current base_fee_burn_bps: {cur}\n\
Proposed base_fee_burn_bps: {prop}\n\
Voting window: {hours}h ({alarm}).",
action = recommendation.action.as_str(),
magnitude = recommendation.magnitude_bps,
deviation = recommendation.deviation_bps,
cur = current.base_fee_burn_bps,
prop = proposed.base_fee_burn_bps,
hours = voting_duration_ms / 3_600_000,
alarm = if recommendation.action.is_alarm() {
"alarm fast-track"
} else {
"normal"
},
);
let proposal_type = tenzro_types::token::ProposalType::AdaptiveBurnConfigUpdate {
base_fee_burn_bps: proposed.base_fee_burn_bps,
local_fee_burn_bps: proposed.local_fee_burn_bps,
paymaster_burn_bps: proposed.paymaster_burn_bps,
};
let proposal_id = self
.governance
.create_system_proposal(title, description, proposal_type, voting_duration_ms)?;
*self.last_issued_at.lock() = Some(std::time::Instant::now());
info!(
proposal_id = %proposal_id,
action = recommendation.action.as_str(),
current_bps = current.base_fee_burn_bps,
proposed_bps = proposed.base_fee_burn_bps,
"AutoProposalGenerator: drafted adaptive burn proposal"
);
Ok(Some(proposal_id))
}
fn should_propose(&self, rec: &BurnRateRecommendation) -> bool {
match rec.action {
RecommendationAction::Disabled | RecommendationAction::NoChange => false,
RecommendationAction::IncreaseBurnPct | RecommendationAction::DecreaseBurnPct => {
rec.above_proposal_floor
}
RecommendationAction::AlarmHighInflation
| RecommendationAction::AlarmHighDeflation => true,
}
}
fn compute_proposed_config(
&self,
current: &BurnRateConfig,
targets: &SupplyTargets,
rec: &BurnRateRecommendation,
) -> BurnRateConfig {
let delta = match rec.action {
RecommendationAction::AlarmHighInflation => targets.magnitude_cap_alarm_bps as i32,
RecommendationAction::AlarmHighDeflation => -(targets.magnitude_cap_alarm_bps as i32),
_ => rec.magnitude_bps,
};
current.with_base_fee_burn_delta(delta)
}
fn voting_duration_ms(&self, rec: &BurnRateRecommendation, targets: &SupplyTargets) -> i64 {
let hours = if rec.action.is_alarm() && targets.alarm_fast_track_enabled {
targets.alarm_timelock_hours
} else {
self.config.normal_voting_hours
};
hours as i64 * 3_600_000
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fee_split_is_exact_with_dust_to_treasury() {
let c = BurnRateConfig::default();
let s = c.split_base_fee(1_000);
assert_eq!((s.burn, s.treasury), (1_000, 0));
assert_eq!(s.total(), 1_000);
let c2 = BurnRateConfig { base_fee_burn_bps: 5_000, ..Default::default() };
let s2 = c2.split_base_fee(1_001);
assert_eq!((s2.burn, s2.treasury), (500, 501));
assert_eq!(s2.total(), 1_001);
let c3 = BurnRateConfig { base_fee_burn_bps: 0, ..Default::default() };
let s3 = c3.split_base_fee(777);
assert_eq!((s3.burn, s3.treasury), (0, 777));
assert_eq!(c.split_local_fee(42), FeeSplit { burn: 42, treasury: 0 });
assert_eq!(c.split_paymaster(42), FeeSplit { burn: 42, treasury: 0 });
}
fn metrics_with_rolling(bps: i32) -> SupplyMetricsSnapshot {
SupplyMetricsSnapshot {
rolling_window_supply_delta_bps: bps,
..SupplyMetricsSnapshot::default()
}
}
#[test]
fn defaults_are_consistent() {
let cfg = BurnRateConfig::default();
cfg.validate().unwrap();
assert_eq!(cfg.base_fee_burn_bps, 10_000);
assert_eq!(cfg.base_fee_treasury_bps(), 0);
assert_eq!(cfg.paymaster_burn_bps, 10_000);
}
#[test]
fn config_with_delta_clamps() {
let cfg = BurnRateConfig::default();
let lower = cfg.with_base_fee_burn_delta(-200);
assert_eq!(lower.base_fee_burn_bps, 9_800);
let raise = cfg.with_base_fee_burn_delta(500);
assert_eq!(raise.base_fee_burn_bps, 10_000);
let big_drop = cfg.with_base_fee_burn_delta(-20_000);
assert_eq!(big_drop.base_fee_burn_bps, 0);
}
#[test]
fn disabled_short_circuits() {
let targets = SupplyTargets {
enabled: false,
..SupplyTargets::default()
};
let m = metrics_with_rolling(1_000);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::Disabled);
assert_eq!(r.magnitude_bps, 0);
}
#[test]
fn neutral_band_returns_no_change() {
let targets = SupplyTargets::default();
let m = metrics_with_rolling(50);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::NoChange);
assert_eq!(r.deviation_bps, 0);
}
#[test]
fn mild_inflation_recommends_increase_burn() {
let targets = SupplyTargets::default();
let m = metrics_with_rolling(500);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::IncreaseBurnPct);
assert!(r.magnitude_bps > 0);
assert_eq!(r.magnitude_bps, 200);
assert!(r.above_proposal_floor);
}
#[test]
fn mild_deflation_recommends_decrease_burn() {
let targets = SupplyTargets::default();
let m = metrics_with_rolling(-400);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::DecreaseBurnPct);
assert!(r.magnitude_bps < 0);
assert_eq!(r.magnitude_bps, -200);
}
#[test]
fn alarm_high_inflation() {
let targets = SupplyTargets::default();
let m = metrics_with_rolling(700);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::AlarmHighInflation);
assert_eq!(r.magnitude_bps, 0);
assert!(r.action.is_alarm());
}
#[test]
fn alarm_high_deflation() {
let targets = SupplyTargets::default();
let m = metrics_with_rolling(-700);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::AlarmHighDeflation);
assert_eq!(r.magnitude_bps, 0);
}
#[test]
fn extreme_reading_forces_alarm_only() {
let targets = SupplyTargets::default();
let m = metrics_with_rolling(1500);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::AlarmHighInflation);
assert_eq!(r.magnitude_bps, 0);
}
#[test]
fn manager_apply_config_rejects_paymaster_unlock() {
let mgr = BurnRateConfigManager::new();
let bad = BurnRateConfig {
paymaster_burn_bps: 9_500,
..BurnRateConfig::default()
};
let err = mgr.apply_config(bad).unwrap_err();
assert!(err.to_string().contains("paymaster_burn_bps is locked"));
}
#[test]
fn manager_apply_config_persists() {
let mgr = BurnRateConfigManager::new();
let updated = BurnRateConfig {
base_fee_burn_bps: 9_500,
..BurnRateConfig::default()
};
mgr.apply_config(updated).unwrap();
assert_eq!(mgr.config().base_fee_burn_bps, 9_500);
}
#[test]
fn record_metrics_round_trips_through_recommendation() {
let mgr = BurnRateConfigManager::new();
mgr.record_metrics(metrics_with_rolling(500)).unwrap();
let r = mgr.current_recommendation();
assert_eq!(r.action, RecommendationAction::IncreaseBurnPct);
}
#[test]
fn breakdown_totals() {
let b = BurnBreakdown {
base_fee: 100,
local_fee: 50,
paymaster: 25,
slash: 10,
};
assert_eq!(b.total(), 185);
let e = EmissionBreakdown {
staking_rewards: 200,
treasury_emissions: 100,
};
assert_eq!(e.total(), 300);
}
#[test]
fn proposal_floor_skips_small_magnitudes() {
let targets = SupplyTargets {
auto_proposal_min_magnitude_bps: 500, rolling_window_epochs: 1, ..SupplyTargets::default()
};
let m = metrics_with_rolling(450);
let r = compute_recommendation(&m, &targets);
assert_eq!(r.action, RecommendationAction::IncreaseBurnPct);
assert_eq!(r.magnitude_bps, 200);
assert!(!r.above_proposal_floor);
}
fn make_auto_gen() -> (
Arc<BurnRateConfigManager>,
Arc<crate::governance::GovernanceEngine>,
Arc<AutoProposalGenerator>,
) {
let manager = Arc::new(BurnRateConfigManager::new());
let governance = Arc::new(crate::governance::GovernanceEngine::new());
let generator = Arc::new(AutoProposalGenerator::new(manager.clone(), governance.clone()));
(manager, governance, generator)
}
#[test]
fn auto_gen_skips_no_change() {
let (mgr, gov, generator) = make_auto_gen();
mgr.record_metrics(metrics_with_rolling(0)).unwrap();
let out = generator.tick_once().unwrap();
assert!(out.is_none());
assert_eq!(gov.proposal_count(), 0);
}
#[test]
fn auto_gen_skips_disabled() {
let (mgr, gov, generator) = make_auto_gen();
let disabled = SupplyTargets {
enabled: false,
..SupplyTargets::default()
};
mgr.apply_targets(disabled).unwrap();
mgr.record_metrics(metrics_with_rolling(10_000)).unwrap();
let out = generator.tick_once().unwrap();
assert!(out.is_none());
assert_eq!(gov.proposal_count(), 0);
}
#[test]
fn auto_gen_skips_below_floor() {
let (mgr, gov, generator) = make_auto_gen();
let targets = SupplyTargets {
auto_proposal_min_magnitude_bps: 500,
rolling_window_epochs: 1,
..SupplyTargets::default()
};
mgr.apply_targets(targets).unwrap();
mgr.record_metrics(metrics_with_rolling(450)).unwrap();
let out = generator.tick_once().unwrap();
assert!(out.is_none());
assert_eq!(gov.proposal_count(), 0);
}
#[test]
fn auto_gen_drafts_increase_burn_proposal() {
let (mgr, gov, generator) = make_auto_gen();
let targets = SupplyTargets {
rolling_window_epochs: 1, ..SupplyTargets::default()
};
mgr.apply_targets(targets).unwrap();
mgr.record_metrics(metrics_with_rolling(450)).unwrap();
let proposal_id = generator.tick_once().unwrap();
assert!(proposal_id.is_some());
assert_eq!(gov.proposal_count(), 1);
}
#[test]
fn auto_gen_alarm_uses_alarm_cap_magnitude() {
let (mgr, gov, generator) = make_auto_gen();
mgr.record_metrics(metrics_with_rolling(600)).unwrap();
let proposal_id = generator.tick_once().unwrap();
assert!(proposal_id.is_some());
assert_eq!(gov.proposal_count(), 1);
}
#[test]
fn auto_gen_alarm_decrease_clamps_at_zero() {
let (mgr, gov, generator) = make_auto_gen();
let low_burn = BurnRateConfig {
base_fee_burn_bps: 50,
..BurnRateConfig::default()
};
mgr.apply_config(low_burn).unwrap();
mgr.record_metrics(metrics_with_rolling(-600)).unwrap();
let proposal_id = generator.tick_once().unwrap();
assert!(proposal_id.is_some());
assert_eq!(gov.proposal_count(), 1);
}
#[test]
fn auto_gen_respects_debounce() {
let (mgr, gov, generator) = make_auto_gen();
let targets = SupplyTargets {
rolling_window_epochs: 1,
..SupplyTargets::default()
};
mgr.apply_targets(targets).unwrap();
mgr.record_metrics(metrics_with_rolling(450)).unwrap();
let first = generator.tick_once().unwrap();
assert!(first.is_some());
let second = generator.tick_once().unwrap();
assert!(second.is_none());
assert_eq!(gov.proposal_count(), 1);
}
#[test]
fn voting_duration_alarm_fast_track() {
let (mgr, _gov, generator) = make_auto_gen();
let targets = mgr.targets();
let rec = BurnRateRecommendation {
action: RecommendationAction::AlarmHighInflation,
magnitude_bps: 0,
above_proposal_floor: true,
deviation_bps: 600,
};
let ms = generator.voting_duration_ms(&rec, &targets);
assert_eq!(ms, targets.alarm_timelock_hours as i64 * 3_600_000);
}
#[test]
fn voting_duration_normal() {
let (mgr, _gov, generator) = make_auto_gen();
let targets = mgr.targets();
let rec = BurnRateRecommendation {
action: RecommendationAction::IncreaseBurnPct,
magnitude_bps: 200,
above_proposal_floor: true,
deviation_bps: 400,
};
let ms = generator.voting_duration_ms(&rec, &targets);
assert_eq!(ms, DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS as i64 * 3_600_000);
}
}