use dashmap::DashMap;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tenzro_types::primitives::Address;
use tenzro_types::transaction::SignedTransaction;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lane {
Verified = 0,
Delegated = 1,
Open = 2,
}
impl Lane {
pub fn as_str(self) -> &'static str {
match self {
Lane::Verified => "verified",
Lane::Delegated => "delegated",
Lane::Open => "open",
}
}
pub fn all() -> [Lane; 3] {
[Lane::Verified, Lane::Delegated, Lane::Open]
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdmissionConfig {
pub verified_refill_rate_per_sec: f64,
pub verified_burst: u32,
pub delegated_refill_rate_per_sec: f64,
pub delegated_burst: u32,
pub open_refill_rate_per_sec: f64,
pub open_burst: u32,
pub min_verified_stake: u128,
pub lane_weights: (u32, u32, u32),
pub verified_floor_mult: f64,
pub delegated_floor_mult: f64,
pub open_floor_mult: f64,
pub bond_promotes_to_delegated: bool,
pub bond_min_for_promotion: u128,
}
impl Default for AdmissionConfig {
fn default() -> Self {
Self {
verified_refill_rate_per_sec: 50.0,
verified_burst: 500,
delegated_refill_rate_per_sec: 10.0,
delegated_burst: 100,
open_refill_rate_per_sec: 1.0,
open_burst: 20,
min_verified_stake: 10_000u128 * 10u128.pow(18), lane_weights: (8, 4, 1),
verified_floor_mult: 1.0,
delegated_floor_mult: 1.5,
open_floor_mult: 4.0,
bond_promotes_to_delegated: true,
bond_min_for_promotion: 1_000u128 * 10u128.pow(18), }
}
}
impl AdmissionConfig {
pub fn fee_floor_mult(&self, lane: Lane) -> f64 {
match lane {
Lane::Verified => self.verified_floor_mult,
Lane::Delegated => self.delegated_floor_mult,
Lane::Open => self.open_floor_mult,
}
}
pub fn refill_rate(&self, lane: Lane) -> f64 {
match lane {
Lane::Verified => self.verified_refill_rate_per_sec,
Lane::Delegated => self.delegated_refill_rate_per_sec,
Lane::Open => self.open_refill_rate_per_sec,
}
}
pub fn burst_capacity(&self, lane: Lane) -> u32 {
match lane {
Lane::Verified => self.verified_burst,
Lane::Delegated => self.delegated_burst,
Lane::Open => self.open_burst,
}
}
pub fn weight(&self, lane: Lane) -> u32 {
let (v, d, o) = self.lane_weights;
match lane {
Lane::Verified => v.max(1),
Lane::Delegated => d.max(1),
Lane::Open => o.max(1),
}
}
}
pub trait LaneResolver: Send + Sync {
fn assign_lane(&self, tx: &SignedTransaction) -> Lane;
fn bucket_key(&self, tx: &SignedTransaction) -> Address;
}
pub struct DefaultLaneResolver;
impl LaneResolver for DefaultLaneResolver {
fn assign_lane(&self, _tx: &SignedTransaction) -> Lane {
Lane::Open
}
fn bucket_key(&self, tx: &SignedTransaction) -> Address {
tx.transaction.from
}
}
#[derive(Debug)]
struct Bucket {
capacity: u32,
tokens: f64,
refill_per_sec: f64,
last_refill: Instant,
lane: Lane,
}
impl Bucket {
fn new(lane: Lane, capacity: u32, refill_per_sec: f64) -> Self {
Self {
capacity,
tokens: capacity as f64, refill_per_sec,
last_refill: Instant::now(),
lane,
}
}
fn try_admit(&mut self) -> std::result::Result<(), u64> {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_refill).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity as f64);
self.last_refill = now;
if self.tokens >= 1.0 {
self.tokens -= 1.0;
Ok(())
} else {
let needed = 1.0 - self.tokens;
let secs = needed / self.refill_per_sec.max(f64::MIN_POSITIVE);
let ms = (secs * 1000.0).ceil().max(1.0) as u64;
Err(ms)
}
}
fn snapshot(&mut self) -> BucketSnapshot {
let now = Instant::now();
let elapsed = now.saturating_duration_since(self.last_refill).as_secs_f64();
self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity as f64);
self.last_refill = now;
BucketSnapshot {
lane: self.lane,
capacity: self.capacity,
tokens: self.tokens,
refill_per_sec: self.refill_per_sec,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BucketSnapshot {
pub lane: Lane,
pub capacity: u32,
pub tokens: f64,
pub refill_per_sec: f64,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct LaneStats {
pub admitted: [u64; 3],
pub rejected_rate_limited: [u64; 3],
pub rejected_fee_floor: [u64; 3],
pub rejected_mempool_full: [u64; 3],
pub bucket_count: u64,
}
impl LaneStats {
pub fn admitted(&self, lane: Lane) -> u64 { self.admitted[lane as usize] }
pub fn rejected_rate_limited(&self, lane: Lane) -> u64 {
self.rejected_rate_limited[lane as usize]
}
pub fn rejected_fee_floor(&self, lane: Lane) -> u64 {
self.rejected_fee_floor[lane as usize]
}
pub fn rejected_mempool_full(&self, lane: Lane) -> u64 {
self.rejected_mempool_full[lane as usize]
}
}
#[derive(Debug, Clone)]
pub enum AdmissionDecision {
Admit { lane: Lane },
RateLimited {
lane: Lane,
retry_after_ms: u64,
burst_remaining: u32,
current_rate: f64,
},
}
pub struct AdmissionController {
config: AdmissionConfig,
resolver: Arc<dyn LaneResolver>,
buckets: Arc<DashMap<Address, Arc<Mutex<Bucket>>>>,
stats: Arc<Mutex<LaneStats>>,
}
impl AdmissionController {
pub fn new(config: AdmissionConfig, resolver: Arc<dyn LaneResolver>) -> Self {
Self {
config,
resolver,
buckets: Arc::new(DashMap::new()),
stats: Arc::new(Mutex::new(LaneStats::default())),
}
}
pub fn with_default_resolver(config: AdmissionConfig) -> Self {
Self::new(config, Arc::new(DefaultLaneResolver))
}
pub fn assign_lane(&self, tx: &SignedTransaction) -> Lane {
self.resolver.assign_lane(tx)
}
pub fn bucket_key(&self, tx: &SignedTransaction) -> Address {
self.resolver.bucket_key(tx)
}
pub fn fee_floor_mult(&self, lane: Lane) -> f64 {
self.config.fee_floor_mult(lane)
}
pub fn config(&self) -> &AdmissionConfig {
&self.config
}
pub fn try_admit(&self, tx: &SignedTransaction) -> AdmissionDecision {
let lane = self.resolver.assign_lane(tx);
let key = self.resolver.bucket_key(tx);
let bucket = self.get_or_create_bucket(&key, lane);
let mut guard = bucket.lock();
if guard.lane != lane {
guard.lane = lane;
guard.capacity = self.config.burst_capacity(lane);
guard.refill_per_sec = self.config.refill_rate(lane);
guard.tokens = guard.capacity as f64;
}
let decision = match guard.try_admit() {
Ok(()) => AdmissionDecision::Admit { lane },
Err(retry_after_ms) => AdmissionDecision::RateLimited {
lane,
retry_after_ms,
burst_remaining: guard.tokens.floor().max(0.0) as u32,
current_rate: guard.refill_per_sec,
},
};
drop(guard);
let mut stats = self.stats.lock();
match decision {
AdmissionDecision::Admit { .. } => {
stats.admitted[lane as usize] = stats.admitted[lane as usize].saturating_add(1);
}
AdmissionDecision::RateLimited { .. } => {
stats.rejected_rate_limited[lane as usize] =
stats.rejected_rate_limited[lane as usize].saturating_add(1);
}
}
stats.bucket_count = self.buckets.len() as u64;
decision
}
pub fn record_rejected_fee_floor(&self, lane: Lane) {
let mut stats = self.stats.lock();
stats.rejected_fee_floor[lane as usize] =
stats.rejected_fee_floor[lane as usize].saturating_add(1);
}
pub fn record_rejected_mempool_full(&self, lane: Lane) {
let mut stats = self.stats.lock();
stats.rejected_mempool_full[lane as usize] =
stats.rejected_mempool_full[lane as usize].saturating_add(1);
}
pub fn bucket_snapshot(&self, key: &Address) -> Option<BucketSnapshot> {
self.buckets.get(key).map(|b| b.lock().snapshot())
}
pub fn stats(&self) -> LaneStats {
self.stats.lock().clone()
}
fn get_or_create_bucket(&self, key: &Address, lane: Lane) -> Arc<Mutex<Bucket>> {
if let Some(existing) = self.buckets.get(key) {
return existing.clone();
}
let bucket = Arc::new(Mutex::new(Bucket::new(
lane,
self.config.burst_capacity(lane),
self.config.refill_rate(lane),
)));
self.buckets
.entry(*key)
.or_insert_with(|| bucket)
.clone()
}
pub fn prune_idle(&self, idle_for: Duration) {
let cutoff = Instant::now() - idle_for;
self.buckets.retain(|_addr, bucket| {
let g = bucket.lock();
g.last_refill > cutoff
});
}
pub fn bucket_count(&self) -> usize {
self.buckets.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tenzro_crypto::pq::MlDsaSigningKey;
use tenzro_types::primitives::{Address, ChainId, Nonce, Signature, Timestamp};
use tenzro_types::transaction::{SignedTransaction, Transaction, TransactionType};
fn dummy_tx(from_byte: u8) -> SignedTransaction {
let mut from = [0u8; 32];
from[0] = from_byte;
let pq = MlDsaSigningKey::generate();
let tx = Transaction {
chain_id: ChainId::from(1337u64),
from: Address::new(from),
to: Address::new([0u8; 32]),
nonce: Nonce::from(0u64),
tx_type: TransactionType::Transfer { amount: 0 },
gas_limit: 21000,
gas_price: 1_000_000_000,
timestamp: Timestamp::now(),
memo: None,
pq_public_key: pq.verifying_key_bytes().to_vec(),
};
SignedTransaction::new(tx, Signature::new(vec![0u8; 64], vec![0u8; 32]), vec![0u8; 3309])
}
#[test]
fn open_lane_burst_then_throttle() {
let cfg = AdmissionConfig {
open_burst: 3,
open_refill_rate_per_sec: 0.1, ..AdmissionConfig::default()
};
let ctrl = AdmissionController::with_default_resolver(cfg);
let tx = dummy_tx(1);
for _ in 0..3 {
assert!(matches!(ctrl.try_admit(&tx), AdmissionDecision::Admit { lane: Lane::Open }));
}
match ctrl.try_admit(&tx) {
AdmissionDecision::RateLimited { lane, retry_after_ms, .. } => {
assert_eq!(lane, Lane::Open);
assert!(retry_after_ms > 0);
}
other => panic!("expected RateLimited, got {:?}", other),
}
let stats = ctrl.stats();
assert_eq!(stats.admitted(Lane::Open), 3);
assert_eq!(stats.rejected_rate_limited(Lane::Open), 1);
}
#[test]
fn separate_addresses_have_independent_buckets() {
let cfg = AdmissionConfig {
open_burst: 1,
open_refill_rate_per_sec: 0.0001,
..AdmissionConfig::default()
};
let ctrl = AdmissionController::with_default_resolver(cfg);
let tx_a = dummy_tx(1);
let tx_b = dummy_tx(2);
assert!(matches!(ctrl.try_admit(&tx_a), AdmissionDecision::Admit { .. }));
assert!(matches!(ctrl.try_admit(&tx_a), AdmissionDecision::RateLimited { .. }));
assert!(matches!(ctrl.try_admit(&tx_b), AdmissionDecision::Admit { .. }));
}
#[test]
fn lane_change_resizes_bucket() {
struct PromotingResolver {
calls: std::sync::atomic::AtomicU64,
}
impl LaneResolver for PromotingResolver {
fn assign_lane(&self, _tx: &SignedTransaction) -> Lane {
let n = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if n < 2 { Lane::Open } else { Lane::Verified }
}
fn bucket_key(&self, tx: &SignedTransaction) -> Address {
tx.transaction.from
}
}
let resolver = Arc::new(PromotingResolver {
calls: std::sync::atomic::AtomicU64::new(0),
});
let cfg = AdmissionConfig {
open_burst: 1,
open_refill_rate_per_sec: 0.0001,
verified_burst: 1000,
verified_refill_rate_per_sec: 1000.0,
..AdmissionConfig::default()
};
let ctrl = AdmissionController::new(cfg, resolver);
let tx = dummy_tx(1);
assert!(matches!(ctrl.try_admit(&tx), AdmissionDecision::Admit { lane: Lane::Open }));
assert!(matches!(ctrl.try_admit(&tx), AdmissionDecision::RateLimited { lane: Lane::Open, .. }));
match ctrl.try_admit(&tx) {
AdmissionDecision::Admit { lane } => assert_eq!(lane, Lane::Verified),
other => panic!("expected Verified admit, got {:?}", other),
}
}
#[test]
fn weights_clamp_open_to_at_least_one() {
let cfg = AdmissionConfig {
lane_weights: (10, 5, 0), ..AdmissionConfig::default()
};
assert_eq!(cfg.weight(Lane::Open), 1, "Open weight must be clamped to >= 1");
}
}