Skip to main content

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}