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}