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