diff --git a/Cargo.lock b/Cargo.lock index 26b44e0cd1d..683b1bbf374 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6523,6 +6523,29 @@ dependencies = [ "spl-token 4.0.0", ] +[[package]] +name = "spl-feature-gate" +version = "0.1.0" +dependencies = [ + "solana-program", + "solana-program-test", + "solana-sdk", + "spl-program-error", +] + +[[package]] +name = "spl-feature-gate-cli" +version = "1.0.0" +dependencies = [ + "clap 2.34.0", + "solana-clap-utils", + "solana-cli-config", + "solana-client", + "solana-logger", + "solana-sdk", + "spl-feature-gate", +] + [[package]] name = "spl-feature-proposal" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index 84f8f1eedd9..a9709845759 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,8 @@ members = [ "examples/rust/sysvar", "examples/rust/transfer-lamports", "examples/rust/transfer-tokens", + "feature-gate/cli", + "feature-gate/program", "feature-proposal/program", "feature-proposal/cli", "governance/addin-mock/program", 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/cli/Cargo.toml b/feature-gate/cli/Cargo.toml new file mode 100644 index 00000000000..fb70241843a --- /dev/null +++ b/feature-gate/cli/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "spl-feature-gate-cli" +version = "1.0.0" +description = "SPL Feature Gate Command-line Utility" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[dependencies] +clap = "2.33.3" +solana-clap-utils = "=1.16.3" +solana-cli-config = "=1.16.3" +solana-client = "=1.16.3" +solana-logger = "=1.16.3" +solana-sdk = "=1.16.3" +spl-feature-gate = { version = "0.1.0", path = "../program", features = ["no-entrypoint"] } + +[[bin]] +name = "spl-feature-gate" +path = "src/main.rs" + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/feature-gate/cli/src/main.rs b/feature-gate/cli/src/main.rs new file mode 100644 index 00000000000..376558d31e6 --- /dev/null +++ b/feature-gate/cli/src/main.rs @@ -0,0 +1,214 @@ +//! Feature gate CLI + +use { + clap::{crate_description, crate_name, crate_version, App, AppSettings, Arg, SubCommand}, + solana_clap_utils::{ + input_parsers::{keypair_of, pubkey_of}, + input_validators::{is_keypair, is_url, is_valid_pubkey, is_valid_signer}, + }, + solana_client::rpc_client::RpcClient, + solana_sdk::{ + commitment_config::CommitmentConfig, + feature::Feature, + pubkey::Pubkey, + rent::Rent, + signature::{read_keypair_file, Keypair, Signer}, + system_instruction, + transaction::Transaction, + }, + spl_feature_gate::instruction::{activate, revoke}, +}; + +#[allow(dead_code)] +struct Config { + keypair: Box, + json_rpc_url: String, + verbose: bool, +} + +fn main() -> Result<(), Box> { + let app_matches = App::new(crate_name!()) + .about(crate_description!()) + .version(crate_version!()) + .setting(AppSettings::SubcommandRequiredElseHelp) + .arg({ + let arg = Arg::with_name("config_file") + .short("C") + .long("config") + .value_name("PATH") + .takes_value(true) + .global(true) + .help("Configuration file to use"); + if let Some(ref config_file) = *solana_cli_config::CONFIG_FILE { + arg.default_value(config_file) + } else { + arg + } + }) + .arg( + Arg::with_name("keypair") + .long("keypair") + .value_name("KEYPAIR") + .validator(is_valid_signer) + .takes_value(true) + .global(true) + .help("Filepath or URL to a keypair [default: client keypair]"), + ) + .arg( + Arg::with_name("verbose") + .long("verbose") + .short("v") + .takes_value(false) + .global(true) + .help("Show additional information"), + ) + .arg( + Arg::with_name("json_rpc_url") + .long("url") + .value_name("URL") + .takes_value(true) + .global(true) + .validator(is_url) + .help("JSON RPC URL for the cluster [default: value from configuration file]"), + ) + .subcommand( + SubCommand::with_name("activate") + .about("Activate a feature") + .arg( + Arg::with_name("feature_keypair") + .value_name("FEATURE_KEYPAIR") + .validator(is_keypair) + .index(1) + .required(true) + .help("Path to keypair of the feature"), + ), + ) + .subcommand( + SubCommand::with_name("revoke") + .about("Revoke a pending feature activation") + .arg( + Arg::with_name("feature_keypair") + .value_name("FEATURE_KEYPAIR") + .validator(is_keypair) + .index(1) + .required(true) + .help("Path to keypair of the feature"), + ) + .arg( + Arg::with_name("destination") + .value_name("DESTINATION") + .validator(is_valid_pubkey) + .index(2) + .required(true) + .help("The address of the destination for the refunded lamports"), + ), + ) + .get_matches(); + + let (sub_command, sub_matches) = app_matches.subcommand(); + let matches = sub_matches.unwrap(); + + let config = { + let cli_config = if let Some(config_file) = matches.value_of("config_file") { + solana_cli_config::Config::load(config_file).unwrap_or_default() + } else { + solana_cli_config::Config::default() + }; + + Config { + json_rpc_url: matches + .value_of("json_rpc_url") + .unwrap_or(&cli_config.json_rpc_url) + .to_string(), + keypair: Box::new(read_keypair_file( + matches + .value_of("keypair") + .unwrap_or(&cli_config.keypair_path), + )?), + verbose: matches.is_present("verbose"), + } + }; + solana_logger::setup_with_default("solana=info"); + let rpc_client = + RpcClient::new_with_commitment(config.json_rpc_url.clone(), CommitmentConfig::confirmed()); + + match (sub_command, sub_matches) { + ("activate", Some(arg_matches)) => { + let feature_keypair = keypair_of(arg_matches, "feature_keypair").unwrap(); + + process_activate(&rpc_client, &config, &feature_keypair) + } + ("revoke", Some(arg_matches)) => { + let feature_keypair = keypair_of(arg_matches, "feature_keypair").unwrap(); + let destination = pubkey_of(arg_matches, "destination").unwrap(); + + process_revoke(&rpc_client, &config, &feature_keypair, &destination) + } + _ => unreachable!(), + } +} + +fn process_activate( + rpc_client: &RpcClient, + config: &Config, + feature_keypair: &Keypair, +) -> Result<(), Box> { + println!(); + println!("Activating feature..."); + println!("Feature ID: {}", feature_keypair.pubkey()); + println!(); + println!("JSON RPC URL: {}", config.json_rpc_url); + println!(); + + let rent_lamports = Rent::default().minimum_balance(Feature::size_of()); + + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &config.keypair.pubkey(), + &feature_keypair.pubkey(), + rent_lamports, + ), + activate(&spl_feature_gate::id(), &feature_keypair.pubkey()), + ], + Some(&config.keypair.pubkey()), + &[config.keypair.as_ref(), feature_keypair], + rpc_client.get_latest_blockhash()?, + ); + rpc_client.send_and_confirm_transaction_with_spinner(&transaction)?; + + println!(); + println!("Feature is marked for activation!"); + Ok(()) +} + +fn process_revoke( + rpc_client: &RpcClient, + config: &Config, + feature_keypair: &Keypair, + destination: &Pubkey, +) -> Result<(), Box> { + println!(); + println!("Revoking feature..."); + println!("Feature ID: {}", feature_keypair.pubkey()); + println!("Destination: {}", destination); + println!(); + println!("JSON RPC URL: {}", config.json_rpc_url); + println!(); + + let transaction = Transaction::new_signed_with_payer( + &[revoke( + &spl_feature_gate::id(), + &feature_keypair.pubkey(), + destination, + )], + Some(&config.keypair.pubkey()), + &[config.keypair.as_ref()], + rpc_client.get_latest_blockhash()?, + ); + rpc_client.send_and_confirm_transaction_with_spinner(&transaction)?; + + println!(); + println!("Feature successfully revoked!"); + Ok(()) +} diff --git a/feature-gate/program/Cargo.toml b/feature-gate/program/Cargo.toml new file mode 100644 index 00000000000..5eac95e4bfd --- /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.3" +spl-program-error = { version = "0.2.0", path = "../../libraries/program-error" } + +[dev-dependencies] +solana-program-test = "1.16.3" +solana-sdk = "1.16.3" + +[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); +}