diff --git a/clients/cli/Cargo.toml b/clients/cli/Cargo.toml new file mode 100644 index 0000000..2099dcf --- /dev/null +++ b/clients/cli/Cargo.toml @@ -0,0 +1,37 @@ +[package] +authors = ["Solana Labs Maintainers "] +description = "SPL Transfer Hook Command-line Utility" +edition = "2021" +homepage = "https://spl.solana.com/token" +license = "Apache-2.0" +name = "spl-transfer-hook-cli" +repository = "https://github.com/solana-labs/solana-program-library" +version = "0.2.0" + +[dependencies] +clap = { version = "3", features = ["cargo"] } +futures-util = "0.3.31" +solana-clap-v3-utils = "2.1.0" +solana-cli-config = "2.1.0" +solana-client = "2.1.0" +solana-logger = "2.1.0" +solana-remote-wallet = "2.1.0" +solana-sdk = "2.1.0" +spl-tlv-account-resolution = { version = "0.9.0", path = "../../../libraries/tlv-account-resolution", features = ["serde-traits"] } +spl-transfer-hook-interface = { version = "0.9.0", path = "../interface" } +strum = "0.26" +strum_macros = "0.26" +tokio = { version = "1", features = ["full"] } +serde = { version = "1.0.216", features = ["derive"] } +serde_json = "1.0.133" +serde_yaml = "0.9.34" + +[dev-dependencies] +solana-test-validator = "2.1.0" +spl-token-2022 = { version = "6.0.0", path = "../../program-2022", features = ["no-entrypoint"] } +spl-token-client = { version = "0.13.0", path = "../../client" } +spl-transfer-hook-example = { version = "0.6.0", path = "../example" } + +[[bin]] +name = "spl-transfer-hook" +path = "src/main.rs" diff --git a/clients/cli/src/main.rs b/clients/cli/src/main.rs new file mode 100644 index 0000000..bf7273b --- /dev/null +++ b/clients/cli/src/main.rs @@ -0,0 +1,636 @@ +pub mod meta; + +use { + crate::meta::parse_transfer_hook_account_arg, + clap::{crate_description, crate_name, crate_version, Arg, ArgAction, Command}, + solana_clap_v3_utils::{ + input_parsers::{ + parse_url_or_moniker, + signer::{SignerSource, SignerSourceParserBuilder}, + }, + input_validators::normalize_to_url_if_moniker, + keypair::signer_from_path, + }, + solana_client::nonblocking::rpc_client::RpcClient, + solana_remote_wallet::remote_wallet::RemoteWalletManager, + solana_sdk::{ + commitment_config::CommitmentConfig, + instruction::Instruction, + pubkey::Pubkey, + signature::{Signature, Signer}, + system_instruction, system_program, + transaction::Transaction, + }, + spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList}, + spl_transfer_hook_interface::{ + get_extra_account_metas_address, + instruction::{initialize_extra_account_meta_list, update_extra_account_meta_list}, + }, + std::{process::exit, rc::Rc}, +}; + +// Helper function to calculate the required lamports for rent +async fn calculate_rent_lamports( + rpc_client: &RpcClient, + account_address: &Pubkey, + account_size: usize, +) -> Result> { + let required_lamports = rpc_client + .get_minimum_balance_for_rent_exemption(account_size) + .await + .map_err(|err| format!("error: unable to fetch rent-exemption: {err}"))?; + let account_info = rpc_client.get_account(account_address).await; + let current_lamports = account_info.map(|a| a.lamports).unwrap_or(0); + Ok(required_lamports.saturating_sub(current_lamports)) +} + +async fn build_transaction_with_rent_transfer( + rpc_client: &RpcClient, + payer: &dyn Signer, + extra_account_metas_address: &Pubkey, + extra_account_metas: &[ExtraAccountMeta], + instruction: Instruction, +) -> Result> { + let account_size = ExtraAccountMetaList::size_of(extra_account_metas.len())?; + let transfer_lamports = + calculate_rent_lamports(rpc_client, extra_account_metas_address, account_size).await?; + + let mut instructions = vec![]; + if transfer_lamports > 0 { + instructions.push(system_instruction::transfer( + &payer.pubkey(), + extra_account_metas_address, + transfer_lamports, + )); + } + + instructions.push(instruction); + + let transaction = Transaction::new_with_payer(&instructions, Some(&payer.pubkey())); + + Ok(transaction) +} + +async fn sign_and_send_transaction( + transaction: &mut Transaction, + rpc_client: &RpcClient, + payer: &dyn Signer, + mint_authority: &dyn Signer, +) -> Result> { + let mut signers = vec![payer]; + if payer.pubkey() != mint_authority.pubkey() { + signers.push(mint_authority); + } + + let blockhash = rpc_client + .get_latest_blockhash() + .await + .map_err(|err| format!("error: unable to get latest blockhash: {err}"))?; + + transaction + .try_sign(&signers, blockhash) + .map_err(|err| format!("error: failed to sign transaction: {err}"))?; + + rpc_client + .send_and_confirm_transaction_with_spinner(transaction) + .await + .map_err(|err| format!("error: send transaction: {err}").into()) +} + +struct Config { + commitment_config: CommitmentConfig, + default_signer: Box, + json_rpc_url: String, + verbose: bool, +} + +async fn process_create_extra_account_metas( + rpc_client: &RpcClient, + program_id: &Pubkey, + token: &Pubkey, + extra_account_metas: Vec, + mint_authority: &dyn Signer, + payer: &dyn Signer, +) -> Result> { + let extra_account_metas_address = get_extra_account_metas_address(token, program_id); + + // Check if the extra meta account has already been initialized + let extra_account_metas_account = rpc_client.get_account(&extra_account_metas_address).await; + if let Ok(account) = &extra_account_metas_account { + if account.owner != system_program::id() { + return Err(format!("error: extra account metas for mint {token} and program {program_id} already exists").into()); + } + } + + let instruction = initialize_extra_account_meta_list( + program_id, + &extra_account_metas_address, + token, + &mint_authority.pubkey(), + &extra_account_metas, + ); + + let mut transaction = build_transaction_with_rent_transfer( + rpc_client, + payer, + &extra_account_metas_address, + &extra_account_metas, + instruction, + ) + .await?; + + sign_and_send_transaction(&mut transaction, rpc_client, payer, mint_authority).await +} + +async fn process_update_extra_account_metas( + rpc_client: &RpcClient, + program_id: &Pubkey, + token: &Pubkey, + extra_account_metas: Vec, + mint_authority: &dyn Signer, + payer: &dyn Signer, +) -> Result> { + let extra_account_metas_address = get_extra_account_metas_address(token, program_id); + + // Check if the extra meta account has been initialized first + let extra_account_metas_account = rpc_client.get_account(&extra_account_metas_address).await; + if extra_account_metas_account.is_err() { + return Err(format!( + "error: extra account metas for mint {token} and program {program_id} does not exist" + ) + .into()); + } + + let instruction = update_extra_account_meta_list( + program_id, + &extra_account_metas_address, + token, + &mint_authority.pubkey(), + &extra_account_metas, + ); + + let mut transaction = build_transaction_with_rent_transfer( + rpc_client, + payer, + &extra_account_metas_address, + &extra_account_metas, + instruction, + ) + .await?; + + sign_and_send_transaction(&mut transaction, rpc_client, payer, mint_authority).await +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let app_matches = Command::new(crate_name!()) + .about(crate_description!()) + .version(crate_version!()) + .subcommand_required(true) + .arg_required_else_help(true) + .arg({ + let arg = Arg::new("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::new("fee_payer") + .long("fee-payer") + .value_name("KEYPAIR") + .value_parser(SignerSourceParserBuilder::default().allow_all().build()) + .takes_value(true) + .global(true) + .help("Filepath or URL to a keypair to pay transaction fee [default: client keypair]"), + ) + .arg( + Arg::new("verbose") + .long("verbose") + .short('v') + .takes_value(false) + .global(true) + .help("Show additional information"), + ) + .arg( + Arg::new("json_rpc_url") + .short('u') + .long("url") + .value_name("URL") + .takes_value(true) + .global(true) + .value_parser(parse_url_or_moniker) + .help("JSON RPC URL for the cluster [default: value from configuration file]"), + ) + .subcommand( + Command::new("create-extra-metas") + .about("Create the extra account metas account for a transfer hook program") + .arg( + Arg::new("program_id") + .value_parser(SignerSourceParserBuilder::default().allow_pubkey().allow_file_path().build()) + .value_name("TRANSFER_HOOK_PROGRAM") + .takes_value(true) + .index(1) + .required(true) + .help("The transfer hook program id"), + ) + .arg( + Arg::new("token") + .value_parser(SignerSourceParserBuilder::default().allow_pubkey().allow_file_path().build()) + .value_name("TOKEN_MINT_ADDRESS") + .takes_value(true) + .index(2) + .required(true) + .help("The token mint address for the transfer hook"), + ) + .arg( + Arg::new("transfer_hook_accounts") + .value_parser(parse_transfer_hook_account_arg) + .value_name("TRANSFER_HOOK_ACCOUNTS") + .takes_value(true) + .action(ArgAction::Append) + .min_values(0) + .index(3) + .help(r#"Additional account(s) required for a transfer hook and their respective configurations, whether they are a fixed address or PDA. + +Additional accounts with known fixed addresses can be passed at the command line in the format ":". The role must be "readonly", "writable". "readonlySigner", or "writableSigner". + +Additional accounts requiring seed configurations can be defined in a configuration file using either JSON or YAML. The format is as follows: + +```json +{ + "extraMetas": [ + { + "pubkey": "39UhV...", + "role": "readonlySigner" + }, + { + "seeds": [ + { + "literal": { + "bytes": [1, 2, 3, 4, 5, 6] + } + }, + { + "accountKey": { + "index": 0 + } + } + ], + "role": "writable" + } + ] +} +``` + +```yaml +extraMetas: + - pubkey: "39UhV..." + role: "readonlySigner" + - seeds: + - literal: + bytes: [1, 2, 3, 4, 5, 6] + - accountKey: + index: 0 + role: "writable" +``` +"#) + ) + .arg( + Arg::new("mint_authority") + .long("mint-authority") + .value_name("KEYPAIR") + .value_parser(SignerSourceParserBuilder::default().allow_all().build()) + .takes_value(true) + .global(true) + .help("Filepath or URL to mint-authority keypair [default: client keypair]"), + ) + ) + .subcommand( + Command::new("update-extra-metas") + .about("Update the extra account metas account for a transfer hook program") + .arg( + Arg::new("program_id") + .value_parser(SignerSourceParserBuilder::default().allow_pubkey().allow_file_path().build()) + .value_name("TRANSFER_HOOK_PROGRAM") + .takes_value(true) + .index(1) + .required(true) + .help("The transfer hook program id"), + ) + .arg( + Arg::new("token") + .value_parser(SignerSourceParserBuilder::default().allow_pubkey().allow_file_path().build()) + .value_name("TOKEN_MINT_ADDRESS") + .takes_value(true) + .index(2) + .required(true) + .help("The token mint address for the transfer hook"), + ) + .arg( + Arg::new("transfer_hook_accounts") + .value_parser(parse_transfer_hook_account_arg) + .value_name("TRANSFER_HOOK_ACCOUNTS") + .takes_value(true) + .action(ArgAction::Append) + .min_values(0) + .index(3) + .help(r#"Additional account(s) required for a transfer hook and their respective configurations, whether they are a fixed address or PDA. + +Additional accounts with known fixed addresses can be passed at the command line in the format ":". The role must be "readonly", "writable". "readonlySigner", or "writableSigner". + +Additional accounts requiring seed configurations can be defined in a configuration file using either JSON or YAML. The format is as follows: + +```json +{ + "extraMetas": [ + { + "pubkey": "39UhV...", + "role": "readonlySigner" + }, + { + "seeds": [ + { + "literal": { + "bytes": [1, 2, 3, 4, 5, 6] + } + }, + { + "accountKey": { + "index": 0 + } + } + ], + "role": "writable" + } + ] +} +``` + +```yaml +extraMetas: + - pubkey: "39UhV..." + role: "readonlySigner" + - seeds: + - literal: + bytes: [1, 2, 3, 4, 5, 6] + - accountKey: + index: 0 + role: "writable" +``` +"#) + ) + .arg( + Arg::new("mint_authority") + .long("mint-authority") + .value_name("KEYPAIR") + .value_parser(SignerSourceParserBuilder::default().allow_all().build()) + .takes_value(true) + .global(true) + .help("Filepath or URL to mint-authority keypair [default: client keypair]"), + ) + ).get_matches(); + + let (command, matches) = app_matches.subcommand().unwrap(); + let mut wallet_manager: Option> = None; + + let cli_config = if let Some(config_file) = matches.get_one::("config_file") { + solana_cli_config::Config::load(config_file).unwrap_or_default() + } else { + solana_cli_config::Config::default() + }; + + let config = { + let default_signer = if let Some((signer, _)) = + SignerSource::try_get_signer(matches, "fee_payer", &mut wallet_manager)? + { + signer + } else { + signer_from_path( + matches, + &cli_config.keypair_path, + "fee_payer", + &mut wallet_manager, + )? + }; + + let json_rpc_url = normalize_to_url_if_moniker( + matches + .get_one::("json_rpc_url") + .unwrap_or(&cli_config.json_rpc_url), + ); + + Config { + commitment_config: CommitmentConfig::confirmed(), + default_signer, + json_rpc_url, + verbose: matches.try_contains_id("verbose")?, + } + }; + solana_logger::setup_with_default("solana=info"); + + if config.verbose { + println!("JSON RPC URL: {}", config.json_rpc_url); + } + let rpc_client = + RpcClient::new_with_commitment(config.json_rpc_url.clone(), config.commitment_config); + + match (command, matches) { + ("create-extra-metas", arg_matches) => { + let program_id = + SignerSource::try_get_pubkey(arg_matches, "program_id", &mut wallet_manager)? + .unwrap(); + let token = + SignerSource::try_get_pubkey(arg_matches, "token", &mut wallet_manager)?.unwrap(); + + let transfer_hook_accounts = arg_matches + .get_many::>("transfer_hook_accounts") + .unwrap_or_default() + .flatten() + .cloned() + .collect(); + let mint_authority = if let Some((signer, _)) = + SignerSource::try_get_signer(matches, "mint_authority", &mut wallet_manager)? + { + signer + } else { + signer_from_path( + matches, + &cli_config.keypair_path, + "mint_authority", + &mut wallet_manager, + )? + }; + let signature = process_create_extra_account_metas( + &rpc_client, + &program_id, + &token, + transfer_hook_accounts, + mint_authority.as_ref(), + config.default_signer.as_ref(), + ) + .await + .unwrap_or_else(|err| { + eprintln!("error: send transaction: {err}"); + exit(1); + }); + println!("Signature: {signature}"); + } + ("update-extra-metas", arg_matches) => { + let program_id = + SignerSource::try_get_pubkey(arg_matches, "program_id", &mut wallet_manager)? + .unwrap(); + let token = + SignerSource::try_get_pubkey(arg_matches, "token", &mut wallet_manager)?.unwrap(); + + let transfer_hook_accounts = arg_matches + .get_many::>("transfer_hook_accounts") + .unwrap_or_default() + .flatten() + .cloned() + .collect(); + let mint_authority = if let Some((signer, _)) = + SignerSource::try_get_signer(matches, "mint_authority", &mut wallet_manager)? + { + signer + } else { + signer_from_path( + matches, + &cli_config.keypair_path, + "mint_authority", + &mut wallet_manager, + )? + }; + let signature = process_update_extra_account_metas( + &rpc_client, + &program_id, + &token, + transfer_hook_accounts, + mint_authority.as_ref(), + config.default_signer.as_ref(), + ) + .await + .unwrap_or_else(|err| { + eprintln!("error: send transaction: {err}"); + exit(1); + }); + println!("Signature: {signature}"); + } + _ => unreachable!(), + }; + + Ok(()) +} + +#[cfg(test)] +mod test { + use { + super::*, + solana_sdk::{ + account::Account, bpf_loader_upgradeable, instruction::AccountMeta, + program_option::COption, signer::keypair::Keypair, + }, + solana_test_validator::{TestValidator, TestValidatorGenesis, UpgradeableProgramInfo}, + spl_token_2022::{ + extension::{ExtensionType, StateWithExtensionsMut}, + state::Mint, + }, + spl_token_client::{ + client::{ProgramRpcClient, ProgramRpcClientSendTransaction}, + token::Token, + }, + std::{path::PathBuf, sync::Arc}, + }; + + async fn new_validator_for_test( + program_id: Pubkey, + mint_authority: &Pubkey, + decimals: u8, + ) -> (TestValidator, Keypair) { + solana_logger::setup(); + let mut test_validator_genesis = TestValidatorGenesis::default(); + test_validator_genesis.add_upgradeable_programs_with_path(&[UpgradeableProgramInfo { + program_id, + loader: bpf_loader_upgradeable::id(), + program_path: PathBuf::from("../../../target/deploy/spl_transfer_hook_example.so"), + upgrade_authority: Pubkey::new_unique(), + }]); + + let mint_size = ExtensionType::try_calculate_account_len::(&[]).unwrap(); + let mut mint_data = vec![0; mint_size]; + let mut state = + StateWithExtensionsMut::::unpack_uninitialized(&mut mint_data).unwrap(); + let token_amount = 1_000_000_000_000; + state.base = Mint { + mint_authority: COption::Some(*mint_authority), + supply: token_amount, + decimals, + is_initialized: true, + freeze_authority: COption::None, + }; + state.pack_base(); + test_validator_genesis.add_account( + spl_transfer_hook_example::mint::id(), + Account { + lamports: 1_000_000_000, + data: mint_data, + owner: spl_token_2022::id(), + ..Account::default() + } + .into(), + ); + test_validator_genesis.start_async().await + } + + #[tokio::test] + async fn test_create() { + let program_id = Pubkey::new_unique(); + + let decimals = 2; + let mint_authority = Keypair::new(); + let (test_validator, payer) = + new_validator_for_test(program_id, &mint_authority.pubkey(), decimals).await; + let payer: Arc = Arc::new(payer); + let rpc_client = Arc::new(test_validator.get_async_rpc_client()); + let client = Arc::new(ProgramRpcClient::new( + rpc_client.clone(), + ProgramRpcClientSendTransaction, + )); + + let token = Token::new( + client.clone(), + &spl_token_2022::id(), + &spl_transfer_hook_example::mint::id(), + Some(decimals), + payer.clone(), + ); + + let required_address = Pubkey::new_unique(); + let accounts = [AccountMeta::new_readonly(required_address, false)]; + process_create_extra_account_metas( + &rpc_client, + &program_id, + token.get_address(), + accounts.iter().map(|a| a.into()).collect(), + &mint_authority, + payer.as_ref(), + ) + .await + .unwrap(); + + let extra_account_metas_address = + get_extra_account_metas_address(token.get_address(), &program_id); + let account = rpc_client + .get_account(&extra_account_metas_address) + .await + .unwrap(); + assert_eq!(account.owner, program_id); + } +} diff --git a/clients/cli/src/meta.rs b/clients/cli/src/meta.rs new file mode 100644 index 0000000..b72dfa8 --- /dev/null +++ b/clients/cli/src/meta.rs @@ -0,0 +1,322 @@ +use { + serde::{Deserialize, Serialize}, + solana_sdk::pubkey::Pubkey, + spl_tlv_account_resolution::{account::ExtraAccountMeta, seeds::Seed}, + std::{path::Path, str::FromStr}, + strum_macros::{EnumString, IntoStaticStr}, +}; + +#[derive(Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Access { + is_signer: bool, + is_writable: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, EnumString, IntoStaticStr, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[strum(serialize_all = "camelCase")] +enum Role { + Readonly, + Writable, + ReadonlySigner, + WritableSigner, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum AddressConfig { + Pubkey(String), + Seeds(Vec), +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Config { + #[serde(flatten)] + address_config: AddressConfig, + role: Role, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConfigFile { + extra_metas: Vec, +} + +impl From<&Role> for Access { + fn from(role: &Role) -> Self { + match role { + Role::Readonly => Access { + is_signer: false, + is_writable: false, + }, + Role::Writable => Access { + is_signer: false, + is_writable: true, + }, + Role::ReadonlySigner => Access { + is_signer: true, + is_writable: false, + }, + Role::WritableSigner => Access { + is_signer: true, + is_writable: true, + }, + } + } +} + +impl From<&Config> for ExtraAccountMeta { + fn from(config: &Config) -> Self { + let Access { + is_signer, + is_writable, + } = Access::from(&config.role); + match &config.address_config { + AddressConfig::Pubkey(pubkey_string) => ExtraAccountMeta::new_with_pubkey( + &Pubkey::from_str(pubkey_string).unwrap(), + is_signer, + is_writable, + ) + .unwrap(), + AddressConfig::Seeds(seeds) => { + ExtraAccountMeta::new_with_seeds(seeds, is_signer, is_writable).unwrap() + } + } + } +} + +type ParseFn = fn(&str) -> Result; + +fn get_parse_function(path: &Path) -> Result { + match path.extension().and_then(|s| s.to_str()) { + Some("json") => Ok(|v: &str| { + serde_json::from_str::(v).map_err(|e| format!("Unable to parse file: {e}")) + }), + Some("yaml") | Some("yml") => Ok(|v: &str| { + serde_yaml::from_str::(v).map_err(|e| format!("Unable to parse file: {e}")) + }), + _ => Err(format!( + "Unsupported file extension: {}. Only JSON and YAML files are supported", + path.display() + )), + } +} + +fn parse_config_file_arg(path_str: &str) -> Result, String> { + let path = Path::new(path_str); + let parse_fn = get_parse_function(path)?; + let file = + std::fs::read_to_string(path).map_err(|err| format!("Unable to read file: {err}"))?; + let parsed_config_file = parse_fn(&file)?; + Ok(parsed_config_file + .extra_metas + .iter() + .map(ExtraAccountMeta::from) + .collect()) +} + +fn parse_pubkey_role_arg(pubkey_string: &str, role: &str) -> Result, String> { + let pubkey = Pubkey::from_str(pubkey_string).map_err(|e| format!("{e}"))?; + let role = &Role::from_str(role).map_err(|e| format!("{e}"))?; + let Access { + is_signer, + is_writable, + } = role.into(); + ExtraAccountMeta::new_with_pubkey(&pubkey, is_signer, is_writable) + .map(|meta| vec![meta]) + .map_err(|e| format!("{e}")) +} + +pub fn parse_transfer_hook_account_arg(arg: &str) -> Result, String> { + match arg.split(':').collect::>().as_slice() { + [pubkey_str, role] => parse_pubkey_role_arg(pubkey_str, role), + _ => parse_config_file_arg(arg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_json() { + let config = r#"{ + "extraMetas": [ + { + "pubkey": "39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6", + "role": "readonlySigner" + }, + { + "pubkey": "6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV", + "role": "readonly" + }, + { + "seeds": [ + { + "literal": { + "bytes": [1, 2, 3, 4, 5, 6] + } + }, + { + "instructionData": { + "index": 0, + "length": 8 + } + }, + { + "accountKey": { + "index": 0 + } + } + ], + "role": "writable" + }, + { + "seeds": [ + { + "accountData": { + "accountIndex": 1, + "dataIndex": 4, + "length": 4 + } + }, + { + "accountKey": { + "index": 1 + } + } + ], + "role": "readonly" + } + ] + }"#; + let parsed_config_file = serde_json::from_str::(config).unwrap(); + let parsed_extra_metas: Vec = parsed_config_file + .extra_metas + .iter() + .map(|config| config.into()) + .collect::>(); + let expected = vec![ + ExtraAccountMeta::new_with_pubkey( + &Pubkey::from_str("39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6").unwrap(), + true, + false, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey( + &Pubkey::from_str("6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV").unwrap(), + false, + false, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: vec![1, 2, 3, 4, 5, 6], + }, + Seed::InstructionData { + index: 0, + length: 8, + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::AccountData { + account_index: 1, + data_index: 4, + length: 4, + }, + Seed::AccountKey { index: 1 }, + ], + false, + false, + ) + .unwrap(), + ]; + assert_eq!(parsed_extra_metas, expected); + } + + #[test] + fn test_parse_yaml() { + let config = r#" + extraMetas: + - pubkey: "39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6" + role: "readonlySigner" + - pubkey: "6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV" + role: "readonly" + - seeds: + - literal: + bytes: [1, 2, 3, 4, 5, 6] + - instructionData: + index: 0 + length: 8 + - accountKey: + index: 0 + role: "writable" + - seeds: + - accountData: + accountIndex: 1 + dataIndex: 4 + length: 4 + - accountKey: + index: 1 + role: "readonly" + "#; + let parsed_config_file = serde_yaml::from_str::(config).unwrap(); + let parsed_extra_metas: Vec = parsed_config_file + .extra_metas + .iter() + .map(|config| config.into()) + .collect::>(); + let expected = vec![ + ExtraAccountMeta::new_with_pubkey( + &Pubkey::from_str("39UhVsxAmJwzPnoWhBSHsZ6nBDtdzt9D8rfDa8zGHrP6").unwrap(), + true, + false, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey( + &Pubkey::from_str("6WEvW9B9jTKc3EhP1ewGEJPrxw5d8vD9eMYCf2snNYsV").unwrap(), + false, + false, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: vec![1, 2, 3, 4, 5, 6], + }, + Seed::InstructionData { + index: 0, + length: 8, + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::AccountData { + account_index: 1, + data_index: 4, + length: 4, + }, + Seed::AccountKey { index: 1 }, + ], + false, + false, + ) + .unwrap(), + ]; + assert_eq!(parsed_extra_metas, expected); + } +} diff --git a/interface/Cargo.toml b/interface/Cargo.toml new file mode 100644 index 0000000..601189d --- /dev/null +++ b/interface/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "spl-transfer-hook-interface" +version = "0.9.0" +description = "Solana Program Library Transfer Hook Interface" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[dependencies] +arrayref = "0.3.9" +bytemuck = { version = "1.20.0", features = ["derive"] } +num-derive = "0.4" +num-traits = "0.2" +solana-account-info = "2.1.0" +solana-cpi = "2.1.0" +solana-decode-error = "2.1.0" +solana-instruction = { version = "2.1.0", features = ["std"] } +solana-msg = "2.1.0" +solana-program-error = "2.1.0" +solana-pubkey = { version = "2.1.0", features = ["curve25519"] } +spl-discriminator = { version = "0.4.0" , path = "../../../libraries/discriminator" } +spl-program-error = { version = "0.6.0", path = "../../../libraries/program-error" } +spl-tlv-account-resolution = { version = "0.9.0", path = "../../../libraries/tlv-account-resolution" } +spl-type-length-value = { version = "0.7.0", path = "../../../libraries/type-length-value" } +spl-pod = { version = "0.5.0", path = "../../../libraries/pod" } +thiserror = "2.0" + +[lib] +crate-type = ["cdylib", "lib"] + +[dev-dependencies] +solana-program = "2.1.0" +tokio = { version = "1.42.0", features = ["full"] } + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/interface/README.md b/interface/README.md new file mode 100644 index 0000000..17c320b --- /dev/null +++ b/interface/README.md @@ -0,0 +1,149 @@ +## Transfer-Hook Interface + +### Example program + +Here is an example program that only implements the required "execute" instruction, +assuming that the proper account data is already written to the appropriate +program-derived address defined by the interface. + +```rust +use { + solana_program::{entrypoint::ProgramResult, program_error::ProgramError}, + spl_tlv_account_resolution::state::ExtraAccountMetaList, + spl_transfer_hook_interface::instruction::{ExecuteInstruction, TransferHookInstruction}, + spl_type_length_value::state::TlvStateBorrowed, +}; +pub fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + let instruction = TransferHookInstruction::unpack(input)?; + let _amount = match instruction { + TransferHookInstruction::Execute { amount } => amount, + _ => return Err(ProgramError::InvalidInstructionData), + }; + let account_info_iter = &mut accounts.iter(); + + // Pull out the accounts in order, none are validated in this test program + let _source_account_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let _destination_account_info = next_account_info(account_info_iter)?; + let _authority_info = next_account_info(account_info_iter)?; + let extra_account_metas_info = next_account_info(account_info_iter)?; + + // Only check that the correct pda and account are provided + let expected_validation_address = get_extra_account_metas_address(mint_info.key, program_id); + if expected_validation_address != *extra_account_metas_info.key { + return Err(ProgramError::InvalidSeeds); + } + + // Load the extra required accounts from the validation account + let data = extra_account_metas_info.try_borrow_data()?; + + // Check the provided accounts against the validation data + ExtraAccountMetaList::check_account_infos::( + accounts, + &TransferHookInstruction::Execute { amount }.pack(), + program_id, + &data, + )?; + + Ok(()) +} +``` + +### Motivation + +Token creators may need more control over how their token is transferred. The +most prominent use case revolves around NFT royalties. Whenever a token is moved, +the creator should be entitled to royalties, but due to the design of the current +token program, it's impossible to stop a transfer at the protocol level. + +Current solutions typically resort to perpetually freezing tokens, which requires +a whole proxy layer to interact with the token. Wallets and marketplaces need +to be aware of the proxy layer in order to properly use the token. + +Worse still, different royalty systems have different proxy layers for using +their token. All in all, these systems harm composability and make development +harder. + +### Solution + +To improve the situation, Token-2022 introduces the concept of the transfer-hook +interface and extension. A token creator must develop and deploy a program that +implements the interface and then configure their token mint to use their program. + +During transfer, Token-2022 calls into the program with the accounts specified +at a well-defined program-derived address for that mint and program id. This +call happens after all other transfer logic, so the accounts reflect the *end* +state of the transfer. + +### How to Use + +Developers must implement the `Execute` instruction, and optionally the +`InitializeExtraAccountMetaList` instruction to write the required additional account +pubkeys into the program-derived address defined by the mint and program id. + +Note: it's technically not required to implement `InitializeExtraAccountMetaList` +at that instruction discriminator. Your program may implement multiple interfaces, +so any other instruction in your program can create the account at the program-derived +address! + +When your program stores configurations for extra required accounts in the +well-defined program-derived address, it's possible to send an instruction - +such as `Execute` (transfer) - to your program with only accounts required +for the interface instruction, and all extra required accounts are +automatically resolved! + +### Account Resolution + +Implementations of the transfer-hook interface are encouraged to make use of the +[spl-tlv-account-resolution](https://github.com/solana-labs/solana-program-library/tree/master/libraries/tlv-account-resolution/README.md) +library to manage the additional required accounts for their transfer hook +program. + +TLV Account Resolution is capable of powering on-chain account resolution +when an instruction that requires extra accounts is invoked. +Read more about how account resolution works in the repository's +[README file](https://github.com/solana-labs/solana-program-library/tree/master/libraries/tlv-account-resolution/README.md). + +### An Example + +You have created a DAO to govern a community. Your DAO's authority is a +multisig account, and you want to ensure that any transfer of your token is +approved by the DAO. You also want to make sure that someone who intends to +transfer your token has the proper permissions to do so. + +Let's assume the DAO multisig has some **fixed address**. And let's assume that +in order to have the `can_transfer` permission, a user must have this +**dynamic program-derived address** associated with their wallet via the +following seeds: `"can_transfer" + `. + +Using the transfer-hook interface, you can store these configurations in the +well-defined program-derived address for your mint and program id. + +When a user attempts to transfer your token, they might provide to Token-2022: + +```rust +[source, mint, destination, owner/delegate] +``` + +Token-2022 will then call into your program, +**resolving the extra required accounts automatically** from your stored +configurations, to result in the following accounts being provided to your +program: + +```rust +[source, mint, destination, owner/delegate, dao_authority, can_transfer_pda] +``` + +### Utilities + +The `spl-transfer-hook-interface` library provides offchain and onchain helpers +for resolving the additional accounts required. See +[onchain.rs](https://github.com/solana-labs/solana-program-library/tree/master/token/transfer-hook/interface/src/onchain.rs) +for usage on-chain, and +[offchain.rs](https://github.com/solana-labs/solana-program-library/tree/master/token/transfer-hook/interface/src/offchain.rs) +for fetching the additional required account metas with any async off-chain client +like `BanksClient` or `RpcClient`. diff --git a/interface/src/error.rs b/interface/src/error.rs new file mode 100644 index 0000000..de50ec8 --- /dev/null +++ b/interface/src/error.rs @@ -0,0 +1,63 @@ +//! Error types + +use { + solana_decode_error::DecodeError, + solana_msg::msg, + solana_program_error::{PrintProgramError, ProgramError}, +}; + +/// Errors that may be returned by the interface. +#[repr(u32)] +#[derive(Clone, Debug, Eq, thiserror::Error, num_derive::FromPrimitive, PartialEq)] +pub enum TransferHookError { + /// Incorrect account provided + #[error("Incorrect account provided")] + IncorrectAccount = 2_110_272_652, + /// Mint has no mint authority + #[error("Mint has no mint authority")] + MintHasNoMintAuthority, + /// Incorrect mint authority has signed the instruction + #[error("Incorrect mint authority has signed the instruction")] + IncorrectMintAuthority, + /// Program called outside of a token transfer + #[error("Program called outside of a token transfer")] + ProgramCalledOutsideOfTransfer, +} + +impl From for ProgramError { + fn from(e: TransferHookError) -> Self { + ProgramError::Custom(e as u32) + } +} + +impl DecodeError for TransferHookError { + fn type_of() -> &'static str { + "TransferHookError" + } +} + +impl PrintProgramError for TransferHookError { + fn print(&self) + where + E: 'static + + std::error::Error + + DecodeError + + PrintProgramError + + num_traits::FromPrimitive, + { + match self { + TransferHookError::IncorrectAccount => { + msg!("Incorrect account provided") + } + TransferHookError::MintHasNoMintAuthority => { + msg!("Mint has no mint authority") + } + TransferHookError::IncorrectMintAuthority => { + msg!("Incorrect mint authority has signed the instruction") + } + TransferHookError::ProgramCalledOutsideOfTransfer => { + msg!("Program called outside of a token transfer") + } + } + } +} diff --git a/interface/src/instruction.rs b/interface/src/instruction.rs new file mode 100644 index 0000000..68485bc --- /dev/null +++ b/interface/src/instruction.rs @@ -0,0 +1,310 @@ +//! Instruction types + +use { + solana_instruction::{AccountMeta, Instruction}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + spl_discriminator::{ArrayDiscriminator, SplDiscriminate}, + spl_pod::{bytemuck::pod_slice_to_bytes, slice::PodSlice}, + spl_tlv_account_resolution::account::ExtraAccountMeta, + std::convert::TryInto, +}; + +const SYSTEM_PROGRAM_ID: Pubkey = Pubkey::from_str_const("11111111111111111111111111111111"); + +/// Instructions supported by the transfer hook interface. +#[repr(C)] +#[derive(Clone, Debug, PartialEq)] +pub enum TransferHookInstruction { + /// Runs additional transfer logic. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[]` Source account + /// 1. `[]` Token mint + /// 2. `[]` Destination account + /// 3. `[]` Source account's owner/delegate + /// 4. `[]` (Optional) Validation account + /// 5. ..`5+M` `[]` `M` optional additional accounts, written in + /// validation account data + Execute { + /// Amount of tokens to transfer + amount: u64, + }, + + /// Initializes the extra account metas on an account, writing into the + /// first open TLV space. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Account with extra account metas + /// 1. `[]` Mint + /// 2. `[s]` Mint authority + /// 3. `[]` System program + InitializeExtraAccountMetaList { + /// List of `ExtraAccountMeta`s to write into the account + extra_account_metas: Vec, + }, + /// Updates the extra account metas on an account by overwriting the + /// existing list. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[w]` Account with extra account metas + /// 1. `[]` Mint + /// 2. `[s]` Mint authority + UpdateExtraAccountMetaList { + /// The new list of `ExtraAccountMetas` to overwrite the existing entry + /// in the account. + extra_account_metas: Vec, + }, +} +/// TLV instruction type only used to define the discriminator. The actual data +/// is entirely managed by `ExtraAccountMetaList`, and it is the only data +/// contained by this type. +#[derive(SplDiscriminate)] +#[discriminator_hash_input("spl-transfer-hook-interface:execute")] +pub struct ExecuteInstruction; + +/// TLV instruction type used to initialize extra account metas +/// for the transfer hook +#[derive(SplDiscriminate)] +#[discriminator_hash_input("spl-transfer-hook-interface:initialize-extra-account-metas")] +pub struct InitializeExtraAccountMetaListInstruction; + +/// TLV instruction type used to update extra account metas +/// for the transfer hook +#[derive(SplDiscriminate)] +#[discriminator_hash_input("spl-transfer-hook-interface:update-extra-account-metas")] +pub struct UpdateExtraAccountMetaListInstruction; + +impl TransferHookInstruction { + /// Unpacks a byte buffer into a + /// [`TransferHookInstruction`](enum.TransferHookInstruction.html). + pub fn unpack(input: &[u8]) -> Result { + if input.len() < ArrayDiscriminator::LENGTH { + return Err(ProgramError::InvalidInstructionData); + } + let (discriminator, rest) = input.split_at(ArrayDiscriminator::LENGTH); + Ok(match discriminator { + ExecuteInstruction::SPL_DISCRIMINATOR_SLICE => { + let amount = rest + .get(..8) + .and_then(|slice| slice.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or(ProgramError::InvalidInstructionData)?; + Self::Execute { amount } + } + InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE => { + let pod_slice = PodSlice::::unpack(rest)?; + let extra_account_metas = pod_slice.data().to_vec(); + Self::InitializeExtraAccountMetaList { + extra_account_metas, + } + } + UpdateExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE => { + let pod_slice = PodSlice::::unpack(rest)?; + let extra_account_metas = pod_slice.data().to_vec(); + Self::UpdateExtraAccountMetaList { + extra_account_metas, + } + } + _ => return Err(ProgramError::InvalidInstructionData), + }) + } + + /// Packs a [`TransferHookInstruction`](enum.TransferHookInstruction.html) + /// into a byte buffer. + pub fn pack(&self) -> Vec { + let mut buf = vec![]; + match self { + Self::Execute { amount } => { + buf.extend_from_slice(ExecuteInstruction::SPL_DISCRIMINATOR_SLICE); + buf.extend_from_slice(&amount.to_le_bytes()); + } + Self::InitializeExtraAccountMetaList { + extra_account_metas, + } => { + buf.extend_from_slice( + InitializeExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE, + ); + buf.extend_from_slice(&(extra_account_metas.len() as u32).to_le_bytes()); + buf.extend_from_slice(pod_slice_to_bytes(extra_account_metas)); + } + Self::UpdateExtraAccountMetaList { + extra_account_metas, + } => { + buf.extend_from_slice( + UpdateExtraAccountMetaListInstruction::SPL_DISCRIMINATOR_SLICE, + ); + buf.extend_from_slice(&(extra_account_metas.len() as u32).to_le_bytes()); + buf.extend_from_slice(pod_slice_to_bytes(extra_account_metas)); + } + }; + buf + } +} + +/// Creates an `Execute` instruction, provided all of the additional required +/// account metas +#[allow(clippy::too_many_arguments)] +pub fn execute_with_extra_account_metas( + program_id: &Pubkey, + source_pubkey: &Pubkey, + mint_pubkey: &Pubkey, + destination_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + validate_state_pubkey: &Pubkey, + additional_accounts: &[AccountMeta], + amount: u64, +) -> Instruction { + let mut instruction = execute( + program_id, + source_pubkey, + mint_pubkey, + destination_pubkey, + authority_pubkey, + amount, + ); + instruction + .accounts + .push(AccountMeta::new_readonly(*validate_state_pubkey, false)); + instruction.accounts.extend_from_slice(additional_accounts); + instruction +} + +/// Creates an `Execute` instruction, without the additional accounts +#[allow(clippy::too_many_arguments)] +pub fn execute( + program_id: &Pubkey, + source_pubkey: &Pubkey, + mint_pubkey: &Pubkey, + destination_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + amount: u64, +) -> Instruction { + let data = TransferHookInstruction::Execute { amount }.pack(); + let accounts = vec![ + AccountMeta::new_readonly(*source_pubkey, false), + AccountMeta::new_readonly(*mint_pubkey, false), + AccountMeta::new_readonly(*destination_pubkey, false), + AccountMeta::new_readonly(*authority_pubkey, false), + ]; + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates a `InitializeExtraAccountMetaList` instruction. +pub fn initialize_extra_account_meta_list( + program_id: &Pubkey, + extra_account_metas_pubkey: &Pubkey, + mint_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + extra_account_metas: &[ExtraAccountMeta], +) -> Instruction { + let data = TransferHookInstruction::InitializeExtraAccountMetaList { + extra_account_metas: extra_account_metas.to_vec(), + } + .pack(); + + let accounts = vec![ + AccountMeta::new(*extra_account_metas_pubkey, false), + AccountMeta::new_readonly(*mint_pubkey, false), + AccountMeta::new_readonly(*authority_pubkey, true), + AccountMeta::new_readonly(SYSTEM_PROGRAM_ID, false), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +/// Creates a `UpdateExtraAccountMetaList` instruction. +pub fn update_extra_account_meta_list( + program_id: &Pubkey, + extra_account_metas_pubkey: &Pubkey, + mint_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + extra_account_metas: &[ExtraAccountMeta], +) -> Instruction { + let data = TransferHookInstruction::UpdateExtraAccountMetaList { + extra_account_metas: extra_account_metas.to_vec(), + } + .pack(); + + let accounts = vec![ + AccountMeta::new(*extra_account_metas_pubkey, false), + AccountMeta::new_readonly(*mint_pubkey, false), + AccountMeta::new_readonly(*authority_pubkey, true), + ]; + + Instruction { + program_id: *program_id, + accounts, + data, + } +} + +#[cfg(test)] +mod test { + use {super::*, crate::NAMESPACE, solana_program::hash, spl_pod::bytemuck::pod_from_bytes}; + + #[test] + fn system_program_id() { + assert_eq!(solana_program::system_program::id(), SYSTEM_PROGRAM_ID); + } + + #[test] + fn validate_packing() { + let amount = 111_111_111; + let check = TransferHookInstruction::Execute { amount }; + let packed = check.pack(); + // Please use ExecuteInstruction::SPL_DISCRIMINATOR in your program, the + // following is just for test purposes + let preimage = hash::hashv(&[format!("{NAMESPACE}:execute").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + let mut expect = vec![]; + expect.extend_from_slice(discriminator.as_ref()); + expect.extend_from_slice(&amount.to_le_bytes()); + assert_eq!(packed, expect); + let unpacked = TransferHookInstruction::unpack(&expect).unwrap(); + assert_eq!(unpacked, check); + } + + #[test] + fn initialize_validation_pubkeys_packing() { + let extra_meta_len_bytes = &[ + 1, 0, 0, 0, // `1u32` + ]; + let extra_meta_bytes = &[ + 0, // `AccountMeta` + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, // pubkey + 0, // is_signer + 0, // is_writable + ]; + let extra_account_metas = + vec![*pod_from_bytes::(extra_meta_bytes).unwrap()]; + let check = TransferHookInstruction::InitializeExtraAccountMetaList { + extra_account_metas, + }; + let packed = check.pack(); + // Please use INITIALIZE_EXTRA_ACCOUNT_METAS_DISCRIMINATOR in your program, + // the following is just for test purposes + let preimage = + hash::hashv(&[format!("{NAMESPACE}:initialize-extra-account-metas").as_bytes()]); + let discriminator = &preimage.as_ref()[..ArrayDiscriminator::LENGTH]; + let mut expect = vec![]; + expect.extend_from_slice(discriminator.as_ref()); + expect.extend_from_slice(extra_meta_len_bytes); + expect.extend_from_slice(extra_meta_bytes); + assert_eq!(packed, expect); + let unpacked = TransferHookInstruction::unpack(&expect).unwrap(); + assert_eq!(unpacked, check); + } +} diff --git a/interface/src/lib.rs b/interface/src/lib.rs new file mode 100644 index 0000000..0e4f382 --- /dev/null +++ b/interface/src/lib.rs @@ -0,0 +1,56 @@ +//! Crate defining an interface for performing a hook on transfer, where the +//! token program calls into a separate program with additional accounts after +//! all other logic, to be sure that a transfer has accomplished all required +//! preconditions. + +#![allow(clippy::arithmetic_side_effects)] +#![deny(missing_docs)] +#![cfg_attr(not(test), forbid(unsafe_code))] + +pub mod error; +pub mod instruction; +pub mod offchain; +pub mod onchain; + +// Export current sdk types for downstream users building with a different sdk +// version +use solana_pubkey::Pubkey; +pub use { + solana_account_info, solana_cpi, solana_decode_error, solana_instruction, solana_msg, + solana_program_error, solana_pubkey, +}; + +/// Namespace for all programs implementing transfer-hook +pub const NAMESPACE: &str = "spl-transfer-hook-interface"; + +/// Seed for the state +const EXTRA_ACCOUNT_METAS_SEED: &[u8] = b"extra-account-metas"; + +/// Get the state address PDA +pub fn get_extra_account_metas_address(mint: &Pubkey, program_id: &Pubkey) -> Pubkey { + get_extra_account_metas_address_and_bump_seed(mint, program_id).0 +} + +/// Function used by programs implementing the interface, when creating the PDA, +/// to also get the bump seed +pub fn get_extra_account_metas_address_and_bump_seed( + mint: &Pubkey, + program_id: &Pubkey, +) -> (Pubkey, u8) { + Pubkey::find_program_address(&collect_extra_account_metas_seeds(mint), program_id) +} + +/// Function used by programs implementing the interface, when creating the PDA, +/// to get all of the PDA seeds +pub fn collect_extra_account_metas_seeds(mint: &Pubkey) -> [&[u8]; 2] { + [EXTRA_ACCOUNT_METAS_SEED, mint.as_ref()] +} + +/// Function used by programs implementing the interface, when creating the PDA, +/// to sign for the PDA +pub fn collect_extra_account_metas_signer_seeds<'a>( + mint: &'a Pubkey, + bump_seed: &'a [u8], +) -> [&'a [u8]; 3] { + [EXTRA_ACCOUNT_METAS_SEED, mint.as_ref(), bump_seed] +} diff --git a/interface/src/offchain.rs b/interface/src/offchain.rs new file mode 100644 index 0000000..46e078e --- /dev/null +++ b/interface/src/offchain.rs @@ -0,0 +1,260 @@ +//! Offchain helper for fetching required accounts to build instructions + +pub use spl_tlv_account_resolution::state::{AccountDataResult, AccountFetchError}; +use { + crate::{ + error::TransferHookError, + get_extra_account_metas_address, + instruction::{execute, ExecuteInstruction}, + }, + solana_instruction::{AccountMeta, Instruction}, + solana_program_error::ProgramError, + solana_pubkey::Pubkey, + spl_tlv_account_resolution::state::ExtraAccountMetaList, + std::future::Future, +}; + +/// Offchain helper to get all additional required account metas for an execute +/// instruction, based on a validation state account. +/// +/// The instruction being provided to this function must contain at least the +/// same account keys as the ones being provided, in order. Specifically: +/// 1. source +/// 2. mint +/// 3. destination +/// 4. authority +/// +/// The `program_id` should be the program ID of the program that the +/// created `ExecuteInstruction` is for. +/// +/// To be client-agnostic and to avoid pulling in the full solana-sdk, this +/// simply takes a function that will return its data as `Future>` for +/// the given address. Can be called in the following way: +/// +/// ```rust,ignore +/// add_extra_account_metas_for_execute( +/// &mut instruction, +/// &program_id, +/// &source, +/// &mint, +/// &destination, +/// &authority, +/// amount, +/// |address| self.client.get_account(&address).map_ok(|opt| opt.map(|acc| acc.data)), +/// ) +/// .await?; +/// ``` +#[allow(clippy::too_many_arguments)] +pub async fn add_extra_account_metas_for_execute( + instruction: &mut Instruction, + program_id: &Pubkey, + source_pubkey: &Pubkey, + mint_pubkey: &Pubkey, + destination_pubkey: &Pubkey, + authority_pubkey: &Pubkey, + amount: u64, + fetch_account_data_fn: F, +) -> Result<(), AccountFetchError> +where + F: Fn(Pubkey) -> Fut, + Fut: Future, +{ + let validate_state_pubkey = get_extra_account_metas_address(mint_pubkey, program_id); + let validate_state_data = fetch_account_data_fn(validate_state_pubkey) + .await? + .ok_or(ProgramError::InvalidAccountData)?; + + // Check to make sure the provided keys are in the instruction + if [ + source_pubkey, + mint_pubkey, + destination_pubkey, + authority_pubkey, + ] + .iter() + .any(|&key| !instruction.accounts.iter().any(|meta| meta.pubkey == *key)) + { + Err(TransferHookError::IncorrectAccount)?; + } + + let mut execute_instruction = execute( + program_id, + source_pubkey, + mint_pubkey, + destination_pubkey, + authority_pubkey, + amount, + ); + execute_instruction + .accounts + .push(AccountMeta::new_readonly(validate_state_pubkey, false)); + + ExtraAccountMetaList::add_to_instruction::( + &mut execute_instruction, + fetch_account_data_fn, + &validate_state_data, + ) + .await?; + + // Add only the extra accounts resolved from the validation state + instruction + .accounts + .extend_from_slice(&execute_instruction.accounts[5..]); + + // Add the program id and validation state account + instruction + .accounts + .push(AccountMeta::new_readonly(*program_id, false)); + instruction + .accounts + .push(AccountMeta::new_readonly(validate_state_pubkey, false)); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use { + super::*, + spl_tlv_account_resolution::{account::ExtraAccountMeta, seeds::Seed}, + tokio, + }; + + const PROGRAM_ID: Pubkey = Pubkey::new_from_array([1u8; 32]); + const EXTRA_META_1: Pubkey = Pubkey::new_from_array([2u8; 32]); + const EXTRA_META_2: Pubkey = Pubkey::new_from_array([3u8; 32]); + + // Mock to return the validation state account data + async fn mock_fetch_account_data_fn(_address: Pubkey) -> AccountDataResult { + let extra_metas = vec![ + ExtraAccountMeta::new_with_pubkey(&EXTRA_META_1, true, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&EXTRA_META_2, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::AccountKey { index: 0 }, // source + Seed::AccountKey { index: 2 }, // destination + Seed::AccountKey { index: 4 }, // validation state + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, + length: 8, + }, // amount + Seed::AccountKey { index: 2 }, // destination + Seed::AccountKey { index: 5 }, // extra meta 1 + Seed::AccountKey { index: 7 }, // extra meta 3 (PDA) + ], + false, + true, + ) + .unwrap(), + ]; + let account_size = ExtraAccountMetaList::size_of(extra_metas.len()).unwrap(); + let mut data = vec![0u8; account_size]; + ExtraAccountMetaList::init::(&mut data, &extra_metas)?; + Ok(Some(data)) + } + + #[tokio::test] + async fn test_add_extra_account_metas_for_execute() { + let source = Pubkey::new_unique(); + let mint = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let authority = Pubkey::new_unique(); + let amount = 100u64; + + let validate_state_pubkey = get_extra_account_metas_address(&mint, &PROGRAM_ID); + let extra_meta_3_pubkey = Pubkey::find_program_address( + &[ + source.as_ref(), + destination.as_ref(), + validate_state_pubkey.as_ref(), + ], + &PROGRAM_ID, + ) + .0; + let extra_meta_4_pubkey = Pubkey::find_program_address( + &[ + amount.to_le_bytes().as_ref(), + destination.as_ref(), + EXTRA_META_1.as_ref(), + extra_meta_3_pubkey.as_ref(), + ], + &PROGRAM_ID, + ) + .0; + + // Fail missing key + let mut instruction = Instruction::new_with_bytes( + PROGRAM_ID, + &[], + vec![ + // source missing + AccountMeta::new_readonly(mint, false), + AccountMeta::new(destination, false), + AccountMeta::new_readonly(authority, true), + ], + ); + assert_eq!( + add_extra_account_metas_for_execute( + &mut instruction, + &PROGRAM_ID, + &source, + &mint, + &destination, + &authority, + amount, + mock_fetch_account_data_fn, + ) + .await + .unwrap_err() + .downcast::() + .unwrap(), + Box::new(TransferHookError::IncorrectAccount) + ); + + // Success + let mut instruction = Instruction::new_with_bytes( + PROGRAM_ID, + &[], + vec![ + AccountMeta::new(source, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new(destination, false), + AccountMeta::new_readonly(authority, true), + ], + ); + add_extra_account_metas_for_execute( + &mut instruction, + &PROGRAM_ID, + &source, + &mint, + &destination, + &authority, + amount, + mock_fetch_account_data_fn, + ) + .await + .unwrap(); + + let check_metas = [ + AccountMeta::new(source, false), + AccountMeta::new_readonly(mint, false), + AccountMeta::new(destination, false), + AccountMeta::new_readonly(authority, true), + AccountMeta::new_readonly(EXTRA_META_1, true), + AccountMeta::new_readonly(EXTRA_META_2, true), + AccountMeta::new(extra_meta_3_pubkey, false), + AccountMeta::new(extra_meta_4_pubkey, false), + AccountMeta::new_readonly(PROGRAM_ID, false), + AccountMeta::new_readonly(validate_state_pubkey, false), + ]; + + assert_eq!(instruction.accounts, check_metas); + } +} diff --git a/interface/src/onchain.rs b/interface/src/onchain.rs new file mode 100644 index 0000000..7bd8eff --- /dev/null +++ b/interface/src/onchain.rs @@ -0,0 +1,521 @@ +//! On-chain program invoke helper to perform on-chain `execute` with correct +//! accounts + +use { + crate::{error::TransferHookError, get_extra_account_metas_address, instruction}, + solana_account_info::AccountInfo, + solana_cpi::invoke, + solana_instruction::{AccountMeta, Instruction}, + solana_program_error::ProgramResult, + solana_pubkey::Pubkey, + spl_tlv_account_resolution::state::ExtraAccountMetaList, +}; +/// Helper to CPI into a transfer-hook program on-chain, looking through the +/// additional account infos to create the proper instruction +pub fn invoke_execute<'a>( + program_id: &Pubkey, + source_info: AccountInfo<'a>, + mint_info: AccountInfo<'a>, + destination_info: AccountInfo<'a>, + authority_info: AccountInfo<'a>, + additional_accounts: &[AccountInfo<'a>], + amount: u64, +) -> ProgramResult { + let mut cpi_instruction = instruction::execute( + program_id, + source_info.key, + mint_info.key, + destination_info.key, + authority_info.key, + amount, + ); + + let validation_pubkey = get_extra_account_metas_address(mint_info.key, program_id); + + let mut cpi_account_infos = vec![source_info, mint_info, destination_info, authority_info]; + + if let Some(validation_info) = additional_accounts + .iter() + .find(|&x| *x.key == validation_pubkey) + { + cpi_instruction + .accounts + .push(AccountMeta::new_readonly(validation_pubkey, false)); + cpi_account_infos.push(validation_info.clone()); + + ExtraAccountMetaList::add_to_cpi_instruction::( + &mut cpi_instruction, + &mut cpi_account_infos, + &validation_info.try_borrow_data()?, + additional_accounts, + )?; + } + + invoke(&cpi_instruction, &cpi_account_infos) +} + +/// Helper to add accounts required for an `ExecuteInstruction` on-chain, +/// looking through the additional account infos to add the proper accounts. +/// +/// Note this helper is designed to add the extra accounts that will be +/// required for a CPI to a transfer hook program. However, the instruction +/// being provided to this helper is for the program that will CPI to the +/// transfer hook program. Because of this, we must resolve the extra accounts +/// for the `ExecuteInstruction` CPI, then add those extra resolved accounts to +/// the provided instruction. +#[allow(clippy::too_many_arguments)] +pub fn add_extra_accounts_for_execute_cpi<'a>( + cpi_instruction: &mut Instruction, + cpi_account_infos: &mut Vec>, + program_id: &Pubkey, + source_info: AccountInfo<'a>, + mint_info: AccountInfo<'a>, + destination_info: AccountInfo<'a>, + authority_info: AccountInfo<'a>, + amount: u64, + additional_accounts: &[AccountInfo<'a>], +) -> ProgramResult { + let validate_state_pubkey = get_extra_account_metas_address(mint_info.key, program_id); + + let program_info = additional_accounts + .iter() + .find(|&x| x.key == program_id) + .ok_or(TransferHookError::IncorrectAccount)?; + + if let Some(validate_state_info) = additional_accounts + .iter() + .find(|&x| *x.key == validate_state_pubkey) + { + let mut execute_instruction = instruction::execute( + program_id, + source_info.key, + mint_info.key, + destination_info.key, + authority_info.key, + amount, + ); + execute_instruction + .accounts + .push(AccountMeta::new_readonly(validate_state_pubkey, false)); + let mut execute_account_infos = vec![ + source_info, + mint_info, + destination_info, + authority_info, + validate_state_info.clone(), + ]; + + ExtraAccountMetaList::add_to_cpi_instruction::( + &mut execute_instruction, + &mut execute_account_infos, + &validate_state_info.try_borrow_data()?, + additional_accounts, + )?; + + // Add only the extra accounts resolved from the validation state + cpi_instruction + .accounts + .extend_from_slice(&execute_instruction.accounts[5..]); + cpi_account_infos.extend_from_slice(&execute_account_infos[5..]); + + // Add the validation state account + cpi_instruction + .accounts + .push(AccountMeta::new_readonly(validate_state_pubkey, false)); + cpi_account_infos.push(validate_state_info.clone()); + } + + // Add the program id + cpi_instruction + .accounts + .push(AccountMeta::new_readonly(*program_id, false)); + cpi_account_infos.push(program_info.clone()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use { + super::*, + crate::instruction::ExecuteInstruction, + solana_program::{bpf_loader_upgradeable, system_program}, + spl_tlv_account_resolution::{ + account::ExtraAccountMeta, error::AccountResolutionError, seeds::Seed, + }, + }; + + const EXTRA_META_1: Pubkey = Pubkey::new_from_array([2u8; 32]); + const EXTRA_META_2: Pubkey = Pubkey::new_from_array([3u8; 32]); + + fn setup_validation_data() -> Vec { + let extra_metas = vec![ + ExtraAccountMeta::new_with_pubkey(&EXTRA_META_1, true, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&EXTRA_META_2, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::AccountKey { index: 0 }, // source + Seed::AccountKey { index: 2 }, // destination + Seed::AccountKey { index: 4 }, // validation state + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, + length: 8, + }, // amount + Seed::AccountKey { index: 2 }, // destination + Seed::AccountKey { index: 5 }, // extra meta 1 + Seed::AccountKey { index: 7 }, // extra meta 3 (PDA) + ], + false, + true, + ) + .unwrap(), + ]; + let account_size = ExtraAccountMetaList::size_of(extra_metas.len()).unwrap(); + let mut data = vec![0u8; account_size]; + ExtraAccountMetaList::init::(&mut data, &extra_metas).unwrap(); + data + } + + #[test] + fn test_add_extra_accounts_for_execute_cpi() { + let spl_token_2022_program_id = Pubkey::new_unique(); // Mock + let transfer_hook_program_id = Pubkey::new_unique(); + + let amount = 100u64; + + let source_pubkey = Pubkey::new_unique(); + let mut source_data = vec![0; 165]; // Mock + let mut source_lamports = 0; // Mock + let source_account_info = AccountInfo::new( + &source_pubkey, + false, + true, + &mut source_lamports, + &mut source_data, + &spl_token_2022_program_id, + false, + 0, + ); + + let mint_pubkey = Pubkey::new_unique(); + let mut mint_data = vec![0; 165]; // Mock + let mut mint_lamports = 0; // Mock + let mint_account_info = AccountInfo::new( + &mint_pubkey, + false, + true, + &mut mint_lamports, + &mut mint_data, + &spl_token_2022_program_id, + false, + 0, + ); + + let destination_pubkey = Pubkey::new_unique(); + let mut destination_data = vec![0; 165]; // Mock + let mut destination_lamports = 0; // Mock + let destination_account_info = AccountInfo::new( + &destination_pubkey, + false, + true, + &mut destination_lamports, + &mut destination_data, + &spl_token_2022_program_id, + false, + 0, + ); + + let authority_pubkey = Pubkey::new_unique(); + let mut authority_data = vec![]; // Mock + let mut authority_lamports = 0; // Mock + let authority_account_info = AccountInfo::new( + &authority_pubkey, + false, + true, + &mut authority_lamports, + &mut authority_data, + &system_program::ID, + false, + 0, + ); + + let validate_state_pubkey = + get_extra_account_metas_address(&mint_pubkey, &transfer_hook_program_id); + + let extra_meta_1_pubkey = EXTRA_META_1; + let mut extra_meta_1_data = vec![]; // Mock + let mut extra_meta_1_lamports = 0; // Mock + let extra_meta_1_account_info = AccountInfo::new( + &extra_meta_1_pubkey, + true, + false, + &mut extra_meta_1_lamports, + &mut extra_meta_1_data, + &system_program::ID, + false, + 0, + ); + + let extra_meta_2_pubkey = EXTRA_META_2; + let mut extra_meta_2_data = vec![]; // Mock + let mut extra_meta_2_lamports = 0; // Mock + let extra_meta_2_account_info = AccountInfo::new( + &extra_meta_2_pubkey, + true, + false, + &mut extra_meta_2_lamports, + &mut extra_meta_2_data, + &system_program::ID, + false, + 0, + ); + + let extra_meta_3_pubkey = Pubkey::find_program_address( + &[ + &source_pubkey.to_bytes(), + &destination_pubkey.to_bytes(), + &validate_state_pubkey.to_bytes(), + ], + &transfer_hook_program_id, + ) + .0; + let mut extra_meta_3_data = vec![]; // Mock + let mut extra_meta_3_lamports = 0; // Mock + let extra_meta_3_account_info = AccountInfo::new( + &extra_meta_3_pubkey, + false, + true, + &mut extra_meta_3_lamports, + &mut extra_meta_3_data, + &transfer_hook_program_id, + false, + 0, + ); + + let extra_meta_4_pubkey = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), + &destination_pubkey.to_bytes(), + &extra_meta_1_pubkey.to_bytes(), + &extra_meta_3_pubkey.to_bytes(), + ], + &transfer_hook_program_id, + ) + .0; + let mut extra_meta_4_data = vec![]; // Mock + let mut extra_meta_4_lamports = 0; // Mock + let extra_meta_4_account_info = AccountInfo::new( + &extra_meta_4_pubkey, + false, + true, + &mut extra_meta_4_lamports, + &mut extra_meta_4_data, + &transfer_hook_program_id, + false, + 0, + ); + + let mut validate_state_data = setup_validation_data(); + let mut validate_state_lamports = 0; // Mock + let validate_state_account_info = AccountInfo::new( + &validate_state_pubkey, + false, + true, + &mut validate_state_lamports, + &mut validate_state_data, + &transfer_hook_program_id, + false, + 0, + ); + + let mut transfer_hook_program_data = vec![]; // Mock + let mut transfer_hook_program_lamports = 0; // Mock + let transfer_hook_program_account_info = AccountInfo::new( + &transfer_hook_program_id, + false, + true, + &mut transfer_hook_program_lamports, + &mut transfer_hook_program_data, + &bpf_loader_upgradeable::ID, + false, + 0, + ); + + let mut cpi_instruction = Instruction::new_with_bytes( + spl_token_2022_program_id, + &[], + vec![ + AccountMeta::new(source_pubkey, false), + AccountMeta::new_readonly(mint_pubkey, false), + AccountMeta::new(destination_pubkey, false), + AccountMeta::new_readonly(authority_pubkey, true), + ], + ); + let mut cpi_account_infos = vec![ + source_account_info.clone(), + mint_account_info.clone(), + destination_account_info.clone(), + authority_account_info.clone(), + ]; + let additional_account_infos = vec![ + extra_meta_1_account_info.clone(), + extra_meta_2_account_info.clone(), + extra_meta_3_account_info.clone(), + extra_meta_4_account_info.clone(), + transfer_hook_program_account_info.clone(), + validate_state_account_info.clone(), + ]; + + // Allow missing validation info from additional account infos + { + let additional_account_infos_missing_infos = vec![ + extra_meta_1_account_info.clone(), + extra_meta_2_account_info.clone(), + extra_meta_3_account_info.clone(), + extra_meta_4_account_info.clone(), + // validate state missing + transfer_hook_program_account_info.clone(), + ]; + let mut cpi_instruction = cpi_instruction.clone(); + let mut cpi_account_infos = cpi_account_infos.clone(); + add_extra_accounts_for_execute_cpi( + &mut cpi_instruction, + &mut cpi_account_infos, + &transfer_hook_program_id, + source_account_info.clone(), + mint_account_info.clone(), + destination_account_info.clone(), + authority_account_info.clone(), + amount, + &additional_account_infos_missing_infos, + ) + .unwrap(); + let check_metas = [ + AccountMeta::new(source_pubkey, false), + AccountMeta::new_readonly(mint_pubkey, false), + AccountMeta::new(destination_pubkey, false), + AccountMeta::new_readonly(authority_pubkey, true), + AccountMeta::new_readonly(transfer_hook_program_id, false), + ]; + + let check_account_infos = vec![ + source_account_info.clone(), + mint_account_info.clone(), + destination_account_info.clone(), + authority_account_info.clone(), + transfer_hook_program_account_info.clone(), + ]; + + assert_eq!(cpi_instruction.accounts, check_metas); + for (a, b) in std::iter::zip(cpi_account_infos, check_account_infos) { + assert_eq!(a.key, b.key); + assert_eq!(a.is_signer, b.is_signer); + assert_eq!(a.is_writable, b.is_writable); + } + } + + // Fail missing program info from additional account infos + let additional_account_infos_missing_infos = vec![ + extra_meta_1_account_info.clone(), + extra_meta_2_account_info.clone(), + extra_meta_3_account_info.clone(), + extra_meta_4_account_info.clone(), + validate_state_account_info.clone(), + // transfer hook program missing + ]; + assert_eq!( + add_extra_accounts_for_execute_cpi( + &mut cpi_instruction, + &mut cpi_account_infos, + &transfer_hook_program_id, + source_account_info.clone(), + mint_account_info.clone(), + destination_account_info.clone(), + authority_account_info.clone(), + amount, + &additional_account_infos_missing_infos, // Missing account info + ) + .unwrap_err(), + TransferHookError::IncorrectAccount.into() + ); + + // Fail missing extra meta info from additional account infos + let additional_account_infos_missing_infos = vec![ + extra_meta_1_account_info.clone(), + extra_meta_2_account_info.clone(), + // extra meta 3 missing + extra_meta_4_account_info.clone(), + validate_state_account_info.clone(), + transfer_hook_program_account_info.clone(), + ]; + assert_eq!( + add_extra_accounts_for_execute_cpi( + &mut cpi_instruction, + &mut cpi_account_infos, + &transfer_hook_program_id, + source_account_info.clone(), + mint_account_info.clone(), + destination_account_info.clone(), + authority_account_info.clone(), + amount, + &additional_account_infos_missing_infos, // Missing account info + ) + .unwrap_err(), + AccountResolutionError::IncorrectAccount.into() // Note the error + ); + + // Success + add_extra_accounts_for_execute_cpi( + &mut cpi_instruction, + &mut cpi_account_infos, + &transfer_hook_program_id, + source_account_info.clone(), + mint_account_info.clone(), + destination_account_info.clone(), + authority_account_info.clone(), + amount, + &additional_account_infos, + ) + .unwrap(); + + let check_metas = [ + AccountMeta::new(source_pubkey, false), + AccountMeta::new_readonly(mint_pubkey, false), + AccountMeta::new(destination_pubkey, false), + AccountMeta::new_readonly(authority_pubkey, true), + AccountMeta::new_readonly(EXTRA_META_1, true), + AccountMeta::new_readonly(EXTRA_META_2, true), + AccountMeta::new(extra_meta_3_pubkey, false), + AccountMeta::new(extra_meta_4_pubkey, false), + AccountMeta::new_readonly(validate_state_pubkey, false), + AccountMeta::new_readonly(transfer_hook_program_id, false), + ]; + + let check_account_infos = vec![ + source_account_info, + mint_account_info, + destination_account_info, + authority_account_info, + extra_meta_1_account_info, + extra_meta_2_account_info, + extra_meta_3_account_info, + extra_meta_4_account_info, + validate_state_account_info, + transfer_hook_program_account_info, + ]; + + assert_eq!(cpi_instruction.accounts, check_metas); + for (a, b) in std::iter::zip(cpi_account_infos, check_account_infos) { + assert_eq!(a.key, b.key); + assert_eq!(a.is_signer, b.is_signer); + assert_eq!(a.is_writable, b.is_writable); + } + } +} diff --git a/program/Cargo.toml b/program/Cargo.toml new file mode 100644 index 0000000..ee6d475 --- /dev/null +++ b/program/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "spl-transfer-hook-example" +version = "0.6.0" +description = "Solana Program Library Transfer Hook Example Program" +authors = ["Solana Labs Maintainers "] +repository = "https://github.com/solana-labs/solana-program-library" +license = "Apache-2.0" +edition = "2021" + +[features] +default = ["forbid-additional-mints"] +no-entrypoint = [] +test-sbf = [] +forbid-additional-mints = [] + +[dependencies] +arrayref = "0.3.9" +solana-program = "2.1.0" +spl-tlv-account-resolution = { version = "0.9.0", path = "../../../libraries/tlv-account-resolution" } +spl-token-2022 = { version = "6.0.0", path = "../../program-2022", features = ["no-entrypoint"] } +spl-transfer-hook-interface = { version = "0.9.0", path = "../interface" } +spl-type-length-value = { version = "0.7.0", path = "../../../libraries/type-length-value" } + +[dev-dependencies] +solana-program-test = "2.1.0" +solana-sdk = "2.1.0" + +[lib] +crate-type = ["cdylib", "lib"] + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu"] diff --git a/program/README.md b/program/README.md new file mode 100644 index 0000000..4f49072 --- /dev/null +++ b/program/README.md @@ -0,0 +1,66 @@ +## Transfer-Hook Example + +Full example program and tests implementing the `spl-transfer-hook-interface`, +to be used for testing a program that calls into the `spl-transfer-hook-interface`. + +See the +[SPL Transfer Hook Interface](https://github.com/solana-labs/solana-program-library/tree/master/token/transfer-hook/interface) +code for more information. + +### Example usage of example + +When testing your program that uses `spl-transfer-hook-interface`, you can also +import this crate, and then use it with `solana-program-test`: + +```rust +use { + solana_program_test::{processor, ProgramTest}, + solana_sdk::{account::Account, instruction::AccountMeta}, + spl_transfer_hook_example::state::example_data, + spl_transfer_hook_interface::get_extra_account_metas_address, +}; + +#[test] +fn my_program_test() { + let mut program_test = ProgramTest::new( + "my_program", + my_program_id, + processor!(my_program_processor), + ); + + let transfer_hook_program_id = Pubkey::new_unique(); + program_test.prefer_bpf(false); // BPF won't work, unless you've built this from scratch! + program_test.add_program( + "spl_transfer_hook_example", + transfer_hook_program_id, + processor!(spl_transfer_hook_example::processor::process), + ); + + let mint = Pubkey::new_unique(); + let extra_accounts_address = get_extra_account_metas_address(&mint, &transfer_hook_program_id); + let account_metas = vec![ + AccountMeta { + pubkey: Pubkey::new_unique(), + is_signer: false, + is_writable: false, + }, + AccountMeta { + pubkey: Pubkey::new_unique(), + is_signer: false, + is_writable: false, + }, + ]; + let data = example_data(&account_metas); + program_test.add_account( + extra_accounts_address, + Account { + lamports: 1_000_000_000, // a lot, just to be safe + data, + owner: transfer_hook_program_id, + ..Account::default() + }, + ); + + // run your test logic! +} +``` diff --git a/program/src/entrypoint.rs b/program/src/entrypoint.rs new file mode 100644 index 0000000..b204603 --- /dev/null +++ b/program/src/entrypoint.rs @@ -0,0 +1,24 @@ +//! Program entrypoint + +use { + crate::processor, + solana_program::{ + account_info::AccountInfo, entrypoint::ProgramResult, program_error::PrintProgramError, + pubkey::Pubkey, + }, + spl_transfer_hook_interface::error::TransferHookError, +}; + +solana_program::entrypoint!(process_instruction); +fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + instruction_data: &[u8], +) -> ProgramResult { + if let Err(error) = processor::process(program_id, accounts, instruction_data) { + // catch the error so we can print it + error.print::(); + return Err(error); + } + Ok(()) +} diff --git a/program/src/lib.rs b/program/src/lib.rs new file mode 100644 index 0000000..aa49d80 --- /dev/null +++ b/program/src/lib.rs @@ -0,0 +1,30 @@ +//! Crate defining an example program for performing a hook on transfer, where +//! the token program calls into a separate program with additional accounts +//! after all other logic, to be sure that a transfer has accomplished all +//! required preconditions. + +#![allow(clippy::arithmetic_side_effects)] +#![deny(missing_docs)] +#![cfg_attr(not(test), forbid(unsafe_code))] + +pub mod processor; +pub mod state; + +#[cfg(not(feature = "no-entrypoint"))] +mod entrypoint; + +// Export current sdk types for downstream users building with a different sdk +// version +pub use solana_program; + +/// Place the mint id that you want to target with your transfer hook program. +/// Any other mint will fail to initialize, protecting the transfer hook program +/// from rogue mints trying to get access to accounts. +/// +/// There are many situations where it's reasonable to support multiple mints +/// with one transfer-hook program, but because it's easy to make something +/// unsafe, this simple example implementation only allows for one mint. +#[cfg(feature = "forbid-additional-mints")] +pub mod mint { + solana_program::declare_id!("Mint111111111111111111111111111111111111111"); +} diff --git a/program/src/processor.rs b/program/src/processor.rs new file mode 100644 index 0000000..0a5f4c1 --- /dev/null +++ b/program/src/processor.rs @@ -0,0 +1,228 @@ +//! Program state processor + +use { + solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + program::invoke_signed, + program_error::ProgramError, + pubkey::Pubkey, + system_instruction, + }, + spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList}, + spl_token_2022::{ + extension::{ + transfer_hook::TransferHookAccount, BaseStateWithExtensions, StateWithExtensions, + }, + state::{Account, Mint}, + }, + spl_transfer_hook_interface::{ + collect_extra_account_metas_signer_seeds, + error::TransferHookError, + get_extra_account_metas_address, get_extra_account_metas_address_and_bump_seed, + instruction::{ExecuteInstruction, TransferHookInstruction}, + }, +}; + +fn check_token_account_is_transferring(account_info: &AccountInfo) -> Result<(), ProgramError> { + let account_data = account_info.try_borrow_data()?; + let token_account = StateWithExtensions::::unpack(&account_data)?; + let extension = token_account.get_extension::()?; + if bool::from(extension.transferring) { + Ok(()) + } else { + Err(TransferHookError::ProgramCalledOutsideOfTransfer.into()) + } +} + +/// Processes an [Execute](enum.TransferHookInstruction.html) instruction. +pub fn process_execute( + program_id: &Pubkey, + accounts: &[AccountInfo], + amount: u64, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let source_account_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let destination_account_info = next_account_info(account_info_iter)?; + let _authority_info = next_account_info(account_info_iter)?; + let extra_account_metas_info = next_account_info(account_info_iter)?; + + // Check that the accounts are properly in "transferring" mode + check_token_account_is_transferring(source_account_info)?; + check_token_account_is_transferring(destination_account_info)?; + + // For the example program, we just check that the correct pda and validation + // pubkeys are provided + let expected_validation_address = get_extra_account_metas_address(mint_info.key, program_id); + if expected_validation_address != *extra_account_metas_info.key { + return Err(ProgramError::InvalidSeeds); + } + + let data = extra_account_metas_info.try_borrow_data()?; + + ExtraAccountMetaList::check_account_infos::( + accounts, + &TransferHookInstruction::Execute { amount }.pack(), + program_id, + &data, + )?; + + Ok(()) +} + +/// Processes a +/// [`InitializeExtraAccountMetaList`](enum.TransferHookInstruction.html) +/// instruction. +pub fn process_initialize_extra_account_meta_list( + program_id: &Pubkey, + accounts: &[AccountInfo], + extra_account_metas: &[ExtraAccountMeta], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let extra_account_metas_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let authority_info = next_account_info(account_info_iter)?; + let _system_program_info = next_account_info(account_info_iter)?; + + // check that the one mint we want to target is trying to create extra + // account metas + #[cfg(feature = "forbid-additional-mints")] + if *mint_info.key != crate::mint::id() { + return Err(ProgramError::InvalidArgument); + } + + // check that the mint authority is valid without fully deserializing + let mint_data = mint_info.try_borrow_data()?; + let mint = StateWithExtensions::::unpack(&mint_data)?; + let mint_authority = mint + .base + .mint_authority + .ok_or(TransferHookError::MintHasNoMintAuthority)?; + + // Check signers + if !authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + if *authority_info.key != mint_authority { + return Err(TransferHookError::IncorrectMintAuthority.into()); + } + + // Check validation account + let (expected_validation_address, bump_seed) = + get_extra_account_metas_address_and_bump_seed(mint_info.key, program_id); + if expected_validation_address != *extra_account_metas_info.key { + return Err(ProgramError::InvalidSeeds); + } + + // Create the account + let bump_seed = [bump_seed]; + let signer_seeds = collect_extra_account_metas_signer_seeds(mint_info.key, &bump_seed); + let length = extra_account_metas.len(); + let account_size = ExtraAccountMetaList::size_of(length)?; + invoke_signed( + &system_instruction::allocate(extra_account_metas_info.key, account_size as u64), + &[extra_account_metas_info.clone()], + &[&signer_seeds], + )?; + invoke_signed( + &system_instruction::assign(extra_account_metas_info.key, program_id), + &[extra_account_metas_info.clone()], + &[&signer_seeds], + )?; + + // Write the data + let mut data = extra_account_metas_info.try_borrow_mut_data()?; + ExtraAccountMetaList::init::(&mut data, extra_account_metas)?; + + Ok(()) +} + +/// Processes a +/// [`UpdateExtraAccountMetaList`](enum.TransferHookInstruction.html) +/// instruction. +pub fn process_update_extra_account_meta_list( + program_id: &Pubkey, + accounts: &[AccountInfo], + extra_account_metas: &[ExtraAccountMeta], +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + + let extra_account_metas_info = next_account_info(account_info_iter)?; + let mint_info = next_account_info(account_info_iter)?; + let authority_info = next_account_info(account_info_iter)?; + + // check that the mint authority is valid without fully deserializing + let mint_data = mint_info.try_borrow_data()?; + let mint = StateWithExtensions::::unpack(&mint_data)?; + let mint_authority = mint + .base + .mint_authority + .ok_or(TransferHookError::MintHasNoMintAuthority)?; + + // Check signers + if !authority_info.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + if *authority_info.key != mint_authority { + return Err(TransferHookError::IncorrectMintAuthority.into()); + } + + // Check validation account + let expected_validation_address = get_extra_account_metas_address(mint_info.key, program_id); + if expected_validation_address != *extra_account_metas_info.key { + return Err(ProgramError::InvalidSeeds); + } + + // Check if the extra metas have been initialized + let min_account_size = ExtraAccountMetaList::size_of(0)?; + let original_account_size = extra_account_metas_info.data_len(); + if program_id != extra_account_metas_info.owner || original_account_size < min_account_size { + return Err(ProgramError::UninitializedAccount); + } + + // If the new extra_account_metas length is different, resize the account and + // update + let length = extra_account_metas.len(); + let account_size = ExtraAccountMetaList::size_of(length)?; + if account_size >= original_account_size { + extra_account_metas_info.realloc(account_size, false)?; + let mut data = extra_account_metas_info.try_borrow_mut_data()?; + ExtraAccountMetaList::update::(&mut data, extra_account_metas)?; + } else { + { + let mut data = extra_account_metas_info.try_borrow_mut_data()?; + ExtraAccountMetaList::update::(&mut data, extra_account_metas)?; + } + extra_account_metas_info.realloc(account_size, false)?; + } + + Ok(()) +} + +/// Processes an [Instruction](enum.Instruction.html). +pub fn process(program_id: &Pubkey, accounts: &[AccountInfo], input: &[u8]) -> ProgramResult { + let instruction = TransferHookInstruction::unpack(input)?; + + match instruction { + TransferHookInstruction::Execute { amount } => { + msg!("Instruction: Execute"); + process_execute(program_id, accounts, amount) + } + TransferHookInstruction::InitializeExtraAccountMetaList { + extra_account_metas, + } => { + msg!("Instruction: InitializeExtraAccountMetaList"); + process_initialize_extra_account_meta_list(program_id, accounts, &extra_account_metas) + } + TransferHookInstruction::UpdateExtraAccountMetaList { + extra_account_metas, + } => { + msg!("Instruction: UpdateExtraAccountMetaList"); + process_update_extra_account_meta_list(program_id, accounts, &extra_account_metas) + } + } +} diff --git a/program/src/state.rs b/program/src/state.rs new file mode 100644 index 0000000..b2c962c --- /dev/null +++ b/program/src/state.rs @@ -0,0 +1,15 @@ +//! State helpers for working with the example program + +use { + solana_program::program_error::ProgramError, + spl_tlv_account_resolution::{account::ExtraAccountMeta, state::ExtraAccountMetaList}, + spl_transfer_hook_interface::instruction::ExecuteInstruction, +}; + +/// Generate example data to be used directly in an account for testing +pub fn example_data(account_metas: &[ExtraAccountMeta]) -> Result, ProgramError> { + let account_size = ExtraAccountMetaList::size_of(account_metas.len())?; + let mut data = vec![0; account_size]; + ExtraAccountMetaList::init::(&mut data, account_metas)?; + Ok(data) +} diff --git a/program/tests/functional.rs b/program/tests/functional.rs new file mode 100644 index 0000000..5e0aa09 --- /dev/null +++ b/program/tests/functional.rs @@ -0,0 +1,1464 @@ +// Mark this test as SBF-only due to current `ProgramTest` limitations when +// CPIing into the system program +#![cfg(feature = "test-sbf")] + +use { + solana_program_test::{processor, tokio, ProgramTest}, + solana_sdk::{ + account::Account as SolanaAccount, + account_info::AccountInfo, + entrypoint::ProgramResult, + instruction::{AccountMeta, InstructionError}, + program_error::ProgramError, + program_option::COption, + pubkey::Pubkey, + signature::Signer, + signer::keypair::Keypair, + system_instruction, sysvar, + transaction::{Transaction, TransactionError}, + }, + spl_tlv_account_resolution::{ + account::ExtraAccountMeta, error::AccountResolutionError, seeds::Seed, + state::ExtraAccountMetaList, + }, + spl_token_2022::{ + extension::{ + transfer_hook::TransferHookAccount, BaseStateWithExtensionsMut, ExtensionType, + StateWithExtensionsMut, + }, + state::{Account, AccountState, Mint}, + }, + spl_transfer_hook_interface::{ + error::TransferHookError, + get_extra_account_metas_address, + instruction::{ + execute_with_extra_account_metas, initialize_extra_account_meta_list, + update_extra_account_meta_list, + }, + onchain, + }, +}; + +fn setup(program_id: &Pubkey) -> ProgramTest { + let mut program_test = ProgramTest::new( + "spl_transfer_hook_example", + *program_id, + processor!(spl_transfer_hook_example::processor::process), + ); + + program_test.prefer_bpf(false); // simplicity in the build + + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(spl_token_2022::processor::Processor::process), + ); + + program_test +} + +#[allow(clippy::too_many_arguments)] +fn setup_token_accounts( + program_test: &mut ProgramTest, + program_id: &Pubkey, + mint_address: &Pubkey, + mint_authority: &Pubkey, + source: &Pubkey, + destination: &Pubkey, + owner: &Pubkey, + decimals: u8, + transferring: bool, +) { + // add mint, source, and destination accounts by hand to always force + // the "transferring" flag to true + let mint_size = ExtensionType::try_calculate_account_len::(&[]).unwrap(); + let mut mint_data = vec![0; mint_size]; + let mut state = StateWithExtensionsMut::::unpack_uninitialized(&mut mint_data).unwrap(); + let token_amount = 1_000_000_000_000; + state.base = Mint { + mint_authority: COption::Some(*mint_authority), + supply: token_amount, + decimals, + is_initialized: true, + freeze_authority: COption::None, + }; + state.pack_base(); + program_test.add_account( + *mint_address, + SolanaAccount { + lamports: 1_000_000_000, + data: mint_data, + owner: *program_id, + ..SolanaAccount::default() + }, + ); + + let account_size = + ExtensionType::try_calculate_account_len::(&[ExtensionType::TransferHookAccount]) + .unwrap(); + let mut account_data = vec![0; account_size]; + let mut state = + StateWithExtensionsMut::::unpack_uninitialized(&mut account_data).unwrap(); + let extension = state.init_extension::(true).unwrap(); + extension.transferring = transferring.into(); + let token_amount = 1_000_000_000_000; + state.base = Account { + mint: *mint_address, + owner: *owner, + amount: token_amount, + delegate: COption::None, + state: AccountState::Initialized, + is_native: COption::None, + delegated_amount: 0, + close_authority: COption::None, + }; + state.pack_base(); + state.init_account_type().unwrap(); + + program_test.add_account( + *source, + SolanaAccount { + lamports: 1_000_000_000, + data: account_data.clone(), + owner: *program_id, + ..SolanaAccount::default() + }, + ); + program_test.add_account( + *destination, + SolanaAccount { + lamports: 1_000_000_000, + data: account_data, + owner: *program_id, + ..SolanaAccount::default() + }, + ); +} + +#[tokio::test] +async fn success_execute() { + let program_id = Pubkey::new_unique(); + let mut program_test = setup(&program_id); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = spl_transfer_hook_example::mint::id(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + let amount = 0u64; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas_address = get_extra_account_metas_address(&mint_address, &program_id); + + let writable_pubkey = Pubkey::new_unique(); + + let init_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let extra_pda_1 = Pubkey::find_program_address( + &[ + b"seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + let extra_pda_2 = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &program_id, + ) + .0; + + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // fail with missing account + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas[..2], + amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with wrong account + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(Pubkey::new_unique(), false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with wrong PDA + let wrong_pda_2 = Pubkey::find_program_address( + &[ + &99u64.to_le_bytes(), // Wrong data + destination.as_ref(), + ], + &program_id, + ) + .0; + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(wrong_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with not signer + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, false), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + amount, + )], + 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::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // success with correct params + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + } +} + +#[tokio::test] +async fn fail_incorrect_derivation() { + let program_id = Pubkey::new_unique(); + let mut program_test = setup(&program_id); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = spl_transfer_hook_example::mint::id(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + // wrong derivation + let extra_account_metas = get_extra_account_metas_address(&program_id, &mint_address); + + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent.minimum_balance(ExtraAccountMetaList::size_of(0).unwrap()); + + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas, + rent_lamports, + ), + initialize_extra_account_meta_list( + &program_id, + &extra_account_metas, + &mint_address, + &mint_authority_pubkey, + &[], + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(1, InstructionError::InvalidSeeds) + ); +} + +#[tokio::test] +async fn fail_incorrect_mint() { + let program_id = Pubkey::new_unique(); + let mut program_test = setup(&program_id); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + // wrong mint, only `spl_transfer_hook_example::mint::id()` allowed + let mint_address = Pubkey::new_unique(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas = get_extra_account_metas_address(&mint_address, &program_id); + + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent.minimum_balance(ExtraAccountMetaList::size_of(0).unwrap()); + + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas, + rent_lamports, + ), + initialize_extra_account_meta_list( + &program_id, + &extra_account_metas, + &mint_address, + &mint_authority_pubkey, + &[], + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError(1, InstructionError::InvalidArgument) + ); +} + +/// Test program to CPI into default transfer-hook-interface program +pub fn process_instruction( + _program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + let amount = input + .get(8..16) + .and_then(|slice| slice.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or(ProgramError::InvalidInstructionData)?; + onchain::invoke_execute( + accounts[0].key, + accounts[1].clone(), + accounts[2].clone(), + accounts[3].clone(), + accounts[4].clone(), + &accounts[5..], + amount, + ) +} + +#[tokio::test] +async fn success_on_chain_invoke() { + let hook_program_id = Pubkey::new_unique(); + let mut program_test = setup(&hook_program_id); + let program_id = Pubkey::new_unique(); + program_test.add_program( + "test_cpi_program", + program_id, + processor!(process_instruction), + ); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = spl_transfer_hook_example::mint::id(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + let amount = 0u64; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas_address = + get_extra_account_metas_address(&mint_address, &hook_program_id); + let writable_pubkey = Pubkey::new_unique(); + + let init_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let extra_pda_1 = Pubkey::find_program_address( + &[ + b"seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &hook_program_id, + ) + .0; + let extra_pda_2 = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &hook_program_id, + ) + .0; + + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &hook_program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + // easier to hack this up! + let mut test_instruction = execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + amount, + ); + test_instruction + .accounts + .insert(0, AccountMeta::new_readonly(hook_program_id, false)); + let transaction = Transaction::new_signed_with_payer( + &[test_instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +#[tokio::test] +async fn fail_without_transferring_flag() { + let program_id = Pubkey::new_unique(); + let mut program_test = setup(&program_id); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = spl_transfer_hook_example::mint::id(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + false, + ); + + let extra_account_metas_address = get_extra_account_metas_address(&mint_address, &program_id); + let extra_account_metas = []; + let init_extra_account_metas = []; + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + 0, + )], + 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::Custom(TransferHookError::ProgramCalledOutsideOfTransfer as u32) + ) + ); +} + +#[tokio::test] +async fn success_on_chain_invoke_with_updated_extra_account_metas() { + let hook_program_id = Pubkey::new_unique(); + let mut program_test = setup(&hook_program_id); + let program_id = Pubkey::new_unique(); + program_test.add_program( + "test_cpi_program", + program_id, + processor!(process_instruction), + ); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = spl_transfer_hook_example::mint::id(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + let amount = 0u64; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas_address = + get_extra_account_metas_address(&mint_address, &hook_program_id); + let writable_pubkey = Pubkey::new_unique(); + + // Create an initial account metas list + let init_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"init-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let init_transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &hook_program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(init_transaction) + .await + .unwrap(); + + // Create an updated account metas list + let updated_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"updated-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(updated_extra_account_metas.len()).unwrap()); + let update_transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + update_extra_account_meta_list( + &hook_program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &updated_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(update_transaction) + .await + .unwrap(); + + let updated_extra_pda_1 = Pubkey::find_program_address( + &[ + b"updated-seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &hook_program_id, + ) + .0; + let extra_pda_2 = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &hook_program_id, + ) + .0; + + let test_updated_extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + + // Use updated account metas list + let mut test_instruction = execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &test_updated_extra_account_metas, + amount, + ); + test_instruction + .accounts + .insert(0, AccountMeta::new_readonly(hook_program_id, false)); + let transaction = Transaction::new_signed_with_payer( + &[test_instruction], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); +} + +#[tokio::test] +async fn success_execute_with_updated_extra_account_metas() { + let program_id = Pubkey::new_unique(); + let mut program_test = setup(&program_id); + + let token_program_id = spl_token_2022::id(); + let wallet = Keypair::new(); + let mint_address = spl_transfer_hook_example::mint::id(); + let mint_authority = Keypair::new(); + let mint_authority_pubkey = mint_authority.pubkey(); + let source = Pubkey::new_unique(); + let destination = Pubkey::new_unique(); + let decimals = 2; + let amount = 0u64; + + setup_token_accounts( + &mut program_test, + &token_program_id, + &mint_address, + &mint_authority_pubkey, + &source, + &destination, + &wallet.pubkey(), + decimals, + true, + ); + + let extra_account_metas_address = get_extra_account_metas_address(&mint_address, &program_id); + + let writable_pubkey = Pubkey::new_unique(); + + let init_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&writable_pubkey, false, true).unwrap(), + ]; + + let extra_pda_1 = Pubkey::find_program_address( + &[ + b"seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + + let extra_pda_2 = Pubkey::find_program_address( + &[ + &amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &program_id, + ) + .0; + + let init_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(extra_pda_1, false), + AccountMeta::new(extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + + let context = program_test.start_with_context().await; + let rent = context.banks_client.get_rent().await.unwrap(); + let rent_lamports = rent + .minimum_balance(ExtraAccountMetaList::size_of(init_extra_account_metas.len()).unwrap()); + let transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + initialize_extra_account_meta_list( + &program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &init_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + + let updated_amount = 1u64; + let updated_writable_pubkey = Pubkey::new_unique(); + + // Create updated extra account metas + let updated_extra_account_metas = [ + ExtraAccountMeta::new_with_pubkey(&sysvar::instructions::id(), false, false).unwrap(), + ExtraAccountMeta::new_with_pubkey(&mint_authority_pubkey, true, false).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"updated-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::InstructionData { + index: 8, // After instruction discriminator + length: 8, // `u64` (amount) + }, + Seed::AccountKey { index: 2 }, + ], + false, + true, + ) + .unwrap(), + ExtraAccountMeta::new_with_pubkey(&updated_writable_pubkey, false, true).unwrap(), + ExtraAccountMeta::new_with_seeds( + &[ + Seed::Literal { + bytes: b"new-seed-prefix".to_vec(), + }, + Seed::AccountKey { index: 0 }, + ], + false, + true, + ) + .unwrap(), + ]; + + let updated_extra_pda_1 = Pubkey::find_program_address( + &[ + b"updated-seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + + let updated_extra_pda_2 = Pubkey::find_program_address( + &[ + &updated_amount.to_le_bytes(), // Instruction data bytes 8 to 16 + destination.as_ref(), // Account at index 2 + ], + &program_id, + ) + .0; + + // add another PDA + let new_extra_pda = Pubkey::find_program_address( + &[ + b"new-seed-prefix", // Literal prefix + source.as_ref(), // Account at index 0 + ], + &program_id, + ) + .0; + + let updated_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(updated_extra_pda_2, false), + AccountMeta::new(updated_writable_pubkey, false), + AccountMeta::new(new_extra_pda, false), + ]; + + let update_transaction = Transaction::new_signed_with_payer( + &[ + system_instruction::transfer( + &context.payer.pubkey(), + &extra_account_metas_address, + rent_lamports, + ), + update_extra_account_meta_list( + &program_id, + &extra_account_metas_address, + &mint_address, + &mint_authority_pubkey, + &updated_extra_account_metas, + ), + ], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + + context + .banks_client + .process_transaction(update_transaction) + .await + .unwrap(); + + // fail with initial account metas list + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &init_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with missing account + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &updated_account_metas[..2], + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with wrong account + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(updated_extra_pda_2, false), + AccountMeta::new(Pubkey::new_unique(), false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with wrong PDA + let wrong_pda_2 = Pubkey::find_program_address( + &[ + &99u64.to_le_bytes(), // Wrong data + destination.as_ref(), + ], + &program_id, + ) + .0; + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, true), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(wrong_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + let error = context + .banks_client + .process_transaction(transaction) + .await + .unwrap_err() + .unwrap(); + assert_eq!( + error, + TransactionError::InstructionError( + 0, + InstructionError::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // fail with not signer + { + let extra_account_metas = [ + AccountMeta::new_readonly(sysvar::instructions::id(), false), + AccountMeta::new_readonly(mint_authority_pubkey, false), + AccountMeta::new(updated_extra_pda_1, false), + AccountMeta::new(updated_extra_pda_2, false), + AccountMeta::new(writable_pubkey, false), + ]; + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &extra_account_metas, + updated_amount, + )], + 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::Custom(AccountResolutionError::IncorrectAccount as u32), + ) + ); + } + + // success with correct params + { + let transaction = Transaction::new_signed_with_payer( + &[execute_with_extra_account_metas( + &program_id, + &source, + &mint_address, + &destination, + &wallet.pubkey(), + &extra_account_metas_address, + &updated_account_metas, + updated_amount, + )], + Some(&context.payer.pubkey()), + &[&context.payer, &mint_authority], + context.last_blockhash, + ); + context + .banks_client + .process_transaction(transaction) + .await + .unwrap(); + } +}