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