Skip to main content

rialo_feature_management_interface/
instruction.rs

1// Copyright (c) Subzero Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Instruction types for the Feature Management Program
5
6extern crate alloc;
7use alloc::{string::String, vec::Vec};
8
9use borsh::{BorshDeserialize, BorshSerialize};
10use rialo_s_pubkey::Pubkey;
11
12/// Instructions supported by the Feature Management Program
13///
14/// **Wire-stable from this commit forward.** Borsh assigns each variant a
15/// discriminant equal to its declaration index (the first byte on the wire),
16/// so reordering variants or inserting one in the middle silently shifts the
17/// discriminants of every following variant and breaks already-deployed
18/// clients. From now on, add new variants only at the tail with the next
19/// unused discriminant. The committed wire discriminants are pinned by the
20/// `discriminants_are_stable` test below.
21#[derive(Clone, Debug, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
22pub enum FeatureManagementInstruction {
23    /// Enable one or more features by name.
24    ///
25    /// Idempotent: re-submitting an existing name is a no-op. Activation is
26    /// presence-based — a feature is active iff its name is in
27    /// `FeaturesState.entries`.
28    ///
29    /// Per-batch cap: `names.len()` MUST be `<= MAX_NAMES_PER_BATCH` and each
30    /// name MUST satisfy `validate_feature_name` (which enforces
31    /// `<= MAX_FEATURE_NAME_LENGTH` and the allowed character set). The
32    /// `MAX_NAMES_PER_BATCH × MAX_FEATURE_NAME_LENGTH` payload is sized to
33    /// fit inside the ~64 KB transaction limit alongside headers and
34    /// signatures.
35    ///
36    /// Accounts expected:
37    /// 0. `[writable]` Storage account (PDA)
38    /// 1. `[signer]` The authority account
39    Enable {
40        /// One or more feature names to add to the active set. See the
41        /// per-batch / per-name caps above.
42        names: Vec<String>,
43    },
44
45    /// Schedule one or more features to be enabled at a future wall-clock
46    /// time, rather than immediately.
47    ///
48    /// The caller supplies `request_id`: it is the subscription nonce and the
49    /// `Cancel` handle. Because the subscription data account is a PDA derived
50    /// from the authority + `request_id`, the client must know the id ahead of
51    /// time to derive (and pass in) that account — so the id is chosen by the
52    /// caller rather than allocated on-chain. The program rejects a
53    /// `request_id` that already has a pending entry (with
54    /// `RequestAlreadyExists`).
55    ///
56    /// Records a pending request in `FeaturesState.pending` keyed by
57    /// `request_id`, and registers a one-shot subscription (via the subscriber
58    /// program) with a `fire_at_ms..u64::MAX` `timestamp_range` predicate — it
59    /// fires on the first commit whose clock is at or after `fire_at_ms` (the
60    /// Rialo clock advances in coarse subdag steps, so a narrow window could
61    /// never match). When it fires, the subscription invokes this program's own
62    /// [`FeatureManagementInstruction::FireScheduledEnable`] for `request_id`,
63    /// signed by the authority that scheduled it; that handler activates the
64    /// names recorded in `pending[request_id]` and removes the pending entry,
65    /// and the one-shot subscription then self-destroys.
66    ///
67    /// Same per-batch / per-name caps as `Enable` (`MAX_NAMES_PER_BATCH` +
68    /// `validate_feature_name`), except `MAX_FEATURE_COUNT` is enforced at fire
69    /// time (in `FireScheduledEnable`), not at schedule time. `fire_at_ms` must
70    /// be in the future (rejected with `ScheduleInPast`) and within
71    /// `MAX_SCHEDULE_HORIZON_MS` of the current block time (rejected with
72    /// `ScheduleTooFarOut`). The pending set is bounded by `MAX_PENDING_REQUESTS`
73    /// (count) and by `MAX_FEATURES_STATE_SIZE` (bytes) — the byte cap is the
74    /// binding one for non-trivial batches and is rejected explicitly with
75    /// `PendingStateTooLarge`.
76    ///
77    /// **Stable across authority transfers.** The subscription is created
78    /// under, and fires signed by, a program-derived scheduler authority
79    /// (`get_scheduler_authority_address`) — not the live human authority.
80    /// The subscription data account is a PDA of `(scheduler_authority,
81    /// request_id)`, so an `UpdateAuthority` / two-step accept between
82    /// schedule and cancel/fire does not orphan the pending entry: the
83    /// program can still derive the same subscription account, and the
84    /// matcher's fired tx still satisfies `FireScheduledEnable`'s authority
85    /// check (signer == scheduler PDA, independent of the human authority).
86    ///
87    /// Accounts expected:
88    /// 0. `[writable]` Storage account (PDA)
89    /// 1. `[writable, signer]` The authority account (pays rent for the
90    ///    subscription data account; the program top-ups the scheduler PDA
91    ///    just-in-time from the authority before the inner subscribe)
92    /// 2. `[writable]` Subscription data account (PDA from scheduler authority
93    ///    + request_id)
94    /// 3. `[]` Subscriber program
95    /// 4. `[]` System program
96    /// 5. `[writable]` Scheduler authority PDA
97    ///    (`get_scheduler_authority_address`) — funded by the authority and
98    ///    used as the subscription's subscriber/signer
99    ScheduleEnable {
100        /// Feature names to enable when the schedule fires. See the
101        /// per-batch / per-name caps above.
102        names: Vec<String>,
103        /// Wall-clock time (ms since the Unix epoch) at which the features
104        /// activate.
105        fire_at_ms: u64,
106        /// Caller-chosen id for this schedule: the subscription nonce and the
107        /// `Cancel` handle. Must not already have a pending entry.
108        request_id: u64,
109    },
110
111    /// Program-internal: fire a previously-scheduled
112    /// [`FeatureManagementInstruction::ScheduleEnable`].
113    ///
114    /// Not meant to be submitted by clients directly: it is registered as the
115    /// handler instruction of the one-shot subscription created by
116    /// `ScheduleEnable`, and is invoked when that subscription's
117    /// `timestamp_range` predicate fires. When it fires it activates the names
118    /// recorded in `pending[request_id]` and removes that pending entry (so a
119    /// fired schedule no longer lingers in `FeaturesState.pending`). Signed by
120    /// the scheduling authority.
121    ///
122    /// Rejected with `RequestNotFound` if no pending request has that id.
123    ///
124    /// Accounts expected:
125    /// 0. `[writable]` Storage account (PDA)
126    /// 1. `[signer]` The authority account
127    FireScheduledEnable {
128        /// Id of the pending scheduled request to activate and drain. Matches
129        /// the `request_id` recorded by `ScheduleEnable`.
130        request_id: u64,
131    },
132
133    /// Cancel a previously-scheduled
134    /// [`FeatureManagementInstruction::ScheduleEnable`] by its `request_id`.
135    ///
136    /// Removes the pending entry from `FeaturesState.pending` and destroys the
137    /// one-shot subscription so it never fires. Rejected with `RequestNotFound`
138    /// if no pending request has that id. Signed by the authority.
139    ///
140    /// A schedule that has already fired is gone from `pending` (the one-shot
141    /// self-destructs), so cancelling it returns `RequestNotFound` — activation
142    /// is append-only and cannot be undone.
143    ///
144    /// Accounts expected:
145    /// 0. `[writable]` Storage account (PDA)
146    /// 1. `[signer]` The authority account
147    /// 2. `[writable]` Subscription data account (PDA from scheduler authority
148    ///    + request_id)
149    /// 3. `[]` Subscriber program
150    /// 4. `[writable]` Scheduler authority PDA — receives the destroyed
151    ///    subscription's kelvins back via the inner unsubscribe
152    Cancel {
153        /// Id of the pending scheduled request to cancel.
154        request_id: u64,
155    },
156
157    /// Update the authority. Single-step path.
158    ///
159    /// This instruction requires a valid signature from the current authority.
160    ///
161    /// Accounts expected:
162    /// 0. `[writable]` Storage account (PDA)
163    /// 1. `[signer]` The current authority account
164    UpdateAuthority {
165        /// The new authority that will control the feature management system.
166        new_authority: Pubkey,
167    },
168
169    /// Step 1 of the two-step authority handshake: propose a transfer.
170    ///
171    /// Sets `pending_authority = Some(new_authority)`. Rejected with
172    /// `PendingTransferExists` if a previous proposal is still outstanding;
173    /// rejected with `InvalidTransferTarget` if `new_authority` equals the
174    /// current authority.
175    ///
176    /// Accounts expected:
177    /// 0. `[writable]` Storage account (PDA)
178    /// 1. `[signer]` The current authority
179    ProposeAuthorityTransfer {
180        /// Pubkey that will become the next authority once it signs an
181        /// `AcceptAuthorityTransfer` against this pending value.
182        new_authority: Pubkey,
183    },
184
185    /// Step 2 of the two-step authority handshake: commit a previously
186    /// proposed transfer.
187    ///
188    /// Requires the **pending** authority's signature. On success the
189    /// authority field moves to the pending value and `pending_authority`
190    /// clears. Rejected with `NoPendingTransfer` if nothing is pending,
191    /// `Unauthorized` if the signer is not the pending authority.
192    ///
193    /// Accounts expected:
194    /// 0. `[writable]` Storage account (PDA)
195    /// 1. `[signer]` The pending authority
196    AcceptAuthorityTransfer,
197
198    /// Cancel a previously proposed authority transfer.
199    ///
200    /// Requires the **current** authority's signature. Clears
201    /// `pending_authority`. Rejected with `NoPendingTransfer` if nothing
202    /// is pending.
203    ///
204    /// Accounts expected:
205    /// 0. `[writable]` Storage account (PDA)
206    /// 1. `[signer]` The current authority
207    CancelAuthorityTransfer,
208}
209
210#[cfg(not(target_os = "solana"))]
211impl FeatureManagementInstruction {
212    /// Serialize instruction data
213    pub fn serialize(&self) -> Result<Vec<u8>, borsh::io::Error> {
214        borsh::to_vec(self)
215    }
216
217    /// Deserialize instruction data
218    pub fn deserialize(data: &[u8]) -> Result<Self, borsh::io::Error> {
219        borsh::from_slice(data)
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use alloc::vec;
226
227    use super::*;
228
229    /// One representative value of every variant, paired with its committed
230    /// borsh discriminant (the first byte on the wire).
231    ///
232    /// **These are the committed wire discriminants — append-only.** Borsh
233    /// numbers variants by declaration order starting at 0; changing the order
234    /// or inserting a variant mid-enum shifts every following discriminant and
235    /// breaks deployed clients. Add new variants at the tail and extend this
236    /// list with the next unused discriminant — never edit an existing entry.
237    fn representatives() -> Vec<(u8, FeatureManagementInstruction)> {
238        let pubkey = Pubkey::new_from_array([1u8; 32]);
239        vec![
240            (
241                0,
242                FeatureManagementInstruction::Enable { names: Vec::new() },
243            ),
244            (
245                1,
246                FeatureManagementInstruction::ScheduleEnable {
247                    names: Vec::new(),
248                    fire_at_ms: 0,
249                    request_id: 0,
250                },
251            ),
252            (
253                2,
254                FeatureManagementInstruction::FireScheduledEnable { request_id: 0 },
255            ),
256            (3, FeatureManagementInstruction::Cancel { request_id: 0 }),
257            (
258                4,
259                FeatureManagementInstruction::UpdateAuthority {
260                    new_authority: pubkey,
261                },
262            ),
263            (
264                5,
265                FeatureManagementInstruction::ProposeAuthorityTransfer {
266                    new_authority: pubkey,
267                },
268            ),
269            (6, FeatureManagementInstruction::AcceptAuthorityTransfer),
270            (7, FeatureManagementInstruction::CancelAuthorityTransfer),
271        ]
272    }
273
274    /// Expected wire discriminant for each variant.
275    ///
276    /// EXHAUSTIVE match — appending a variant to the enum is a **compile error
277    /// here** until the author assigns its discriminant. That compile error is
278    /// the prompt to also add a `representatives()` entry and bump
279    /// `PINNED_VARIANT_COUNT`; once the count is bumped, the count/contiguity
280    /// guard in `test_discriminants_are_stable` fails until a matching
281    /// representative exists, so the new variant's wire byte actually gets
282    /// pinned. (`PINNED_VARIANT_COUNT` is hand-maintained — this is a strong,
283    /// hard-to-miss nudge via the compile error, not a full compile-time
284    /// lockstep. An exhaustive match *over `representatives()`* would not help:
285    /// it only sees the variants already listed.)
286    fn discriminant_of(instruction: &FeatureManagementInstruction) -> u8 {
287        match instruction {
288            FeatureManagementInstruction::Enable { .. } => 0,
289            FeatureManagementInstruction::ScheduleEnable { .. } => 1,
290            FeatureManagementInstruction::FireScheduledEnable { .. } => 2,
291            FeatureManagementInstruction::Cancel { .. } => 3,
292            FeatureManagementInstruction::UpdateAuthority { .. } => 4,
293            FeatureManagementInstruction::ProposeAuthorityTransfer { .. } => 5,
294            FeatureManagementInstruction::AcceptAuthorityTransfer => 6,
295            FeatureManagementInstruction::CancelAuthorityTransfer => 7,
296        }
297    }
298
299    /// Count of variants with a pinned discriminant. Bump ONLY when appending a
300    /// variant (and add its `representatives()` entry + `discriminant_of` arm).
301    const PINNED_VARIANT_COUNT: u8 = 8;
302
303    #[test]
304    fn test_discriminants_are_stable() {
305        let reps = representatives();
306
307        // A new variant forces a `discriminant_of` arm (compile error otherwise);
308        // these two assertions then fail unless `representatives()` gains a
309        // matching entry and `PINNED_VARIANT_COUNT` is bumped — so the golden
310        // list can't silently fall behind the enum.
311        assert_eq!(
312            reps.len(),
313            PINNED_VARIANT_COUNT as usize,
314            "every variant must have exactly one representative",
315        );
316        let mut discriminants: Vec<u8> = reps.iter().map(|(d, _)| *d).collect();
317        discriminants.sort_unstable();
318        assert_eq!(
319            discriminants,
320            (0..PINNED_VARIANT_COUNT).collect::<Vec<u8>>(),
321            "discriminants must be the contiguous set 0..N, one representative each",
322        );
323
324        for (expected, instruction) in reps {
325            let bytes = instruction.serialize().expect("serialize");
326            let discriminant = *bytes
327                .first()
328                .expect("borsh enum output always carries a leading discriminant byte");
329            assert_eq!(
330                discriminant, expected,
331                "wire discriminant for {instruction:?} changed; \
332                 borsh discriminants are append-only",
333            );
334            assert_eq!(
335                discriminant_of(&instruction),
336                expected,
337                "discriminant_of disagrees with the pinned golden for {instruction:?}",
338            );
339        }
340    }
341
342    #[test]
343    fn test_every_variant_round_trips() {
344        for (_, instruction) in representatives() {
345            let bytes = instruction.serialize().expect("serialize");
346            let decoded = FeatureManagementInstruction::deserialize(&bytes).expect("deserialize");
347            assert_eq!(
348                decoded, instruction,
349                "round-trip mismatch for {instruction:?}"
350            );
351        }
352    }
353}