sui_core/
gasless_rate_limiter.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5use std::time::Instant;
6
7use parking_lot::Mutex;
8use sui_protocol_config::ProtocolConfig;
9
10/// Tracks how many gasless transactions were included in consensus commits
11/// within the current 1-second window. Updated by the consensus handler on
12/// each commit, and read by the rate limiter to make admission decisions.
13pub struct ConsensusGaslessCounter {
14    inner: Mutex<ConsensusWindowInner>,
15}
16
17struct ConsensusWindowInner {
18    window_second: u64,
19    count: u64,
20}
21
22impl Default for ConsensusGaslessCounter {
23    fn default() -> Self {
24        Self {
25            inner: Mutex::new(ConsensusWindowInner {
26                window_second: 0,
27                count: 0,
28            }),
29        }
30    }
31}
32
33impl ConsensusGaslessCounter {
34    pub fn record_commit(&self, commit_timestamp_ms: u64, gasless_count: u64) {
35        let second = commit_timestamp_ms / 1000;
36        let mut inner = self.inner.lock();
37        if second > inner.window_second {
38            inner.window_second = second;
39            inner.count = gasless_count;
40        } else if second == inner.window_second {
41            inner.count += gasless_count;
42        }
43    }
44
45    pub fn current_count(&self) -> u64 {
46        self.inner.lock().count
47    }
48}
49
50/// Per-validator fixed-window counter. Resets every second.
51struct FixedWindowCounter {
52    count: u64,
53    window_start: Instant,
54}
55
56impl FixedWindowCounter {
57    fn try_acquire(&mut self, max_tps: u64) -> bool {
58        if self.window_start.elapsed().as_secs() >= 1 {
59            self.count = 0;
60            self.window_start = Instant::now();
61        }
62        if self.count < max_tps {
63            self.count += 1;
64            true
65        } else {
66            false
67        }
68    }
69}
70
71/// Per-validator rate limiter for gasless transactions. Uses two layers:
72///
73/// 1. A local fixed-window counter to cap per-validator burst rate.
74/// 2. A consensus-fed global counter for sustained network-wide accuracy.
75///
76/// Both are checked against `gasless_max_tps`. A transaction is admitted
77/// only if both counters are under the limit.
78#[derive(Clone)]
79pub struct GaslessRateLimiter {
80    consensus_counter: Arc<ConsensusGaslessCounter>,
81    local: Arc<Mutex<FixedWindowCounter>>,
82}
83
84impl GaslessRateLimiter {
85    pub fn new(consensus_counter: Arc<ConsensusGaslessCounter>) -> Self {
86        Self {
87            consensus_counter,
88            local: Arc::new(Mutex::new(FixedWindowCounter {
89                count: 0,
90                window_start: Instant::now(),
91            })),
92        }
93    }
94
95    pub fn try_acquire(&self, config: &ProtocolConfig) -> bool {
96        let Some(max_tps) = config.gasless_max_tps_as_option() else {
97            return true;
98        };
99        if self.consensus_counter.current_count() >= max_tps {
100            return false;
101        }
102        // no single validator can admit more than max_tps burst
103        self.local.lock().try_acquire(max_tps)
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use sui_protocol_config::ProtocolVersion;
111
112    fn make_config(max_tps: u64) -> ProtocolConfig {
113        let mut config = ProtocolConfig::get_for_version(
114            ProtocolVersion::MAX,
115            sui_protocol_config::Chain::Unknown,
116        );
117        config.enable_gasless_for_testing();
118        config.set_gasless_max_tps_for_testing(max_tps);
119        config
120    }
121
122    fn make_limiter() -> (Arc<ConsensusGaslessCounter>, GaslessRateLimiter) {
123        let counter = Arc::new(ConsensusGaslessCounter::default());
124        let limiter = GaslessRateLimiter::new(counter.clone());
125        (counter, limiter)
126    }
127
128    // -- Config behavior tests --
129
130    #[test]
131    fn test_unset_is_unlimited() {
132        let (_, limiter) = make_limiter();
133        let config = ProtocolConfig::get_for_version(
134            ProtocolVersion::new(117),
135            sui_protocol_config::Chain::Unknown,
136        );
137        for _ in 0..100 {
138            assert!(limiter.try_acquire(&config));
139        }
140    }
141
142    #[test]
143    fn test_zero_max_tps_always_rejects() {
144        let (_, limiter) = make_limiter();
145        let config = make_config(0);
146        assert!(!limiter.try_acquire(&config));
147    }
148
149    // -- Consensus counter tests --
150
151    #[test]
152    fn test_record_commit_new_window_resets() {
153        let counter = ConsensusGaslessCounter::default();
154        counter.record_commit(1000, 10);
155        assert_eq!(counter.current_count(), 10);
156
157        counter.record_commit(2000, 5);
158        assert_eq!(counter.current_count(), 5);
159    }
160
161    #[test]
162    fn test_record_commit_same_window_accumulates() {
163        let counter = ConsensusGaslessCounter::default();
164        counter.record_commit(1000, 10);
165        counter.record_commit(1500, 7);
166        assert_eq!(counter.current_count(), 17);
167    }
168
169    #[test]
170    fn test_record_commit_past_window_ignored() {
171        let counter = ConsensusGaslessCounter::default();
172        counter.record_commit(2000, 10);
173        counter.record_commit(1000, 99);
174        assert_eq!(counter.current_count(), 10);
175    }
176
177    // -- Local admission counter tests --
178
179    #[test]
180    fn test_local_counter_prevents_burst() {
181        let (_, limiter) = make_limiter();
182        let config = make_config(5);
183        for _ in 0..5 {
184            assert!(limiter.try_acquire(&config));
185        }
186        assert!(!limiter.try_acquire(&config));
187    }
188
189    #[test]
190    fn test_local_window_resets() {
191        let (_, limiter) = make_limiter();
192        let config = make_config(5);
193        for _ in 0..5 {
194            assert!(limiter.try_acquire(&config));
195        }
196        assert!(!limiter.try_acquire(&config));
197        std::thread::sleep(std::time::Duration::from_secs(1));
198        for _ in 0..5 {
199            assert!(limiter.try_acquire(&config));
200        }
201        assert!(!limiter.try_acquire(&config));
202    }
203
204    // -- Two-layer interaction tests --
205
206    #[test]
207    fn test_consensus_blocks_before_local_increment() {
208        let (counter, limiter) = make_limiter();
209        let config = make_config(5);
210        counter.record_commit(1000, 5);
211        assert!(!limiter.try_acquire(&config));
212        counter.record_commit(2000, 0);
213        for _ in 0..5 {
214            assert!(limiter.try_acquire(&config));
215        }
216    }
217
218    #[test]
219    fn test_consensus_rejects_at_capacity() {
220        let (counter, limiter) = make_limiter();
221        counter.record_commit(1000, 5);
222        let config = make_config(5);
223        assert!(!limiter.try_acquire(&config));
224    }
225
226    #[test]
227    fn test_consensus_allows_under_capacity() {
228        let (counter, limiter) = make_limiter();
229        counter.record_commit(1000, 4);
230        let config = make_config(5);
231        assert!(limiter.try_acquire(&config));
232    }
233
234    #[test]
235    fn test_window_resets_after_non_gasless_commit() {
236        let (counter, limiter) = make_limiter();
237        counter.record_commit(1000, 5);
238        let config = make_config(5);
239        assert!(!limiter.try_acquire(&config));
240
241        counter.record_commit(2000, 0);
242        assert!(limiter.try_acquire(&config));
243    }
244}