diff --git a/feature-gate/README.md b/feature-gate/README.md new file mode 100644 index 00000000000..e83c14df86a --- /dev/null +++ b/feature-gate/README.md @@ -0,0 +1,10 @@ +# Feature Gate Program + +This program serves to manage new features on Solana. + +It serves two main purposes: activating new features and revoking features that +are pending activation. + +More information & documentation will follow as this program matures, but you +can follow the discussions +[here](https://github.com/solana-labs/solana/issues/32780)! diff --git a/feature-gate/program/Cargo.toml b/feature-gate/program/Cargo.toml new file mode 100644 index 00000000000..5e36a9192ca --- /dev/null +++ b/feature-gate/program/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "spl-feature-gate" +version = "0.1.0" +description = "Solana Program Library Feature Gate Program" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[features] +no-entrypoint = [] +test-sbf = [] + +[dependencies] +solana-program = "1.16.16" +spl-program-error = { version = "0.3.0", path = "../../libraries/program-error" } + +[dev-dependencies] +solana-program-test = "1.16.16" +solana-sdk = "1.16.16" + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/feature-gate/program/src/entrypoint.rs b/feature-gate/program/src/entrypoint.rs new file mode 100644 index 00000000000..c261a918a42 --- /dev/null +++ b/feature-gate/program/src/entrypoint.rs @@ -0,0 +1,17 @@ +//! Program entrypoint + +use { + crate::processor, + solana_program::{ + account_info::AccountInfo, entrypoint, entrypoint::ProgramResult, pubkey::Pubkey, + }, +}; + +entrypoint!(process_instruction); +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + processor::process(program_id, accounts, input) +} diff --git a/feature-gate/program/src/error.rs b/feature-gate/program/src/error.rs new file mode 100644 index 00000000000..d4241a01c14 --- /dev/null +++ b/feature-gate/program/src/error.rs @@ -0,0 +1,17 @@ +//! Program error types + +use spl_program_error::*; + +/// Program specific errors +#[spl_program_error] +pub enum FeatureGateError { + /// Operation overflowed + #[error("Operation overflowed")] + Overflow, + /// Feature account must be a system account + #[error("Feature account must be a system account")] + FeatureNotSystemAccount, + /// Feature not inactive + #[error("Feature not inactive")] + FeatureNotInactive, +} diff --git a/feature-gate/program/src/instruction.rs b/feature-gate/program/src/instruction.rs new file mode 100644 index 00000000000..59cfe7c9da1 --- /dev/null +++ b/feature-gate/program/src/instruction.rs @@ -0,0 +1,126 @@ +//! Program instructions + +use solana_program::{ + feature::Feature, + instruction::{AccountMeta, Instruction}, + program_error::ProgramError, + pubkey::Pubkey, + rent::Rent, + system_instruction, system_program, +}; + +/// Feature Gate program instructions +#[derive(Clone, Debug, PartialEq)] +pub enum FeatureGateInstruction { + /// Submit a feature for activation. + /// + /// Note: This instruction expects the account to exist and be owned by the + /// system program. The account should also have enough rent-exempt lamports + /// to cover the cost of the account creation for a + /// `solana_program::feature::Feature` state prior to invoking this + /// instruction. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w+s]` Feature account (must be a system account) + /// 1. `[]` System program + Activate, + /// Revoke a pending feature activation. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w+s]` Feature account + /// 1. `[w]` Destination (for rent lamports) + RevokePendingActivation, +} +impl FeatureGateInstruction { + /// Unpacks a byte buffer into a + /// [FeatureGateInstruction](enum.FeatureGateInstruction.html). + pub fn unpack(input: &[u8]) -> Result { + if input.is_empty() { + return Err(ProgramError::InvalidInstructionData); + } + match input[0] { + 0 => Ok(Self::Activate), + 1 => Ok(Self::RevokePendingActivation), + _ => Err(ProgramError::InvalidInstructionData), + } + } + + /// Packs a [FeatureGateInstruction](enum.FeatureGateInstruction.html) into + /// a byte buffer. + pub fn pack(&self) -> Vec { + match self { + Self::Activate => vec![0], + Self::RevokePendingActivation => vec![1], + } + } +} + +/// Creates an 'Activate' instruction. +pub fn activate(program_id: &Pubkey, feature: &Pubkey) -> Instruction { + let accounts = vec![ + AccountMeta::new(*feature, true), + AccountMeta::new_readonly(system_program::id(), false), + ]; + + let data = FeatureGateInstruction::Activate.pack(); + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates a set of two instructions: +/// * One to fund the feature account with rent-exempt lamports +/// * Another is the Feature Gate Program's 'Activate' instruction +pub fn activate_with_rent_transfer( + program_id: &Pubkey, + feature: &Pubkey, + payer: &Pubkey, +) -> [Instruction; 2] { + let lamports = Rent::default().minimum_balance(Feature::size_of()); + [ + system_instruction::transfer(payer, feature, lamports), + activate(program_id, feature), + ] +} + +/// Creates a 'RevokePendingActivation' instruction. +pub fn revoke(program_id: &Pubkey, feature: &Pubkey, destination: &Pubkey) -> Instruction { + let accounts = vec![ + AccountMeta::new(*feature, true), + AccountMeta::new(*destination, false), + ]; + + let data = FeatureGateInstruction::RevokePendingActivation.pack(); + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +#[cfg(test)] +mod test { + use super::*; + + fn test_pack_unpack(instruction: &FeatureGateInstruction) { + let packed = instruction.pack(); + let unpacked = FeatureGateInstruction::unpack(&packed).unwrap(); + assert_eq!(instruction, &unpacked); + } + + #[test] + fn test_pack_unpack_activate() { + test_pack_unpack(&FeatureGateInstruction::Activate); + } + + #[test] + fn test_pack_unpack_revoke() { + test_pack_unpack(&FeatureGateInstruction::RevokePendingActivation); + } +} diff --git a/feature-gate/program/src/lib.rs b/feature-gate/program/src/lib.rs new file mode 100644 index 00000000000..b8a1bde2cbb --- /dev/null +++ b/feature-gate/program/src/lib.rs @@ -0,0 +1,15 @@ +//! Feature Gate program + +#![deny(missing_docs)] +#![cfg_attr(not(test), forbid(unsafe_code))] + +mod entrypoint; +pub mod error; +pub mod instruction; +pub mod processor; + +// Export current SDK types for downstream users building with a different SDK +// version +pub use solana_program; + +solana_program::declare_id!("Feature111111111111111111111111111111111111"); diff --git a/feature-gate/program/src/processor.rs b/feature-gate/program/src/processor.rs new file mode 100644 index 00000000000..c9b7f09af7f --- /dev/null +++ b/feature-gate/program/src/processor.rs @@ -0,0 +1,93 @@ +//! Program state processor + +use { + crate::{error::FeatureGateError, instruction::FeatureGateInstruction}, + solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + feature::Feature, + msg, + program::invoke, + program_error::ProgramError, + pubkey::Pubkey, + system_instruction, system_program, + }, +}; + +/// Processes an [Activate](enum.FeatureGateInstruction.html) instruction. +pub fn process_activate(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let feature_info = next_account_info(account_info_iter)?; + let _system_program_info = next_account_info(account_info_iter)?; + + if !feature_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + if feature_info.owner != &system_program::id() { + return Err(FeatureGateError::FeatureNotSystemAccount.into()); + } + + invoke( + &system_instruction::allocate(feature_info.key, Feature::size_of() as u64), + &[feature_info.clone()], + )?; + invoke( + &system_instruction::assign(feature_info.key, program_id), + &[feature_info.clone()], + )?; + + Ok(()) +} + +/// Processes an [revoke](enum.FeatureGateInstruction.html) instruction. +pub fn process_revoke(program_id: &Pubkey, accounts: &[AccountInfo]) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let feature_info = next_account_info(account_info_iter)?; + let destination_info = next_account_info(account_info_iter)?; + + if !feature_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + if feature_info.owner != program_id { + return Err(ProgramError::IllegalOwner); + } + + if Feature::from_account_info(feature_info)? + .activated_at + .is_some() + { + return Err(FeatureGateError::FeatureNotInactive.into()); + } + + let new_destination_lamports = feature_info + .lamports() + .checked_add(destination_info.lamports()) + .ok_or::(FeatureGateError::Overflow.into())?; + + **feature_info.try_borrow_mut_lamports()? = 0; + **destination_info.try_borrow_mut_lamports()? = new_destination_lamports; + + feature_info.realloc(0, true)?; + feature_info.assign(&system_program::id()); + + Ok(()) +} + +/// Processes an [Instruction](enum.Instruction.html). +pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { + let instruction = FeatureGateInstruction::unpack(input)?; + match instruction { + FeatureGateInstruction::Activate => { + msg!("Instruction: Activate"); + process_activate(program_id, accounts) + } + FeatureGateInstruction::RevokePendingActivation => { + msg!("Instruction: RevokePendingActivation"); + process_revoke(program_id, accounts) + } + } +} diff --git a/feature-gate/program/tests/functional.rs b/feature-gate/program/tests/functional.rs new file mode 100644 index 00000000000..b7ce9b5cac3 --- /dev/null +++ b/feature-gate/program/tests/functional.rs @@ -0,0 +1,259 @@ +// #![cfg(feature = "test-sbf")] + +use { + solana_program::instruction::InstructionError, + solana_program_test::{processor, tokio, ProgramTest, ProgramTestContext}, + solana_sdk::{ + account::Account as SolanaAccount, + feature::Feature, + pubkey::Pubkey, + signature::{Keypair, Signer}, + system_instruction, + transaction::{Transaction, TransactionError}, + }, + spl_feature_gate::{ + error::FeatureGateError, + instruction::{activate, activate_with_rent_transfer, revoke}, + }, +}; + +async fn setup_feature(context: &mut ProgramTestContext, feature_keypair: &Keypair) { + let transaction = Transaction::new_signed_with_payer( + &activate_with_rent_transfer( + &spl_feature_gate::id(), + &feature_keypair.pubkey(), + &context.payer.pubkey(), + ), + Some(&context.payer.pubkey()), + &[&context.payer, feature_keypair], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +#[tokio::test] +async fn test_activate() { + let mock_feature_keypair = Keypair::new(); + let feature_keypair = Keypair::new(); + + let mut program_test = ProgramTest::new( + "spl_feature_gate", + spl_feature_gate::id(), + processor!(spl_feature_gate::processor::process), + ); + + // Add a mock feature for testing later + program_test.add_account( + mock_feature_keypair.pubkey(), + SolanaAccount { + lamports: 500_000_000, + owner: spl_feature_gate::id(), + ..SolanaAccount::default() + }, + ); + + let mut context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent.minimum_balance(Feature::size_of()); + + // Activate: Fail feature not signer + let mut activate_ix = activate(&spl_feature_gate::id(), &feature_keypair.pubkey()); + activate_ix.accounts[0].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &feature_keypair.pubkey(), + rent_lamports, + ), + activate_ix, + ], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(1, InstructionError::MissingRequiredSignature) + ); + + // Activate: Fail feature not owned by system program + let transaction = Transaction::new_signed_with_payer( + &[activate( + &spl_feature_gate::id(), + &mock_feature_keypair.pubkey(), + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mock_feature_keypair], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(FeatureGateError::FeatureNotSystemAccount as u32), + ) + ); + + // Submit a feature for activation + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &feature_keypair.pubkey(), + rent_lamports, + ), + activate(&spl_feature_gate::id(), &feature_keypair.pubkey()), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &feature_keypair], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Confirm feature account exists with proper configurations + let feature_account = context + .banks_client + .get_account(feature_keypair.pubkey()) + .await + .unwrap() + .unwrap(); + assert_eq!(feature_account.owner, spl_feature_gate::id()); +} + +#[tokio::test] +async fn test_revoke() { + let feature_keypair = Keypair::new(); + let destination = Pubkey::new_unique(); + let mock_active_feature_keypair = Keypair::new(); + + let mut program_test = ProgramTest::new( + "spl_feature_gate", + spl_feature_gate::id(), + processor!(spl_feature_gate::processor::process), + ); + + // Add a mock feature that might be active for testing later + program_test.add_account( + mock_active_feature_keypair.pubkey(), + SolanaAccount { + lamports: 500_000_000, + owner: spl_feature_gate::id(), + data: vec![ + 1, // `Some()` + 45, 0, 0, 0, 0, 0, 0, 0, // Random slot `u64` + ], + ..SolanaAccount::default() + }, + ); + + let mut context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent.minimum_balance(Feature::size_of()); // For checking account balance later + + setup_feature(&mut context, &feature_keypair).await; + + // Revoke: Fail feature not signer + let mut revoke_ix = revoke( + &spl_feature_gate::id(), + &feature_keypair.pubkey(), + &destination, + ); + revoke_ix.accounts[0].is_signer = false; + let transaction = Transaction::new_signed_with_payer( + &[revoke_ix], + Some(&context.payer.pubkey()), + &[&context.payer], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature) + ); + + // Revoke: Fail feature not inactive + let transaction = Transaction::new_signed_with_payer( + &[revoke( + &spl_feature_gate::id(), + &mock_active_feature_keypair.pubkey(), + &destination, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mock_active_feature_keypair], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(FeatureGateError::FeatureNotInactive as u32) + ) + ); + + // Revoke a feature activation + let transaction = Transaction::new_signed_with_payer( + &[revoke( + &spl_feature_gate::id(), + &feature_keypair.pubkey(), + &destination, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &feature_keypair], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // Confirm feature account was closed and destination account received lamports + let feature_account = context + .banks_client + .get_account(feature_keypair.pubkey()) + .await + .unwrap(); + assert!(feature_account.is_none()); + let destination_account = context + .banks_client + .get_account(destination) + .await + .unwrap() + .unwrap(); + assert_eq!(destination_account.lamports, rent_lamports); +}