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