rialo-feature-management-program-interface 0.11.0

Rialo Feature Management Program Interface
Documentation
// 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:?}"
            );
        }
    }
}