rialo_feature_management_interface/state.rs
1// Copyright (c) Subzero Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Feature state management
5//!
6//! On-chain state for the feature management program. Activation is
7//! **presence-based**: a feature is active iff its name is in `entries`. No
8//! per-entry payload, no clock. See `runtime/execution/NORTHSTAR-protocol-upgrades.md`.
9
10extern crate alloc;
11
12#[cfg(test)]
13use alloc::string::ToString;
14use alloc::{
15 collections::{BTreeMap, BTreeSet},
16 string::String,
17 vec::Vec,
18};
19
20use borsh::{BorshDeserialize, BorshSerialize};
21use rialo_s_pubkey::Pubkey;
22
23use crate::error::FeatureManagementError;
24
25/// A deferred feature-activation request, recorded by `ScheduleEnable` and
26/// keyed in [`FeaturesState::pending`] by its `request_id`.
27///
28/// The actual firing is handled out-of-band: `ScheduleEnable` registers a
29/// one-shot subscription whose `timestamp_range` predicate fires at
30/// `fire_at_ms` and invokes `FireScheduledEnable { request_id }`, which reads
31/// `names` from this record, activates them, and drains the entry. This record
32/// exists so the pending set is enumerable (tooling / `Cancel`) until it fires
33/// or is cancelled.
34#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
35pub struct ScheduledRequest {
36 /// Feature names to enable when the schedule fires.
37 pub names: Vec<String>,
38 /// Wall-clock time (ms since the Unix epoch) at which the features
39 /// activate.
40 pub fire_at_ms: u64,
41}
42
43/// The program's global state.
44///
45/// Stored under the `STORAGE_ACCOUNT_SEED` PDA owned by the feature
46/// management program. Carries the authority pubkey, an optional pending
47/// authority for the two-step transfer handshake, and the set of feature
48/// names that are currently active.
49///
50/// This is the in-memory type. On-disk encoding goes through
51/// [`FeaturesStateVersioned`] — the single versioned seam — not a direct borsh
52/// derive on this struct; that is why `FeaturesState` does **not** derive
53/// `BorshSerialize`/`BorshDeserialize`. The current wire layout is frozen as
54/// [`FeaturesStateV1`], and [`Self::serialize`] / [`Self::deserialize`] delegate
55/// to the envelope.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct FeaturesState {
58 /// Authority pubkey.
59 authority: Pubkey,
60 /// Pubkey of the proposed next authority while a two-step transfer is
61 /// in flight. `None` while no transfer is pending.
62 ///
63 /// Set by `ProposeAuthorityTransfer`, cleared by `CancelAuthorityTransfer`,
64 /// and consumed by `AcceptAuthorityTransfer` (which promotes it to
65 /// `authority`). The single-step `UpdateAuthority` path sets `authority`
66 /// directly and leaves this slot untouched.
67 pending_authority: Option<Pubkey>,
68 /// Active feature names. Presence is the activation; there is no
69 /// per-entry payload.
70 ///
71 /// `pub(crate)` so external callers cannot bypass `enable`'s validation
72 /// by writing directly. Read access through `Self::entries()`; tests
73 /// construct fixtures via `Self::insert_for_test()`.
74 pub(crate) entries: BTreeSet<String>,
75 /// Deferred activation requests, keyed by `request_id`. Populated by
76 /// `ScheduleEnable` and drained by `Cancel` or by the one-shot
77 /// subscription firing — which invokes `FireScheduledEnable`, whose
78 /// `fire_scheduled` removes the entry as it activates.
79 ///
80 /// **Stale-entry caveat.** An entry is drained only by a *successful* fire
81 /// or by `Cancel`. A one-shot is removed from the matcher when it matches,
82 /// and its `Destroy` is the last instruction of the fired transaction — so
83 /// if `FireScheduledEnable` fails (compute budget, `MAX_FEATURE_COUNT` at
84 /// fire time, …) the transaction reverts, the entry is not drained, and the
85 /// subscription does not retry. The slot is reclaimable by `Cancel` (the
86 /// subscription account still exists), except after an authority transfer
87 /// ([[SUB-2605]]). Automatic reaping / retry (reification) is tracked as
88 /// SUB-2608; until then a wedged `pending` is cleared by the authority via
89 /// `Cancel`.
90 ///
91 /// `pub(crate)` for the same reason as `entries` — mutated only through
92 /// `schedule` / `cancel` / `fire_scheduled`.
93 pub(crate) pending: BTreeMap<u64, ScheduledRequest>,
94}
95
96#[cfg(test)]
97pub const DETERMINISTIC_TEST_KEYPAIR: &str =
98 "57Vqb7tHij5NhQnTgrgYXA19pC8ZVHQoCHpapSiQ8LJaeUvTcSBzKoB6CazhR6VtxmyVAbWnoeDSzD1Vm672NaKp";
99
100impl FeaturesState {
101 /// Create a new state with the given authority, no pending transfer, and
102 /// an empty entry set.
103 ///
104 /// Genesis intentionally pre-seeds no features — features active from
105 /// genesis are part of the implementation, not feature flags.
106 pub fn new(authority: Pubkey) -> Self {
107 Self {
108 authority,
109 pending_authority: None,
110 entries: BTreeSet::new(),
111 pending: BTreeMap::new(),
112 }
113 }
114
115 #[cfg(test)]
116 pub fn new_for_test() -> Self {
117 use rialo_s_keypair::Keypair;
118 use rialo_s_signer::Signer;
119
120 let pubkey: Pubkey = Keypair::from_base58_string(DETERMINISTIC_TEST_KEYPAIR)
121 .try_pubkey()
122 .expect("Failed to get pubkey from deterministic test keypair");
123 Self::new(pubkey)
124 }
125
126 pub fn get_authority(&self) -> &Pubkey {
127 &self.authority
128 }
129
130 pub fn set_authority(&mut self, new_authority: Pubkey) {
131 self.authority = new_authority;
132 }
133
134 pub fn pending_authority(&self) -> Option<&Pubkey> {
135 self.pending_authority.as_ref()
136 }
137
138 pub fn set_pending_authority(&mut self, pending: Option<Pubkey>) {
139 self.pending_authority = pending;
140 }
141
142 /// Propose a two-step authority transfer.
143 ///
144 /// Sets `pending_authority` to `Some(new_authority)`. Returns
145 /// `PendingTransferExists` if a previous proposal is still
146 /// outstanding — callers must `cancel_transfer` first. Returns
147 /// `InvalidTransferTarget` if `new_authority` equals the current
148 /// authority (degenerate target; distinct from a signer mismatch,
149 /// which is the processor's `Unauthorized`).
150 ///
151 /// Caller is responsible for verifying the current-authority signature
152 /// before invoking this.
153 pub fn propose_transfer(
154 &mut self,
155 new_authority: Pubkey,
156 ) -> Result<(), FeatureManagementError> {
157 if new_authority == self.authority {
158 return Err(FeatureManagementError::InvalidTransferTarget);
159 }
160 if self.pending_authority.is_some() {
161 return Err(FeatureManagementError::PendingTransferExists);
162 }
163 self.pending_authority = Some(new_authority);
164 Ok(())
165 }
166
167 /// Commit a previously-proposed authority transfer.
168 ///
169 /// On success the authority moves to the pending value and
170 /// `pending_authority` clears. Returns `NoPendingTransfer` if nothing
171 /// is pending.
172 ///
173 /// **Contract:** the caller MUST have verified that the transaction
174 /// signer equals `pending_authority()` before invoking this. This
175 /// method does not re-check; the processor proves signer == pending
176 /// via `verify_authority(&pending, ...)` and then commits here. Code
177 /// outside the processor that calls this without the signer check
178 /// would let any keypair commit the pending authority.
179 pub fn accept_transfer(&mut self) -> Result<(), FeatureManagementError> {
180 let Some(pending) = self.pending_authority else {
181 return Err(FeatureManagementError::NoPendingTransfer);
182 };
183 self.authority = pending;
184 self.pending_authority = None;
185 Ok(())
186 }
187
188 /// Cancel a previously-proposed authority transfer.
189 ///
190 /// Clears `pending_authority`. Returns `NoPendingTransfer` if nothing
191 /// is pending. Caller is responsible for verifying the
192 /// current-authority signature before invoking this.
193 pub fn cancel_transfer(&mut self) -> Result<(), FeatureManagementError> {
194 if self.pending_authority.is_none() {
195 return Err(FeatureManagementError::NoPendingTransfer);
196 }
197 self.pending_authority = None;
198 Ok(())
199 }
200
201 /// Encode to the on-disk byte layout, through the versioned envelope.
202 ///
203 /// Delegates to [`FeaturesStateVersioned`], which today writes the bare
204 /// borsh of [`FeaturesStateV1`] — byte-identical to the pre-envelope format,
205 /// so existing accounts read back unchanged.
206 pub fn serialize(&self) -> Result<Vec<u8>, borsh::io::Error> {
207 FeaturesStateVersioned::from(self).serialize()
208 }
209
210 /// Decode from the on-disk byte layout, through the versioned envelope's
211 /// tolerant reader.
212 pub fn deserialize(data: &[u8]) -> Result<Self, borsh::io::Error> {
213 Ok(FeaturesStateVersioned::deserialize(data)?.into())
214 }
215
216 /// Whether `feature_name` is active.
217 ///
218 /// Activation is membership: a feature is active iff its name is in
219 /// `entries`. No clock consult.
220 pub fn is_active(&self, feature_name: &str) -> bool {
221 self.entries.contains(feature_name)
222 }
223
224 /// Read-only view of the active entry set. Use `enable` to mutate so the
225 /// `MAX_FEATURE_COUNT` cap stays enforced.
226 pub fn entries(&self) -> &BTreeSet<String> {
227 &self.entries
228 }
229
230 /// Test-only direct insert that skips `enable`'s validation. Use to
231 /// seed fixtures that need specific names. Never call from production
232 /// paths.
233 ///
234 /// Not `cfg(test)`-gated because cross-crate test crates (e.g.
235 /// `svm-execution`'s bank tests) need to construct `FeaturesState`
236 /// fixtures, and `cfg(test)` from the declaring crate is invisible to
237 /// dependent test crates. Documented as fixtures-only and never invoked
238 /// by production paths.
239 pub fn insert_for_test(&mut self, name: String) {
240 self.entries.insert(name);
241 }
242
243 /// Add one or more feature names to the active set.
244 ///
245 /// Idempotent: re-submitting an existing name is a no-op (presence is
246 /// the activation, so a name that is already present is already
247 /// active). Enforces:
248 ///
249 /// * `MAX_NAMES_PER_BATCH` per-instruction cap → `TooManyNames`.
250 /// * `validate_feature_name` on every name (length, allowed charset,
251 /// no leading/trailing whitespace) → `InvalidFeatureName`. Owning
252 /// the check here keeps it the single source of truth — callers
253 /// reaching `enable` outside the processor path cannot smuggle in
254 /// malformed names.
255 /// * `MAX_FEATURE_COUNT` cap on the resulting set size — checked
256 /// against the post-insert count of distinct new names so a single
257 /// batch cannot push the registry past the cap →
258 /// `MaxFeatureCountExceeded`.
259 pub fn enable(&mut self, names: Vec<String>) -> Result<(), FeatureManagementError> {
260 if names.len() > crate::MAX_NAMES_PER_BATCH {
261 return Err(FeatureManagementError::TooManyNames);
262 }
263 for name in &names {
264 if !crate::validate_feature_name(name) {
265 return Err(FeatureManagementError::InvalidFeatureName);
266 }
267 }
268 // Note: `enable` is permissive on `KNOWN_FEATURES` membership. The
269 // on-chain program does not (and cannot, deterministically) reject
270 // a name absent from a given binary's `KNOWN_FEATURES`; that's a
271 // binary-level concept. An active feature unknown to the running
272 // binary is caught by `Bank::can_process_block`, which halts the
273 // node at the activation block. Operators staging a name that no
274 // released binary yet recognises is therefore on the hook to ship
275 // the binary first.
276 // Count *distinct* names absent from the existing set. Without the
277 // collect-into-set, intra-batch duplicates (`["x", "x"]`) would
278 // double-count and reject a request that BTreeSet semantics would
279 // otherwise accept.
280 let new_distinct = names
281 .iter()
282 .filter(|n| !self.entries.contains(*n))
283 .collect::<BTreeSet<_>>()
284 .len();
285 if self.entries.len().saturating_add(new_distinct) > crate::MAX_FEATURE_COUNT {
286 return Err(FeatureManagementError::MaxFeatureCountExceeded);
287 }
288 for name in names {
289 self.entries.insert(name);
290 }
291 Ok(())
292 }
293
294 /// Record a deferred activation request under the caller-chosen
295 /// `request_id`.
296 ///
297 /// Validates `names` per-name (charset/length) and per-batch
298 /// (`MAX_NAMES_PER_BATCH`) at schedule time, enforces `MAX_PENDING_REQUESTS`
299 /// on the pending entry count, and rejects a request that would push the
300 /// serialized state over `MAX_FEATURES_STATE_SIZE` with
301 /// `PendingStateTooLarge`. It does **not** enforce the `MAX_FEATURE_COUNT`
302 /// registry cap here — that cap is checked when the schedule fires, via
303 /// [`fire_scheduled`](Self::fire_scheduled) → [`enable`](Self::enable). The
304 /// `fire_at_ms` future / horizon check is the **caller's** responsibility —
305 /// it needs the clock, which this deliberately clock-free state type does
306 /// not consult. The `request_id` is supplied by the caller (it is the
307 /// cancel handle and the subscription nonce); a `request_id` that already
308 /// has a pending entry is rejected with `RequestAlreadyExists`.
309 ///
310 /// Note the count cap and the byte cap interact: `MAX_PENDING_REQUESTS`
311 /// (100) is only reachable with small requests. A request carrying near-max
312 /// batches (≈51 KB serialized) exhausts the `MAX_FEATURES_STATE_SIZE`
313 /// (100 KB) budget after one or two entries, at which point
314 /// `PendingStateTooLarge` — not `TooManyPendingRequests` — is the operative
315 /// rejection. The explicit byte check here means that surfaces as a clear
316 /// error rather than an opaque `SerializationError` at save time.
317 pub fn schedule(
318 &mut self,
319 request_id: u64,
320 names: Vec<String>,
321 fire_at_ms: u64,
322 ) -> Result<(), FeatureManagementError> {
323 if names.len() > crate::MAX_NAMES_PER_BATCH {
324 return Err(FeatureManagementError::TooManyNames);
325 }
326 for name in &names {
327 if !crate::validate_feature_name(name) {
328 return Err(FeatureManagementError::InvalidFeatureName);
329 }
330 }
331 // Duplicate-id check before the capacity check: a re-used `request_id`
332 // is a deterministic client error regardless of how full the map is, so
333 // report `RequestAlreadyExists` rather than masking it with
334 // `TooManyPendingRequests` (which would wrongly suggest retrying under a
335 // different id). A duplicate also would not grow the map, so the
336 // capacity guard does not apply to it.
337 if self.pending.contains_key(&request_id) {
338 return Err(FeatureManagementError::RequestAlreadyExists);
339 }
340 if self.pending.len() >= crate::MAX_PENDING_REQUESTS {
341 return Err(FeatureManagementError::TooManyPendingRequests);
342 }
343 self.pending
344 .insert(request_id, ScheduledRequest { names, fire_at_ms });
345 // Reject (and roll back) a request that would exceed the on-chain byte
346 // budget, so the operator sees `PendingStateTooLarge` here rather than a
347 // bare `SerializationError` when the processor tries to persist. Mirrors
348 // the `save_state` guard so the two never disagree.
349 let too_large = self
350 .serialize()
351 .map(|bytes| bytes.len() > crate::MAX_FEATURES_STATE_SIZE)
352 .unwrap_or(true);
353 if too_large {
354 self.pending.remove(&request_id);
355 return Err(FeatureManagementError::PendingStateTooLarge);
356 }
357 Ok(())
358 }
359
360 /// Fire a previously-scheduled request: drain its pending entry and
361 /// activate its names.
362 ///
363 /// Removes `pending[request_id]` (returning `RequestNotFound` if absent),
364 /// then runs [`enable`](Self::enable) on the recorded names — so the same
365 /// `MAX_FEATURE_COUNT` / charset / per-batch validation that a direct
366 /// `Enable` enforces is applied at fire time, not just at schedule time.
367 ///
368 /// Atomicity: the processor saves state after this returns and a failed
369 /// `enable` aborts the whole transaction, rolling back the `remove`. So the
370 /// drain-and-enable is all-or-nothing — a fired schedule never half-drains.
371 pub fn fire_scheduled(&mut self, request_id: u64) -> Result<(), FeatureManagementError> {
372 let req = self
373 .pending
374 .remove(&request_id)
375 .ok_or(FeatureManagementError::RequestNotFound)?;
376 self.enable(req.names)
377 }
378
379 /// Remove a pending scheduled request by id, returning the withdrawn entry.
380 ///
381 /// Returns `RequestNotFound` if no pending request has that id — already
382 /// fired (the one-shot self-removes), already cancelled, or never existed.
383 /// Activation is append-only, so a request that has already fired cannot
384 /// be undone here.
385 pub fn cancel(&mut self, request_id: u64) -> Result<ScheduledRequest, FeatureManagementError> {
386 self.pending
387 .remove(&request_id)
388 .ok_or(FeatureManagementError::RequestNotFound)
389 }
390
391 /// Read-only view of the pending scheduled requests, keyed by `request_id`.
392 pub fn pending(&self) -> &BTreeMap<u64, ScheduledRequest> {
393 &self.pending
394 }
395}
396
397/// Frozen on-disk layout of [`FeaturesState`] at schema version 1.
398///
399/// A snapshot of the current `FeaturesState` field layout, kept as a
400/// **distinct** type — not an alias — so that when the in-memory `FeaturesState`
401/// gains a field in a future schema bump, `FeaturesStateV1`'s wire layout stays
402/// frozen and old accounts keep decoding. Field order and types match
403/// `FeaturesState` exactly, so its borsh encoding is byte-identical to the
404/// pre-envelope format (pinned by the golden tests).
405///
406/// Fields are `pub(crate)` so the conversions in this module can build it while
407/// external callers still cannot bypass [`FeaturesState::enable`]'s validation
408/// by constructing state directly.
409#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
410pub struct FeaturesStateV1 {
411 pub(crate) authority: Pubkey,
412 pub(crate) pending_authority: Option<Pubkey>,
413 pub(crate) entries: BTreeSet<String>,
414 pub(crate) pending: BTreeMap<u64, ScheduledRequest>,
415}
416
417/// Versioned envelope over the on-disk [`FeaturesState`] layouts.
418///
419/// **On-disk format.** This is the single-variant shell: V1 is the bare borsh of
420/// [`FeaturesStateV1`] with no discriminant, byte-identical to the pre-envelope
421/// format. The seam exists so the *next* field addition is a "new variant +
422/// lifter" change rather than an unguarded wire bump: that bump adds a `V2`
423/// variant chosen by a writer-side feature gate, so an old binary halts at
424/// `can_process_block` before reading bytes it cannot decode (the
425/// `StakeInfoVersioned::encode` template). Until then [`Self::deserialize`]
426/// always decodes the untagged V1 record.
427///
428/// **Why V2 cannot reuse leading-byte tag dispatch.** `StakeInfoVersioned`
429/// tells its tagged V2 apart from an untagged V1 by the leading byte, but only
430/// because `StakeInfoV1` begins with an `Option` discriminant constrained to
431/// `0`/`1`, leaving tag `2` provably disjoint from every valid V1 record.
432/// `FeaturesStateV1` instead begins with `authority: Pubkey`, whose first byte
433/// is an arbitrary `u8` (`0..=255`); no tag byte is disjoint from a V1 record
434/// whose authority happens to start with it. A future V2 must therefore
435/// disambiguate structurally (the scheme is chosen when the variant is
436/// designed), not on a single leading byte.
437#[derive(Clone, Debug, PartialEq, Eq)]
438pub enum FeaturesStateVersioned {
439 /// Schema version 1 — the original `FeaturesState` layout (untagged on
440 /// disk).
441 V1(FeaturesStateV1),
442}
443
444impl FeaturesStateVersioned {
445 /// Encode to the on-disk byte layout.
446 ///
447 /// V1 is the bare borsh of [`FeaturesStateV1`] (byte-identical to the
448 /// pre-envelope format). A future variant encodes its version marker here.
449 pub fn serialize(&self) -> Result<Vec<u8>, borsh::io::Error> {
450 match self {
451 FeaturesStateVersioned::V1(v1) => borsh::to_vec(v1),
452 }
453 }
454
455 /// Decode the on-disk byte layout.
456 ///
457 /// Today only the untagged V1 record exists, so the whole slice is decoded
458 /// as [`FeaturesStateV1`]. A future `V2` cannot be told apart from V1 by a
459 /// leading byte (V1 begins with an unconstrained `Pubkey`, not an `Option`
460 /// discriminant), so when V2 lands this will need a structural cue rather
461 /// than leading-byte dispatch. See [`FeaturesStateVersioned`].
462 pub fn deserialize(data: &[u8]) -> Result<Self, borsh::io::Error> {
463 Ok(FeaturesStateVersioned::V1(borsh::from_slice(data)?))
464 }
465}
466
467impl From<&FeaturesState> for FeaturesStateV1 {
468 fn from(s: &FeaturesState) -> Self {
469 FeaturesStateV1 {
470 authority: s.authority,
471 pending_authority: s.pending_authority,
472 entries: s.entries.clone(),
473 pending: s.pending.clone(),
474 }
475 }
476}
477
478impl From<FeaturesState> for FeaturesStateV1 {
479 fn from(s: FeaturesState) -> Self {
480 FeaturesStateV1 {
481 authority: s.authority,
482 pending_authority: s.pending_authority,
483 entries: s.entries,
484 pending: s.pending,
485 }
486 }
487}
488
489impl From<FeaturesStateV1> for FeaturesState {
490 fn from(v: FeaturesStateV1) -> Self {
491 FeaturesState {
492 authority: v.authority,
493 pending_authority: v.pending_authority,
494 entries: v.entries,
495 pending: v.pending,
496 }
497 }
498}
499
500/// Defaults to the V1 wire form. A future feature-gated writer would use an
501/// `encode(state, feature_active)` picker (the `StakeInfoVersioned` template) to
502/// choose a newer variant; this conversion is the no-feature-context path.
503impl From<&FeaturesState> for FeaturesStateVersioned {
504 fn from(s: &FeaturesState) -> Self {
505 FeaturesStateVersioned::V1(FeaturesStateV1::from(s))
506 }
507}
508
509impl From<FeaturesState> for FeaturesStateVersioned {
510 fn from(s: FeaturesState) -> Self {
511 FeaturesStateVersioned::V1(FeaturesStateV1::from(s))
512 }
513}
514
515impl From<FeaturesStateVersioned> for FeaturesState {
516 fn from(v: FeaturesStateVersioned) -> Self {
517 match v {
518 FeaturesStateVersioned::V1(v1) => FeaturesState::from(v1),
519 }
520 }
521}
522
523#[cfg(test)]
524mod tests {
525 use alloc::{format, vec};
526
527 use super::*;
528
529 #[test]
530 fn test_new_has_empty_set() {
531 let state = FeaturesState::new_for_test();
532 assert!(state.entries().is_empty());
533 }
534
535 #[test]
536 fn test_enable_inserts_name() {
537 let mut state = FeaturesState::new_for_test();
538 state.enable(vec!["feat_a".to_string()]).unwrap();
539 assert!(state.is_active("feat_a"));
540 assert!(!state.is_active("feat_b"));
541 }
542
543 #[test]
544 fn test_enable_is_idempotent() {
545 let mut state = FeaturesState::new_for_test();
546 state.enable(vec!["feat_a".to_string()]).unwrap();
547 state.enable(vec!["feat_a".to_string()]).unwrap();
548 assert_eq!(state.entries().len(), 1);
549 }
550
551 #[test]
552 fn test_enable_multi_names() {
553 let mut state = FeaturesState::new_for_test();
554 state
555 .enable(vec!["a".to_string(), "b".to_string(), "c".to_string()])
556 .unwrap();
557 assert_eq!(state.entries().len(), 3);
558 assert!(state.is_active("a"));
559 assert!(state.is_active("b"));
560 assert!(state.is_active("c"));
561 }
562
563 #[test]
564 fn test_enable_dedup_within_batch() {
565 let mut state = FeaturesState::new_for_test();
566 state
567 .enable(vec!["a".to_string(), "a".to_string(), "b".to_string()])
568 .unwrap();
569 assert_eq!(state.entries().len(), 2);
570 }
571
572 /// Helper: fill state by submitting MAX_NAMES_PER_BATCH-sized chunks
573 /// until it carries `target` names.
574 fn fill_to(state: &mut FeaturesState, target: usize) {
575 let batch = crate::MAX_NAMES_PER_BATCH;
576 let mut filled = 0;
577 while filled < target {
578 let take = (target - filled).min(batch);
579 let names: Vec<String> = (filled..filled + take).map(|i| format!("f{i}")).collect();
580 state.enable(names).unwrap();
581 filled += take;
582 }
583 assert_eq!(state.entries().len(), target);
584 }
585
586 #[test]
587 fn test_enable_max_count_enforced() {
588 let mut state = FeaturesState::new_for_test();
589 fill_to(&mut state, crate::MAX_FEATURE_COUNT);
590 assert_eq!(
591 state.enable(vec!["overflow".to_string()]),
592 Err(FeatureManagementError::MaxFeatureCountExceeded)
593 );
594 }
595
596 #[test]
597 fn test_enable_intra_batch_dup_at_boundary() {
598 // At `MAX_FEATURE_COUNT - 1`, enabling `["x", "x"]` must succeed:
599 // BTreeSet inserts a single new name, so the post-insert count is
600 // exactly `MAX_FEATURE_COUNT`. A naive occurrence count would
601 // wrongly reject this.
602 let mut state = FeaturesState::new_for_test();
603 fill_to(&mut state, crate::MAX_FEATURE_COUNT - 1);
604 state
605 .enable(vec!["x".to_string(), "x".to_string()])
606 .expect("intra-batch duplicate must not double-count against the cap");
607 assert_eq!(state.entries().len(), crate::MAX_FEATURE_COUNT);
608 }
609
610 #[test]
611 fn test_set_authority_works() {
612 let mut state = FeaturesState::new_for_test();
613 let new = Pubkey::new_from_array([7u8; 32]);
614 state.set_authority(new);
615 assert_eq!(state.get_authority(), &new);
616 }
617
618 #[test]
619 fn test_borsh_round_trip_empty() {
620 let state = FeaturesState::new_for_test();
621 let bytes = state.serialize().unwrap();
622 let back = FeaturesState::deserialize(&bytes).unwrap();
623 assert_eq!(state, back);
624 }
625
626 #[test]
627 fn test_borsh_round_trip_with_entries() {
628 let mut state = FeaturesState::new_for_test();
629 state
630 .enable(vec!["alpha".to_string(), "beta".to_string()])
631 .unwrap();
632 let bytes = state.serialize().unwrap();
633 let back = FeaturesState::deserialize(&bytes).unwrap();
634 assert_eq!(state, back);
635 }
636
637 /// Golden wire-format test. Pins the exact borsh byte layout so any
638 /// accidental field reorder, type swap, or new field added without a
639 /// schema bump trips here rather than silently mutating the on-chain
640 /// shape.
641 ///
642 /// Layout (borsh):
643 /// * `authority`: 32 bytes (Pubkey, raw)
644 /// * `pending_authority`: 1-byte tag (0 = None, 1 = Some) + 32 bytes when Some
645 /// * `entries`: `BTreeSet<String>` — 4-byte LE length, then each item as
646 /// 4-byte LE length + utf-8 bytes, in sorted order
647 #[test]
648 fn test_wire_format_golden_none_pending() {
649 let mut state = FeaturesState::new(Pubkey::new_from_array([1u8; 32]));
650 state.insert_for_test("a".to_string());
651 state.insert_for_test("bb".to_string());
652 let bytes = state.serialize().unwrap();
653
654 let mut expected = vec![0u8; 0];
655 expected.extend_from_slice(&[1u8; 32]); // authority
656 expected.push(0); // pending_authority = None tag
657 expected.extend_from_slice(&2u32.to_le_bytes()); // entries len
658 expected.extend_from_slice(&1u32.to_le_bytes()); // "a" len
659 expected.extend_from_slice(b"a");
660 expected.extend_from_slice(&2u32.to_le_bytes()); // "bb" len
661 expected.extend_from_slice(b"bb");
662 expected.extend_from_slice(&0u32.to_le_bytes()); // pending (scheduled) len = 0
663
664 assert_eq!(bytes, expected, "wire format drift detected");
665 let back = FeaturesState::deserialize(&bytes).unwrap();
666 assert_eq!(state, back, "golden bytes must round-trip");
667 }
668
669 #[test]
670 fn test_wire_format_golden_some_pending() {
671 let mut state = FeaturesState::new(Pubkey::new_from_array([1u8; 32]));
672 state.set_pending_authority(Some(Pubkey::new_from_array([2u8; 32])));
673 state.insert_for_test("a".to_string());
674 let bytes = state.serialize().unwrap();
675
676 let mut expected = vec![0u8; 0];
677 expected.extend_from_slice(&[1u8; 32]); // authority
678 expected.push(1); // pending_authority = Some tag
679 expected.extend_from_slice(&[2u8; 32]); // pending_authority payload
680 expected.extend_from_slice(&1u32.to_le_bytes()); // entries len
681 expected.extend_from_slice(&1u32.to_le_bytes()); // "a" len
682 expected.extend_from_slice(b"a");
683 expected.extend_from_slice(&0u32.to_le_bytes()); // pending (scheduled) len = 0
684
685 assert_eq!(bytes, expected, "wire format drift detected");
686 let back = FeaturesState::deserialize(&bytes).unwrap();
687 assert_eq!(state, back, "golden bytes must round-trip");
688 }
689
690 /// Golden wire-format test with a non-empty `pending` map. Pins the exact
691 /// borsh layout of a single scheduled request so a field reorder / type
692 /// swap inside `ScheduledRequest` (or a change to how `pending` is encoded)
693 /// trips here.
694 ///
695 /// Layout of the `pending` `BTreeMap<u64, ScheduledRequest>`:
696 /// * 4-byte LE entry count
697 /// * per entry: `request_id` (u64 LE), then `ScheduledRequest` borsh =
698 /// `names` `Vec<String>` (4-byte LE count + per-name 4-byte LE len + utf8)
699 /// followed by `fire_at_ms` (u64 LE)
700 #[test]
701 fn test_wire_format_golden_with_pending() {
702 let mut state = FeaturesState::new(Pubkey::new_from_array([1u8; 32]));
703 state
704 .schedule(9, vec!["feat_x".to_string()], 1_700_000_000_000)
705 .expect("schedule must succeed");
706 let bytes = state.serialize().unwrap();
707
708 let mut expected = vec![0u8; 0];
709 expected.extend_from_slice(&[1u8; 32]); // authority
710 expected.push(0); // pending_authority = None tag
711 expected.extend_from_slice(&0u32.to_le_bytes()); // entries len = 0
712 expected.extend_from_slice(&1u32.to_le_bytes()); // pending (scheduled) count = 1
713 expected.extend_from_slice(&9u64.to_le_bytes()); // request_id key
714 // ScheduledRequest.names: Vec<String>
715 expected.extend_from_slice(&1u32.to_le_bytes()); // names len = 1
716 expected.extend_from_slice(&6u32.to_le_bytes()); // "feat_x" len
717 expected.extend_from_slice(b"feat_x");
718 // ScheduledRequest.fire_at_ms: u64 LE
719 expected.extend_from_slice(&1_700_000_000_000u64.to_le_bytes());
720
721 assert_eq!(bytes, expected, "wire format drift detected");
722 let back = FeaturesState::deserialize(&bytes).unwrap();
723 assert_eq!(state, back, "golden bytes must round-trip");
724 }
725
726 /// Golden wire-format test pinning the FULL-shape byte layout that the
727 /// other `test_wire_format_golden_*` fixtures don't cover together:
728 /// `pending_authority = Some(..)` AND a non-empty `entries` set AND a
729 /// non-empty `pending` map, in one record. A field reorder, type swap, or
730 /// new field added without a schema bump trips this rather than silently
731 /// mutating the on-chain shape.
732 ///
733 /// Full borsh layout, in declaration order:
734 /// * `authority`: 32 raw bytes (Pubkey)
735 /// * `pending_authority`: 1-byte Option tag (1 = Some) + 32 bytes payload
736 /// * `entries`: `BTreeSet<String>` — 4-byte LE count, then each name as
737 /// 4-byte LE len + utf-8, in sorted order ("a" < "bb")
738 /// * `pending`: `BTreeMap<u64, ScheduledRequest>` — 4-byte LE count, then
739 /// per entry (ascending key): `request_id` (u64 LE) + `ScheduledRequest`
740 /// = `names` `Vec<String>` (4-byte LE count + per-name 4-byte LE len +
741 /// utf-8) + `fire_at_ms` (u64 LE)
742 #[test]
743 fn test_wire_format_golden_full_shape() {
744 let mut state = FeaturesState::new(Pubkey::new_from_array([7u8; 32]));
745 state.set_pending_authority(Some(Pubkey::new_from_array([8u8; 32])));
746 state.insert_for_test("a".to_string());
747 state.insert_for_test("bb".to_string());
748 // request_id 1 < 2, so BTreeMap iterates 1 then 2.
749 state
750 .schedule(1, vec!["x".to_string()], 1_000)
751 .expect("schedule id 1");
752 state
753 .schedule(2, vec!["y".to_string(), "z".to_string()], 2_000)
754 .expect("schedule id 2");
755 let bytes = state.serialize().unwrap();
756
757 let mut expected = vec![0u8; 0];
758 expected.extend_from_slice(&[7u8; 32]); // authority
759 expected.push(1); // pending_authority = Some tag
760 expected.extend_from_slice(&[8u8; 32]); // pending_authority payload
761 expected.extend_from_slice(&2u32.to_le_bytes()); // entries count = 2
762 expected.extend_from_slice(&1u32.to_le_bytes()); // "a" len
763 expected.extend_from_slice(b"a");
764 expected.extend_from_slice(&2u32.to_le_bytes()); // "bb" len
765 expected.extend_from_slice(b"bb");
766 expected.extend_from_slice(&2u32.to_le_bytes()); // pending count = 2
767 // pending[1]
768 expected.extend_from_slice(&1u64.to_le_bytes()); // request_id key
769 expected.extend_from_slice(&1u32.to_le_bytes()); // names count = 1
770 expected.extend_from_slice(&1u32.to_le_bytes()); // "x" len
771 expected.extend_from_slice(b"x");
772 expected.extend_from_slice(&1_000u64.to_le_bytes()); // fire_at_ms
773 // pending[2]
774 expected.extend_from_slice(&2u64.to_le_bytes()); // request_id key
775 expected.extend_from_slice(&2u32.to_le_bytes()); // names count = 2
776 expected.extend_from_slice(&1u32.to_le_bytes()); // "y" len
777 expected.extend_from_slice(b"y");
778 expected.extend_from_slice(&1u32.to_le_bytes()); // "z" len
779 expected.extend_from_slice(b"z");
780 expected.extend_from_slice(&2_000u64.to_le_bytes()); // fire_at_ms
781
782 assert_eq!(bytes, expected, "wire format drift detected");
783 let back = FeaturesState::deserialize(&bytes).unwrap();
784 assert_eq!(state, back, "golden bytes must round-trip");
785 }
786
787 #[test]
788 fn test_pending_authority_round_trip() {
789 let mut state = FeaturesState::new_for_test();
790 assert!(state.pending_authority().is_none());
791 let p = Pubkey::new_from_array([3u8; 32]);
792 state.set_pending_authority(Some(p));
793 assert_eq!(state.pending_authority(), Some(&p));
794 state.set_pending_authority(None);
795 assert!(state.pending_authority().is_none());
796 }
797
798 #[test]
799 fn test_propose_then_accept_transfers_authority() {
800 let mut state = FeaturesState::new_for_test();
801 let original = *state.get_authority();
802 let new = Pubkey::new_from_array([4u8; 32]);
803
804 state.propose_transfer(new).unwrap();
805 assert_eq!(state.pending_authority(), Some(&new));
806 assert_eq!(state.get_authority(), &original);
807
808 state.accept_transfer().unwrap();
809 assert_eq!(state.get_authority(), &new);
810 assert!(state.pending_authority().is_none());
811 }
812
813 #[test]
814 fn test_propose_to_self_rejected() {
815 let mut state = FeaturesState::new_for_test();
816 let current = *state.get_authority();
817 assert_eq!(
818 state.propose_transfer(current),
819 Err(FeatureManagementError::InvalidTransferTarget)
820 );
821 assert!(state.pending_authority().is_none());
822 }
823
824 #[test]
825 fn test_propose_with_pending_rejected() {
826 let mut state = FeaturesState::new_for_test();
827 state.propose_transfer(Pubkey::new_unique()).unwrap();
828 assert_eq!(
829 state.propose_transfer(Pubkey::new_unique()),
830 Err(FeatureManagementError::PendingTransferExists)
831 );
832 }
833
834 #[test]
835 fn test_accept_with_no_pending_rejected() {
836 let mut state = FeaturesState::new_for_test();
837 assert_eq!(
838 state.accept_transfer(),
839 Err(FeatureManagementError::NoPendingTransfer)
840 );
841 }
842
843 #[test]
844 fn test_cancel_clears_pending() {
845 let mut state = FeaturesState::new_for_test();
846 let original = *state.get_authority();
847 state.propose_transfer(Pubkey::new_unique()).unwrap();
848 state.cancel_transfer().unwrap();
849 assert!(state.pending_authority().is_none());
850 assert_eq!(state.get_authority(), &original);
851 }
852
853 #[test]
854 fn test_cancel_with_no_pending_rejected() {
855 let mut state = FeaturesState::new_for_test();
856 assert_eq!(
857 state.cancel_transfer(),
858 Err(FeatureManagementError::NoPendingTransfer)
859 );
860 }
861
862 #[test]
863 fn test_full_cycle_propose_cancel_propose_accept() {
864 // Operator cancels a proposal and replaces it with a different
865 // target; the new target accepts. Guards against a cancelled
866 // proposal leaking into the post-accept authority.
867 let mut state = FeaturesState::new_for_test();
868 let original = *state.get_authority();
869 let first = Pubkey::new_from_array([10u8; 32]);
870 let second = Pubkey::new_from_array([20u8; 32]);
871
872 state.propose_transfer(first).unwrap();
873 state.cancel_transfer().unwrap();
874 assert_eq!(state.get_authority(), &original);
875 assert!(state.pending_authority().is_none());
876
877 state.propose_transfer(second).unwrap();
878 state.accept_transfer().unwrap();
879 assert_eq!(state.get_authority(), &second);
880 assert!(state.pending_authority().is_none());
881 }
882
883 #[test]
884 fn test_accept_then_propose_works_with_new_authority() {
885 // After a transfer commits, the new authority can propose
886 // another transfer — the pending slot is back to None
887 // post-accept so no `PendingTransferExists` leakage.
888 let mut state = FeaturesState::new_for_test();
889 let mid = Pubkey::new_from_array([11u8; 32]);
890 let final_target = Pubkey::new_from_array([22u8; 32]);
891
892 state.propose_transfer(mid).unwrap();
893 state.accept_transfer().unwrap();
894 assert_eq!(state.get_authority(), &mid);
895
896 state.propose_transfer(final_target).unwrap();
897 assert_eq!(state.pending_authority(), Some(&final_target));
898 }
899
900 #[test]
901 fn test_propose_does_not_touch_authority() {
902 // A proposal in flight must not modify the active authority field
903 // — only Accept commits.
904 let mut state = FeaturesState::new_for_test();
905 let original = *state.get_authority();
906 state
907 .propose_transfer(Pubkey::new_from_array([33u8; 32]))
908 .unwrap();
909 assert_eq!(state.get_authority(), &original);
910 }
911
912 #[test]
913 fn test_single_step_clear_pending_blocks_stale_accept() {
914 // Models the processor's `UpdateAuthority` sequence:
915 // `set_authority(new)` + `set_pending_authority(None)`. After
916 // that sequence the previously-pending party cannot displace
917 // the just-set authority via `accept_transfer`. Guards the
918 // displacement vector flagged in PR3790 review.
919 let mut state = FeaturesState::new_for_test();
920 let pending = Pubkey::new_from_array([44u8; 32]);
921 let single_step_target = Pubkey::new_from_array([55u8; 32]);
922
923 state.propose_transfer(pending).unwrap();
924 assert_eq!(state.pending_authority(), Some(&pending));
925
926 // Operator picks the single-step path instead of accepting.
927 state.set_authority(single_step_target);
928 state.set_pending_authority(None);
929
930 // Stale pending party cannot displace the new authority.
931 assert_eq!(
932 state.accept_transfer(),
933 Err(FeatureManagementError::NoPendingTransfer)
934 );
935 assert_eq!(state.get_authority(), &single_step_target);
936 }
937
938 #[test]
939 fn test_enable_rejects_oversized_batch() {
940 let mut state = FeaturesState::new_for_test();
941 let names: Vec<String> = (0..=crate::MAX_NAMES_PER_BATCH)
942 .map(|i| format!("f{i}"))
943 .collect();
944 assert_eq!(
945 state.enable(names),
946 Err(FeatureManagementError::TooManyNames)
947 );
948 }
949
950 #[test]
951 fn test_enable_rejects_name_above_max_length() {
952 let mut state = FeaturesState::new_for_test();
953 let long_name = "a".repeat(crate::MAX_FEATURE_NAME_LENGTH + 1);
954 assert_eq!(
955 state.enable(vec![long_name]),
956 Err(FeatureManagementError::InvalidFeatureName)
957 );
958 }
959
960 #[test]
961 fn test_enable_accepts_name_at_exact_max_length() {
962 let mut state = FeaturesState::new_for_test();
963 let at_max = "a".repeat(crate::MAX_FEATURE_NAME_LENGTH);
964 state
965 .enable(vec![at_max])
966 .expect("name at exact MAX_FEATURE_NAME_LENGTH must be accepted");
967 assert_eq!(state.entries().len(), 1);
968 }
969
970 #[test]
971 fn test_enable_rejects_one_long_name_in_otherwise_valid_batch() {
972 // The check is per-name, not per-batch: a single oversized name
973 // among well-formed ones must still trip the rejection.
974 let mut state = FeaturesState::new_for_test();
975 let names = vec![
976 "shortish".to_string(),
977 "a".repeat(crate::MAX_FEATURE_NAME_LENGTH + 1),
978 "also_shortish".to_string(),
979 ];
980 assert_eq!(
981 state.enable(names),
982 Err(FeatureManagementError::InvalidFeatureName)
983 );
984 assert!(
985 state.entries().is_empty(),
986 "rejected enable must leave state untouched"
987 );
988 }
989
990 #[test]
991 fn test_enable_rejects_empty_name() {
992 let mut state = FeaturesState::new_for_test();
993 assert_eq!(
994 state.enable(vec![String::new()]),
995 Err(FeatureManagementError::InvalidFeatureName)
996 );
997 }
998
999 #[test]
1000 fn test_enable_rejects_invalid_charset() {
1001 // `validate_feature_name` allows alphanumeric + `_` + `-` only.
1002 let mut state = FeaturesState::new_for_test();
1003 assert_eq!(
1004 state.enable(vec!["has space".to_string()]),
1005 Err(FeatureManagementError::InvalidFeatureName)
1006 );
1007 assert_eq!(
1008 state.enable(vec!["dot.name".to_string()]),
1009 Err(FeatureManagementError::InvalidFeatureName)
1010 );
1011 }
1012
1013 #[test]
1014 fn schedule_rejects_duplicate_request_id() {
1015 let mut state = FeaturesState::new_for_test();
1016 state
1017 .schedule(7, vec!["feat_a".to_string()], 1_000)
1018 .expect("first schedule with id 7 must succeed");
1019 assert_eq!(
1020 state.schedule(7, vec!["feat_b".to_string()], 2_000),
1021 Err(FeatureManagementError::RequestAlreadyExists)
1022 );
1023 // The original entry is untouched by the rejected duplicate.
1024 assert_eq!(state.pending().len(), 1);
1025 let entry = state.pending().get(&7).expect("entry must exist");
1026 assert_eq!(entry.names, vec!["feat_a".to_string()]);
1027 assert_eq!(entry.fire_at_ms, 1_000);
1028 }
1029
1030 #[test]
1031 fn schedule_duplicate_id_at_capacity_reports_duplicate_not_full() {
1032 // Fill the pending map to MAX_PENDING_REQUESTS, then resubmit an id that
1033 // already exists. The duplicate-id check runs before the capacity check,
1034 // so the caller gets RequestAlreadyExists (the precise error) rather than
1035 // TooManyPendingRequests masking the collision.
1036 let mut state = FeaturesState::new_for_test();
1037 for id in 0..crate::MAX_PENDING_REQUESTS as u64 {
1038 state
1039 .schedule(id, vec!["feat".to_string()], 1_000)
1040 .expect("fill under cap");
1041 }
1042 assert_eq!(state.pending().len(), crate::MAX_PENDING_REQUESTS);
1043 assert_eq!(
1044 state.schedule(0, vec!["feat".to_string()], 2_000),
1045 Err(FeatureManagementError::RequestAlreadyExists),
1046 "a duplicate id at capacity is RequestAlreadyExists, not TooManyPendingRequests"
1047 );
1048 // A new id at capacity still correctly reports the capacity error.
1049 assert_eq!(
1050 state.schedule(
1051 crate::MAX_PENDING_REQUESTS as u64,
1052 vec!["feat".to_string()],
1053 2_000
1054 ),
1055 Err(FeatureManagementError::TooManyPendingRequests)
1056 );
1057 }
1058
1059 #[test]
1060 fn schedule_rejects_oversized_pending_state() {
1061 // A batch of MAX_NAMES_PER_BATCH names each at MAX_FEATURE_NAME_LENGTH
1062 // serializes to ≈51 KB, so two of them blow past MAX_FEATURES_STATE_SIZE
1063 // (100 KB). This pins the real binding cap: the byte budget, not the
1064 // MAX_PENDING_REQUESTS count.
1065 let max_batch =
1066 || vec!["a".repeat(crate::MAX_FEATURE_NAME_LENGTH); crate::MAX_NAMES_PER_BATCH];
1067
1068 let mut state = FeaturesState::new_for_test();
1069 // One near-max request fits within the byte budget.
1070 state
1071 .schedule(1, max_batch(), 1_000)
1072 .expect("one max-size request must fit");
1073 assert!(
1074 state.serialize().expect("serialize").len() <= crate::MAX_FEATURES_STATE_SIZE,
1075 "a single max-size request must stay within MAX_FEATURES_STATE_SIZE"
1076 );
1077
1078 // A second pushes the serialized state past the cap and is rejected
1079 // with the explicit error — not a bare SerializationError at save time.
1080 assert_eq!(
1081 state.schedule(2, max_batch(), 2_000),
1082 Err(FeatureManagementError::PendingStateTooLarge)
1083 );
1084 // The rejected request is rolled back; only the first entry remains.
1085 assert_eq!(state.pending().len(), 1);
1086 assert!(state.pending().get(&2).is_none());
1087 // The byte budget tripped well before the count cap (100).
1088 assert!(state.pending().len() < crate::MAX_PENDING_REQUESTS);
1089 }
1090
1091 #[test]
1092 fn fire_scheduled_drains_pending_and_activates() {
1093 let mut state = FeaturesState::new_for_test();
1094 state
1095 .schedule(5, vec!["feat_a".to_string(), "feat_b".to_string()], 1_000)
1096 .expect("schedule must succeed");
1097 assert_eq!(state.pending().len(), 1);
1098
1099 state.fire_scheduled(5).expect("fire must succeed");
1100
1101 // Names are now active and the pending entry is drained.
1102 assert!(state.is_active("feat_a"));
1103 assert!(state.is_active("feat_b"));
1104 assert!(state.pending().get(&5).is_none());
1105 assert!(state.pending().is_empty());
1106 }
1107
1108 #[test]
1109 fn fire_scheduled_unknown_request_id() {
1110 let mut state = FeaturesState::new_for_test();
1111 assert_eq!(
1112 state.fire_scheduled(99),
1113 Err(FeatureManagementError::RequestNotFound)
1114 );
1115 }
1116
1117 #[test]
1118 fn test_enable_is_permissive_on_unknown_names() {
1119 // Documents the design: `state.enable` does not check
1120 // `KNOWN_FEATURES`. A name unknown to a binary may be stored on
1121 // chain; the runtime's `can_process_block` is what halts the node
1122 // at the activation block if the name is also active. The on-chain
1123 // program lacks a deterministic view of every binary's
1124 // `KNOWN_FEATURES`, so the check cannot live here.
1125 let mut state = FeaturesState::new_for_test();
1126 state
1127 .enable(vec!["totally_fabricated_name_not_in_any_binary".to_string()])
1128 .expect("enable must be permissive on KNOWN_FEATURES membership");
1129 }
1130
1131 /// The envelope seam is byte-identical to a bare borsh of the frozen V1
1132 /// layout — load-bearing "existing accounts read unchanged" guarantee. If
1133 /// `FeaturesStateV1`'s field order/types ever drift from `FeaturesState`,
1134 /// this trips (alongside the golden tests).
1135 #[test]
1136 fn v1_serialize_is_byte_identical_to_bare_borsh() {
1137 let mut state = FeaturesState::new(Pubkey::new_from_array([1u8; 32]));
1138 state.set_pending_authority(Some(Pubkey::new_from_array([2u8; 32])));
1139 state.insert_for_test("a".to_string());
1140 state
1141 .schedule(9, vec!["feat_x".to_string()], 1_700_000_000_000)
1142 .expect("schedule must succeed");
1143
1144 let via_envelope = state.serialize().unwrap();
1145 let bare_v1 = borsh::to_vec(&FeaturesStateV1::from(&state)).unwrap();
1146 assert_eq!(
1147 via_envelope, bare_v1,
1148 "envelope V1 output must equal a bare borsh of FeaturesStateV1"
1149 );
1150 }
1151
1152 /// `FeaturesStateVersioned` round-trips through its own serialize /
1153 /// tolerant-deserialize, and the decoded variant is V1.
1154 #[test]
1155 fn versioned_envelope_round_trips_v1() {
1156 let mut state = FeaturesState::new(Pubkey::new_from_array([5u8; 32]));
1157 state.insert_for_test("alpha".to_string());
1158
1159 let versioned = FeaturesStateVersioned::from(&state);
1160 let bytes = versioned.serialize().unwrap();
1161 let back = FeaturesStateVersioned::deserialize(&bytes).unwrap();
1162
1163 assert_eq!(versioned, back);
1164 match &back {
1165 FeaturesStateVersioned::V1(_) => {}
1166 }
1167 // And the envelope decodes back to the original in-memory state.
1168 assert_eq!(FeaturesState::from(back), state);
1169 }
1170
1171 /// The public `FeaturesState::serialize` / `deserialize` seam round-trips
1172 /// through the envelope.
1173 #[test]
1174 fn features_state_seam_round_trips_through_envelope() {
1175 let mut state = FeaturesState::new(Pubkey::new_from_array([7u8; 32]));
1176 state.set_pending_authority(Some(Pubkey::new_from_array([8u8; 32])));
1177 state
1178 .enable(vec!["one".to_string(), "two".to_string()])
1179 .unwrap();
1180 state
1181 .schedule(3, vec!["later".to_string()], 1_800_000_000_000)
1182 .unwrap();
1183
1184 let bytes = state.serialize().unwrap();
1185 let back = FeaturesState::deserialize(&bytes).unwrap();
1186 assert_eq!(state, back);
1187 }
1188
1189 mod proptests {
1190 use alloc::collections::{BTreeMap, BTreeSet};
1191
1192 use proptest::{prelude::*, test_runner::TestCaseError};
1193
1194 use super::*;
1195
1196 prop_compose! {
1197 /// A name that always passes `validate_feature_name`: 1..=16
1198 /// alphanumeric / `_` / `-` chars (capped well under
1199 /// `MAX_FEATURE_NAME_LENGTH` to keep generation cheap).
1200 fn valid_name()(s in "[a-zA-Z0-9_-]{1,16}") -> String {
1201 s
1202 }
1203 }
1204
1205 prop_compose! {
1206 /// A small batch (0..=8 names) of valid feature names.
1207 fn valid_batch()(batch in prop::collection::vec(valid_name(), 0..=8)) -> Vec<String> {
1208 batch
1209 }
1210 }
1211
1212 prop_compose! {
1213 /// An arbitrary [`FeaturesState`] built only from valid inputs:
1214 /// random authority bytes, an optional pending authority, a set of
1215 /// valid `entries`, and a `pending` map of `ScheduledRequest`.
1216 ///
1217 /// Bounds are deliberately tight (≤8 distinct entry names, ≤8
1218 /// pending ids each carrying ≤4 names of ≤16 chars) so the
1219 /// serialized form stays far below `MAX_FEATURES_STATE_SIZE`
1220 /// (100 KB) and generation stays cheap. `entries` / `pending` are
1221 /// written through the `pub(crate)` fields directly rather than via
1222 /// `enable` / `schedule`, so the round-trip exercises arbitrary
1223 /// in-memory shapes, not just validator-reachable ones.
1224 fn arbitrary_state()(
1225 authority in any::<[u8; 32]>(),
1226 pending_authority in proptest::option::of(any::<[u8; 32]>()),
1227 entries in prop::collection::btree_set(valid_name(), 0..=8),
1228 pending in prop::collection::btree_map(
1229 any::<u64>(),
1230 (prop::collection::vec(valid_name(), 0..=4), any::<u64>()),
1231 0..=8,
1232 ),
1233 ) -> FeaturesState {
1234 let mut state = FeaturesState::new(Pubkey::new_from_array(authority));
1235 state.set_pending_authority(pending_authority.map(Pubkey::new_from_array));
1236 state.entries = entries;
1237 state.pending = pending
1238 .into_iter()
1239 .map(|(id, (names, fire_at_ms))| (id, ScheduledRequest { names, fire_at_ms }))
1240 .collect::<BTreeMap<u64, ScheduledRequest>>();
1241 state
1242 }
1243 }
1244
1245 proptest! {
1246 /// Borsh round-trips any `FeaturesState`: deserializing its own
1247 /// serialization yields an equal value across the full shape —
1248 /// random authority, optional pending authority, arbitrary
1249 /// `entries`, and an arbitrary `pending` map. Complements the
1250 /// fixed-input `test_wire_format_golden_*` goldens (which pin exact
1251 /// bytes for hand-picked shapes) by exercising the encode/decode
1252 /// pair over a wide input space.
1253 #[test]
1254 fn borsh_round_trips_arbitrary_state(state in arbitrary_state()) {
1255 // `map_err(TestCaseError::fail)?` rather than `.expect()` so a
1256 // failure preserves proptest's input shrinking instead of
1257 // aborting the whole run with a panic.
1258 let bytes = state
1259 .serialize()
1260 .map_err(|e| TestCaseError::fail(e.to_string()))?;
1261 // Stay well within the on-chain byte budget for the bounds we
1262 // generate; a regression that bloats the encoding trips here.
1263 prop_assert!(
1264 bytes.len() <= crate::MAX_FEATURES_STATE_SIZE,
1265 "serialized {} bytes exceeds MAX_FEATURES_STATE_SIZE",
1266 bytes.len()
1267 );
1268 let back = FeaturesState::deserialize(&bytes)
1269 .map_err(|e| TestCaseError::fail(e.to_string()))?;
1270 prop_assert_eq!(state, back);
1271 }
1272
1273 /// enable is idempotent, append-only (monotone), and the final set
1274 /// is exactly the union of every enabled name; `is_active` tracks
1275 /// membership.
1276 #[test]
1277 fn enable_is_monotone_and_unions(batches in prop::collection::vec(valid_batch(), 0..=10)) {
1278 let mut state = FeaturesState::new_for_test();
1279 let mut expected: BTreeSet<String> = BTreeSet::new();
1280 let mut prev_len = 0usize;
1281 for batch in &batches {
1282 let r = state.enable(batch.clone());
1283 prop_assert!(r.is_ok(), "valid batch must enable: {r:?}");
1284 expected.extend(batch.iter().cloned());
1285 // Append-only: the set never shrinks.
1286 prop_assert!(state.entries().len() >= prev_len);
1287 prev_len = state.entries().len();
1288 }
1289 prop_assert_eq!(state.entries(), &expected);
1290 for name in &expected {
1291 prop_assert!(state.is_active(name));
1292 }
1293 // Re-enabling everything already present is a no-op.
1294 let all: Vec<String> = expected.iter().cloned().collect();
1295 for chunk in all.chunks(crate::MAX_NAMES_PER_BATCH) {
1296 let r = state.enable(chunk.to_vec());
1297 prop_assert!(r.is_ok(), "re-enable is a no-op: {r:?}");
1298 }
1299 prop_assert_eq!(state.entries().len(), expected.len());
1300 }
1301
1302 /// `is_active(name)` agrees with an independent oracle (the set of
1303 /// names actually enabled) for any probe name. Checking against the
1304 /// input batch rather than `entries()` keeps this from collapsing
1305 /// into `entries.contains(x) == entries.contains(x)` — it would
1306 /// catch an `is_active` that drifted from the backing set.
1307 #[test]
1308 fn is_active_equals_membership(
1309 batch in valid_batch(),
1310 probe in valid_name(),
1311 ) {
1312 let oracle: BTreeSet<String> = batch.iter().cloned().collect();
1313 let mut state = FeaturesState::new_for_test();
1314 let r = state.enable(batch);
1315 prop_assert!(r.is_ok(), "valid batch must enable: {r:?}");
1316 prop_assert_eq!(state.is_active(&probe), oracle.contains(&probe));
1317 }
1318
1319 /// A batch larger than `MAX_NAMES_PER_BATCH` is rejected with
1320 /// `TooManyNames`, leaving the set untouched.
1321 #[test]
1322 fn enable_rejects_oversized_batch(
1323 extra in 1usize..=20,
1324 ) {
1325 let mut state = FeaturesState::new_for_test();
1326 let names: Vec<String> = (0..crate::MAX_NAMES_PER_BATCH + extra)
1327 .map(|i| format!("f{i}"))
1328 .collect();
1329 prop_assert_eq!(state.enable(names), Err(FeatureManagementError::TooManyNames));
1330 prop_assert!(state.entries().is_empty());
1331 }
1332
1333 /// A name that fails `validate_feature_name` (illegal char or over
1334 /// length) is rejected with `InvalidFeatureName`.
1335 #[test]
1336 fn enable_rejects_invalid_name(
1337 bad in prop_oneof![
1338 "[a-z]{0,4}[ .!@/][a-z]{0,4}",
1339 Just("a".repeat(crate::MAX_FEATURE_NAME_LENGTH + 1)),
1340 ],
1341 ) {
1342 prop_assume!(!crate::validate_feature_name(&bad));
1343 let mut state = FeaturesState::new_for_test();
1344 prop_assert_eq!(
1345 state.enable(vec![bad]),
1346 Err(FeatureManagementError::InvalidFeatureName)
1347 );
1348 prop_assert!(state.entries().is_empty());
1349 }
1350
1351 /// schedule records each distinct request keyed by id with `names`
1352 /// and `fire_at_ms` preserved; a duplicate id is rejected; `cancel`
1353 /// returns and removes the entry; cancelling an unknown id is
1354 /// `RequestNotFound`; draining everything empties `pending`.
1355 #[test]
1356 fn schedule_cancel_round_trip(
1357 // Bounded well under `MAX_PENDING_REQUESTS` (100) so distinct
1358 // ids can never overflow `pending` and trip `TooManyPendingRequests`.
1359 reqs in prop::collection::vec(
1360 (any::<u64>(), valid_batch(), any::<u64>()),
1361 0..=50,
1362 ),
1363 ) {
1364 let mut state = FeaturesState::new_for_test();
1365 let mut recorded: Vec<(u64, Vec<String>, u64)> = Vec::new();
1366 for (id, names, fire_at_ms) in reqs {
1367 if recorded.iter().any(|(rid, ..)| *rid == id) {
1368 // Duplicate id: rejected, original untouched.
1369 prop_assert_eq!(
1370 state.schedule(id, names.clone(), fire_at_ms),
1371 Err(FeatureManagementError::RequestAlreadyExists)
1372 );
1373 } else {
1374 let r = state.schedule(id, names.clone(), fire_at_ms);
1375 prop_assert!(r.is_ok(), "distinct id must schedule: {r:?}");
1376 let entry = state.pending().get(&id);
1377 prop_assert!(entry.is_some(), "entry recorded");
1378 let entry = entry.unwrap();
1379 prop_assert_eq!(&entry.names, &names);
1380 prop_assert_eq!(entry.fire_at_ms, fire_at_ms);
1381 recorded.push((id, names, fire_at_ms));
1382 }
1383 }
1384 prop_assert_eq!(state.pending().len(), recorded.len());
1385
1386 // Cancelling an id never scheduled is RequestNotFound.
1387 let mut absent = 0u64;
1388 while recorded.iter().any(|(rid, ..)| *rid == absent) {
1389 absent = absent.wrapping_add(1);
1390 }
1391 prop_assert_eq!(
1392 state.cancel(absent),
1393 Err(FeatureManagementError::RequestNotFound)
1394 );
1395
1396 // Cancel returns the entry and removes it; draining empties.
1397 for (id, names, fire_at_ms) in &recorded {
1398 let got = state.cancel(*id);
1399 prop_assert!(got.is_ok(), "recorded id must cancel: {got:?}");
1400 let got = got.unwrap();
1401 prop_assert_eq!(&got.names, names);
1402 prop_assert_eq!(got.fire_at_ms, *fire_at_ms);
1403 prop_assert_eq!(
1404 state.cancel(*id),
1405 Err(FeatureManagementError::RequestNotFound)
1406 );
1407 }
1408 prop_assert!(state.pending().is_empty());
1409 }
1410
1411 /// Scheduling `MAX_PENDING_REQUESTS` distinct ids fills `pending`;
1412 /// one more distinct id is rejected with `TooManyPendingRequests`
1413 /// and leaves the set at the cap.
1414 #[test]
1415 fn schedule_rejects_over_capacity(
1416 fire_at_ms in any::<u64>(),
1417 ) {
1418 let mut state = FeaturesState::new_for_test();
1419 for id in 0..crate::MAX_PENDING_REQUESTS as u64 {
1420 let r = state.schedule(id, vec!["feat".to_string()], fire_at_ms);
1421 prop_assert!(r.is_ok(), "id {id} under cap must schedule: {r:?}");
1422 }
1423 prop_assert_eq!(state.pending().len(), crate::MAX_PENDING_REQUESTS);
1424 // One past the cap, with a distinct id, is rejected.
1425 prop_assert_eq!(
1426 state.schedule(
1427 crate::MAX_PENDING_REQUESTS as u64,
1428 vec!["feat".to_string()],
1429 fire_at_ms,
1430 ),
1431 Err(FeatureManagementError::TooManyPendingRequests)
1432 );
1433 prop_assert_eq!(state.pending().len(), crate::MAX_PENDING_REQUESTS);
1434 }
1435
1436 /// `schedule` runs the same per-batch / per-name validation as
1437 /// `enable`: an oversized batch is `TooManyNames` and an invalid
1438 /// name is `InvalidFeatureName`, in both cases leaving `pending`
1439 /// untouched. Symmetric with `enable_rejects_oversized_batch` /
1440 /// `enable_rejects_invalid_name` so a regression in either gate is
1441 /// caught on the schedule path too.
1442 #[test]
1443 fn schedule_rejects_invalid_input(
1444 extra in 1usize..=20,
1445 bad in prop_oneof![
1446 "[a-z]{0,4}[ .!@/][a-z]{0,4}",
1447 Just("a".repeat(crate::MAX_FEATURE_NAME_LENGTH + 1)),
1448 ],
1449 ) {
1450 prop_assume!(!crate::validate_feature_name(&bad));
1451
1452 let mut state = FeaturesState::new_for_test();
1453 let oversized: Vec<String> = (0..crate::MAX_NAMES_PER_BATCH + extra)
1454 .map(|i| format!("f{i}"))
1455 .collect();
1456 prop_assert_eq!(
1457 state.schedule(1, oversized, 1_000),
1458 Err(FeatureManagementError::TooManyNames)
1459 );
1460 prop_assert_eq!(
1461 state.schedule(2, vec![bad], 1_000),
1462 Err(FeatureManagementError::InvalidFeatureName)
1463 );
1464 // Neither rejected request left a pending entry.
1465 prop_assert!(state.pending().is_empty());
1466 }
1467 }
1468 }
1469}