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