Skip to main content

tenzro_token/
adaptive_burn.rs

1//! Adaptive burn governance dial (Agent-Swarm Spec 8).
2//!
3//! The full spec ([adaptive-burn.md](../../../docs/architecture/agent-swarm/adaptive-burn.md))
4//! describes an epoch observer that reads `UsageTracker` and the EIP-1559
5//! burn ledger, computes a recommended adjustment to the protocol's
6//! `base_fee_burn_pct` dial via a published transfer function, and
7//! auto-drafts a typed governance proposal to apply it (with a fast-track
8//! timelock when the recommendation is an alarm).
9//!
10//! This module lands the protocol-side primitives:
11//!
12//! - [`BurnRateConfig`] — the four governance dials (base fee burn / treasury
13//!   split, local fee burn, paymaster burn). Persisted as a singleton in
14//!   `CF_TOKENS/burn_rate:current`. Wallets, dapps, and the EIP-1559 fee
15//!   market read this to decide where each TNZO base-fee chunk goes.
16//! - [`SupplyTargets`] — governance-tunable target band, rolling window,
17//!   alarm thresholds, gain. Persisted as a singleton in
18//!   `CF_TOKENS/burn_targets:current`.
19//! - [`SupplyMetricsSnapshot`] — observed metrics for an epoch (or rolling
20//!   window). Persisted under `burn_metrics:latest`. The epoch observer
21//!   that aggregates these from `UsageTracker` and the burn ledger lands
22//!   alongside this primitive — wave 1 ships the storage shape and a
23//!   no-op default snapshot.
24//! - [`compute_recommendation`] — the pure transfer function. Reads a
25//!   snapshot + targets + gain, returns a [`BurnRateRecommendation`] with
26//!   action and magnitude. Same input → same output, deterministic.
27//! - [`BurnRateConfigManager`] — write-through manager mirroring
28//!   `BurnQuotaManager`'s pattern. Constructed in-memory for tests, with
29//!   `Arc<dyn KvStore>` for production. Hydrates on construction.
30//!
31//! Wave 1 deferrals:
32//!
33//! - The `AutoProposalGenerator` and the `tenzro/burn-rate-changed`
34//!   gossipsub topic land alongside the governance executor wiring.
35//! - The EIP-1559 fee market reading `base_fee_burn_pct` to gate burn vs
36//!   treasury split lands when the dial actually starts gating fees. Until
37//!   then the dial is a published parameter only.
38//! - `SupplyMetricsSnapshot` aggregation across all burn flows (base fee,
39//!   local fee, paymaster, slash) and emission flows (staking rewards,
40//!   treasury emissions) lands alongside the epoch observer. The
41//!   primitive's storage shape is fixed now so the observer can write
42//!   into it without retrofitting.
43//! - Per-controller-DID wash-detection mitigation lives in the per-DID
44//!   flow control crate (Spec 2); this module reads aggregate metrics only.
45
46use 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
53// ---------- Storage keys -----------------------------------------------------
54
55/// Singleton key for the live `BurnRateConfig` under `CF_TOKENS`.
56pub const BURN_RATE_CONFIG_KEY: &[u8] = b"burn_rate:current";
57
58/// Singleton key for the live `SupplyTargets` under `CF_TOKENS`.
59pub const SUPPLY_TARGETS_KEY: &[u8] = b"burn_targets:current";
60
61/// Singleton key for the latest `SupplyMetricsSnapshot` under `CF_TOKENS`.
62pub const SUPPLY_METRICS_KEY: &[u8] = b"burn_metrics:latest";
63
64// ---------- Genesis defaults (mirror adaptive-burn.md §"Governance dials") ---
65
66/// Genesis: 100% of base fee is burned (0% to treasury).
67pub const DEFAULT_BASE_FEE_BURN_BPS: u16 = 10_000;
68/// Genesis: 100% of Spec 6 local fees are burned.
69pub const DEFAULT_LOCAL_FEE_BURN_BPS: u16 = 10_000;
70/// Genesis: 100% of paymaster (Spec 3) sponsored TNZO is burned. Locked at
71/// 100% — `compute_recommendation` never adjusts this.
72pub const DEFAULT_PAYMASTER_BURN_BPS: u16 = 10_000;
73
74/// Genesis neutral band: 0.005% of supply per epoch (5bps).
75pub const DEFAULT_NEUTRAL_BAND_BPS: u16 = 5;
76/// Genesis rolling window: 90 epochs (~30 days at 8h epochs).
77pub const DEFAULT_ROLLING_WINDOW_EPOCHS: u32 = 90;
78/// Genesis inflation alarm: +5% annualized = 500 bps.
79pub const DEFAULT_INFLATION_ALARM_BPS: u16 = 500;
80/// Genesis deflation alarm: -5% annualized = 500 bps (magnitude).
81pub const DEFAULT_DEFLATION_ALARM_BPS: u16 = 500;
82/// Genesis target: +50bps annualized (slight inflation, paying providers
83/// from emission). Range per spec is 0-100bps — pick the midpoint.
84pub const DEFAULT_TARGET_ANNUAL_SUPPLY_BPS: i32 = 50;
85/// Genesis gain: 50bps adjustment per 1% deviation from target.
86pub const DEFAULT_GAIN_BPS_PER_PCT: u16 = 50;
87/// Genesis cap on per-epoch normal magnitude: 200 bps.
88pub const DEFAULT_MAGNITUDE_CAP_NORMAL_BPS: u16 = 200;
89/// Genesis cap on alarm-state magnitude: 100 bps (smaller, faster).
90pub const DEFAULT_MAGNITUDE_CAP_ALARM_BPS: u16 = 100;
91/// Genesis floor below which auto-proposals are skipped: 25 bps.
92pub const DEFAULT_AUTO_PROPOSAL_MIN_MAGNITUDE_BPS: u16 = 25;
93/// Genesis: alarm states get fast-track 6h timelock.
94pub const DEFAULT_ALARM_FAST_TRACK_ENABLED: bool = true;
95/// Genesis alarm timelock: 6 hours (vs 24 hours normal).
96pub const DEFAULT_ALARM_TIMELOCK_HOURS: u32 = 6;
97
98// ---------- Types ------------------------------------------------------------
99
100/// Live burn-rate dials. Each pair `*_burn_bps + *_treasury_bps` must sum to
101/// 10_000 — the burn share is what's destroyed; the treasury share is what
102/// flows to `NetworkTreasury`. Wave 1 surfaces only the burn shares (the
103/// treasury share is the complement) to keep the wire shape compact.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
105pub struct BurnRateConfig {
106    /// 0..10_000. Genesis: 10_000 (100%). The transfer function adjusts
107    /// this primarily.
108    pub base_fee_burn_bps: u16,
109    /// 0..10_000. Genesis: 10_000 (100%). Adjusted only by explicit
110    /// governance proposals, never by the auto-adjuster.
111    pub local_fee_burn_bps: u16,
112    /// 0..10_000. **Genesis: 10_000 — locked at 100%.** Lowering this would
113    /// mean USDC-paid gas stops eating TNZO supply, defeating the purpose
114    /// of Spec 3. The function never recommends adjusting this.
115    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/// The burn/treasury split of a single fee amount (`burn + treasury == amount`).
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub struct FeeSplit {
131    /// Amount destroyed (burned).
132    pub burn: u128,
133    /// Amount routed to `NetworkTreasury`.
134    pub treasury: u128,
135}
136
137impl FeeSplit {
138    /// `burn + treasury`.
139    pub fn total(&self) -> u128 {
140        self.burn.saturating_add(self.treasury)
141    }
142}
143
144/// Split `amount` by `burn_bps`: `burn = floor(amount * bps / 10_000)`, and
145/// `treasury = amount - burn` so the two always sum to `amount` exactly — any
146/// rounding dust goes to the treasury rather than being lost.
147fn 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    /// Split a base-fee amount into (burn, treasury) per `base_fee_burn_bps`.
158    pub fn split_base_fee(&self, amount: u128) -> FeeSplit {
159        split_amount(amount, self.base_fee_burn_bps)
160    }
161    /// Split a Spec-6 local-fee amount per `local_fee_burn_bps`.
162    pub fn split_local_fee(&self, amount: u128) -> FeeSplit {
163        split_amount(amount, self.local_fee_burn_bps)
164    }
165    /// Split a paymaster-sponsored amount per `paymaster_burn_bps`.
166    pub fn split_paymaster(&self, amount: u128) -> FeeSplit {
167        split_amount(amount, self.paymaster_burn_bps)
168    }
169
170    /// Treasury share of base fee = 10_000 - burn share.
171    pub fn base_fee_treasury_bps(&self) -> u16 {
172        10_000_u16.saturating_sub(self.base_fee_burn_bps)
173    }
174    /// Treasury share of local fee = 10_000 - burn share.
175    pub fn local_fee_treasury_bps(&self) -> u16 {
176        10_000_u16.saturating_sub(self.local_fee_burn_bps)
177    }
178    /// Treasury share of paymaster spend = 10_000 - burn share. Always 0
179    /// at genesis (paymaster burn locked at 100%).
180    pub fn paymaster_treasury_bps(&self) -> u16 {
181        10_000_u16.saturating_sub(self.paymaster_burn_bps)
182    }
183
184    /// Apply a delta (in bps, positive = increase burn) to `base_fee_burn_bps`.
185    /// Clamps to `[0, 10_000]`. Returns the new config; does not mutate.
186    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    /// Validate that all bps values are in `[0, 10_000]`.
195    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/// Governance-tunable supply targets. All bps values are basis points
213/// (1bps = 0.01%); `target_annual_supply_bps` is signed because the target
214/// can be negative (deflationary regime).
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
216pub struct SupplyTargets {
217    /// Master kill switch. When `false` the function returns
218    /// [`RecommendationAction::Disabled`] regardless of metrics.
219    pub enabled: bool,
220    /// `|epoch_supply_delta / supply| < this` → no action.
221    pub epoch_neutral_band_bps: u16,
222    /// Smoothing window in epochs.
223    pub rolling_window_epochs: u32,
224    /// Sustained `> this` over window → alarm.
225    pub inflation_alarm_bps: u16,
226    /// Sustained `< -this` over window → alarm. Stored as magnitude.
227    pub deflation_alarm_bps: u16,
228    /// Long-run target for net supply change, annualized. Signed:
229    /// negative = mild deflation, positive = mild inflation.
230    pub target_annual_supply_bps: i32,
231    /// Gain on the proportional adjustment, in bps-per-1%-deviation.
232    pub gain_bps_per_pct: u16,
233    /// Per-epoch volatility cap on normal-state magnitude.
234    pub magnitude_cap_normal_bps: u16,
235    /// Per-alarm-proposal magnitude cap (smaller than normal cap by spec).
236    pub magnitude_cap_alarm_bps: u16,
237    /// Floor below which `compute_recommendation` skips proposal generation.
238    pub auto_proposal_min_magnitude_bps: u16,
239    /// True iff alarm states get the fast-track 6h timelock.
240    pub alarm_fast_track_enabled: bool,
241    /// Alarm-state timelock in hours.
242    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/// Breakdown of an epoch's burns. Sum becomes `BaseFeeBurn + LocalFeeBurn +
265/// PaymasterBurn + SlashedAndBurned` in the NetSupplyDelta formula. Amounts
266/// are TNZO base units (1e18).
267#[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/// Breakdown of an epoch's emissions (inflationary side of NetSupplyDelta).
285#[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/// Snapshot of supply mechanics for one epoch (or the latest rolling window
299/// summary). Written by the epoch observer; read by the transfer function
300/// and the `tenzro_getSupplyMetrics` RPC.
301#[derive(Debug, Clone, Default, Serialize, Deserialize)]
302pub struct SupplyMetricsSnapshot {
303    /// Block height at which this snapshot was taken.
304    pub block_height: BlockHeight,
305    /// Wall-clock timestamp of the snapshot.
306    pub captured_at: Timestamp,
307    /// Total TNZO in circulation at snapshot time.
308    pub circulating_supply: u128,
309    /// `Σ(emissions) - Σ(burns)` for the epoch. Signed: negative = epoch
310    /// was deflationary. Stored as i128 to allow either sign.
311    pub epoch_supply_delta: i128,
312    /// Annualized rolling-window supply delta as a signed bps value.
313    /// Same denominator as `target_annual_supply_bps` so they're directly
314    /// comparable.
315    pub rolling_window_supply_delta_bps: i32,
316    pub burn_breakdown: BurnBreakdown,
317    pub emission_breakdown: EmissionBreakdown,
318}
319
320/// What the transfer function recommends doing this epoch.
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322pub enum RecommendationAction {
323    /// Adjuster disabled by governance — no action.
324    Disabled,
325    /// Within the neutral band — no action.
326    NoChange,
327    /// Inflationary deviation — proposal to raise `base_fee_burn_bps`.
328    IncreaseBurnPct,
329    /// Deflationary deviation — proposal to lower `base_fee_burn_bps`.
330    DecreaseBurnPct,
331    /// Reading exceeded inflation alarm threshold (or 2× extreme) — fast-
332    /// track proposal with shortened timelock.
333    AlarmHighInflation,
334    /// Reading exceeded deflation alarm threshold (or 2× extreme).
335    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    /// True iff this action represents an alarm condition.
351    pub fn is_alarm(&self) -> bool {
352        matches!(
353            self,
354            RecommendationAction::AlarmHighInflation
355                | RecommendationAction::AlarmHighDeflation
356        )
357    }
358}
359
360/// Output of `compute_recommendation`. `magnitude_bps` is signed: positive =
361/// raise burn pct, negative = lower it. For alarm/no-change/disabled the
362/// magnitude is 0 (the alarm itself is the signal — the magnitude of the
363/// follow-up proposal is set by the auto-proposal generator using
364/// `magnitude_cap_alarm_bps`).
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct BurnRateRecommendation {
367    pub action: RecommendationAction,
368    pub magnitude_bps: i32,
369    /// True iff `|magnitude_bps| >= auto_proposal_min_magnitude_bps`. The
370    /// auto-proposal generator skips proposals below this floor.
371    pub above_proposal_floor: bool,
372    /// Echo of the `rolling_window_supply_delta_bps - target` deviation
373    /// the function read. Signed.
374    pub deviation_bps: i32,
375}
376
377// ---------- Pure transfer function -------------------------------------------
378
379/// Compute the burn-rate recommendation for the current epoch.
380///
381/// Pure function: same `(metrics, targets)` input → same output. The full
382/// algorithm is in [adaptive-burn.md](../../../docs/architecture/agent-swarm/adaptive-burn.md)
383/// §"Transfer function".
384///
385/// Logic:
386/// 1. If `targets.enabled == false` → `Disabled`.
387/// 2. Compute `delta = rolling_supply_delta_bps - target_annual_supply_bps`.
388/// 3. Sanity check: if `|rolling_supply_delta_bps| > 2 × inflation_alarm_bps`
389///    or `> 2 × deflation_alarm_bps` → output alarm only, never auto-adjust.
390/// 4. If `|delta_per_epoch_equivalent| < neutral_band_bps` → `NoChange`.
391///    Convert the annualized delta to per-epoch by dividing by
392///    `rolling_window_epochs` (the band is per-epoch in spec).
393/// 5. If sustained `delta > inflation_alarm_bps` → `AlarmHighInflation`.
394/// 6. If sustained `delta < -deflation_alarm_bps` → `AlarmHighDeflation`.
395/// 7. Otherwise: proportional adjustment, capped by `magnitude_cap_normal_bps`.
396pub 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    // Step 3: extreme reading sanity check. Forces alarm before any
414    // proportional adjustment runs.
415    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    // Step 4: neutral band. The band is defined per-epoch; the rolling
435    // delta is annualized, so divide by window length to get the per-epoch
436    // equivalent.
437    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    // Steps 5-6: alarm thresholds on the annualized delta.
450    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    // Step 7: proportional adjustment. `gain_bps_per_pct` is in bps-per-1%-
468    // deviation. `deviation` is already in bps. So:
469    //     magnitude = (|deviation| / 100) * gain_bps_per_pct
470    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    // Sign convention: inflationary (deviation > 0) → increase burn pct
477    // (magnitude positive). Deflationary → decrease burn pct (negative).
478    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
495// ---------- Manager ----------------------------------------------------------
496
497/// Manages the singleton `BurnRateConfig`, `SupplyTargets`, and latest
498/// `SupplyMetricsSnapshot` records. Mirrors `BurnQuotaManager`'s shape:
499/// `parking_lot::RwLock` over the live state with optional write-through to
500/// `CF_TOKENS`.
501pub 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    /// In-memory manager seeded with genesis defaults.
516    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    /// Construct with RocksDB write-through. Hydrates all three singletons
526    /// from `CF_TOKENS`. If a key is absent the manager initializes with
527    /// the corresponding `Default::default()` and persists it so subsequent
528    /// reads are consistent across restarts.
529    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    /// Read a snapshot of the current burn-rate config.
541    pub fn config(&self) -> BurnRateConfig {
542        *self.config.read()
543    }
544
545    /// Read a snapshot of the current supply targets.
546    pub fn targets(&self) -> SupplyTargets {
547        *self.targets.read()
548    }
549
550    /// Read the latest persisted supply metrics snapshot.
551    pub fn latest_metrics(&self) -> SupplyMetricsSnapshot {
552        self.metrics.read().clone()
553    }
554
555    /// Apply a governance-ratified update to the burn-rate config.
556    /// Validates the new config and persists immediately. Used by the
557    /// governance executor when an adaptive-burn proposal passes.
558    pub fn apply_config(&self, new_config: BurnRateConfig) -> Result<()> {
559        new_config.validate()?;
560        // Wave-1 invariant: paymaster burn pct is locked at 100%. The
561        // function never adjusts it; we also reject explicit governance
562        // updates that drop it below 100% to keep that invariant
563        // discoverable from one place.
564        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    /// Apply a governance-ratified update to supply targets.
582    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    /// Record the latest supply metrics snapshot (called by the epoch
597    /// observer after aggregating UsageTracker + burn-ledger data).
598    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    /// Compute the current burn-rate recommendation from the latest
611    /// persisted metrics + targets. Pure read; does not mutate state and
612    /// does not draft a proposal.
613    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        // BurnRateConfig
626        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        // SupplyTargets
649        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        // SupplyMetricsSnapshot — absence is fine; observer hasn't run yet.
668        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
737// ---------- Auto-proposal generator -----------------------------------------
738
739/// Default poll interval for the auto-proposal generator: 8 hours, matching
740/// the genesis epoch boundary in `adaptive-burn.md`.
741pub const DEFAULT_AUTO_PROPOSAL_POLL_INTERVAL_SECS: u64 = 8 * 60 * 60;
742
743/// Default debounce between auto-proposals: 24 hours. The generator will not
744/// emit a second proposal within this window even if metrics keep drifting,
745/// to prevent flooding governance with overlapping `AdaptiveBurnConfigUpdate`
746/// votes.
747pub const DEFAULT_AUTO_PROPOSAL_DEBOUNCE_SECS: u64 = 24 * 60 * 60;
748
749/// Normal-state voting duration for an auto-issued proposal: 24 hours.
750pub const DEFAULT_AUTO_PROPOSAL_NORMAL_VOTING_HOURS: u32 = 24;
751
752/// Configuration for [`AutoProposalGenerator`]. All values default to the
753/// genesis spec; operators tune via `NodeConfig` later if needed.
754#[derive(Debug, Clone, Copy)]
755pub struct AutoProposalGeneratorConfig {
756    /// How often the generator polls `current_recommendation()`. Defaults to
757    /// the epoch period (8h).
758    pub poll_interval_secs: u64,
759    /// Minimum elapsed time between two consecutive auto-issued proposals.
760    /// Defaults to 24h. Independent of voting duration — even if a prior
761    /// proposal finishes voting in 6h (alarm fast-track), the generator
762    /// still respects this debounce before drafting another.
763    pub debounce_secs: u64,
764    /// Voting duration for non-alarm proposals, hours.
765    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
778/// Wraps a [`BurnRateConfigManager`] + [`crate::governance::GovernanceEngine`]
779/// and runs a background tokio task that:
780///
781/// 1. Periodically reads `manager.current_recommendation()`.
782/// 2. If the recommendation has `above_proposal_floor` and the action is
783///    [`RecommendationAction::IncreaseBurnPct`], [`DecreaseBurnPct`],
784///    [`AlarmHighInflation`], or [`AlarmHighDeflation`], drafts a typed
785///    [`tenzro_types::token::ProposalType::AdaptiveBurnConfigUpdate`]
786///    proposal via the engine's `create_system_proposal` (bypasses
787///    min-proposer-stake since the protocol has no stake of its own).
788/// 3. Respects a debounce window to avoid flooding governance.
789///
790/// Alarm states use the `magnitude_cap_alarm_bps` for magnitude (the
791/// recommendation itself carries `magnitude_bps = 0` for alarms — the alarm
792/// is the signal, the size is set by the alarm cap). Alarm voting duration
793/// = `targets.alarm_timelock_hours` when fast-track is enabled, else
794/// `normal_voting_hours`.
795pub 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    /// Spawn the background loop. The returned `JoinHandle` lives for the
824    /// process lifetime; callers may drop it (no shutdown channel — the loop
825    /// is a no-op when the dial is disabled or the recommendation is
826    /// `NoChange`).
827    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            // Skip the immediate fire — wait one full interval before the
833            // first poll so metrics have a chance to be observed.
834            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    /// One iteration: read recommendation, draft a proposal if applicable.
845    /// Public for tests; also called once per poll interval by `spawn`.
846    /// Returns `Ok(Some(proposal_id))` if a proposal was issued, `Ok(None)`
847    /// otherwise.
848    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        // Debounce window. Note: tests bypass this by constructing the
855        // generator fresh; production runs hold a single instance.
856        {
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(&current, &targets, &recommendation);
874
875        // Validate the proposed config before drafting the proposal so we
876        // don't waste a vote on something the executor will reject.
877        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        // For alarms the recommendation magnitude is 0 — pull the per-alarm
948        // cap from targets. Sign comes from which alarm fired.
949        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        // Genesis = 100% burn.
974        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        // 50/50 on an odd amount: floor to burn, remainder (dust) to treasury.
980        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        // 0% burn → everything to treasury.
986        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        // local + paymaster use their own dials (genesis 100% burn).
991        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        // Already at 10_000, can't go higher.
1018        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        // Target = 50bps. Rolling = 50bps. Deviation = 0. Per-epoch = 0.
1038        // Band = 5bps → no change.
1039        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        // Target = 50bps. Rolling = +200bps. Deviation = +150bps.
1049        // Per-epoch = 150/90 ≈ 1bps < band (5bps) → no change actually.
1050        // To trigger proportional, we need deviation > band * window =
1051        // 5 * 90 = 450. So set rolling = 500 → deviation = 450. Per-epoch
1052        // = 5 = band; abs(per_epoch_deviation) < band fails (not strict
1053        // greater, but `< band` is the band-inside condition). 5 is NOT
1054        // less than 5 → falls through to proportional path.
1055        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        // |dev| = 450bps = 4.5% deviation. gain = 50 bps/pct → 225bps raw,
1061        // capped to magnitude_cap_normal (200).
1062        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        // rolling = -400bps. Target = 50bps. Deviation = -450bps. Per-epoch
1070        // = -5 (passes band check at boundary). Within deflation alarm
1071        // magnitude (500). Proportional path.
1072        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        // Inflation alarm = 500. Rolling = 700 → above alarm but below
1083        // 2× extreme (1000). Action = AlarmHighInflation.
1084        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        // 2× alarm = 1000. Rolling = 1500 → forced alarm, no proportional.
1104        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        // Construct targets with a high floor so any small magnitude is
1159        // skipped, AND a tight rolling window so the per-epoch band check
1160        // doesn't swallow modest deviations.
1161        let targets = SupplyTargets {
1162            auto_proposal_min_magnitude_bps: 500, // require >= 500 bps
1163            rolling_window_epochs: 1,             // per-epoch == annualized for the band check
1164            ..SupplyTargets::default()
1165        };
1166        // rolling=450, target=50 → deviation=400bps=4%. Below alarm (500).
1167        // gain=50/pct → raw magnitude=200bps, capped at 200. Below floor 500.
1168        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    // ---- AutoProposalGenerator tests --------------------------------------
1176
1177    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        // metrics with rolling=0 → deviation = -50 → per-epoch tiny, NoChange.
1192        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        // Disable targets, then push extreme metrics.
1202        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        // Floor=500, rolling=450 → magnitude=200 → below floor.
1217        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        // gain=50/pct, rolling=450 → magnitude=200 → above default floor 25.
1233        let targets = SupplyTargets {
1234            rolling_window_epochs: 1, // per-epoch band check passes
1235            ..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        // rolling=600 → exceeds inflation_alarm_bps=500 → AlarmHighInflation.
1248        // Generator should still draft a proposal with magnitude derived
1249        // from magnitude_cap_alarm_bps.
1250        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        // Start config with base_fee_burn_bps = 50, then alarm-deflation
1260        // wants to push it negative — clamps at 0.
1261        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        // The proposal should be valid (validate() passed)
1270        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        // Second tick within the 24h debounce — should be skipped.
1285        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}