forester_utils/
forester_epoch.rs

1use anchor_lang::{
2    prelude::borsh, solana_program::pubkey::Pubkey, AnchorDeserialize, AnchorSerialize,
3};
4use light_client::rpc::{Rpc, RpcError};
5use light_compressed_account::TreeType;
6use light_registry::{
7    protocol_config::state::{EpochState, ProtocolConfig},
8    sdk::{create_register_forester_epoch_pda_instruction, create_report_work_instruction},
9    utils::{get_epoch_pda_address, get_forester_epoch_pda_from_authority},
10    EpochPda, ForesterEpochPda,
11};
12use solana_sdk::signature::{Keypair, Signature, Signer};
13
14use crate::error::ForesterUtilsError;
15
16// What does the forester need to know?
17// What are my public keys (current epoch account, last epoch account, known Merkle trees)
18// 1. The current epoch
19// 2. When does the next registration start
20// 3. When is my turn.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ForesterSlot {
23    pub slot: u64,
24    pub start_solana_slot: u64,
25    pub end_solana_slot: u64,
26    pub forester_index: u64,
27}
28
29#[derive(Debug, Default, Clone, PartialEq, Eq)]
30pub struct Forester {
31    pub registration: Epoch,
32    pub active: Epoch,
33    pub report_work: Epoch,
34}
35
36impl Forester {
37    pub fn switch_to_report_work(&mut self) {
38        self.report_work = self.active.clone();
39        self.active = self.registration.clone();
40    }
41
42    pub async fn report_work(
43        &mut self,
44        rpc: &mut impl Rpc,
45        forester_keypair: &Keypair,
46        derivation: &Pubkey,
47    ) -> Result<Signature, RpcError> {
48        let ix = create_report_work_instruction(
49            &forester_keypair.pubkey(),
50            derivation,
51            self.report_work.epoch,
52        );
53        rpc.create_and_send_transaction(&[ix], &forester_keypair.pubkey(), &[forester_keypair])
54            .await
55    }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct TreeAccounts {
60    pub merkle_tree: Pubkey,
61    pub queue: Pubkey,
62    // TODO: evaluate whether we need
63    pub is_rolledover: bool,
64    pub tree_type: TreeType,
65}
66
67impl TreeAccounts {
68    pub fn new(
69        merkle_tree: Pubkey,
70        queue: Pubkey,
71        tree_type: TreeType,
72        is_rolledover: bool,
73    ) -> Self {
74        Self {
75            merkle_tree,
76            queue,
77            tree_type,
78            is_rolledover,
79        }
80    }
81}
82
83pub fn get_schedule_for_queue(
84    mut start_solana_slot: u64,
85    queue_pubkey: &Pubkey,
86    protocol_config: &ProtocolConfig,
87    total_epoch_weight: u64,
88    epoch: u64,
89    current_phase_start_slot: u64,
90) -> Result<Vec<Option<ForesterSlot>>, ForesterUtilsError> {
91    let mut vec = Vec::new();
92
93    let current_light_slot = if start_solana_slot >= current_phase_start_slot {
94        (start_solana_slot - current_phase_start_slot) / protocol_config.slot_length
95    } else {
96        return Err(ForesterUtilsError::InvalidSlotNumber);
97    };
98
99    let start_slot = current_light_slot;
100    start_solana_slot =
101        current_phase_start_slot + (current_light_slot * protocol_config.slot_length);
102    let end_slot = protocol_config.active_phase_length / protocol_config.slot_length;
103
104    for light_slot in start_slot..end_slot {
105        let forester_index = ForesterEpochPda::get_eligible_forester_index(
106            light_slot,
107            queue_pubkey,
108            total_epoch_weight,
109            epoch,
110        )
111        .unwrap();
112        vec.push(Some(ForesterSlot {
113            slot: light_slot,
114            start_solana_slot,
115            end_solana_slot: start_solana_slot + protocol_config.slot_length,
116            forester_index,
117        }));
118        start_solana_slot += protocol_config.slot_length;
119    }
120    Ok(vec)
121}
122
123pub fn get_schedule_for_forester_in_queue(
124    start_solana_slot: u64,
125    queue_pubkey: &Pubkey,
126    total_epoch_weight: u64,
127    forester_epoch_pda: &ForesterEpochPda,
128) -> Result<Vec<Option<ForesterSlot>>, ForesterUtilsError> {
129    let mut slots = get_schedule_for_queue(
130        start_solana_slot,
131        queue_pubkey,
132        &forester_epoch_pda.protocol_config,
133        total_epoch_weight,
134        forester_epoch_pda.epoch,
135        forester_epoch_pda.epoch_active_phase_start_slot,
136    )?;
137    slots.iter_mut().for_each(|slot_option| {
138        if let Some(slot) = slot_option {
139            if !forester_epoch_pda.is_eligible(slot.forester_index) {
140                *slot_option = None;
141            }
142        }
143    });
144    Ok(slots)
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct TreeForesterSchedule {
149    pub tree_accounts: TreeAccounts,
150    /// Vec with the slots that the forester is eligible to perform work.
151    /// Non-eligible slots are None.
152    pub slots: Vec<Option<ForesterSlot>>,
153}
154
155impl TreeForesterSchedule {
156    pub fn new(tree_accounts: TreeAccounts) -> Self {
157        Self {
158            tree_accounts,
159            slots: Vec::new(),
160        }
161    }
162
163    pub fn new_with_schedule(
164        tree_accounts: &TreeAccounts,
165        solana_slot: u64,
166        forester_epoch_pda: &ForesterEpochPda,
167        epoch_pda: &EpochPda,
168    ) -> Result<Self, ForesterUtilsError> {
169        let mut _self = Self {
170            tree_accounts: *tree_accounts,
171            slots: Vec::new(),
172        };
173        _self.slots = get_schedule_for_forester_in_queue(
174            solana_slot,
175            &_self.tree_accounts.queue,
176            epoch_pda.registered_weight,
177            forester_epoch_pda,
178        )?;
179        Ok(_self)
180    }
181
182    pub fn is_eligible(&self, forester_slot: u64) -> bool {
183        self.slots[forester_slot as usize].is_some()
184    }
185}
186
187#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, Default, PartialEq, Eq)]
188pub struct EpochPhases {
189    pub registration: Phase,
190    pub active: Phase,
191    pub report_work: Phase,
192    pub post: Phase,
193}
194
195impl EpochPhases {
196    pub fn get_current_phase(&self, current_slot: u64) -> Phase {
197        if current_slot >= self.registration.start && current_slot <= self.registration.end {
198            self.registration.clone()
199        } else if current_slot >= self.active.start && current_slot <= self.active.end {
200            self.active.clone()
201        } else if current_slot >= self.report_work.start && current_slot <= self.report_work.end {
202            self.report_work.clone()
203        } else {
204            self.post.clone()
205        }
206    }
207    pub fn get_current_epoch_state(&self, current_slot: u64) -> EpochState {
208        if current_slot >= self.registration.start && current_slot <= self.registration.end {
209            EpochState::Registration
210        } else if current_slot >= self.active.start && current_slot <= self.active.end {
211            EpochState::Active
212        } else if current_slot >= self.report_work.start && current_slot <= self.report_work.end {
213            EpochState::ReportWork
214        } else {
215            EpochState::Post
216        }
217    }
218}
219
220#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, Default, PartialEq, Eq)]
221pub struct Phase {
222    pub start: u64,
223    pub end: u64,
224}
225
226impl Phase {
227    pub fn length(&self) -> u64 {
228        self.end - self.start
229    }
230}
231
232pub fn get_epoch_phases(protocol_config: &ProtocolConfig, epoch: u64) -> EpochPhases {
233    let epoch_start_slot = protocol_config
234        .genesis_slot
235        .saturating_add(epoch.saturating_mul(protocol_config.active_phase_length));
236
237    let registration_start = epoch_start_slot;
238    let registration_end = registration_start
239        .saturating_add(protocol_config.registration_phase_length)
240        .saturating_sub(1);
241
242    let active_start = registration_end.saturating_add(1);
243    let active_end = active_start
244        .saturating_add(protocol_config.active_phase_length)
245        .saturating_sub(1);
246
247    let report_work_start = active_end.saturating_add(1);
248    let report_work_end = report_work_start
249        .saturating_add(protocol_config.report_work_phase_length)
250        .saturating_sub(1);
251
252    let post_start = report_work_end.saturating_add(1);
253    let post_end = u64::MAX;
254
255    EpochPhases {
256        registration: Phase {
257            start: registration_start,
258            end: registration_end,
259        },
260        active: Phase {
261            start: active_start,
262            end: active_end,
263        },
264        report_work: Phase {
265            start: report_work_start,
266            end: report_work_end,
267        },
268        post: Phase {
269            start: post_start,
270            end: post_end,
271        },
272    }
273}
274
275#[derive(Debug, Clone, Default, PartialEq, Eq)]
276pub struct Epoch {
277    pub epoch: u64,
278    pub epoch_pda: Pubkey,
279    pub forester_epoch_pda: Pubkey,
280    pub phases: EpochPhases,
281    pub state: EpochState,
282    pub merkle_trees: Vec<TreeForesterSchedule>,
283}
284
285#[derive(Debug, Clone, AnchorSerialize, AnchorDeserialize, Default, PartialEq, Eq)]
286pub struct EpochRegistration {
287    pub epoch: u64,
288    pub slots_until_registration_starts: u64,
289    pub slots_until_registration_ends: u64,
290}
291
292impl Epoch {
293    /// returns slots until next epoch and that epoch
294    /// registration is open if
295    pub async fn slots_until_next_epoch_registration<R: Rpc>(
296        rpc: &mut R,
297        protocol_config: &ProtocolConfig,
298    ) -> Result<EpochRegistration, RpcError> {
299        let current_solana_slot = rpc.get_slot().await?;
300
301        let mut epoch = protocol_config
302            .get_latest_register_epoch(current_solana_slot)
303            .unwrap();
304        let registration_start_slot =
305            protocol_config.genesis_slot + epoch * protocol_config.active_phase_length;
306
307        let registration_end_slot =
308            registration_start_slot + protocol_config.registration_phase_length;
309        if current_solana_slot > registration_end_slot {
310            epoch += 1;
311        }
312        let next_registration_start_slot =
313            protocol_config.genesis_slot + epoch * protocol_config.active_phase_length;
314        let next_registration_end_slot =
315            next_registration_start_slot + protocol_config.registration_phase_length;
316        let slots_until_registration_ends =
317            next_registration_end_slot.saturating_sub(current_solana_slot);
318        let slots_until_registration_starts =
319            next_registration_start_slot.saturating_sub(current_solana_slot);
320        Ok(EpochRegistration {
321            epoch,
322            slots_until_registration_starts,
323            slots_until_registration_ends,
324        })
325    }
326
327    /// creates forester account and fetches epoch account
328    pub async fn register<R: Rpc>(
329        rpc: &mut R,
330        protocol_config: &ProtocolConfig,
331        authority: &Keypair,
332        derivation: &Pubkey,
333    ) -> Result<Option<Epoch>, RpcError> {
334        let epoch_registration =
335            Self::slots_until_next_epoch_registration(rpc, protocol_config).await?;
336        if epoch_registration.slots_until_registration_starts > 0
337            || epoch_registration.slots_until_registration_ends == 0
338        {
339            return Ok(None);
340        }
341
342        let instruction = create_register_forester_epoch_pda_instruction(
343            &authority.pubkey(),
344            derivation,
345            epoch_registration.epoch,
346        );
347        let signature = rpc
348            .create_and_send_transaction(&[instruction], &authority.pubkey(), &[authority])
349            .await?;
350        rpc.confirm_transaction(signature).await?;
351        let epoch_pda_pubkey = get_epoch_pda_address(epoch_registration.epoch);
352        let epoch_pda = rpc
353            .get_anchor_account::<EpochPda>(&epoch_pda_pubkey)
354            .await?
355            .unwrap();
356        let forester_epoch_pda_pubkey =
357            get_forester_epoch_pda_from_authority(derivation, epoch_registration.epoch).0;
358
359        let phases = get_epoch_phases(protocol_config, epoch_pda.epoch);
360        Ok(Some(Self {
361            // epoch: epoch_registration.epoch,
362            epoch_pda: epoch_pda_pubkey,
363            forester_epoch_pda: forester_epoch_pda_pubkey,
364            merkle_trees: Vec::new(),
365            epoch: epoch_pda.epoch,
366            state: phases.get_current_epoch_state(rpc.get_slot().await?),
367            phases,
368        }))
369    }
370    // TODO: implement
371    /// forester account and epoch account already exist
372    /// -> fetch accounts and init
373    pub fn fetch_registered() {}
374
375    pub async fn fetch_account_and_add_trees_with_schedule<R: Rpc>(
376        &mut self,
377        rpc: &mut R,
378        trees: &[TreeAccounts],
379    ) -> Result<(), RpcError> {
380        let current_solana_slot = rpc.get_slot().await?;
381
382        if self.phases.active.end < current_solana_slot
383            || self.phases.active.start > current_solana_slot
384        {
385            println!("current_solana_slot {:?}", current_solana_slot);
386            println!("registration phase {:?}", self.phases.registration);
387            println!("active phase {:?}", self.phases.active);
388            // return Err(RpcError::EpochNotActive);
389            panic!("TODO: throw epoch not active error");
390        }
391        let epoch_pda = rpc
392            .get_anchor_account::<EpochPda>(&self.epoch_pda)
393            .await?
394            .unwrap();
395        let mut forester_epoch_pda = rpc
396            .get_anchor_account::<ForesterEpochPda>(&self.forester_epoch_pda)
397            .await?
398            .unwrap();
399        // IF active phase has started and total_epoch_weight is not set, set it now to
400        if forester_epoch_pda.total_epoch_weight.is_none() {
401            forester_epoch_pda.total_epoch_weight = Some(epoch_pda.registered_weight);
402        }
403        self.add_trees_with_schedule(&forester_epoch_pda, &epoch_pda, trees, current_solana_slot)
404            .map_err(|e| {
405                println!("Error adding trees with schedule: {:?}", e);
406                RpcError::AssertRpcError("Error adding trees with schedule".to_string())
407            })?;
408        Ok(())
409    }
410    /// Internal function to init Epoch struct with registered account
411    /// 1. calculate epoch phases
412    /// 2. set current epoch state
413    /// 3. derive tree schedule for all input trees
414    pub fn add_trees_with_schedule(
415        &mut self,
416        forester_epoch_pda: &ForesterEpochPda,
417        epoch_pda: &EpochPda,
418        trees: &[TreeAccounts],
419        current_solana_slot: u64,
420    ) -> Result<(), ForesterUtilsError> {
421        // TODO: add epoch state to sync schedule
422        for tree in trees {
423            let tree_schedule = TreeForesterSchedule::new_with_schedule(
424                tree,
425                current_solana_slot,
426                forester_epoch_pda,
427                epoch_pda,
428            )?;
429            self.merkle_trees.push(tree_schedule);
430        }
431        Ok(())
432    }
433
434    pub fn update_state(&mut self, current_solana_slot: u64) -> EpochState {
435        let current_state = self.phases.get_current_epoch_state(current_solana_slot);
436        if current_state != self.state {
437            self.state = current_state.clone();
438        }
439        current_state
440    }
441
442    /// execute active phase test:
443    /// (multi thread)
444    /// - iterate over all trees, check whether eligible and empty queues
445    ///
446    /// forester:
447    /// - start a new thread per tree
448    /// - this thread will sleep when it is not eligible and wake up with
449    ///   some buffer time prior to the start of the slot
450    /// - threads shut down when the active phase ends
451    pub fn execute_active_phase() {}
452
453    /// report work phase:
454    /// (single thread)
455    /// - free Merkle tree memory
456    /// - execute report work tx (single thread)
457    pub fn execute_report_work_phase() {}
458    /// post phase:
459    /// (single thread)
460    /// - claim rewards
461    /// - close forester epoch account
462    pub fn execute_post_phase() {}
463}
464
465#[cfg(test)]
466mod test {
467    use super::*;
468
469    #[test]
470    fn test_epoch_phases() {
471        let config = ProtocolConfig {
472            genesis_slot: 200,
473            min_weight: 0,
474            slot_length: 10,
475            registration_phase_length: 100,
476            active_phase_length: 1000,
477            report_work_phase_length: 100,
478            network_fee: 5000,
479            ..Default::default()
480        };
481
482        let epoch = 1;
483        let phases = get_epoch_phases(&config, epoch);
484
485        assert_eq!(phases.registration.start, 1200);
486        assert_eq!(phases.registration.end, 1299);
487
488        assert_eq!(phases.active.start, 1300);
489        assert_eq!(phases.active.end, 2299);
490
491        assert_eq!(phases.report_work.start, 2300);
492        assert_eq!(phases.report_work.end, 2399);
493
494        assert_eq!(phases.post.start, 2400);
495        assert_eq!(phases.post.end, u64::MAX);
496    }
497
498    #[test]
499    fn test_get_schedule_for_queue() {
500        let protocol_config = ProtocolConfig {
501            genesis_slot: 0,
502            min_weight: 100,
503            slot_length: 10,
504            registration_phase_length: 100,
505            active_phase_length: 1000,
506            report_work_phase_length: 100,
507            network_fee: 5000,
508            ..Default::default()
509        };
510
511        let total_epoch_weight = 500;
512        let queue_pubkey = Pubkey::new_unique();
513        let start_solana_slot = 0;
514        let epoch = 0;
515        let current_phase_start_slot = 0;
516
517        let schedule = get_schedule_for_queue(
518            start_solana_slot,
519            &queue_pubkey,
520            &protocol_config,
521            total_epoch_weight,
522            epoch,
523            current_phase_start_slot,
524        )
525        .unwrap();
526
527        // Expected number of light slots in the active phase
528        let expected_light_slots =
529            (protocol_config.active_phase_length / protocol_config.slot_length) as usize;
530        assert_eq!(schedule.len(), expected_light_slots); // Should generate 100 slots
531
532        assert_eq!(
533            schedule.len(),
534            (protocol_config.active_phase_length / protocol_config.slot_length) as usize
535        );
536
537        for (i, slot_option) in schedule.iter().enumerate() {
538            let slot = slot_option.as_ref().unwrap();
539            assert_eq!(slot.slot, i as u64);
540            assert_eq!(
541                slot.start_solana_slot,
542                start_solana_slot + (i as u64 * protocol_config.slot_length)
543            );
544            assert_eq!(
545                slot.end_solana_slot,
546                slot.start_solana_slot + protocol_config.slot_length
547            );
548            assert!(slot.forester_index < total_epoch_weight);
549        }
550    }
551
552    #[test]
553    fn test_get_schedule_for_queue_offset_phase_start() {
554        let protocol_config = ProtocolConfig {
555            genesis_slot: 1000, // Genesis starts later
556            min_weight: 100,
557            slot_length: 10,
558            registration_phase_length: 100,
559            active_phase_length: 1000, // 100 light slots
560            report_work_phase_length: 100,
561            network_fee: 5000,
562            ..Default::default()
563        };
564
565        let total_epoch_weight = 500;
566        let queue_pubkey = Pubkey::new_unique();
567        let epoch = 0;
568
569        // Calculate actual start of the active phase for epoch 0
570        // Registration: 1000 to 1099
571        // Active: 1100 to 2099
572        let current_phase_start_slot = 1100;
573
574        // Start calculating right from the beginning of this active phase
575        let start_solana_slot = current_phase_start_slot;
576
577        let schedule = get_schedule_for_queue(
578            start_solana_slot,
579            &queue_pubkey,
580            &protocol_config,
581            total_epoch_weight,
582            epoch,
583            current_phase_start_slot, // Pass the calculated start slot
584        )
585        .unwrap();
586
587        let expected_light_slots =
588            (protocol_config.active_phase_length / protocol_config.slot_length) as usize;
589        assert_eq!(schedule.len(), expected_light_slots); // Still 100 light slots expected
590
591        // Check the first slot details
592        let first_slot = schedule[0].as_ref().unwrap();
593        assert_eq!(first_slot.slot, 0); // First light slot index is 0
594                                        // Its Solana start slot should be the phase start slot
595        assert_eq!(first_slot.start_solana_slot, current_phase_start_slot);
596        assert_eq!(
597            first_slot.end_solana_slot,
598            current_phase_start_slot + protocol_config.slot_length
599        );
600
601        // Check the second slot details
602        let second_slot = schedule[1].as_ref().unwrap();
603        assert_eq!(second_slot.slot, 1); // Second light slot index is 1
604                                         // Its Solana start slot should be offset by one slot_length
605        assert_eq!(
606            second_slot.start_solana_slot,
607            current_phase_start_slot + protocol_config.slot_length
608        );
609        assert_eq!(
610            second_slot.end_solana_slot,
611            current_phase_start_slot + 2 * protocol_config.slot_length
612        );
613    }
614
615    // NEW TEST: Case where current_light_slot > 0
616    #[test]
617    fn test_get_schedule_for_queue_mid_phase_start() {
618        let protocol_config = ProtocolConfig {
619            genesis_slot: 0,
620            min_weight: 100,
621            slot_length: 10,
622            registration_phase_length: 100, // Reg: 0-99
623            active_phase_length: 1000,      // Active: 100-1099 (100 light slots)
624            report_work_phase_length: 100,
625            network_fee: 5000,
626            ..Default::default()
627        };
628
629        let total_epoch_weight = 500;
630        let queue_pubkey = Pubkey::new_unique();
631        let epoch = 0;
632        let current_phase_start_slot = 100; // Active phase starts at slot 100
633
634        // Start calculating from Solana slot 155, which is within the active phase
635        let start_solana_slot = 155;
636
637        // Calculation:
638        // current_light_slot = floor((155 - 100) / 10) = floor(55 / 10) = 5
639        // Effective start_solana_slot for loop = 100 + (5 * 10) = 150
640        // End light slot = 1000 / 10 = 100
641        // Loop runs from light_slot 5 to 99 (inclusive). Length = 100 - 5 = 95
642
643        let schedule = get_schedule_for_queue(
644            start_solana_slot,
645            &queue_pubkey,
646            &protocol_config,
647            total_epoch_weight,
648            epoch,
649            current_phase_start_slot,
650        )
651        .unwrap();
652
653        let expected_light_slots_total =
654            protocol_config.active_phase_length / protocol_config.slot_length; // 100
655        let expected_start_light_slot = 5;
656        let expected_schedule_len =
657            (expected_light_slots_total - expected_start_light_slot) as usize; // 100 - 5 = 95
658
659        assert_eq!(schedule.len(), expected_schedule_len); // Should generate 95 slots
660
661        // Check the first slot in the *returned* schedule
662        let first_returned_slot = schedule[0].as_ref().unwrap();
663        assert_eq!(first_returned_slot.slot, expected_start_light_slot); // Light slot index starts at 5
664                                                                         // Its Solana start slot should align to the beginning of light slot 5
665        let expected_first_solana_start =
666            current_phase_start_slot + expected_start_light_slot * protocol_config.slot_length; // 100 + 5 * 10 = 150
667        assert_eq!(
668            first_returned_slot.start_solana_slot,
669            expected_first_solana_start
670        );
671        assert_eq!(
672            first_returned_slot.end_solana_slot,
673            expected_first_solana_start + protocol_config.slot_length // 150 + 10 = 160
674        );
675
676        // Check the second slot in the *returned* schedule
677        let second_returned_slot = schedule[1].as_ref().unwrap();
678        assert_eq!(second_returned_slot.slot, expected_start_light_slot + 1); // Light slot index 6
679                                                                              // Its Solana start slot should be 160
680        assert_eq!(
681            second_returned_slot.start_solana_slot,
682            expected_first_solana_start + protocol_config.slot_length
683        );
684        assert_eq!(
685            second_returned_slot.end_solana_slot,
686            expected_first_solana_start + 2 * protocol_config.slot_length // 170
687        );
688    }
689}