espresso_node/api/
unlock_schedule.rs

1use std::sync::LazyLock;
2
3use alloy::primitives::{U256, utils::parse_ether};
4use chrono::{Months, NaiveDate, NaiveDateTime, NaiveTime};
5use espresso_types::{v0_1::ChainId, v0_3::RewardAmount};
6use serde::Deserialize;
7
8const SCHEDULE_TOML: &str = include_str!("../../../../../data/token-unlock-schedule.toml");
9
10pub(crate) const MAINNET_CHAIN_ID: u64 = 1;
11
12#[derive(Deserialize)]
13struct UnlockEntry {
14    month: u64,
15    amount_esp: u64,
16}
17
18#[derive(Deserialize)]
19struct UnlockSchedule {
20    tge_date: String,
21    unlocks: Vec<UnlockEntry>,
22}
23
24struct UnlockCliff {
25    timestamp: u64,
26    amount_wei: U256,
27}
28
29struct ParsedSchedule {
30    /// Sorted by timestamp. Each entry is a calendar month boundary from TGE
31    /// with the cumulative unlocked amount at that point.
32    unlocks: Vec<UnlockCliff>,
33}
34
35static SCHEDULE: LazyLock<ParsedSchedule> = LazyLock::new(|| {
36    let schedule: UnlockSchedule =
37        toml::from_str(SCHEDULE_TOML).expect("valid token-unlock-schedule.toml");
38
39    let tge_date =
40        NaiveDate::parse_from_str(&schedule.tge_date, "%Y-%m-%d").expect("valid tge_date");
41
42    assert!(
43        schedule.unlocks.windows(2).all(|w| w[0].month < w[1].month),
44        "unlock schedule must be sorted by month"
45    );
46
47    let unlocks = schedule
48        .unlocks
49        .iter()
50        .map(|entry| {
51            let date = tge_date
52                .checked_add_months(Months::new(
53                    u32::try_from(entry.month).expect("month fits in u32"),
54                ))
55                .expect("valid calendar month from TGE");
56            let ts = NaiveDateTime::new(date, NaiveTime::MIN)
57                .and_utc()
58                .timestamp();
59            assert!(ts >= 0, "unlock dates must be after unix epoch");
60            let timestamp = ts as u64;
61            let amount_wei = parse_ether(&entry.amount_esp.to_string()).unwrap();
62            UnlockCliff {
63                timestamp,
64                amount_wei,
65            }
66        })
67        .collect();
68
69    ParsedSchedule { unlocks }
70});
71
72/// Unlocked token amount (in WEI) at the given unix timestamp.
73///
74/// Cliff-based unlock using calendar months from TGE: the amount stays at the
75/// previous month's value until the next calendar month boundary is reached.
76pub fn unlocked_amount_at(timestamp_secs: u64) -> U256 {
77    SCHEDULE
78        .unlocks
79        .iter()
80        .rev()
81        .find(|cliff| timestamp_secs >= cliff.timestamp)
82        .map(|cliff| cliff.amount_wei)
83        .unwrap_or(U256::ZERO)
84}
85
86/// Computes circulating supply from on-chain data and the unlock schedule.
87///
88/// - `circulating           = initial_supply + reward_distributed - locked`
89/// - `circulating_ethereum  = total_supply_l1 - locked`
90///
91/// `locked` is the only mainnet/non-mainnet branching point:
92/// - Mainnet: `locked = initial_supply - unlocked(now)`
93/// - Non-mainnet: `locked = 0` (no unlock schedule)
94pub struct SupplyCalculator {
95    chain_id: U256,
96    now_secs: u64,
97    initial_supply: U256,
98    total_supply_l1: U256,
99    total_reward_distributed: U256,
100}
101
102impl SupplyCalculator {
103    pub fn new(
104        chain_id: ChainId,
105        now_secs: u64,
106        initial_supply: U256,
107        total_supply_l1: U256,
108        total_reward_distributed: Option<RewardAmount>,
109    ) -> Self {
110        Self {
111            chain_id: chain_id.0,
112            now_secs,
113            initial_supply,
114            total_supply_l1,
115            total_reward_distributed: total_reward_distributed.map(|r| r.0).unwrap_or(U256::ZERO),
116        }
117    }
118
119    /// Tokens still locked on L1 per the unlock schedule.
120    /// Mainnet: `initial_supply - unlocked(now)`. Non-mainnet: `0`.
121    fn locked(&self) -> U256 {
122        if self.chain_id == U256::from(MAINNET_CHAIN_ID) {
123            self.initial_supply
124                .saturating_sub(unlocked_amount_at(self.now_secs))
125        } else {
126            U256::ZERO
127        }
128    }
129
130    /// Circulating supply across Espresso + Ethereum.
131    /// `= initial_supply + reward_distributed - locked`
132    pub fn circulating_supply(&self) -> U256 {
133        self.initial_supply
134            .saturating_add(self.total_reward_distributed)
135            .saturating_sub(self.locked())
136    }
137
138    /// Circulating supply on Ethereum L1 only.
139    /// `= total_supply_l1 - locked`
140    pub fn circulating_supply_ethereum(&self) -> U256 {
141        self.total_supply_l1.saturating_sub(self.locked())
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn month_boundary(n: usize) -> u64 {
150        SCHEDULE.unlocks[n].timestamp
151    }
152
153    fn tge_timestamp() -> u64 {
154        month_boundary(0)
155    }
156
157    #[test]
158    fn test_before_tge() {
159        assert_eq!(unlocked_amount_at(0), U256::ZERO);
160        assert_eq!(unlocked_amount_at(tge_timestamp() - 1), U256::ZERO);
161    }
162
163    #[test]
164    fn test_at_tge() {
165        let expected = parse_ether("520550000").unwrap();
166        assert_eq!(unlocked_amount_at(tge_timestamp()), expected);
167    }
168
169    #[test]
170    fn test_after_last_month() {
171        let far_future = tge_timestamp() + 10 * 365 * 86400; // ~10 years
172        let expected = parse_ether("3590000000").unwrap();
173        assert_eq!(unlocked_amount_at(far_future), expected);
174    }
175
176    #[test]
177    fn test_cliff_mid_month_equals_start_of_month() {
178        // Mid-month should return the same amount as the start of the month (cliff behavior)
179        let half_month = (month_boundary(0) + month_boundary(1)) / 2;
180        let result = unlocked_amount_at(half_month);
181        let expected = parse_ether("520550000").unwrap();
182        assert_eq!(result, expected);
183    }
184
185    #[test]
186    fn test_cliff_just_before_next_month() {
187        // One second before month 1 boundary should still return month 0 amount
188        let just_before_month_1 = month_boundary(1) - 1;
189        let expected = parse_ether("520550000").unwrap();
190        assert_eq!(unlocked_amount_at(just_before_month_1), expected);
191    }
192
193    #[test]
194    fn test_at_exact_month_boundary() {
195        let expected = parse_ether("540400476").unwrap();
196        assert_eq!(unlocked_amount_at(month_boundary(1)), expected);
197
198        let expected = parse_ether("1198597668").unwrap();
199        assert_eq!(unlocked_amount_at(month_boundary(12)), expected);
200    }
201
202    #[test]
203    fn test_at_month_72() {
204        let expected = parse_ether("3590000000").unwrap();
205        assert_eq!(unlocked_amount_at(month_boundary(72)), expected);
206    }
207
208    #[test]
209    fn test_schedule_is_monotonically_increasing() {
210        for window in SCHEDULE.unlocks.windows(2) {
211            assert!(window[1].amount_wei >= window[0].amount_wei);
212            assert!(window[1].timestamp > window[0].timestamp);
213        }
214    }
215
216    #[test]
217    fn test_schedule_has_expected_entries() {
218        assert_eq!(SCHEDULE.unlocks.len(), 73);
219    }
220
221    #[test]
222    fn test_tge_date_parses() {
223        use chrono::{Datelike, TimeZone, Utc};
224        let dt = Utc
225            .timestamp_opt(SCHEDULE.unlocks[0].timestamp as i64, 0)
226            .single()
227            .unwrap();
228        assert_eq!(dt.year(), 2026);
229        assert_eq!(dt.month(), 2);
230        assert_eq!(dt.day(), 12);
231    }
232
233    // --- SupplyCalculator tests ---
234
235    fn mainnet_id() -> ChainId {
236        ChainId(U256::from(MAINNET_CHAIN_ID))
237    }
238
239    fn testnet_id() -> ChainId {
240        ChainId(U256::from(35353u64))
241    }
242
243    fn post_tge_time() -> u64 {
244        month_boundary(6)
245    }
246
247    /// Mainnet initial supply (3.59B tokens) -- must be >= final unlock schedule amount.
248    fn mainnet_initial_supply() -> U256 {
249        parse_ether("3590000000").unwrap()
250    }
251
252    #[test]
253    fn test_locked_mainnet() {
254        let now = post_tge_time();
255        let initial_supply = mainnet_initial_supply();
256        let total_supply_l1 = initial_supply + parse_ether("50").unwrap();
257        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, None);
258
259        let unlocked = unlocked_amount_at(now);
260        assert_eq!(calc.locked(), initial_supply - unlocked);
261    }
262
263    #[test]
264    fn test_locked_non_mainnet() {
265        let now = post_tge_time();
266        let calc = SupplyCalculator::new(
267            testnet_id(),
268            now,
269            parse_ether("1000").unwrap(),
270            parse_ether("1000").unwrap(),
271            None,
272        );
273        assert_eq!(calc.locked(), U256::ZERO);
274    }
275
276    #[test]
277    fn test_locked_before_tge() {
278        // Before TGE, nothing is unlocked so locked = entire initial supply
279        let before_tge = tge_timestamp() - 1;
280        let initial_supply = mainnet_initial_supply();
281        let calc = SupplyCalculator::new(
282            mainnet_id(),
283            before_tge,
284            initial_supply,
285            initial_supply,
286            None,
287        );
288        assert_eq!(calc.locked(), initial_supply);
289    }
290
291    #[test]
292    fn test_locked_after_full_vest() {
293        // After month 72, everything is unlocked so locked = 0
294        // (assuming initial_supply <= final unlock amount)
295        let far_future = tge_timestamp() + 100 * 365 * 86400;
296        let initial_supply = mainnet_initial_supply();
297        let calc = SupplyCalculator::new(
298            mainnet_id(),
299            far_future,
300            initial_supply,
301            initial_supply,
302            None,
303        );
304        assert_eq!(calc.locked(), U256::ZERO);
305    }
306
307    #[test]
308    fn test_supply_calculator_mainnet_circulating() {
309        let now = post_tge_time();
310        let initial_supply = mainnet_initial_supply();
311        let total_supply_l1 = initial_supply + parse_ether("50").unwrap();
312        let reward = Some(RewardAmount(parse_ether("100").unwrap()));
313        let calc =
314            SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, reward);
315
316        // circulating = initial_supply + reward - locked = unlocked + reward
317        let unlocked = unlocked_amount_at(now);
318        assert_eq!(
319            calc.circulating_supply(),
320            unlocked + parse_ether("100").unwrap()
321        );
322    }
323
324    #[test]
325    fn test_supply_calculator_mainnet_ethereum() {
326        let now = post_tge_time();
327        let initial_supply = mainnet_initial_supply();
328        let claimed = parse_ether("50").unwrap();
329        let total_supply_l1 = initial_supply + claimed;
330        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, None);
331
332        // circulating_ethereum = total_supply_l1 - locked = total_supply_l1 - (initial - unlocked)
333        //                      = unlocked + claimed
334        let unlocked = unlocked_amount_at(now);
335        assert_eq!(calc.circulating_supply_ethereum(), unlocked + claimed);
336    }
337
338    #[test]
339    fn test_supply_calculator_non_mainnet_circulating() {
340        let now = post_tge_time();
341        let reward = Some(RewardAmount(parse_ether("200").unwrap()));
342        // locked=0: initial_supply + reward = 1000 + 200 = 1200
343        let calc = SupplyCalculator::new(
344            testnet_id(),
345            now,
346            parse_ether("1000").unwrap(),
347            parse_ether("1000").unwrap(),
348            reward,
349        );
350
351        assert_eq!(calc.circulating_supply(), parse_ether("1200").unwrap());
352    }
353
354    #[test]
355    fn test_supply_calculator_non_mainnet_ethereum() {
356        let now = post_tge_time();
357        let calc = SupplyCalculator::new(
358            testnet_id(),
359            now,
360            parse_ether("5000").unwrap(),
361            parse_ether("5000").unwrap(),
362            Some(RewardAmount(parse_ether("10").unwrap())),
363        );
364
365        // locked=0: total_supply_l1
366        assert_eq!(
367            calc.circulating_supply_ethereum(),
368            parse_ether("5000").unwrap()
369        );
370    }
371
372    #[test]
373    fn test_supply_calculator_invariant() {
374        // circulating - circulating_ethereum = initial_supply + reward - total_supply_l1
375        // (locked cancels out in subtraction)
376        for chain_id in [mainnet_id(), testnet_id()] {
377            let now = post_tge_time();
378            let initial_supply = parse_ether("10000").unwrap();
379            let reward = parse_ether("500").unwrap();
380            let total_supply_l1 = parse_ether("10200").unwrap();
381            let calc = SupplyCalculator::new(
382                chain_id,
383                now,
384                initial_supply,
385                total_supply_l1,
386                Some(RewardAmount(reward)),
387            );
388
389            // circulating = initial + reward - locked
390            // ethereum    = total_supply_l1 - locked
391            // diff        = initial + reward - total_supply_l1
392            assert_eq!(
393                calc.circulating_supply() - calc.circulating_supply_ethereum(),
394                initial_supply + reward - total_supply_l1,
395            );
396        }
397    }
398
399    #[test]
400    fn test_supply_calculator_zero_rewards() {
401        let now = post_tge_time();
402        let initial_supply = mainnet_initial_supply();
403        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, initial_supply, None);
404
405        let unlocked = unlocked_amount_at(now);
406        assert_eq!(calc.circulating_supply(), unlocked);
407    }
408
409    #[test]
410    fn test_supply_calculator_zero_claimed() {
411        let now = post_tge_time();
412        let initial_supply = parse_ether("1000").unwrap();
413        let reward = Some(RewardAmount(parse_ether("100").unwrap()));
414        let calc = SupplyCalculator::new(testnet_id(), now, initial_supply, initial_supply, reward);
415        assert_eq!(calc.circulating_supply(), parse_ether("1100").unwrap());
416    }
417
418    #[test]
419    fn test_supply_calculator_no_underflow() {
420        // Before TGE: locked = initial_supply, so circulating = 0 via saturating_sub
421        let before_tge = tge_timestamp() - 1;
422        let calc = SupplyCalculator::new(
423            mainnet_id(),
424            before_tge,
425            parse_ether("100").unwrap(),
426            parse_ether("100").unwrap(),
427            None,
428        );
429        assert_eq!(calc.circulating_supply(), U256::ZERO);
430    }
431
432    #[test]
433    fn test_locked_saturates_when_initial_less_than_unlocked() {
434        // If initial_supply < unlocked(now), locked saturates to 0 (not negative)
435        let now = post_tge_time();
436        let small_initial = parse_ether("100").unwrap();
437        let calc = SupplyCalculator::new(mainnet_id(), now, small_initial, small_initial, None);
438        assert_eq!(calc.locked(), U256::ZERO);
439        // circulating = initial + reward - locked = 100 + 0 - 0 = 100
440        assert_eq!(calc.circulating_supply(), small_initial);
441    }
442}