1use crate::error::{Result, TokenError};
47use serde::{Deserialize, Serialize};
48use std::sync::Arc;
49use tenzro_storage::{KvStore, WriteOp, CF_TOKENS};
50use tenzro_types::primitives::{BlockHeight, Timestamp};
51use tracing::{debug, info};
52
53pub const BURN_RATE_CONFIG_KEY: &[u8] = b"burn_rate:current";
57
58pub const SUPPLY_TARGETS_KEY: &[u8] = b"burn_targets:current";
60
61pub const SUPPLY_METRICS_KEY: &[u8] = b"burn_metrics:latest";
63
64pub const DEFAULT_BASE_FEE_BURN_BPS: u16 = 10_000;
68pub const DEFAULT_LOCAL_FEE_BURN_BPS: u16 = 10_000;
70pub const DEFAULT_PAYMASTER_BURN_BPS: u16 = 10_000;
73
74pub const DEFAULT_NEUTRAL_BAND_BPS: u16 = 5;
76pub const DEFAULT_ROLLING_WINDOW_EPOCHS: u32 = 90;
78pub const DEFAULT_INFLATION_ALARM_BPS: u16 = 500;
80pub const DEFAULT_DEFLATION_ALARM_BPS: u16 = 500;
82pub const DEFAULT_TARGET_ANNUAL_SUPPLY_BPS: i32 = 50;
85pub const DEFAULT_GAIN_BPS_PER_PCT: u16 = 50;
87pub const DEFAULT_MAGNITUDE_CAP_NORMAL_BPS: u16 = 200;
89pub const DEFAULT_MAGNITUDE_CAP_ALARM_BPS: u16 = 100;
91pub const DEFAULT_AUTO_PROPOSAL_MIN_MAGNITUDE_BPS: u16 = 25;
93pub const DEFAULT_ALARM_FAST_TRACK_ENABLED: bool = true;
95pub const DEFAULT_ALARM_TIMELOCK_HOURS: u32 = 6;
97
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105pub struct BurnRateConfig {
106 pub base_fee_burn_bps: u16,
109 pub local_fee_burn_bps: u16,
112 pub paymaster_burn_bps: u16,
116}
117
118impl Default for BurnRateConfig {
119 fn default() -> Self {
120 Self {
121 base_fee_burn_bps: DEFAULT_BASE_FEE_BURN_BPS,
122 local_fee_burn_bps: DEFAULT_LOCAL_FEE_BURN_BPS,
123 paymaster_burn_bps: DEFAULT_PAYMASTER_BURN_BPS,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct FeeSplit {
131 pub burn: u128,
133 pub treasury: u128,
135}
136
137impl FeeSplit {
138 pub fn total(&self) -> u128 {
140 self.burn.saturating_add(self.treasury)
141 }
142}
143
144fn split_amount(amount: u128, burn_bps: u16) -> FeeSplit {
148 let bps = burn_bps.min(10_000) as u128;
149 let burn = amount.saturating_mul(bps) / 10_000;
150 FeeSplit {
151 burn,
152 treasury: amount.saturating_sub(burn),
153 }
154}
155
156impl BurnRateConfig {
157 pub fn split_base_fee(&self, amount: u128) -> FeeSplit {
159 split_amount(amount, self.base_fee_burn_bps)
160 }
161 pub fn split_local_fee(&self, amount: u128) -> FeeSplit {
163 split_amount(amount, self.local_fee_burn_bps)
164 }
165 pub fn split_paymaster(&self, amount: u128) -> FeeSplit {
167 split_amount(amount, self.paymaster_burn_bps)
168 }
169
170 pub fn base_fee_treasury_bps(&self) -> u16 {
172 10_000_u16.saturating_sub(self.base_fee_burn_bps)
173 }
174 pub fn local_fee_treasury_bps(&self) -> u16 {
176 10_000_u16.saturating_sub(self.local_fee_burn_bps)
177 }
178 pub fn paymaster_treasury_bps(&self) -> u16 {
181 10_000_u16.saturating_sub(self.paymaster_burn_bps)
182 }
183
184 pub fn with_base_fee_burn_delta(self, delta_bps: i32) -> Self {
187 let new = (self.base_fee_burn_bps as i32 + delta_bps).clamp(0, 10_000) as u16;
188 Self {
189 base_fee_burn_bps: new,
190 ..self
191 }
192 }
193
194 pub fn validate(&self) -> Result<()> {
196 for (name, bps) in [
197 ("base_fee_burn_bps", self.base_fee_burn_bps),
198 ("local_fee_burn_bps", self.local_fee_burn_bps),
199 ("paymaster_burn_bps", self.paymaster_burn_bps),
200 ] {
201 if bps > 10_000 {
202 return Err(TokenError::InvalidParameter(format!(
203 "{} {} > 10000",
204 name, bps
205 )));
206 }
207 }
208 Ok(())
209 }
210}
211
212#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216pub struct SupplyTargets {
217 pub enabled: bool,
220 pub epoch_neutral_band_bps: u16,
222 pub rolling_window_epochs: u32,
224 pub inflation_alarm_bps: u16,
226 pub deflation_alarm_bps: u16,
228 pub target_annual_supply_bps: i32,
231 pub gain_bps_per_pct: u16,
233 pub magnitude_cap_normal_bps: u16,
235 pub magnitude_cap_alarm_bps: u16,
237 pub auto_proposal_min_magnitude_bps: u16,
239 pub alarm_fast_track_enabled: bool,
241 pub alarm_timelock_hours: u32,
243}
244
245impl Default for SupplyTargets {
246 fn default() -> Self {
247 Self {
248 enabled: true,
249 epoch_neutral_band_bps: DEFAULT_NEUTRAL_BAND_BPS,
250 rolling_window_epochs: DEFAULT_ROLLING_WINDOW_EPOCHS,
251 inflation_alarm_bps: DEFAULT_INFLATION_ALARM_BPS,
252 deflation_alarm_bps: DEFAULT_DEFLATION_ALARM_BPS,
253 target_annual_supply_bps: DEFAULT_TARGET_ANNUAL_SUPPLY_BPS,
254 gain_bps_per_pct: DEFAULT_GAIN_BPS_PER_PCT,
255 magnitude_cap_normal_bps: DEFAULT_MAGNITUDE_CAP_NORMAL_BPS,
256 magnitude_cap_alarm_bps: DEFAULT_MAGNITUDE_CAP_ALARM_BPS,
257 auto_proposal_min_magnitude_bps: DEFAULT_AUTO_PROPOSAL_MIN_MAGNITUDE_BPS,
258 alarm_fast_track_enabled: DEFAULT_ALARM_FAST_TRACK_ENABLED,
259 alarm_timelock_hours: DEFAULT_ALARM_TIMELOCK_HOURS,
260 }
261 }
262}
263
264#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
268pub struct BurnBreakdown {
269 pub base_fee: u128,
270 pub local_fee: u128,
271 pub paymaster: u128,
272 pub slash: u128,
273}
274
275impl BurnBreakdown {
276 pub fn total(&self) -> u128 {
277 self.base_fee
278 .saturating_add(self.local_fee)
279 .saturating_add(self.paymaster)
280 .saturating_add(self.slash)
281 }
282}
283
284#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
286pub struct EmissionBreakdown {
287 pub staking_rewards: u128,
288 pub treasury_emissions: u128,
289}
290
291impl EmissionBreakdown {
292 pub fn total(&self) -> u128 {
293 self.staking_rewards
294 .saturating_add(self.treasury_emissions)
295 }
296}
297
298#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct SupplyMetricsSnapshot {
303 pub block_height: BlockHeight,
305 pub captured_at: Timestamp,
307 pub circulating_supply: u128,
309 pub epoch_supply_delta: i128,
312 pub rolling_window_supply_delta_bps: i32,
316 pub burn_breakdown: BurnBreakdown,
317 pub emission_breakdown: EmissionBreakdown,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322pub enum RecommendationAction {
323 Disabled,
325 NoChange,
327 IncreaseBurnPct,
329 DecreaseBurnPct,
331 AlarmHighInflation,
334 AlarmHighDeflation,
336}
337
338impl RecommendationAction {
339 pub fn as_str(&self) -> &'static str {
340 match self {
341 RecommendationAction::Disabled => "disabled",
342 RecommendationAction::NoChange => "no_change",
343 RecommendationAction::IncreaseBurnPct => "increase_burn_pct",
344 RecommendationAction::DecreaseBurnPct => "decrease_burn_pct",
345 RecommendationAction::AlarmHighInflation => "alarm_high_inflation",
346 RecommendationAction::AlarmHighDeflation => "alarm_high_deflation",
347 }
348 }
349
350 pub fn is_alarm(&self) -> bool {
352 matches!(
353 self,
354 RecommendationAction::AlarmHighInflation
355 | RecommendationAction::AlarmHighDeflation
356 )
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct BurnRateRecommendation {
367 pub action: RecommendationAction,
368 pub magnitude_bps: i32,
369 pub above_proposal_floor: bool,
372 pub deviation_bps: i32,
375}
376
377pub fn compute_recommendation(
397 metrics: &SupplyMetricsSnapshot,
398 targets: &SupplyTargets,
399) -> BurnRateRecommendation {
400 if !targets.enabled {
401 return BurnRateRecommendation {
402 action: RecommendationAction::Disabled,
403 magnitude_bps: 0,
404 above_proposal_floor: false,
405 deviation_bps: 0,
406 };
407 }
408
409 let rolling = metrics.rolling_window_supply_delta_bps;
410 let target = targets.target_annual_supply_bps;
411 let deviation = rolling.saturating_sub(target);
412
413 let extreme_inflation = (targets.inflation_alarm_bps as i32).saturating_mul(2);
416 let extreme_deflation = (targets.deflation_alarm_bps as i32).saturating_mul(2);
417 if rolling > extreme_inflation {
418 return BurnRateRecommendation {
419 action: RecommendationAction::AlarmHighInflation,
420 magnitude_bps: 0,
421 above_proposal_floor: true,
422 deviation_bps: deviation,
423 };
424 }
425 if rolling < -extreme_deflation {
426 return BurnRateRecommendation {
427 action: RecommendationAction::AlarmHighDeflation,
428 magnitude_bps: 0,
429 above_proposal_floor: true,
430 deviation_bps: deviation,
431 };
432 }
433
434 let window = targets.rolling_window_epochs.max(1) as i32;
438 let per_epoch_deviation = deviation / window;
439 let band = targets.epoch_neutral_band_bps as i32;
440 if per_epoch_deviation.abs() < band {
441 return BurnRateRecommendation {
442 action: RecommendationAction::NoChange,
443 magnitude_bps: 0,
444 above_proposal_floor: false,
445 deviation_bps: deviation,
446 };
447 }
448
449 if rolling > targets.inflation_alarm_bps as i32 {
451 return BurnRateRecommendation {
452 action: RecommendationAction::AlarmHighInflation,
453 magnitude_bps: 0,
454 above_proposal_floor: true,
455 deviation_bps: deviation,
456 };
457 }
458 if rolling < -(targets.deflation_alarm_bps as i32) {
459 return BurnRateRecommendation {
460 action: RecommendationAction::AlarmHighDeflation,
461 magnitude_bps: 0,
462 above_proposal_floor: true,
463 deviation_bps: deviation,
464 };
465 }
466
467 let abs_dev = deviation.unsigned_abs();
471 let raw_magnitude =
472 (abs_dev as u64 * targets.gain_bps_per_pct as u64 / 100) as i32;
473 let cap = targets.magnitude_cap_normal_bps as i32;
474 let capped = raw_magnitude.min(cap);
475
476 let (action, magnitude) = if deviation > 0 {
479 (RecommendationAction::IncreaseBurnPct, capped)
480 } else {
481 (RecommendationAction::DecreaseBurnPct, -capped)
482 };
483
484 let above_floor =
485 magnitude.unsigned_abs() >= targets.auto_proposal_min_magnitude_bps as u32;
486
487 BurnRateRecommendation {
488 action,
489 magnitude_bps: magnitude,
490 above_proposal_floor: above_floor,
491 deviation_bps: deviation,
492 }
493}
494
495pub struct BurnRateConfigManager {
502 config: parking_lot::RwLock<BurnRateConfig>,
503 targets: parking_lot::RwLock<SupplyTargets>,
504 metrics: parking_lot::RwLock<SupplyMetricsSnapshot>,
505 storage: Option<Arc<dyn KvStore>>,
506}
507
508impl Default for BurnRateConfigManager {
509 fn default() -> Self {
510 Self::new()
511 }
512}
513
514impl BurnRateConfigManager {
515 pub fn new() -> Self {
517 Self {
518 config: parking_lot::RwLock::new(BurnRateConfig::default()),
519 targets: parking_lot::RwLock::new(SupplyTargets::default()),
520 metrics: parking_lot::RwLock::new(SupplyMetricsSnapshot::default()),
521 storage: None,
522 }
523 }
524
525 pub fn with_storage(storage: Arc<dyn KvStore>) -> Result<Self> {
530 let mgr = Self {
531 config: parking_lot::RwLock::new(BurnRateConfig::default()),
532 targets: parking_lot::RwLock::new(SupplyTargets::default()),
533 metrics: parking_lot::RwLock::new(SupplyMetricsSnapshot::default()),
534 storage: Some(storage),
535 };
536 mgr.hydrate_from_storage()?;
537 Ok(mgr)
538 }
539
540 pub fn config(&self) -> BurnRateConfig {
542 *self.config.read()
543 }
544
545 pub fn targets(&self) -> SupplyTargets {
547 *self.targets.read()
548 }
549
550 pub fn latest_metrics(&self) -> SupplyMetricsSnapshot {
552 self.metrics.read().clone()
553 }
554
555 pub fn apply_config(&self, new_config: BurnRateConfig) -> Result<()> {
559 new_config.validate()?;
560 if new_config.paymaster_burn_bps != DEFAULT_PAYMASTER_BURN_BPS {
565 return Err(TokenError::InvalidParameter(format!(
566 "paymaster_burn_bps is locked at {} (got {})",
567 DEFAULT_PAYMASTER_BURN_BPS, new_config.paymaster_burn_bps
568 )));
569 }
570 *self.config.write() = new_config;
571 self.persist_config(&new_config)?;
572 info!(
573 base_fee_burn_bps = new_config.base_fee_burn_bps,
574 local_fee_burn_bps = new_config.local_fee_burn_bps,
575 paymaster_burn_bps = new_config.paymaster_burn_bps,
576 "BurnRateConfig updated"
577 );
578 Ok(())
579 }
580
581 pub fn apply_targets(&self, new_targets: SupplyTargets) -> Result<()> {
583 if new_targets.epoch_neutral_band_bps > 10_000
584 || new_targets.gain_bps_per_pct > 10_000
585 {
586 return Err(TokenError::InvalidParameter(
587 "epoch_neutral_band_bps / gain_bps_per_pct must be <= 10000".into(),
588 ));
589 }
590 *self.targets.write() = new_targets;
591 self.persist_targets(&new_targets)?;
592 info!("SupplyTargets updated");
593 Ok(())
594 }
595
596 pub fn record_metrics(&self, snapshot: SupplyMetricsSnapshot) -> Result<()> {
599 *self.metrics.write() = snapshot.clone();
600 self.persist_metrics(&snapshot)?;
601 debug!(
602 block_height = snapshot.block_height.0,
603 epoch_supply_delta = snapshot.epoch_supply_delta,
604 rolling_bps = snapshot.rolling_window_supply_delta_bps,
605 "SupplyMetricsSnapshot recorded"
606 );
607 Ok(())
608 }
609
610 pub fn current_recommendation(&self) -> BurnRateRecommendation {
614 let metrics = self.metrics.read().clone();
615 let targets = *self.targets.read();
616 compute_recommendation(&metrics, &targets)
617 }
618
619 fn hydrate_from_storage(&self) -> Result<()> {
620 let storage = match &self.storage {
621 Some(s) => s.clone(),
622 None => return Ok(()),
623 };
624
625 match storage
627 .get(CF_TOKENS, BURN_RATE_CONFIG_KEY)
628 .map_err(|e| TokenError::StorageError(format!("get burn rate config: {}", e)))?
629 {
630 Some(value) => {
631 let cfg: BurnRateConfig = serde_json::from_slice(&value).map_err(|e| {
632 TokenError::StorageError(format!("decode burn rate config: {}", e))
633 })?;
634 cfg.validate()?;
635 *self.config.write() = cfg;
636 info!(
637 base_fee_burn_bps = cfg.base_fee_burn_bps,
638 "BurnRateConfig hydrated from storage"
639 );
640 }
641 None => {
642 let genesis = *self.config.read();
643 self.persist_config(&genesis)?;
644 info!("BurnRateConfig initialized from genesis defaults");
645 }
646 }
647
648 match storage
650 .get(CF_TOKENS, SUPPLY_TARGETS_KEY)
651 .map_err(|e| TokenError::StorageError(format!("get supply targets: {}", e)))?
652 {
653 Some(value) => {
654 let t: SupplyTargets = serde_json::from_slice(&value).map_err(|e| {
655 TokenError::StorageError(format!("decode supply targets: {}", e))
656 })?;
657 *self.targets.write() = t;
658 info!("SupplyTargets hydrated from storage");
659 }
660 None => {
661 let genesis = *self.targets.read();
662 self.persist_targets(&genesis)?;
663 info!("SupplyTargets initialized from genesis defaults");
664 }
665 }
666
667 if let Some(value) = storage
669 .get(CF_TOKENS, SUPPLY_METRICS_KEY)
670 .map_err(|e| TokenError::StorageError(format!("get supply metrics: {}", e)))?
671 {
672 let m: SupplyMetricsSnapshot = serde_json::from_slice(&value).map_err(|e| {
673 TokenError::StorageError(format!("decode supply metrics: {}", e))
674 })?;
675 *self.metrics.write() = m;
676 info!("SupplyMetricsSnapshot hydrated from storage");
677 }
678
679 Ok(())
680 }
681
682 fn persist_config(&self, cfg: &BurnRateConfig) -> Result<()> {
683 if let Some(storage) = &self.storage {
684 let value = serde_json::to_vec(cfg).map_err(|e| {
685 TokenError::StorageError(format!("encode burn rate config: {}", e))
686 })?;
687 storage
688 .write_batch_sync(vec![WriteOp::Put {
689 cf: CF_TOKENS.to_string(),
690 key: BURN_RATE_CONFIG_KEY.to_vec(),
691 value,
692 }])
693 .map_err(|e| {
694 TokenError::StorageError(format!("persist burn rate config: {}", e))
695 })?;
696 }
697 Ok(())
698 }
699
700 fn persist_targets(&self, t: &SupplyTargets) -> Result<()> {
701 if let Some(storage) = &self.storage {
702 let value = serde_json::to_vec(t).map_err(|e| {
703 TokenError::StorageError(format!("encode supply targets: {}", e))
704 })?;
705 storage
706 .write_batch_sync(vec![WriteOp::Put {
707 cf: CF_TOKENS.to_string(),
708 key: SUPPLY_TARGETS_KEY.to_vec(),
709 value,
710 }])
711 .map_err(|e| {
712 TokenError::StorageError(format!("persist supply targets: {}", e))
713 })?;
714 }
715 Ok(())
716 }
717
718 fn persist_metrics(&self, m: &SupplyMetricsSnapshot) -> Result<()> {
719 if let Some(storage) = &self.storage {
720 let value = serde_json::to_vec(m).map_err(|e| {
721 TokenError::StorageError(format!("encode supply metrics: {}", e))
722 })?;
723 storage
724 .write_batch_sync(vec![WriteOp::Put {
725 cf: CF_TOKENS.to_string(),
726 key: SUPPLY_METRICS_KEY.to_vec(),
727 value,
728 }])
729 .map_err(|e| {
730 TokenError::StorageError(format!("persist supply metrics: {}", e))
731 })?;
732 }
733 Ok(())
734 }
735}
736
737pub const DEFAULT_AUTO_PROPOSAL_POLL_INTERVAL_SECS: u64 = 8 * 60 * 60;
742
743pub const DEFAULT_AUTO_PROPOSAL_DEBOUNCE_SECS: u64 = 24 * 60 * 60;
748
749pub const DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS: u32 = 24;
751
752#[derive(Debug, Clone, Copy)]
755pub struct AutoProposalGeneratorConfig {
756 pub poll_interval_secs: u64,
759 pub debounce_secs: u64,
764 pub normal_voting_hours: u32,
766}
767
768impl Default for AutoProposalGeneratorConfig {
769 fn default() -> Self {
770 Self {
771 poll_interval_secs: DEFAULT_AUTO_PROPOSAL_POLL_INTERVAL_SECS,
772 debounce_secs: DEFAULT_AUTO_PROPOSAL_DEBOUNCE_SECS,
773 normal_voting_hours: DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS,
774 }
775 }
776}
777
778pub struct AutoProposalGenerator {
796 manager: Arc<BurnRateConfigManager>,
797 governance: Arc<crate::governance::GovernanceEngine>,
798 config: AutoProposalGeneratorConfig,
799 last_issued_at: parking_lot::Mutex<Option<std::time::Instant>>,
800}
801
802impl AutoProposalGenerator {
803 pub fn new(
804 manager: Arc<BurnRateConfigManager>,
805 governance: Arc<crate::governance::GovernanceEngine>,
806 ) -> Self {
807 Self::with_config(manager, governance, AutoProposalGeneratorConfig::default())
808 }
809
810 pub fn with_config(
811 manager: Arc<BurnRateConfigManager>,
812 governance: Arc<crate::governance::GovernanceEngine>,
813 config: AutoProposalGeneratorConfig,
814 ) -> Self {
815 Self {
816 manager,
817 governance,
818 config,
819 last_issued_at: parking_lot::Mutex::new(None),
820 }
821 }
822
823 pub fn spawn(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
828 let poll = std::time::Duration::from_secs(self.config.poll_interval_secs);
829 tokio::spawn(async move {
830 let mut ticker = tokio::time::interval(poll);
831 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
832 ticker.tick().await;
835 loop {
836 ticker.tick().await;
837 if let Err(e) = self.tick_once() {
838 tracing::warn!(error = %e, "AutoProposalGenerator: tick_once failed");
839 }
840 }
841 })
842 }
843
844 pub fn tick_once(&self) -> Result<Option<String>> {
849 let recommendation = self.manager.current_recommendation();
850 if !self.should_propose(&recommendation) {
851 return Ok(None);
852 }
853
854 {
857 let last = self.last_issued_at.lock();
858 if let Some(t) = *last {
859 let elapsed = t.elapsed();
860 if elapsed.as_secs() < self.config.debounce_secs {
861 debug!(
862 elapsed_secs = elapsed.as_secs(),
863 debounce_secs = self.config.debounce_secs,
864 "AutoProposalGenerator: within debounce window, skipping"
865 );
866 return Ok(None);
867 }
868 }
869 }
870
871 let targets = self.manager.targets();
872 let current = self.manager.config();
873 let proposed = self.compute_proposed_config(¤t, &targets, &recommendation);
874
875 proposed.validate()?;
878
879 let voting_duration_ms = self.voting_duration_ms(&recommendation, &targets);
880
881 let title = format!(
882 "Adaptive burn: {} (deviation {} bps)",
883 recommendation.action.as_str(),
884 recommendation.deviation_bps,
885 );
886 let description = format!(
887 "Auto-issued by AutoProposalGenerator.\n\
888 Action: {action}\n\
889 Magnitude: {magnitude} bps\n\
890 Deviation: {deviation} bps\n\
891 Current base_fee_burn_bps: {cur}\n\
892 Proposed base_fee_burn_bps: {prop}\n\
893 Voting window: {hours}h ({alarm}).",
894 action = recommendation.action.as_str(),
895 magnitude = recommendation.magnitude_bps,
896 deviation = recommendation.deviation_bps,
897 cur = current.base_fee_burn_bps,
898 prop = proposed.base_fee_burn_bps,
899 hours = voting_duration_ms / 3_600_000,
900 alarm = if recommendation.action.is_alarm() {
901 "alarm fast-track"
902 } else {
903 "normal"
904 },
905 );
906
907 let proposal_type = tenzro_types::token::ProposalType::AdaptiveBurnConfigUpdate {
908 base_fee_burn_bps: proposed.base_fee_burn_bps,
909 local_fee_burn_bps: proposed.local_fee_burn_bps,
910 paymaster_burn_bps: proposed.paymaster_burn_bps,
911 };
912
913 let proposal_id = self
914 .governance
915 .create_system_proposal(title, description, proposal_type, voting_duration_ms)?;
916
917 *self.last_issued_at.lock() = Some(std::time::Instant::now());
918
919 info!(
920 proposal_id = %proposal_id,
921 action = recommendation.action.as_str(),
922 current_bps = current.base_fee_burn_bps,
923 proposed_bps = proposed.base_fee_burn_bps,
924 "AutoProposalGenerator: drafted adaptive burn proposal"
925 );
926
927 Ok(Some(proposal_id))
928 }
929
930 fn should_propose(&self, rec: &BurnRateRecommendation) -> bool {
931 match rec.action {
932 RecommendationAction::Disabled | RecommendationAction::NoChange => false,
933 RecommendationAction::IncreaseBurnPct | RecommendationAction::DecreaseBurnPct => {
934 rec.above_proposal_floor
935 }
936 RecommendationAction::AlarmHighInflation
937 | RecommendationAction::AlarmHighDeflation => true,
938 }
939 }
940
941 fn compute_proposed_config(
942 &self,
943 current: &BurnRateConfig,
944 targets: &SupplyTargets,
945 rec: &BurnRateRecommendation,
946 ) -> BurnRateConfig {
947 let delta = match rec.action {
950 RecommendationAction::AlarmHighInflation => targets.magnitude_cap_alarm_bps as i32,
951 RecommendationAction::AlarmHighDeflation => -(targets.magnitude_cap_alarm_bps as i32),
952 _ => rec.magnitude_bps,
953 };
954 current.with_base_fee_burn_delta(delta)
955 }
956
957 fn voting_duration_ms(&self, rec: &BurnRateRecommendation, targets: &SupplyTargets) -> i64 {
958 let hours = if rec.action.is_alarm() && targets.alarm_fast_track_enabled {
959 targets.alarm_timelock_hours
960 } else {
961 self.config.normal_voting_hours
962 };
963 hours as i64 * 3_600_000
964 }
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn fee_split_is_exact_with_dust_to_treasury() {
973 let c = BurnRateConfig::default();
975 let s = c.split_base_fee(1_000);
976 assert_eq!((s.burn, s.treasury), (1_000, 0));
977 assert_eq!(s.total(), 1_000);
978
979 let c2 = BurnRateConfig { base_fee_burn_bps: 5_000, ..Default::default() };
981 let s2 = c2.split_base_fee(1_001);
982 assert_eq!((s2.burn, s2.treasury), (500, 501));
983 assert_eq!(s2.total(), 1_001);
984
985 let c3 = BurnRateConfig { base_fee_burn_bps: 0, ..Default::default() };
987 let s3 = c3.split_base_fee(777);
988 assert_eq!((s3.burn, s3.treasury), (0, 777));
989
990 assert_eq!(c.split_local_fee(42), FeeSplit { burn: 42, treasury: 0 });
992 assert_eq!(c.split_paymaster(42), FeeSplit { burn: 42, treasury: 0 });
993 }
994
995 fn metrics_with_rolling(bps: i32) -> SupplyMetricsSnapshot {
996 SupplyMetricsSnapshot {
997 rolling_window_supply_delta_bps: bps,
998 ..SupplyMetricsSnapshot::default()
999 }
1000 }
1001
1002 #[test]
1003 fn defaults_are_consistent() {
1004 let cfg = BurnRateConfig::default();
1005 cfg.validate().unwrap();
1006 assert_eq!(cfg.base_fee_burn_bps, 10_000);
1007 assert_eq!(cfg.base_fee_treasury_bps(), 0);
1008 assert_eq!(cfg.paymaster_burn_bps, 10_000);
1009 }
1010
1011 #[test]
1012 fn config_with_delta_clamps() {
1013 let cfg = BurnRateConfig::default();
1014 let lower = cfg.with_base_fee_burn_delta(-200);
1015 assert_eq!(lower.base_fee_burn_bps, 9_800);
1016 let raise = cfg.with_base_fee_burn_delta(500);
1017 assert_eq!(raise.base_fee_burn_bps, 10_000);
1019 let big_drop = cfg.with_base_fee_burn_delta(-20_000);
1020 assert_eq!(big_drop.base_fee_burn_bps, 0);
1021 }
1022
1023 #[test]
1024 fn disabled_short_circuits() {
1025 let targets = SupplyTargets {
1026 enabled: false,
1027 ..SupplyTargets::default()
1028 };
1029 let m = metrics_with_rolling(1_000);
1030 let r = compute_recommendation(&m, &targets);
1031 assert_eq!(r.action, RecommendationAction::Disabled);
1032 assert_eq!(r.magnitude_bps, 0);
1033 }
1034
1035 #[test]
1036 fn neutral_band_returns_no_change() {
1037 let targets = SupplyTargets::default();
1040 let m = metrics_with_rolling(50);
1041 let r = compute_recommendation(&m, &targets);
1042 assert_eq!(r.action, RecommendationAction::NoChange);
1043 assert_eq!(r.deviation_bps, 0);
1044 }
1045
1046 #[test]
1047 fn mild_inflation_recommends_increase_burn() {
1048 let targets = SupplyTargets::default();
1056 let m = metrics_with_rolling(500);
1057 let r = compute_recommendation(&m, &targets);
1058 assert_eq!(r.action, RecommendationAction::IncreaseBurnPct);
1059 assert!(r.magnitude_bps > 0);
1060 assert_eq!(r.magnitude_bps, 200);
1063 assert!(r.above_proposal_floor);
1064 }
1065
1066 #[test]
1067 fn mild_deflation_recommends_decrease_burn() {
1068 let targets = SupplyTargets::default();
1069 let m = metrics_with_rolling(-400);
1073 let r = compute_recommendation(&m, &targets);
1074 assert_eq!(r.action, RecommendationAction::DecreaseBurnPct);
1075 assert!(r.magnitude_bps < 0);
1076 assert_eq!(r.magnitude_bps, -200);
1077 }
1078
1079 #[test]
1080 fn alarm_high_inflation() {
1081 let targets = SupplyTargets::default();
1082 let m = metrics_with_rolling(700);
1085 let r = compute_recommendation(&m, &targets);
1086 assert_eq!(r.action, RecommendationAction::AlarmHighInflation);
1087 assert_eq!(r.magnitude_bps, 0);
1088 assert!(r.action.is_alarm());
1089 }
1090
1091 #[test]
1092 fn alarm_high_deflation() {
1093 let targets = SupplyTargets::default();
1094 let m = metrics_with_rolling(-700);
1095 let r = compute_recommendation(&m, &targets);
1096 assert_eq!(r.action, RecommendationAction::AlarmHighDeflation);
1097 assert_eq!(r.magnitude_bps, 0);
1098 }
1099
1100 #[test]
1101 fn extreme_reading_forces_alarm_only() {
1102 let targets = SupplyTargets::default();
1103 let m = metrics_with_rolling(1500);
1105 let r = compute_recommendation(&m, &targets);
1106 assert_eq!(r.action, RecommendationAction::AlarmHighInflation);
1107 assert_eq!(r.magnitude_bps, 0);
1108 }
1109
1110 #[test]
1111 fn manager_apply_config_rejects_paymaster_unlock() {
1112 let mgr = BurnRateConfigManager::new();
1113 let bad = BurnRateConfig {
1114 paymaster_burn_bps: 9_500,
1115 ..BurnRateConfig::default()
1116 };
1117 let err = mgr.apply_config(bad).unwrap_err();
1118 assert!(err.to_string().contains("paymaster_burn_bps is locked"));
1119 }
1120
1121 #[test]
1122 fn manager_apply_config_persists() {
1123 let mgr = BurnRateConfigManager::new();
1124 let updated = BurnRateConfig {
1125 base_fee_burn_bps: 9_500,
1126 ..BurnRateConfig::default()
1127 };
1128 mgr.apply_config(updated).unwrap();
1129 assert_eq!(mgr.config().base_fee_burn_bps, 9_500);
1130 }
1131
1132 #[test]
1133 fn record_metrics_round_trips_through_recommendation() {
1134 let mgr = BurnRateConfigManager::new();
1135 mgr.record_metrics(metrics_with_rolling(500)).unwrap();
1136 let r = mgr.current_recommendation();
1137 assert_eq!(r.action, RecommendationAction::IncreaseBurnPct);
1138 }
1139
1140 #[test]
1141 fn breakdown_totals() {
1142 let b = BurnBreakdown {
1143 base_fee: 100,
1144 local_fee: 50,
1145 paymaster: 25,
1146 slash: 10,
1147 };
1148 assert_eq!(b.total(), 185);
1149 let e = EmissionBreakdown {
1150 staking_rewards: 200,
1151 treasury_emissions: 100,
1152 };
1153 assert_eq!(e.total(), 300);
1154 }
1155
1156 #[test]
1157 fn proposal_floor_skips_small_magnitudes() {
1158 let targets = SupplyTargets {
1162 auto_proposal_min_magnitude_bps: 500, rolling_window_epochs: 1, ..SupplyTargets::default()
1165 };
1166 let m = metrics_with_rolling(450);
1169 let r = compute_recommendation(&m, &targets);
1170 assert_eq!(r.action, RecommendationAction::IncreaseBurnPct);
1171 assert_eq!(r.magnitude_bps, 200);
1172 assert!(!r.above_proposal_floor);
1173 }
1174
1175 fn make_auto_gen() -> (
1178 Arc<BurnRateConfigManager>,
1179 Arc<crate::governance::GovernanceEngine>,
1180 Arc<AutoProposalGenerator>,
1181 ) {
1182 let manager = Arc::new(BurnRateConfigManager::new());
1183 let governance = Arc::new(crate::governance::GovernanceEngine::new());
1184 let generator = Arc::new(AutoProposalGenerator::new(manager.clone(), governance.clone()));
1185 (manager, governance, generator)
1186 }
1187
1188 #[test]
1189 fn auto_gen_skips_no_change() {
1190 let (mgr, gov, generator) = make_auto_gen();
1191 mgr.record_metrics(metrics_with_rolling(0)).unwrap();
1193 let out = generator.tick_once().unwrap();
1194 assert!(out.is_none());
1195 assert_eq!(gov.proposal_count(), 0);
1196 }
1197
1198 #[test]
1199 fn auto_gen_skips_disabled() {
1200 let (mgr, gov, generator) = make_auto_gen();
1201 let disabled = SupplyTargets {
1203 enabled: false,
1204 ..SupplyTargets::default()
1205 };
1206 mgr.apply_targets(disabled).unwrap();
1207 mgr.record_metrics(metrics_with_rolling(10_000)).unwrap();
1208 let out = generator.tick_once().unwrap();
1209 assert!(out.is_none());
1210 assert_eq!(gov.proposal_count(), 0);
1211 }
1212
1213 #[test]
1214 fn auto_gen_skips_below_floor() {
1215 let (mgr, gov, generator) = make_auto_gen();
1216 let targets = SupplyTargets {
1218 auto_proposal_min_magnitude_bps: 500,
1219 rolling_window_epochs: 1,
1220 ..SupplyTargets::default()
1221 };
1222 mgr.apply_targets(targets).unwrap();
1223 mgr.record_metrics(metrics_with_rolling(450)).unwrap();
1224 let out = generator.tick_once().unwrap();
1225 assert!(out.is_none());
1226 assert_eq!(gov.proposal_count(), 0);
1227 }
1228
1229 #[test]
1230 fn auto_gen_drafts_increase_burn_proposal() {
1231 let (mgr, gov, generator) = make_auto_gen();
1232 let targets = SupplyTargets {
1234 rolling_window_epochs: 1, ..SupplyTargets::default()
1236 };
1237 mgr.apply_targets(targets).unwrap();
1238 mgr.record_metrics(metrics_with_rolling(450)).unwrap();
1239 let proposal_id = generator.tick_once().unwrap();
1240 assert!(proposal_id.is_some());
1241 assert_eq!(gov.proposal_count(), 1);
1242 }
1243
1244 #[test]
1245 fn auto_gen_alarm_uses_alarm_cap_magnitude() {
1246 let (mgr, gov, generator) = make_auto_gen();
1247 mgr.record_metrics(metrics_with_rolling(600)).unwrap();
1251 let proposal_id = generator.tick_once().unwrap();
1252 assert!(proposal_id.is_some());
1253 assert_eq!(gov.proposal_count(), 1);
1254 }
1255
1256 #[test]
1257 fn auto_gen_alarm_decrease_clamps_at_zero() {
1258 let (mgr, gov, generator) = make_auto_gen();
1259 let low_burn = BurnRateConfig {
1262 base_fee_burn_bps: 50,
1263 ..BurnRateConfig::default()
1264 };
1265 mgr.apply_config(low_burn).unwrap();
1266 mgr.record_metrics(metrics_with_rolling(-600)).unwrap();
1267 let proposal_id = generator.tick_once().unwrap();
1268 assert!(proposal_id.is_some());
1269 assert_eq!(gov.proposal_count(), 1);
1271 }
1272
1273 #[test]
1274 fn auto_gen_respects_debounce() {
1275 let (mgr, gov, generator) = make_auto_gen();
1276 let targets = SupplyTargets {
1277 rolling_window_epochs: 1,
1278 ..SupplyTargets::default()
1279 };
1280 mgr.apply_targets(targets).unwrap();
1281 mgr.record_metrics(metrics_with_rolling(450)).unwrap();
1282 let first = generator.tick_once().unwrap();
1283 assert!(first.is_some());
1284 let second = generator.tick_once().unwrap();
1286 assert!(second.is_none());
1287 assert_eq!(gov.proposal_count(), 1);
1288 }
1289
1290 #[test]
1291 fn voting_duration_alarm_fast_track() {
1292 let (mgr, _gov, generator) = make_auto_gen();
1293 let targets = mgr.targets();
1294 let rec = BurnRateRecommendation {
1295 action: RecommendationAction::AlarmHighInflation,
1296 magnitude_bps: 0,
1297 above_proposal_floor: true,
1298 deviation_bps: 600,
1299 };
1300 let ms = generator.voting_duration_ms(&rec, &targets);
1301 assert_eq!(ms, targets.alarm_timelock_hours as i64 * 3_600_000);
1302 }
1303
1304 #[test]
1305 fn voting_duration_normal() {
1306 let (mgr, _gov, generator) = make_auto_gen();
1307 let targets = mgr.targets();
1308 let rec = BurnRateRecommendation {
1309 action: RecommendationAction::IncreaseBurnPct,
1310 magnitude_bps: 200,
1311 above_proposal_floor: true,
1312 deviation_bps: 400,
1313 };
1314 let ms = generator.voting_duration_ms(&rec, &targets);
1315 assert_eq!(ms, DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS as i64 * 3_600_000);
1316 }
1317}