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}