use crate::error::{Result, TokenError};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tenzro_storage::{KvStore, WriteOp, CF_TOKENS};
use tenzro_types::primitives::Timestamp;
use tracing::{debug, info};
pub const BURN_QUOTA_KEY: &[u8] = b"burn_quota:singleton";
pub const DEFAULT_DAILY_REFILL_TARGET: u128 = 1_000_000 * 1_000_000_000_000_000_000;
pub const DEFAULT_CAP: u128 = 10 * DEFAULT_DAILY_REFILL_TARGET;
pub const DEFAULT_MIN_RESERVE_BPS: u16 = 1000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BurnQuota {
pub balance: u128,
pub cap: u128,
pub daily_target: u128,
pub min_reserve_bps: u16,
pub last_refill: Timestamp,
pub total_drained: u128,
pub total_refilled: u128,
pub deficit: i128,
}
impl Default for BurnQuota {
fn default() -> Self {
Self {
balance: 0,
cap: DEFAULT_CAP,
daily_target: DEFAULT_DAILY_REFILL_TARGET,
min_reserve_bps: DEFAULT_MIN_RESERVE_BPS,
last_refill: Timestamp::default(),
total_drained: 0,
total_refilled: 0,
deficit: 0,
}
}
}
impl BurnQuota {
pub fn min_reserve(&self) -> u128 {
let q = self.cap / 10_000;
let r = self.cap % 10_000;
let bps = self.min_reserve_bps as u128;
q.saturating_mul(bps)
.saturating_add(r.saturating_mul(bps) / 10_000)
}
pub fn can_drain(&self, amount: u128) -> bool {
self.balance.saturating_sub(amount) >= self.min_reserve()
}
}
pub struct BurnQuotaManager {
state: parking_lot::RwLock<BurnQuota>,
storage: Option<Arc<dyn KvStore>>,
}
impl Default for BurnQuotaManager {
fn default() -> Self {
Self::new()
}
}
impl BurnQuotaManager {
pub fn new() -> Self {
Self {
state: parking_lot::RwLock::new(BurnQuota::default()),
storage: None,
}
}
pub fn with_storage(storage: Arc<dyn KvStore>) -> Result<Self> {
let mgr = Self {
state: parking_lot::RwLock::new(BurnQuota::default()),
storage: Some(storage.clone()),
};
mgr.hydrate_from_storage()?;
Ok(mgr)
}
pub fn set_governance_params(
&self,
cap: u128,
daily_target: u128,
min_reserve_bps: u16,
) -> Result<()> {
if min_reserve_bps > 10_000 {
return Err(TokenError::InvalidParameter(format!(
"min_reserve_bps {} > 10000",
min_reserve_bps
)));
}
let snapshot = {
let mut guard = self.state.write();
guard.cap = cap;
guard.daily_target = daily_target;
guard.min_reserve_bps = min_reserve_bps;
if guard.balance > cap {
guard.balance = cap;
}
guard.clone()
};
self.persist(&snapshot)?;
info!(
cap,
daily_target,
min_reserve_bps,
"BurnQuota governance params updated"
);
Ok(())
}
pub fn state(&self) -> BurnQuota {
self.state.read().clone()
}
pub fn try_drain(&self, amount: u128) -> Result<()> {
if amount == 0 {
return Err(TokenError::InvalidAmount("burn quota drain == 0".into()));
}
let snapshot = {
let mut guard = self.state.write();
if !guard.can_drain(amount) {
return Err(TokenError::InsufficientBalance {
required: amount.saturating_add(guard.min_reserve()),
available: guard.balance,
});
}
guard.balance = guard
.balance
.checked_sub(amount)
.ok_or_else(|| TokenError::ArithmeticOverflow {
operation: "burn_quota.drain".into(),
})?;
guard.total_drained = guard.total_drained.saturating_add(amount);
guard.clone()
};
self.persist(&snapshot)?;
debug!(
amount,
balance = snapshot.balance,
total_drained = snapshot.total_drained,
"BurnQuota drained"
);
Ok(())
}
pub fn refill(&self, amount: u128, now: Timestamp) -> Result<RefillReceipt> {
if amount == 0 {
return Err(TokenError::InvalidAmount("burn quota refill == 0".into()));
}
let receipt;
let snapshot = {
let mut guard = self.state.write();
let new_balance = guard.balance.saturating_add(amount);
let (credited, excess) = if new_balance > guard.cap {
let credited = guard.cap - guard.balance;
(credited, amount - credited)
} else {
(amount, 0u128)
};
guard.balance = guard.balance.saturating_add(credited);
guard.total_refilled = guard.total_refilled.saturating_add(credited);
guard.last_refill = now;
if guard.deficit > 0 {
let reduce = (credited as i128).min(guard.deficit);
guard.deficit -= reduce;
}
receipt = RefillReceipt {
requested: amount,
credited,
excess_clamped: excess,
new_balance: guard.balance,
last_refill: guard.last_refill,
};
guard.clone()
};
self.persist(&snapshot)?;
info!(
requested = receipt.requested,
credited = receipt.credited,
excess = receipt.excess_clamped,
new_balance = receipt.new_balance,
"BurnQuota refilled"
);
Ok(receipt)
}
pub fn record_deficit(&self, missed_amount: u128) -> Result<()> {
if missed_amount == 0 {
return Ok(());
}
let snapshot = {
let mut guard = self.state.write();
guard.deficit = guard.deficit.saturating_add(missed_amount as i128);
guard.clone()
};
self.persist(&snapshot)?;
Ok(())
}
fn hydrate_from_storage(&self) -> Result<()> {
let storage = match &self.storage {
Some(s) => s.clone(),
None => return Ok(()),
};
match storage
.get(CF_TOKENS, BURN_QUOTA_KEY)
.map_err(|e| TokenError::StorageError(format!("get burn quota: {}", e)))?
{
Some(value) => {
let quota: BurnQuota = serde_json::from_slice(&value).map_err(|e| {
TokenError::StorageError(format!("decode burn quota: {}", e))
})?;
info!(
balance = quota.balance,
cap = quota.cap,
daily_target = quota.daily_target,
"BurnQuota hydrated from storage"
);
*self.state.write() = quota;
}
None => {
let genesis = self.state.read().clone();
self.persist(&genesis)?;
info!("BurnQuota initialized from genesis defaults");
}
}
Ok(())
}
fn persist(&self, quota: &BurnQuota) -> Result<()> {
if let Some(storage) = &self.storage {
let value = serde_json::to_vec(quota).map_err(|e| {
TokenError::StorageError(format!("encode burn quota: {}", e))
})?;
storage
.write_batch_sync(vec![WriteOp::Put {
cf: CF_TOKENS.to_string(),
key: BURN_QUOTA_KEY.to_vec(),
value,
}])
.map_err(|e| {
TokenError::StorageError(format!("persist burn quota: {}", e))
})?;
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefillReceipt {
pub requested: u128,
pub credited: u128,
pub excess_clamped: u128,
pub new_balance: u128,
pub last_refill: Timestamp,
}
#[cfg(test)]
mod tests {
use super::*;
fn small_quota() -> BurnQuotaManager {
let mgr = BurnQuotaManager::new();
mgr.set_governance_params(1000, 100, 1000).unwrap();
mgr
}
#[test]
fn default_quota_is_empty() {
let mgr = BurnQuotaManager::new();
let s = mgr.state();
assert_eq!(s.balance, 0);
assert_eq!(s.cap, DEFAULT_CAP);
assert_eq!(s.daily_target, DEFAULT_DAILY_REFILL_TARGET);
assert_eq!(s.min_reserve_bps, DEFAULT_MIN_RESERVE_BPS);
}
#[test]
fn drain_fails_when_balance_below_reserve() {
let mgr = small_quota();
let err = mgr.try_drain(10).unwrap_err();
assert!(matches!(err, TokenError::InsufficientBalance { .. }));
}
#[test]
fn refill_then_drain_above_reserve() {
let mgr = small_quota();
let r = mgr.refill(500, Timestamp::new(1_000)).unwrap();
assert_eq!(r.credited, 500);
assert_eq!(r.excess_clamped, 0);
assert_eq!(r.new_balance, 500);
mgr.try_drain(300).unwrap();
assert_eq!(mgr.state().balance, 200);
assert_eq!(mgr.state().total_drained, 300);
}
#[test]
fn drain_rejected_when_post_drain_below_reserve() {
let mgr = small_quota();
mgr.refill(500, Timestamp::new(1_000)).unwrap();
let err = mgr.try_drain(410).unwrap_err();
assert!(matches!(err, TokenError::InsufficientBalance { .. }));
assert_eq!(mgr.state().balance, 500);
}
#[test]
fn refill_clamps_to_cap() {
let mgr = small_quota();
mgr.refill(800, Timestamp::new(1_000)).unwrap();
let r = mgr.refill(500, Timestamp::new(2_000)).unwrap();
assert_eq!(r.credited, 200);
assert_eq!(r.excess_clamped, 300);
assert_eq!(r.new_balance, 1000);
}
#[test]
fn deficit_accumulates_and_clears_on_refill() {
let mgr = small_quota();
mgr.record_deficit(50).unwrap();
mgr.record_deficit(30).unwrap();
assert_eq!(mgr.state().deficit, 80);
mgr.refill(100, Timestamp::new(1_000)).unwrap();
assert_eq!(mgr.state().deficit, 0);
}
#[test]
fn zero_drain_rejected() {
let mgr = small_quota();
let err = mgr.try_drain(0).unwrap_err();
assert!(matches!(err, TokenError::InvalidAmount(_)));
}
#[test]
fn zero_refill_rejected() {
let mgr = small_quota();
let err = mgr.refill(0, Timestamp::new(1_000)).unwrap_err();
assert!(matches!(err, TokenError::InvalidAmount(_)));
}
#[test]
fn invalid_min_reserve_bps_rejected() {
let mgr = BurnQuotaManager::new();
let err = mgr.set_governance_params(1000, 100, 10_001).unwrap_err();
assert!(matches!(err, TokenError::InvalidParameter(_)));
}
#[test]
fn governance_params_clamp_balance_to_new_cap() {
let mgr = BurnQuotaManager::new();
mgr.set_governance_params(1000, 100, 1000).unwrap();
mgr.refill(900, Timestamp::new(1_000)).unwrap();
mgr.set_governance_params(500, 100, 1000).unwrap();
assert_eq!(mgr.state().balance, 500);
}
}