Skip to main content

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 token supply metrics from on-chain data and the unlock schedule.
87///
88/// - `total_issued          = initial_supply + reward_distributed`
89/// - `circulating           = initial_supply + reward_distributed - locked`
90/// - `circulating_ethereum  = total_supply_l1 - locked`
91///
92/// `locked` is the only mainnet/non-mainnet branching point:
93/// - Mainnet: `locked = initial_supply - unlocked(now)`
94/// - Non-mainnet: `locked = 0` (no unlock schedule)
95pub struct SupplyCalculator {
96    chain_id: U256,
97    now_secs: u64,
98    initial_supply: U256,
99    total_supply_l1: U256,
100    total_reward_distributed: U256,
101}
102
103impl SupplyCalculator {
104    pub fn new(
105        chain_id: ChainId,
106        now_secs: u64,
107        initial_supply: U256,
108        total_supply_l1: U256,
109        total_reward_distributed: Option<RewardAmount>,
110    ) -> Self {
111        Self {
112            chain_id: chain_id.0,
113            now_secs,
114            initial_supply,
115            total_supply_l1,
116            total_reward_distributed: total_reward_distributed.map(|r| r.0).unwrap_or(U256::ZERO),
117        }
118    }
119
120    /// Tokens still locked on L1 per the unlock schedule.
121    /// Mainnet: `initial_supply - unlocked(now)`. Non-mainnet: `0`.
122    fn locked(&self) -> U256 {
123        if self.chain_id == U256::from(MAINNET_CHAIN_ID) {
124            self.initial_supply
125                .saturating_sub(unlocked_amount_at(self.now_secs))
126        } else {
127            U256::ZERO
128        }
129    }
130
131    /// Circulating supply across Espresso + Ethereum.
132    /// `= initial_supply + reward_distributed - locked`
133    pub fn circulating_supply(&self) -> U256 {
134        self.initial_supply
135            .saturating_add(self.total_reward_distributed)
136            .saturating_sub(self.locked())
137    }
138
139    /// Circulating supply on Ethereum L1 only.
140    /// `= total_supply_l1 - locked`
141    pub fn circulating_supply_ethereum(&self) -> U256 {
142        self.total_supply_l1.saturating_sub(self.locked())
143    }
144
145    /// Total issued supply: `initial_supply + total_reward_distributed`.
146    pub fn total_issued_supply(&self) -> U256 {
147        self.initial_supply
148            .saturating_add(self.total_reward_distributed)
149    }
150
151    /// Total rewards distributed by consensus.
152    pub fn total_reward_distributed(&self) -> U256 {
153        self.total_reward_distributed
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    fn month_boundary(n: usize) -> u64 {
162        SCHEDULE.unlocks[n].timestamp
163    }
164
165    fn tge_timestamp() -> u64 {
166        month_boundary(0)
167    }
168
169    #[test]
170    fn test_before_tge() {
171        assert_eq!(unlocked_amount_at(0), U256::ZERO);
172        assert_eq!(unlocked_amount_at(tge_timestamp() - 1), U256::ZERO);
173    }
174
175    #[test]
176    fn test_at_tge() {
177        let expected = parse_ether("520550000").unwrap();
178        assert_eq!(unlocked_amount_at(tge_timestamp()), expected);
179    }
180
181    #[test]
182    fn test_after_last_month() {
183        let far_future = tge_timestamp() + 10 * 365 * 86400; // ~10 years
184        let expected = parse_ether("3590000000").unwrap();
185        assert_eq!(unlocked_amount_at(far_future), expected);
186    }
187
188    #[test]
189    fn test_cliff_mid_month_equals_start_of_month() {
190        // Mid-month should return the same amount as the start of the month (cliff behavior)
191        let half_month = (month_boundary(0) + month_boundary(1)) / 2;
192        let result = unlocked_amount_at(half_month);
193        let expected = parse_ether("520550000").unwrap();
194        assert_eq!(result, expected);
195    }
196
197    #[test]
198    fn test_cliff_just_before_next_month() {
199        // One second before month 1 boundary should still return month 0 amount
200        let just_before_month_1 = month_boundary(1) - 1;
201        let expected = parse_ether("520550000").unwrap();
202        assert_eq!(unlocked_amount_at(just_before_month_1), expected);
203    }
204
205    #[test]
206    fn test_at_exact_month_boundary() {
207        let expected = parse_ether("540400476").unwrap();
208        assert_eq!(unlocked_amount_at(month_boundary(1)), expected);
209
210        let expected = parse_ether("1198597668").unwrap();
211        assert_eq!(unlocked_amount_at(month_boundary(12)), expected);
212    }
213
214    #[test]
215    fn test_at_month_72() {
216        let expected = parse_ether("3590000000").unwrap();
217        assert_eq!(unlocked_amount_at(month_boundary(72)), expected);
218    }
219
220    #[test]
221    fn test_schedule_is_monotonically_increasing() {
222        for window in SCHEDULE.unlocks.windows(2) {
223            assert!(window[1].amount_wei >= window[0].amount_wei);
224            assert!(window[1].timestamp > window[0].timestamp);
225        }
226    }
227
228    #[test]
229    fn test_schedule_has_expected_entries() {
230        assert_eq!(SCHEDULE.unlocks.len(), 73);
231    }
232
233    #[test]
234    fn test_tge_date_parses() {
235        use chrono::{Datelike, TimeZone, Utc};
236        let dt = Utc
237            .timestamp_opt(SCHEDULE.unlocks[0].timestamp as i64, 0)
238            .single()
239            .unwrap();
240        assert_eq!(dt.year(), 2026);
241        assert_eq!(dt.month(), 2);
242        assert_eq!(dt.day(), 12);
243    }
244
245    // --- SupplyCalculator tests ---
246
247    fn mainnet_id() -> ChainId {
248        ChainId(U256::from(MAINNET_CHAIN_ID))
249    }
250
251    fn testnet_id() -> ChainId {
252        ChainId(U256::from(35353u64))
253    }
254
255    fn post_tge_time() -> u64 {
256        month_boundary(6)
257    }
258
259    /// Mainnet initial supply (3.59B tokens) -- must be >= final unlock schedule amount.
260    fn mainnet_initial_supply() -> U256 {
261        parse_ether("3590000000").unwrap()
262    }
263
264    #[test]
265    fn test_locked_mainnet() {
266        let now = post_tge_time();
267        let initial_supply = mainnet_initial_supply();
268        let total_supply_l1 = initial_supply + parse_ether("50").unwrap();
269        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, None);
270
271        let unlocked = unlocked_amount_at(now);
272        assert_eq!(calc.locked(), initial_supply - unlocked);
273    }
274
275    #[test]
276    fn test_locked_non_mainnet() {
277        let now = post_tge_time();
278        let calc = SupplyCalculator::new(
279            testnet_id(),
280            now,
281            parse_ether("1000").unwrap(),
282            parse_ether("1000").unwrap(),
283            None,
284        );
285        assert_eq!(calc.locked(), U256::ZERO);
286    }
287
288    #[test]
289    fn test_locked_before_tge() {
290        // Before TGE, nothing is unlocked so locked = entire initial supply
291        let before_tge = tge_timestamp() - 1;
292        let initial_supply = mainnet_initial_supply();
293        let calc = SupplyCalculator::new(
294            mainnet_id(),
295            before_tge,
296            initial_supply,
297            initial_supply,
298            None,
299        );
300        assert_eq!(calc.locked(), initial_supply);
301    }
302
303    #[test]
304    fn test_locked_after_full_vest() {
305        // After month 72, everything is unlocked so locked = 0
306        // (assuming initial_supply <= final unlock amount)
307        let far_future = tge_timestamp() + 100 * 365 * 86400;
308        let initial_supply = mainnet_initial_supply();
309        let calc = SupplyCalculator::new(
310            mainnet_id(),
311            far_future,
312            initial_supply,
313            initial_supply,
314            None,
315        );
316        assert_eq!(calc.locked(), U256::ZERO);
317    }
318
319    #[test]
320    fn test_supply_calculator_mainnet_circulating() {
321        let now = post_tge_time();
322        let initial_supply = mainnet_initial_supply();
323        let total_supply_l1 = initial_supply + parse_ether("50").unwrap();
324        let reward = Some(RewardAmount(parse_ether("100").unwrap()));
325        let calc =
326            SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, reward);
327
328        // circulating = initial_supply + reward - locked = unlocked + reward
329        let unlocked = unlocked_amount_at(now);
330        assert_eq!(
331            calc.circulating_supply(),
332            unlocked + parse_ether("100").unwrap()
333        );
334    }
335
336    #[test]
337    fn test_supply_calculator_mainnet_ethereum() {
338        let now = post_tge_time();
339        let initial_supply = mainnet_initial_supply();
340        let claimed = parse_ether("50").unwrap();
341        let total_supply_l1 = initial_supply + claimed;
342        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, None);
343
344        // circulating_ethereum = total_supply_l1 - locked = total_supply_l1 - (initial - unlocked)
345        //                      = unlocked + claimed
346        let unlocked = unlocked_amount_at(now);
347        assert_eq!(calc.circulating_supply_ethereum(), unlocked + claimed);
348    }
349
350    #[test]
351    fn test_supply_calculator_non_mainnet_circulating() {
352        let now = post_tge_time();
353        let reward = Some(RewardAmount(parse_ether("200").unwrap()));
354        // locked=0: initial_supply + reward = 1000 + 200 = 1200
355        let calc = SupplyCalculator::new(
356            testnet_id(),
357            now,
358            parse_ether("1000").unwrap(),
359            parse_ether("1000").unwrap(),
360            reward,
361        );
362
363        assert_eq!(calc.circulating_supply(), parse_ether("1200").unwrap());
364    }
365
366    #[test]
367    fn test_supply_calculator_non_mainnet_ethereum() {
368        let now = post_tge_time();
369        let calc = SupplyCalculator::new(
370            testnet_id(),
371            now,
372            parse_ether("5000").unwrap(),
373            parse_ether("5000").unwrap(),
374            Some(RewardAmount(parse_ether("10").unwrap())),
375        );
376
377        // locked=0: total_supply_l1
378        assert_eq!(
379            calc.circulating_supply_ethereum(),
380            parse_ether("5000").unwrap()
381        );
382    }
383
384    #[test]
385    fn test_supply_calculator_invariant() {
386        // circulating - circulating_ethereum = initial_supply + reward - total_supply_l1
387        // (locked cancels out in subtraction)
388        for chain_id in [mainnet_id(), testnet_id()] {
389            let now = post_tge_time();
390            let initial_supply = parse_ether("10000").unwrap();
391            let reward = parse_ether("500").unwrap();
392            let total_supply_l1 = parse_ether("10200").unwrap();
393            let calc = SupplyCalculator::new(
394                chain_id,
395                now,
396                initial_supply,
397                total_supply_l1,
398                Some(RewardAmount(reward)),
399            );
400
401            // circulating = initial + reward - locked
402            // ethereum    = total_supply_l1 - locked
403            // diff        = initial + reward - total_supply_l1
404            assert_eq!(
405                calc.circulating_supply() - calc.circulating_supply_ethereum(),
406                initial_supply + reward - total_supply_l1,
407            );
408        }
409    }
410
411    #[test]
412    fn test_supply_calculator_zero_rewards() {
413        let now = post_tge_time();
414        let initial_supply = mainnet_initial_supply();
415        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, initial_supply, None);
416
417        let unlocked = unlocked_amount_at(now);
418        assert_eq!(calc.circulating_supply(), unlocked);
419    }
420
421    #[test]
422    fn test_supply_calculator_zero_claimed() {
423        let now = post_tge_time();
424        let initial_supply = parse_ether("1000").unwrap();
425        let reward = Some(RewardAmount(parse_ether("100").unwrap()));
426        let calc = SupplyCalculator::new(testnet_id(), now, initial_supply, initial_supply, reward);
427        assert_eq!(calc.circulating_supply(), parse_ether("1100").unwrap());
428    }
429
430    #[test]
431    fn test_supply_calculator_no_underflow() {
432        // Before TGE: locked = initial_supply, so circulating = 0 via saturating_sub
433        let before_tge = tge_timestamp() - 1;
434        let calc = SupplyCalculator::new(
435            mainnet_id(),
436            before_tge,
437            parse_ether("100").unwrap(),
438            parse_ether("100").unwrap(),
439            None,
440        );
441        assert_eq!(calc.circulating_supply(), U256::ZERO);
442    }
443
444    #[test]
445    fn test_locked_saturates_when_initial_less_than_unlocked() {
446        // If initial_supply < unlocked(now), locked saturates to 0 (not negative)
447        let now = post_tge_time();
448        let small_initial = parse_ether("100").unwrap();
449        let calc = SupplyCalculator::new(mainnet_id(), now, small_initial, small_initial, None);
450        assert_eq!(calc.locked(), U256::ZERO);
451        // circulating = initial + reward - locked = 100 + 0 - 0 = 100
452        assert_eq!(calc.circulating_supply(), small_initial);
453    }
454
455    #[test]
456    fn test_total_issued_supply_with_rewards() {
457        let now = post_tge_time();
458        let initial_supply = mainnet_initial_supply();
459        let reward = Some(RewardAmount(parse_ether("100").unwrap()));
460        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, initial_supply, reward);
461        assert_eq!(
462            calc.total_issued_supply(),
463            initial_supply + parse_ether("100").unwrap()
464        );
465    }
466
467    #[test]
468    fn test_total_issued_supply_zero_rewards() {
469        let now = post_tge_time();
470        let initial_supply = mainnet_initial_supply();
471        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, initial_supply, None);
472        assert_eq!(calc.total_issued_supply(), initial_supply);
473    }
474
475    #[test]
476    fn test_total_issued_supply_chain_invariant() {
477        let now = post_tge_time();
478        let initial_supply = parse_ether("10000").unwrap();
479        let reward = Some(RewardAmount(parse_ether("500").unwrap()));
480        let total_supply_l1 = parse_ether("10200").unwrap();
481
482        let mainnet =
483            SupplyCalculator::new(mainnet_id(), now, initial_supply, total_supply_l1, reward);
484        let testnet =
485            SupplyCalculator::new(testnet_id(), now, initial_supply, total_supply_l1, reward);
486
487        assert_eq!(mainnet.total_issued_supply(), testnet.total_issued_supply());
488    }
489
490    #[test]
491    fn test_total_reward_distributed() {
492        let now = post_tge_time();
493        let initial_supply = mainnet_initial_supply();
494        let reward = Some(RewardAmount(parse_ether("200").unwrap()));
495        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, initial_supply, reward);
496        assert_eq!(calc.total_reward_distributed(), parse_ether("200").unwrap());
497    }
498
499    #[test]
500    fn test_total_reward_distributed_zero() {
501        let now = post_tge_time();
502        let initial_supply = mainnet_initial_supply();
503        let calc = SupplyCalculator::new(mainnet_id(), now, initial_supply, initial_supply, None);
504        assert_eq!(calc.total_reward_distributed(), U256::ZERO);
505    }
506}