Skip to main content

rialo_validator_registry_interface/
instruction.rs

1// Copyright (c) Subzero Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Instructions for the Validator registry program.
5
6use std::collections::BTreeMap;
7
8use rialo_s_instruction::{AccountMeta, Instruction};
9use rialo_s_program::system_program;
10use rialo_s_pubkey::Pubkey;
11use serde::{Deserialize, Serialize};
12
13/// Instructions supported by the Validator Registry program
14#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
15pub enum ValidatorRegistryInstruction {
16    /// Register a new validator in the registry. The node_identity account pays for and
17    /// becomes the initial owner/withdrawer of the validator info PDA account.
18    ///
19    /// Returns `InstructionError::AccountAlreadyInitialized` if the validator is already
20    /// registered.
21    ///
22    /// Accounts expected:
23    /// 0. `[writable]` Validator info account (PDA derived from authority_key)
24    /// 1. `[signer, writable]` Node identity account (must match node_identity in instruction data, pays for PDA creation)
25    /// 2. `[]` System program
26    /// 3. `[writable]` Self-bond stake account (PDA derived from validator_info)
27    /// 4. `[]` StakeManager program
28    /// 5. `[]` ValidatorRegistry program (needed for ActivateStake → AddStake CPI callback)
29    Register {
30        /// The node's public key (the initial authorized withdrawer)
31        node_identity: Pubkey,
32        /// Network address for communicating with the authority.
33        address: Vec<u8>,
34        /// The authority's hostname, for metrics and logging.
35        hostname: String,
36        /// The authority's public key as Rialo identity.
37        authority_key: Vec<u8>,
38        /// The authority's public key for verifying blocks.
39        protocol_key: Pubkey,
40        /// The authority's public key for TLS and as network identity.
41        network_key: Pubkey,
42        /// The validator's commission from the inflation rewards
43        /// in basis points, e.g. 835 corresponds to 8.35%
44        commission_rate: u16,
45        /// The minimum duration in milliseconds that StakeInfo accounts must commit
46        /// to delegate for; can be set by the validator to earn
47        /// increased rewards (bonus)
48        lockup_period: u64,
49    },
50
51    /// Update the authorized withdrawer for a validator
52    ///
53    /// Accounts expected:
54    /// 0. `[writable]` Validator info account (PDA - cannot sign)
55    /// 1. `[signer]` Current authorized withdrawer
56    /// 2. `[]` New authorized withdrawer
57    UpdateWithdrawer {
58        /// New authority who can withdraw rewards
59        new_authorized_withdrawer: Pubkey,
60    },
61
62    /// Add stake to a validator's aggregate stake.
63    ///
64    /// This instruction is called via CPI from the StakeManager program when
65    /// a stake account is activated (delegated to a validator). It increases
66    /// the validator's `stake` field by the specified amount.
67    ///
68    /// This is a privileged operation that can only be called via CPI from
69    /// the StakeManager program.
70    ///
71    /// Accounts expected:
72    /// 0. `[writable]` Validator info account (PDA derived from authority_key)
73    AddStake {
74        /// Amount of stake to add
75        amount: u64,
76    },
77
78    /// Subtract stake from a validator's aggregate stake.
79    ///
80    /// This instruction is called via CPI from the StakeManager program when
81    /// a stake account is deactivated. It decreases the validator's `stake`
82    /// field by the specified amount.
83    ///
84    /// This is a privileged operation that can only be called via CPI from
85    /// the StakeManager program.
86    ///
87    /// Accounts expected:
88    /// 0. `[writable]` Validator info account (PDA derived from authority_key)
89    SubStake {
90        /// Amount of stake to subtract
91        amount: u64,
92    },
93
94    /// Set the unbonding period for a validator.
95    ///
96    /// This instruction sets the `unbonding_period` field on a validator's info account.
97    /// The unbonding period determines how long deactivated stake must wait before
98    /// it can be withdrawn. This is intended to be set by the runtime to punish
99    /// misbehaving validators.
100    ///
101    /// **IMPORTANT:** This instruction can ONLY be called via CPI from the
102    /// TokenomicsGovernance program. The processor verifies the CPI caller.
103    /// Direct invocation will fail with `IncorrectProgramId`.
104    ///
105    /// Accounts expected:
106    /// 0. `[writable]` Validator info account (PDA derived from authority_key)
107    SetUnbondingPeriod {
108        /// The new unbonding period in milliseconds
109        unbonding_period: u64,
110    },
111
112    /// Change the commission rate for a validator.
113    ///
114    /// This instruction allows a validator to request a commission rate change.
115    /// The new rate is stored in `new_commission_rate` and will be applied at
116    /// the next epoch boundary (during FreezeStakes) to give delegators time
117    /// to react to the change.
118    ///
119    /// Validation:
120    /// - 0 ≤ new_commission_rate ≤ MAX_COMMISSION_RATE (10000 basis points = 100%)
121    /// - |new_commission_rate - current_commission_rate| ≤ MAX_COMMISSION_CHANGE (200 bp)
122    ///   (protects delegators from sudden large changes)
123    /// - If lockup_period > 0, only decreases are allowed (increases are rejected).
124    ///   Validators with a lockup period commit to their commission rate in exchange
125    ///   for higher delegation and commission revenue from the higher lockup rewards.
126    ///
127    /// Accounts expected:
128    /// 0. `[writable]` Validator info account (PDA)
129    /// 1. `[signer]` Node identity (must match validator_account's node_identity)
130    ChangeCommissionRate {
131        /// New commission rate in basis points (e.g., 835 = 8.35%)
132        new_commission_rate: u16,
133    },
134
135    /// Withdraw kelvins from a validator account.
136    ///
137    /// This instruction allows the authorized withdrawer to withdraw kelvins
138    /// from the validator info account. This is typically used to withdraw
139    /// accumulated rewards or excess funds.
140    ///
141    /// The withdrawal transfers kelvins from the validator info account to
142    /// the authorized_withdrawer account. The validator account must have
143    /// sufficient kelvins after the withdrawal to remain rent-exempt.
144    ///
145    /// Accounts expected:
146    /// 0. `[writable]` Validator info account (PDA)
147    /// 1. `[signer, writable]` Authorized withdrawer (must match validator_account's authorized_withdrawer)
148    Withdraw {
149        /// Amount of kelvins to withdraw
150        amount: u64,
151    },
152
153    /// Change the earliest shutdown timestamp for a validator.
154    ///
155    /// This instruction allows a validator to announce a future timestamp after which
156    /// they don't intend to participate in committees and earn rewards.
157    /// This is particularly important for validators with a lockup_period, to prevent
158    /// new delegators from locking up their capital longer than it would be rewarded.
159    ///
160    /// When `earliest_shutdown` is `Some(timestamp)`:
161    /// - The timestamp must be >= current_timestamp + lockup_period.
162    ///   This ensures every currently-locked staker will have their full lockup
163    ///   honored before the validator stops.
164    ///
165    /// When `earliest_shutdown` is `None`:
166    /// - Resets the field, signaling the validator intends to continue operating.
167    ///
168    /// Accounts expected:
169    /// 0. `[writable]` Validator info account (PDA)
170    /// 1. `[signer]` Node identity (must match validator_account's node_identity)
171    ChangeEarliestShutdown {
172        /// The earliest timestamp (ms) after which the validator intends to shut down,
173        /// or None to reset/clear the shutdown signal.
174        earliest_shutdown: Option<u64>,
175    },
176}
177
178/// Error type for converting a `StoredAccount` to `ValidatorInfo`.
179#[cfg(feature = "typed-account")]
180#[derive(Debug, thiserror::Error)]
181pub enum ValidatorInfoParseError {
182    /// The account is not owned by the ValidatorRegistry program.
183    #[error("Account owner mismatch: expected {expected}, got {actual}")]
184    InvalidOwner {
185        expected: rialo_s_pubkey::Pubkey,
186        actual: rialo_s_pubkey::Pubkey,
187    },
188    /// The account data could not be deserialized as `ValidatorInfo`.
189    #[error("Failed to deserialize ValidatorInfo: {0}")]
190    DeserializationFailed(#[from] bincode::Error),
191}
192
193/// Data structure for the validator information account. This data structure is meant to hold
194/// all information about a validator, such that we can leverage it in Epoch negotiation
195/// and create a proper `Authority` object on Epoch change.
196#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)]
197pub struct ValidatorInfo {
198    pub node_identity: Pubkey, // Authority's public key used to interact with the replicated execution
199    pub authorized_withdrawer: Pubkey, // Authority who can withdraw rewards
200    pub stake: u64,            // Amount of token staked
201    pub address: Vec<u8>,      // Network address for communicating with the authority.
202    pub hostname: String,      // The authority's hostname, for metrics and logging.
203    pub authority_key: Vec<u8>, // The authority's public key as the authority identity.
204    pub protocol_key: Pubkey,  // The authority's public key for verifying blocks.
205    pub network_key: Pubkey,   // The authority's public key for TLS and as network identity.
206    pub registration_time: i64, // When they registered
207    pub last_update: i64,      // Last time info was updated
208    /// Historical unbonding periods: effective_from_timestamp → unbonding_period_ms.
209    ///
210    /// The duration in milliseconds that deactivating StakeInfo accounts wait before becoming
211    /// inactive. Set by the runtime (via SetUnbondingPeriod) to punish misbehaving validators.
212    ///
213    /// The first entry (key = registration_time) contains the default unbonding period.
214    /// Subsequent entries are added by SetUnbondingPeriod to extend or reduce the period.
215    /// Entries are in ascending order by timestamp (guaranteed by BTreeMap).
216    ///
217    /// Note: A deactivated stake cannot be withdrawn until the unbonding period that was
218    /// effective at deactivation time has elapsed — see `end_of_unbonding()`.
219    pub unbonding_periods: BTreeMap<u64, u64>,
220    /// The minimum duration in milliseconds that StakeInfo accounts must commit to delegate for.
221    /// Can be set by the validator to earn increased rewards (bonus).
222    /// Note: An activated stake cannot be deactivated until
223    /// `current_timestamp >= activation_requested + lockup_period`.
224    /// This is fixed at registration and cannot be changed.
225    pub lockup_period: u64,
226    /// The validator's commission from the inflation rewards
227    /// in basis points, e.g. 835 corresponds to 8.35%
228    pub commission_rate: u16,
229    /// New commission_rate to be applied from the next epoch
230    pub new_commission_rate: Option<u16>,
231    /// Optional timestamp (ms) after which the validator does not intend to participate
232    /// in committees. Must be >= current_timestamp + lockup_period when set.
233    /// Passing None resets the field (validator decides to continue).
234    pub earliest_shutdown: Option<u64>,
235}
236
237impl ValidatorInfo {
238    /// Maximum number of historical unbonding period entries allowed in the `unbonding_periods` map.
239    /// 100 entries × 16 bytes (8-byte key + 8-byte value) = 1600 bytes of map data.
240    /// Enforced by the SetUnbondingPeriod handler to prevent unbounded account growth.
241    // Old entries predating all currently-unbonding stakes could be pruned to reclaim space.
242    pub const MAX_UNBONDING_PERIOD_ENTRIES: usize = 100;
243
244    /// Calculate the account data size needed to store a `ValidatorInfo` with the given
245    /// variable-length field sizes.
246    ///
247    /// Uses the in-memory struct size as a safe upper bound, plus the actual lengths of
248    /// the variable-length fields (address, hostname, authority_key).
249    #[inline]
250    pub const fn account_size(
251        address_len: usize,
252        hostname_len: usize,
253        authority_key_len: usize,
254    ) -> usize {
255        std::mem::size_of::<Self>() + address_len + hostname_len + authority_key_len
256    }
257
258    /// Calculate when unbonding ends for a stake that requested deactivation at the given timestamp.
259    ///
260    /// This handles three cases correctly:
261    /// 1. **Active stake**: Deactivated after punishment → uses punished period
262    /// 2. **Unbonding stake**: Was unbonding when punishment hit → gets extended
263    /// 3. **Fully unbonded**: Completed unbonding before punishment → exempt
264    ///
265    /// # Algorithm (Dual-Tracking)
266    ///
267    /// Uses two variables to achieve **fairness** (reductions apply to still-unbonding
268    /// accounts) while preventing **flash attacks** (brief 0-period can't free accounts):
269    ///
270    /// - `max_end`: monotonically increasing, tracks the longest unbonding window ever
271    ///   applicable. Used ONLY for the exemption check ("was this account genuinely done
272    ///   before this entry?"). Prevents flash reductions from creating false exemption gaps.
273    ///
274    /// - `effective_end`: tracks the latest applicable period. This is the ACTUAL result.
275    ///   Can decrease when governance reduces a punishment.
276    ///
277    /// # Complexity
278    /// O(log n + k) where n = total entries, k = entries after deactivation (typically 0-2)
279    ///
280    /// # Arguments
281    /// * `deactivation_requested` - When DeactivateStake was called (in milliseconds)
282    ///
283    /// # Returns
284    /// The timestamp when unbonding completes
285    pub fn end_of_unbonding(&self, deactivation_requested: u64) -> u64 {
286        // Step 1: Binary search to find the unbonding_period at deactivation time.
287        let initial_period = self
288            .unbonding_periods
289            .range(..=deactivation_requested)
290            .next_back()
291            .map(|(_, &period)| period)
292            .unwrap_or(0);
293
294        let initial_end = deactivation_requested.saturating_add(initial_period);
295        let mut max_end = initial_end; // monotonic — for exemption check only
296        let mut effective_end = initial_end; // latest applicable — the actual result
297
298        // Step 2: Iterate entries AFTER deactivation.
299        for (&entry_ts, &entry_period) in self.unbonding_periods.range((
300            std::ops::Bound::Excluded(deactivation_requested),
301            std::ops::Bound::Unbounded,
302        )) {
303            // Step 3a: Exemption check uses max_end (never decreases).
304            // If the account was genuinely done before this entry, stop.
305            if max_end < entry_ts {
306                break;
307            }
308
309            // Step 3b: Apply this entry.
310            let new_end = deactivation_requested.saturating_add(entry_period);
311            max_end = max_end.max(new_end); // only grows (preserves exemption window)
312            effective_end = new_end; // tracks latest (CAN decrease for reductions)
313        }
314
315        effective_end
316    }
317}
318
319#[cfg(feature = "typed-account")]
320impl TryFrom<rialo_s_account::StoredAccount> for ValidatorInfo {
321    type Error = ValidatorInfoParseError;
322
323    /// Attempts to parse a `StoredAccount` as a `ValidatorInfo`.
324    ///
325    /// # Errors
326    /// - `ValidatorInfoParseError::InvalidOwner` if the account is not owned by the ValidatorRegistry program.
327    /// - `ValidatorInfoParseError::DeserializationFailed` if the account data cannot be deserialized.
328    fn try_from(account: rialo_s_account::StoredAccount) -> Result<Self, Self::Error> {
329        use rialo_s_account::ReadableAccount;
330
331        // Verify ownership
332        let owner = *account.owner();
333        if owner != crate::id() {
334            return Err(ValidatorInfoParseError::InvalidOwner {
335                expected: crate::id(),
336                actual: owner,
337            });
338        }
339
340        // Deserialize the account data
341        let validator_info: ValidatorInfo = bincode::deserialize(account.data())?;
342        Ok(validator_info)
343    }
344}
345
346#[cfg(feature = "typed-account")]
347impl TryFrom<&rialo_s_account::StoredAccount> for ValidatorInfo {
348    type Error = ValidatorInfoParseError;
349
350    /// Attempts to parse a reference to a `StoredAccount` as a `ValidatorInfo`.
351    ///
352    /// # Errors
353    /// - `ValidatorInfoParseError::InvalidOwner` if the account is not owned by the ValidatorRegistry program.
354    /// - `ValidatorInfoParseError::DeserializationFailed` if the account data cannot be deserialized.
355    fn try_from(account: &rialo_s_account::StoredAccount) -> Result<Self, Self::Error> {
356        use rialo_s_account::ReadableAccount;
357
358        // Verify ownership
359        let owner = *account.owner();
360        if owner != crate::id() {
361            return Err(ValidatorInfoParseError::InvalidOwner {
362                expected: crate::id(),
363                actual: owner,
364            });
365        }
366
367        // Deserialize the account data
368        let validator_info: ValidatorInfo = bincode::deserialize(account.data())?;
369        Ok(validator_info)
370    }
371}
372
373impl ValidatorRegistryInstruction {
374    /// Create a `ValidatorRegistryInstruction::Register` `Instruction`
375    ///
376    /// # Account references
377    ///   0. `[WRITABLE]` Validator info account (PDA derived from authority_key)
378    ///   1. `[SIGNER, WRITABLE]` Node identity account (must match node_identity in instruction data, pays for PDA creation)
379    ///   2. `[]` System program
380    ///   3. `[WRITABLE]` Self-bond stake account (PDA derived from validator_info)
381    ///   4. `[]` StakeManager program
382    ///   5. `[]` ValidatorRegistry program (needed for ActivateStake → AddStake CPI callback)
383    ///
384    /// # Arguments
385    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
386    /// * `node_identity` - The public key of the validator node (must sign, pays for PDA creation)
387    /// * `address` - The network address for communicating with the authority
388    /// * `hostname` - The hostname of the authority
389    /// * `authority_key` - The public key of the authority (used to derive the PDA)
390    /// * `protocol_key` - The public key for verifying blocks
391    /// * `network_key` - The public key for TLS and as network identity
392    /// * `commission_rate` - The validator's commission in basis points (e.g. 835 = 8.35%)
393    /// * `lockup_period` - Minimum duration in milliseconds stake must be delegated for
394    ///
395    /// # Returns
396    /// * `Instruction` - A Solana instruction to be included in a transaction
397    pub fn register(
398        validator_info_pubkey: Pubkey,
399        node_identity: Pubkey,
400        address: Vec<u8>,
401        hostname: String,
402        authority_key: Vec<u8>,
403        protocol_key: Pubkey,
404        network_key: Pubkey,
405        commission_rate: u16,
406        lockup_period: u64,
407    ) -> Instruction {
408        // Derive self-bond PDA internally to avoid breaking caller interface
409        let self_bond_pubkey = crate::pda::derive_self_bond_address(&validator_info_pubkey);
410
411        Instruction::new_with_bincode(
412            crate::id(),
413            &ValidatorRegistryInstruction::Register {
414                node_identity,
415                address,
416                hostname,
417                authority_key,
418                protocol_key,
419                network_key,
420                commission_rate,
421                lockup_period,
422            },
423            vec![
424                AccountMeta::new(validator_info_pubkey, false), // PDA, not a signer
425                AccountMeta::new(node_identity, true), // node identity must sign and pays for PDA
426                AccountMeta::new_readonly(system_program::id(), false), // system program
427                AccountMeta::new(self_bond_pubkey, false), // self-bond PDA (writable)
428                AccountMeta::new_readonly(crate::STAKE_MANAGER_PROGRAM_ID, false), // StakeManager program
429                AccountMeta::new_readonly(crate::id(), false), // ValidatorRegistry program (for ActivateStake → AddStake CPI)
430            ],
431        )
432    }
433
434    /// Create a `ValidatorRegistryInstruction::UpdateWithdrawer` `Instruction`
435    ///
436    /// # Account references
437    ///   0. `[WRITABLE]` Validator info account (PDA - cannot sign)
438    ///   1. `[SIGNER]` Current authorized withdrawer account
439    ///   2. `[READONLY]` New authorized withdrawer account
440    ///
441    /// # Arguments
442    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
443    /// * `current_withdrawer` - The public key of the current authorized withdrawer
444    /// * `new_authorized_withdrawer` - The public key of the new authorized withdrawer
445    ///
446    /// # Returns
447    /// * `Instruction` - A Solana instruction to be included in a transaction
448    pub fn update_withdrawer(
449        validator_info_pubkey: Pubkey,
450        current_withdrawer: Pubkey,
451        new_authorized_withdrawer: Pubkey,
452    ) -> Instruction {
453        Instruction::new_with_bincode(
454            crate::id(),
455            &ValidatorRegistryInstruction::UpdateWithdrawer {
456                new_authorized_withdrawer,
457            },
458            vec![
459                AccountMeta::new(validator_info_pubkey, false), // PDA cannot sign
460                AccountMeta::new_readonly(current_withdrawer, true),
461                AccountMeta::new_readonly(new_authorized_withdrawer, false),
462            ],
463        )
464    }
465
466    /// Create a `ValidatorRegistryInstruction::AddStake` `Instruction`
467    ///
468    /// This instruction adds stake to a validator's aggregate stake.
469    /// It is called via CPI from the StakeManager program when a stake
470    /// account is activated (delegated to a validator).
471    ///
472    /// # Account references
473    ///   0. `[WRITABLE]` Validator info account (PDA derived from authority_key)
474    ///
475    /// # Arguments
476    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
477    /// * `amount` - The amount of stake to add
478    ///
479    /// # Returns
480    /// * `Instruction` - A Solana instruction to be included in a transaction
481    pub fn add_stake(validator_info_pubkey: Pubkey, amount: u64) -> Instruction {
482        Instruction::new_with_bincode(
483            crate::id(),
484            &ValidatorRegistryInstruction::AddStake { amount },
485            vec![AccountMeta::new(validator_info_pubkey, false)],
486        )
487    }
488
489    /// Create a `ValidatorRegistryInstruction::SubStake` `Instruction`
490    ///
491    /// This instruction subtracts stake from a validator's aggregate stake.
492    /// It is called via CPI from the StakeManager program when a stake
493    /// account is deactivated.
494    ///
495    /// # Account references
496    ///   0. `[WRITABLE]` Validator info account (PDA derived from authority_key)
497    ///
498    /// # Arguments
499    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
500    /// * `amount` - The amount of stake to subtract
501    ///
502    /// # Returns
503    /// * `Instruction` - A Solana instruction to be included in a transaction
504    pub fn sub_stake(validator_info_pubkey: Pubkey, amount: u64) -> Instruction {
505        Instruction::new_with_bincode(
506            crate::id(),
507            &ValidatorRegistryInstruction::SubStake { amount },
508            vec![AccountMeta::new(validator_info_pubkey, false)],
509        )
510    }
511
512    /// Create a `ValidatorRegistryInstruction::SetUnbondingPeriod` `Instruction`
513    ///
514    /// This instruction sets the unbonding period for a validator. The unbonding
515    /// period determines how long deactivated stake must wait before it can be
516    /// withdrawn. This is intended to be set by the runtime to punish misbehaving
517    /// validators.
518    ///
519    /// **IMPORTANT:** This instruction can ONLY be called via CPI from the
520    /// TokenomicsGovernance program. The processor verifies the CPI caller.
521    /// Direct invocation will fail with `IncorrectProgramId`.
522    ///
523    /// # Account references
524    ///   0. `[WRITABLE]` Validator info account (PDA derived from authority_key)
525    ///
526    /// # Arguments
527    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
528    /// * `unbonding_period` - The new unbonding period in milliseconds
529    ///
530    /// # Returns
531    /// * `Instruction` - A Solana instruction to be included in a transaction
532    pub fn set_unbonding_period(
533        validator_info_pubkey: Pubkey,
534        unbonding_period: u64,
535    ) -> Instruction {
536        Instruction::new_with_bincode(
537            crate::id(),
538            &ValidatorRegistryInstruction::SetUnbondingPeriod { unbonding_period },
539            vec![
540                AccountMeta::new(validator_info_pubkey, false), // Validator info account (writable)
541            ],
542        )
543    }
544
545    /// Create a `ValidatorRegistryInstruction::ChangeCommissionRate` `Instruction`
546    ///
547    /// This instruction allows a validator to request a commission rate change.
548    /// The new rate is stored in `new_commission_rate` and will be applied at
549    /// the next epoch boundary (during FreezeStakes).
550    ///
551    /// # Account references
552    ///   0. `[WRITABLE]` Validator info account (PDA)
553    ///   1. `[SIGNER]` Node identity (must match validator_account's node_identity)
554    ///
555    /// # Arguments
556    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
557    /// * `node_identity` - The public key of the node identity (must sign)
558    /// * `new_commission_rate` - The new commission rate in basis points (0-10000)
559    ///
560    /// # Returns
561    /// * `Instruction` - A Solana instruction to be included in a transaction
562    pub fn change_commission_rate(
563        validator_info_pubkey: Pubkey,
564        node_identity: Pubkey,
565        new_commission_rate: u16,
566    ) -> Instruction {
567        Instruction::new_with_bincode(
568            crate::id(),
569            &ValidatorRegistryInstruction::ChangeCommissionRate {
570                new_commission_rate,
571            },
572            vec![
573                AccountMeta::new(validator_info_pubkey, false), // Validator info account (writable)
574                AccountMeta::new_readonly(node_identity, true), // Node identity must sign
575            ],
576        )
577    }
578
579    /// Create a `ValidatorRegistryInstruction::Withdraw` `Instruction`
580    ///
581    /// This instruction allows the authorized withdrawer to withdraw kelvins
582    /// from a validator info account. The withdrawal transfers kelvins to the
583    /// authorized_withdrawer account.
584    ///
585    /// # Account references
586    ///   0. `[WRITABLE]` Validator info account (PDA)
587    ///   1. `[SIGNER, WRITABLE]` Authorized withdrawer (must match validator_account's authorized_withdrawer)
588    ///
589    /// # Arguments
590    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
591    /// * `authorized_withdrawer` - The public key of the authorized withdrawer (must sign)
592    /// * `amount` - The amount of kelvins to withdraw
593    ///
594    /// # Returns
595    /// * `Instruction` - A Solana instruction to be included in a transaction
596    pub fn withdraw(
597        validator_info_pubkey: Pubkey,
598        authorized_withdrawer: Pubkey,
599        amount: u64,
600    ) -> Instruction {
601        Instruction::new_with_bincode(
602            crate::id(),
603            &ValidatorRegistryInstruction::Withdraw { amount },
604            vec![
605                AccountMeta::new(validator_info_pubkey, false), // Validator info account (writable)
606                AccountMeta::new(authorized_withdrawer, true), // Authorized withdrawer must sign, receives kelvins
607            ],
608        )
609    }
610
611    /// Create a `ValidatorRegistryInstruction::ChangeEarliestShutdown` `Instruction`
612    ///
613    /// This instruction allows a validator to announce (or clear) a future timestamp
614    /// after which they don't intend to participate in committees.
615    ///
616    /// # Account references
617    ///   0. `[WRITABLE]` Validator info account (PDA)
618    ///   1. `[SIGNER]` Node identity (must match validator_account's node_identity)
619    ///
620    /// # Arguments
621    /// * `validator_info_pubkey` - The public key of the validator info account (PDA)
622    /// * `node_identity` - The public key of the node identity (must sign)
623    /// * `earliest_shutdown` - The shutdown timestamp in ms, or None to clear
624    ///
625    /// # Returns
626    /// * `Instruction` - A Solana instruction to be included in a transaction
627    pub fn change_earliest_shutdown(
628        validator_info_pubkey: Pubkey,
629        node_identity: Pubkey,
630        earliest_shutdown: Option<u64>,
631    ) -> Instruction {
632        Instruction::new_with_bincode(
633            crate::id(),
634            &ValidatorRegistryInstruction::ChangeEarliestShutdown { earliest_shutdown },
635            vec![
636                AccountMeta::new(validator_info_pubkey, false), // Validator info account (writable)
637                AccountMeta::new_readonly(node_identity, true), // Node identity must sign
638            ],
639        )
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use std::net::Ipv4Addr;
646
647    use fastcrypto::{
648        bls12381::min_sig::BLS12381KeyPair,
649        traits::{KeyPair, ToFromBytes},
650    };
651    use multiaddr::Multiaddr;
652
653    use super::*;
654
655    fn create_test_pubkeys() -> (Pubkey, Pubkey, Pubkey) {
656        let validator_info_pubkey = Pubkey::new_unique();
657        let node_identity = Pubkey::new_unique();
658        let new_withdrawer = Pubkey::new_unique();
659        (validator_info_pubkey, node_identity, new_withdrawer)
660    }
661
662    #[test]
663    fn test_register_instruction() {
664        let mut rng = rand::thread_rng();
665
666        let (validator_info_pubkey, node_identity, protocol_key) = create_test_pubkeys();
667
668        let hostname = String::from("localhost");
669        let authority_key = BLS12381KeyPair::generate(&mut rng)
670            .public()
671            .as_bytes()
672            .to_vec();
673        let network_key = Pubkey::new_unique();
674        let address = Multiaddr::from(Ipv4Addr::LOCALHOST);
675        let commission_rate = 500u16; // 5%
676        let lockup_period = 10u64;
677
678        // Create the register instruction
679        let instruction = ValidatorRegistryInstruction::register(
680            validator_info_pubkey,
681            node_identity,
682            address.to_vec(),
683            hostname.clone(),
684            authority_key.clone(),
685            protocol_key,
686            network_key,
687            commission_rate,
688            lockup_period,
689        );
690
691        // Verify the program ID
692        assert_eq!(instruction.program_id, crate::id());
693
694        // Verify first 3 accounts (the remaining 3 are derived internally)
695        assert_eq!(instruction.accounts.len(), 6);
696        assert_eq!(
697            instruction.accounts[0],
698            AccountMeta::new(validator_info_pubkey, false)
699        ); // PDA
700        assert_eq!(
701            instruction.accounts[1],
702            AccountMeta::new(node_identity, true)
703        ); // node identity (signer)
704        assert_eq!(
705            instruction.accounts[2],
706            AccountMeta::new_readonly(system_program::id(), false)
707        ); // system program
708           // [3] = self-bond PDA (derived from validator_info_pubkey)
709        assert!(instruction.accounts[3].is_writable);
710        assert!(!instruction.accounts[3].is_signer);
711        // [4] = StakeManager program
712        assert_eq!(
713            instruction.accounts[4].pubkey,
714            crate::STAKE_MANAGER_PROGRAM_ID
715        );
716        assert!(!instruction.accounts[4].is_writable);
717        // [5] = ValidatorRegistry program (for ActivateStake → AddStake CPI)
718        assert_eq!(instruction.accounts[5].pubkey, crate::id());
719        assert!(!instruction.accounts[5].is_writable);
720
721        // Deserialize and verify instruction data
722        let decoded_instruction: ValidatorRegistryInstruction =
723            bincode::deserialize(&instruction.data).unwrap();
724
725        match decoded_instruction {
726            ValidatorRegistryInstruction::Register {
727                node_identity: decoded_node_identity,
728                address: decoded_address,
729                hostname: decoded_hostname,
730                authority_key: decoded_authority_key,
731                protocol_key: decoded_protocol_key,
732                network_key: decoded_network_key,
733                commission_rate: decoded_commission_rate,
734                lockup_period: decoded_lockup_period,
735            } => {
736                assert_eq!(decoded_node_identity, node_identity);
737                assert_eq!(decoded_address, address.to_vec());
738                assert_eq!(decoded_hostname, hostname);
739                assert_eq!(decoded_authority_key, authority_key);
740                assert_eq!(decoded_protocol_key, protocol_key);
741                assert_eq!(decoded_network_key, network_key);
742                assert_eq!(decoded_commission_rate, commission_rate);
743                assert_eq!(decoded_lockup_period, lockup_period);
744            }
745            _ => panic!("Expected Register instruction"),
746        }
747    }
748
749    /// Helper to create a ValidatorInfo with the given unbonding_periods map.
750    fn vi_with_periods(periods: BTreeMap<u64, u64>) -> ValidatorInfo {
751        ValidatorInfo {
752            node_identity: Pubkey::default(),
753            authorized_withdrawer: Pubkey::default(),
754            stake: 0,
755            address: vec![],
756            hostname: String::new(),
757            authority_key: vec![],
758            protocol_key: Pubkey::default(),
759            network_key: Pubkey::default(),
760            registration_time: 0,
761            last_update: 0,
762            unbonding_periods: periods,
763            lockup_period: 0,
764            commission_rate: 0,
765            new_commission_rate: None,
766            earliest_shutdown: None,
767        }
768    }
769
770    /// Single entry — uses the one period (backward compatible with old behavior).
771    #[test]
772    fn test_end_of_unbonding_single_entry() {
773        let vi = vi_with_periods(BTreeMap::from([(0, 7)]));
774        assert_eq!(vi.end_of_unbonding(100), 107); // 100 + 7
775    }
776
777    /// Empty map — should return deactivation_requested + 0 = deactivation_requested.
778    #[test]
779    fn test_end_of_unbonding_empty_map() {
780        let vi = vi_with_periods(BTreeMap::new());
781        assert_eq!(vi.end_of_unbonding(100), 100); // 100 + 0
782    }
783
784    /// No punishment during unbonding (exempt).
785    ///
786    /// unbonding_periods = { 0: 7, 1000: 30 }
787    /// deactivation_requested = 100
788    /// initial_end = 100 + 7 = 107
789    /// Check entry at 1000: max_end(107) < 1000 → exempt, break
790    /// Result: 107 (original period, not affected by later punishment)
791    #[test]
792    fn test_end_of_unbonding_example1_exempt() {
793        let vi = vi_with_periods(BTreeMap::from([(0, 7), (1000, 30)]));
794        assert_eq!(vi.end_of_unbonding(100), 107);
795    }
796
797    /// Punishment during unbonding (extended).
798    ///
799    /// unbonding_periods = { 0: 7, 50: 30 }
800    /// deactivation_requested = 100
801    /// initial_period = 30 (entry at 50, which is <= 100)
802    /// Result: 100 + 30 = 130
803    #[test]
804    fn test_end_of_unbonding_example2_deactivated_after_punishment() {
805        let vi = vi_with_periods(BTreeMap::from([(0, 7), (50, 30)]));
806        assert_eq!(vi.end_of_unbonding(100), 130);
807    }
808
809    /// Deactivated before punishment, still unbonding when hit.
810    ///
811    /// unbonding_periods = { 0: 7, 105: 30 }
812    /// deactivation_requested = 100
813    /// initial_end = 107, max_end = 107
814    /// Entry at 105: max_end(107) >= 105 → apply. new_end = 130, max_end = 130
815    /// Result: 130 = 100 + 30
816    #[test]
817    fn test_end_of_unbonding_example3_extended_during_unbonding() {
818        let vi = vi_with_periods(BTreeMap::from([(0, 7), (105, 30)]));
819        assert_eq!(vi.end_of_unbonding(100), 130);
820    }
821
822    /// Multiple punishments, partial application.
823    ///
824    /// unbonding_periods = { 0: 5, 104: 10, 200: 30 }
825    /// deactivation_requested = 100
826    /// initial_end = 105, max_end = 105
827    /// Entry at 104: max_end(105) >= 104 → apply. new_end = 110, max_end = 110
828    /// Entry at 200: max_end(110) < 200 → exempt, break
829    /// Result: 110 = 100 + 10
830    #[test]
831    fn test_end_of_unbonding_example4_partial_application() {
832        let vi = vi_with_periods(BTreeMap::from([(0, 5), (104, 10), (200, 30)]));
833        assert_eq!(vi.end_of_unbonding(100), 110);
834    }
835
836    /// Genuine reduction (fairness).
837    ///
838    /// unbonding_periods = { 0: 2, 100: 30, 500: 7 }
839    /// deactivation_requested = 200 (during 30-period phase)
840    /// initial_period = 30 (entry at 100), initial_end = 230
841    /// Entry at 500: max_end(230) >= 500? No (230 < 500) → exempt, break
842    ///
843    /// In that case, 200 + 30 = 230 < 500. So the account IS exempt and gets 230.
844    ///
845    /// This is because in real milliseconds, 30 days = 2,592,000,000 ms >> 500.
846    ///
847    #[test]
848    fn test_end_of_unbonding_example5_reduction_fairness() {
849        let day: u64 = 86_400_000; // 1 day in ms
850        let vi = vi_with_periods(BTreeMap::from([
851            (0, 2 * day),
852            (100, 30 * day),
853            (500, 7 * day),
854        ]));
855        // deactivation at 200 (during 30-day phase)
856        // initial_period = 30 days, initial_end = 200 + 30*day
857        // Entry at 500: max_end(200 + 30*day) >= 500? Yes (huge) → apply
858        //   new_end = 200 + 7*day, max_end = max(200+30d, 200+7d) = 200+30d
859        //   effective_end = 200 + 7*day
860        // Result: 200 + 7 days ← reduction applied! ✅
861        assert_eq!(vi.end_of_unbonding(200), 200 + 7 * day);
862    }
863
864    ///  Mistaken lowering then revert (flash resistance).
865    ///
866    /// unbonding_periods = { 0: 30_days, 100: 0, 101: 30_days }
867    /// deactivation_requested = 50
868    /// initial_end = 50 + 30d, max_end = 50+30d
869    /// Entry at 100: max_end(50+30d) >= 100 → apply. new_end = 50. max_end stays 50+30d.
870    /// Entry at 101: max_end(50+30d) >= 101 → apply. new_end = 50+30d. max_end stays.
871    /// effective_end = 50 + 30d ← punishment preserved! ✅
872    #[test]
873    fn test_end_of_unbonding_example6_flash_resistance() {
874        let day: u64 = 86_400_000;
875        let vi = vi_with_periods(BTreeMap::from([(0, 30 * day), (100, 0), (101, 30 * day)]));
876        assert_eq!(vi.end_of_unbonding(50), 50 + 30 * day);
877    }
878
879    /// Deactivation at exact timestamp of a period change — uses that entry.
880    #[test]
881    fn test_end_of_unbonding_deactivation_at_exact_entry() {
882        let vi = vi_with_periods(BTreeMap::from([(0, 5), (100, 20)]));
883        // Deactivation at exactly 100 → initial_period = 20 (entry at 100 is <= 100)
884        assert_eq!(vi.end_of_unbonding(100), 120);
885    }
886
887    /// Deactivation before any entry — uses 0 as default.
888    #[test]
889    fn test_end_of_unbonding_deactivation_before_first_entry() {
890        let vi = vi_with_periods(BTreeMap::from([(100, 10)]));
891        // Deactivation at 50 — no entry <= 50, so period = 0
892        // initial_end = 50, max_end = 50
893        // Entry at 100: max_end(50) < 100 → exempt
894        assert_eq!(vi.end_of_unbonding(50), 50);
895    }
896
897    /// Multiple reductions — effective_end decreases with each.
898    #[test]
899    fn test_end_of_unbonding_multiple_reductions() {
900        let day: u64 = 86_400_000;
901        let vi = vi_with_periods(BTreeMap::from([
902            (0, 30 * day),
903            (500, 14 * day),
904            (600, 7 * day),
905        ]));
906        // deactivation at 200 (during 30-day phase)
907        // initial_end = 200 + 30d, max_end = 200 + 30d
908        // Entry at 500: apply → new_end = 200 + 14d, max_end stays 200+30d, effective = 200+14d
909        // Entry at 600: apply → new_end = 200 + 7d, max_end stays, effective = 200+7d
910        assert_eq!(vi.end_of_unbonding(200), 200 + 7 * day);
911    }
912
913    /// Punishment after full unbonding — must NOT extend (exempt).
914    #[test]
915    fn test_end_of_unbonding_punishment_after_full_unbonding() {
916        let vi = vi_with_periods(BTreeMap::from([(0, 5), (200, 30)]));
917        // deactivation at 100, initial_end = 105
918        // Entry at 200: max_end(105) < 200 → exempt
919        assert_eq!(vi.end_of_unbonding(100), 105);
920    }
921
922    /// Increasing punishment chain — each extends further.
923    #[test]
924    fn test_end_of_unbonding_increasing_punishments() {
925        let vi = vi_with_periods(BTreeMap::from([(0, 5), (104, 10), (109, 20)]));
926        // deactivation at 100, initial_end = 105
927        // Entry at 104: max_end(105) >= 104 → apply. new_end = 110, max_end = 110
928        // Entry at 109: max_end(110) >= 109 → apply. new_end = 120, max_end = 120
929        assert_eq!(vi.end_of_unbonding(100), 120);
930    }
931
932    #[test]
933    fn test_update_withdrawer_instruction() {
934        let (validator_info_pubkey, current_withdrawer, new_withdrawer) = create_test_pubkeys();
935
936        // Create the update withdrawer instruction
937        let instruction = ValidatorRegistryInstruction::update_withdrawer(
938            validator_info_pubkey,
939            current_withdrawer,
940            new_withdrawer,
941        );
942
943        // Verify the program ID
944        assert_eq!(instruction.program_id, crate::id());
945
946        // Expected accounts:
947        // Note: validator_info_pubkey is a PDA and cannot sign
948        let expected_accounts = vec![
949            AccountMeta::new(validator_info_pubkey, false), // validator info account (PDA, writable, NOT a signer)
950            AccountMeta::new_readonly(current_withdrawer, true), // current withdrawer (signer, readonly)
951            AccountMeta::new_readonly(new_withdrawer, false),    // new withdrawer (readonly)
952        ];
953
954        // Verify accounts
955        assert_eq!(instruction.accounts, expected_accounts);
956
957        // Deserialize and verify instruction data
958        let decoded_instruction: ValidatorRegistryInstruction =
959            bincode::deserialize(&instruction.data).unwrap();
960
961        match decoded_instruction {
962            ValidatorRegistryInstruction::UpdateWithdrawer {
963                new_authorized_withdrawer,
964            } => {
965                assert_eq!(new_authorized_withdrawer, new_withdrawer);
966            }
967            _ => panic!("Expected UpdateWithdrawer instruction"),
968        }
969    }
970
971    #[test]
972    fn test_change_earliest_shutdown_instruction() {
973        let validator_info_pubkey = Pubkey::new_unique();
974        let node_identity = Pubkey::new_unique();
975        let shutdown_ts = 1_000_000u64;
976
977        // Test with Some(timestamp)
978        let instruction = ValidatorRegistryInstruction::change_earliest_shutdown(
979            validator_info_pubkey,
980            node_identity,
981            Some(shutdown_ts),
982        );
983
984        assert_eq!(instruction.program_id, crate::id());
985        assert_eq!(instruction.accounts.len(), 2);
986        assert_eq!(instruction.accounts[0].pubkey, validator_info_pubkey);
987        assert!(!instruction.accounts[0].is_signer);
988        assert!(instruction.accounts[0].is_writable);
989        assert_eq!(instruction.accounts[1].pubkey, node_identity);
990        assert!(instruction.accounts[1].is_signer);
991        assert!(!instruction.accounts[1].is_writable);
992
993        let decoded: ValidatorRegistryInstruction =
994            bincode::deserialize(&instruction.data).unwrap();
995        assert_eq!(
996            decoded,
997            ValidatorRegistryInstruction::ChangeEarliestShutdown {
998                earliest_shutdown: Some(shutdown_ts),
999            }
1000        );
1001
1002        // Test with None
1003        let instruction_none = ValidatorRegistryInstruction::change_earliest_shutdown(
1004            validator_info_pubkey,
1005            node_identity,
1006            None,
1007        );
1008
1009        let decoded_none: ValidatorRegistryInstruction =
1010            bincode::deserialize(&instruction_none.data).unwrap();
1011        assert_eq!(
1012            decoded_none,
1013            ValidatorRegistryInstruction::ChangeEarliestShutdown {
1014                earliest_shutdown: None,
1015            }
1016        );
1017    }
1018}