diff --git a/token/client/src/token.rs b/token/client/src/token.rs index b68d498c947..ef723d40ed9 100644 --- a/token/client/src/token.rs +++ b/token/client/src/token.rs @@ -40,9 +40,9 @@ use { self, account_info::WithheldTokensInfo, ConfidentialTransferFeeAmount, ConfidentialTransferFeeConfig, }, - cpi_guard, default_account_state, group_pointer, interest_bearing_mint, memo_transfer, - metadata_pointer, transfer_fee, transfer_hook, BaseStateWithExtensions, ExtensionType, - StateWithExtensionsOwned, + cpi_guard, default_account_state, group_member_pointer, group_pointer, + interest_bearing_mint, memo_transfer, metadata_pointer, transfer_fee, transfer_hook, + BaseStateWithExtensions, ExtensionType, StateWithExtensionsOwned, }, instruction, offchain, proof::ProofLocation, @@ -182,6 +182,10 @@ pub enum ExtensionInitializationParams { authority: Option, group_address: Option, }, + GroupMemberPointer { + authority: Option, + member_address: Option, + }, } impl ExtensionInitializationParams { /// Get the extension type associated with the init params @@ -200,6 +204,7 @@ impl ExtensionInitializationParams { ExtensionType::ConfidentialTransferFeeConfig } Self::GroupPointer { .. } => ExtensionType::GroupPointer, + Self::GroupMemberPointer { .. } => ExtensionType::GroupMemberPointer, } } /// Generate an appropriate initialization instruction for the given mint @@ -300,6 +305,15 @@ impl ExtensionInitializationParams { authority, group_address, ), + Self::GroupMemberPointer { + authority, + member_address, + } => group_member_pointer::instruction::initialize( + token_program_id, + mint, + authority, + member_address, + ), } } } @@ -1703,6 +1717,29 @@ where .await } + /// Update group member pointer address + pub async fn update_group_member_address( + &self, + authority: &Pubkey, + new_member_address: Option, + signing_keypairs: &S, + ) -> TokenResult { + let signing_pubkeys = signing_keypairs.pubkeys(); + let multisig_signers = self.get_multisig_signers(authority, &signing_pubkeys); + + self.process_ixs( + &[group_member_pointer::instruction::update( + &self.program_id, + self.get_address(), + authority, + &multisig_signers, + new_member_address, + )?], + signing_keypairs, + ) + .await + } + /// Update confidential transfer mint pub async fn confidential_transfer_update_mint( &self, diff --git a/token/program-2022-test/tests/group_member_pointer.rs b/token/program-2022-test/tests/group_member_pointer.rs new file mode 100644 index 00000000000..c79d023b227 --- /dev/null +++ b/token/program-2022-test/tests/group_member_pointer.rs @@ -0,0 +1,253 @@ +#![cfg(feature = "test-sbf")] + +mod program_test; +use { + program_test::TestContext, + solana_program_test::{processor, tokio, ProgramTest}, + solana_sdk::{ + instruction::InstructionError, pubkey::Pubkey, signature::Signer, signer::keypair::Keypair, + transaction::TransactionError, transport::TransportError, + }, + spl_token_2022::{ + error::TokenError, + extension::{group_member_pointer::GroupMemberPointer, BaseStateWithExtensions}, + instruction, + processor::Processor, + }, + spl_token_client::token::{ExtensionInitializationParams, TokenError as TokenClientError}, + std::{convert::TryInto, sync::Arc}, +}; + +fn setup_program_test() -> ProgramTest { + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(false); + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(Processor::process), + ); + program_test +} + +async fn setup(mint: Keypair, member_address: &Pubkey, authority: &Pubkey) -> TestContext { + let program_test = setup_program_test(); + + let context = program_test.start_with_context().await; + let context = Arc::new(tokio::sync::Mutex::new(context)); + let mut context = TestContext { + context, + token_context: None, + }; + context + .init_token_with_mint_keypair_and_freeze_authority( + mint, + vec![ExtensionInitializationParams::GroupMemberPointer { + authority: Some(*authority), + member_address: Some(*member_address), + }], + None, + ) + .await + .unwrap(); + context +} + +#[tokio::test] +async fn success_init() { + let authority = Pubkey::new_unique(); + let member_address = Pubkey::new_unique(); + let mint_keypair = Keypair::new(); + let token = setup(mint_keypair, &member_address, &authority) + .await + .token_context + .take() + .unwrap() + .token; + + let state = token.get_mint_info().await.unwrap(); + assert!(state.base.is_initialized); + let extension = state.get_extension::().unwrap(); + assert_eq!(extension.authority, Some(authority).try_into().unwrap()); + assert_eq!( + extension.member_address, + Some(member_address).try_into().unwrap() + ); +} + +#[tokio::test] +async fn fail_init_all_none() { + let mut program_test = ProgramTest::default(); + program_test.prefer_bpf(false); + program_test.add_program( + "spl_token_2022", + spl_token_2022::id(), + processor!(Processor::process), + ); + let context = program_test.start_with_context().await; + let context = Arc::new(tokio::sync::Mutex::new(context)); + let mut context = TestContext { + context, + token_context: None, + }; + let err = context + .init_token_with_mint_keypair_and_freeze_authority( + Keypair::new(), + vec![ExtensionInitializationParams::GroupMemberPointer { + authority: None, + member_address: None, + }], + None, + ) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 1, + InstructionError::Custom(TokenError::InvalidInstruction as u32) + ) + ))) + ); +} + +#[tokio::test] +async fn set_authority() { + let authority = Keypair::new(); + let member_address = Pubkey::new_unique(); + let mint_keypair = Keypair::new(); + let token = setup(mint_keypair, &member_address, &authority.pubkey()) + .await + .token_context + .take() + .unwrap() + .token; + let new_authority = Keypair::new(); + + // fail, wrong signature + let wrong = Keypair::new(); + let err = token + .set_authority( + token.get_address(), + &wrong.pubkey(), + Some(&new_authority.pubkey()), + instruction::AuthorityType::GroupMemberPointer, + &[&wrong], + ) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::OwnerMismatch as u32) + ) + ))) + ); + + // success + token + .set_authority( + token.get_address(), + &authority.pubkey(), + Some(&new_authority.pubkey()), + instruction::AuthorityType::GroupMemberPointer, + &[&authority], + ) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!( + extension.authority, + Some(new_authority.pubkey()).try_into().unwrap(), + ); + + // set to none + token + .set_authority( + token.get_address(), + &new_authority.pubkey(), + None, + instruction::AuthorityType::GroupMemberPointer, + &[&new_authority], + ) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(extension.authority, None.try_into().unwrap(),); + + // fail set again + let err = token + .set_authority( + token.get_address(), + &new_authority.pubkey(), + Some(&authority.pubkey()), + instruction::AuthorityType::GroupMemberPointer, + &[&new_authority], + ) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::AuthorityTypeNotSupported as u32) + ) + ))) + ); +} + +#[tokio::test] +async fn update_group_member_address() { + let authority = Keypair::new(); + let member_address = Pubkey::new_unique(); + let mint_keypair = Keypair::new(); + let token = setup(mint_keypair, &member_address, &authority.pubkey()) + .await + .token_context + .take() + .unwrap() + .token; + let new_member_address = Pubkey::new_unique(); + + // fail, wrong signature + let wrong = Keypair::new(); + let err = token + .update_group_member_address(&wrong.pubkey(), Some(new_member_address), &[&wrong]) + .await + .unwrap_err(); + assert_eq!( + err, + TokenClientError::Client(Box::new(TransportError::TransactionError( + TransactionError::InstructionError( + 0, + InstructionError::Custom(TokenError::OwnerMismatch as u32) + ) + ))) + ); + + // success + token + .update_group_member_address(&authority.pubkey(), Some(new_member_address), &[&authority]) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!( + extension.member_address, + Some(new_member_address).try_into().unwrap(), + ); + + // set to none + token + .update_group_member_address(&authority.pubkey(), None, &[&authority]) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(extension.member_address, None.try_into().unwrap(),); +} diff --git a/token/program-2022/src/extension/group_member_pointer/instruction.rs b/token/program-2022/src/extension/group_member_pointer/instruction.rs new file mode 100644 index 00000000000..ac11246990b --- /dev/null +++ b/token/program-2022/src/extension/group_member_pointer/instruction.rs @@ -0,0 +1,131 @@ +use { + crate::{ + check_program_account, + instruction::{encode_instruction, TokenInstruction}, + }, + bytemuck::{Pod, Zeroable}, + num_enum::{IntoPrimitive, TryFromPrimitive}, + solana_program::{ + instruction::{AccountMeta, Instruction}, + program_error::ProgramError, + pubkey::Pubkey, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, + std::convert::TryInto, +}; + +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; + +/// Group member pointer extension instructions +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Debug, PartialEq, IntoPrimitive, TryFromPrimitive)] +#[repr(u8)] +pub enum GroupMemberPointerInstruction { + /// Initialize a new mint with a group member pointer + /// + /// Fails if the mint has already been initialized, so must be called before + /// `InitializeMint`. + /// + /// The mint must have exactly enough space allocated for the base mint (82 + /// bytes), plus 83 bytes of padding, 1 byte reserved for the account type, + /// then space required for this extension, plus any others. + /// + /// Accounts expected by this instruction: + /// + /// 0. `[writable]` The mint to initialize. + /// + /// Data expected by this instruction: + /// `crate::extension::group_member_pointer::instruction::InitializeInstructionData` + /// + Initialize, + /// Update the group member pointer address. Only supported for mints that + /// include the `GroupMemberPointer` extension. + /// + /// Accounts expected by this instruction: + /// + /// * Single authority + /// 0. `[writable]` The mint. + /// 1. `[signer]` The group member pointer authority. + /// + /// * Multisignature authority + /// 0. `[writable]` The mint. + /// 1. `[]` The mint's group member pointer authority. + /// 2. ..2+M `[signer]` M signer accounts. + /// + /// Data expected by this instruction: + /// `crate::extension::group_member_pointer::instruction::UpdateInstructionData` + /// + Update, +} + +/// Data expected by `Initialize` +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct InitializeInstructionData { + /// The public key for the account that can update the group address + pub authority: OptionalNonZeroPubkey, + /// The account address that holds the group + pub member_address: OptionalNonZeroPubkey, +} + +/// Data expected by `Update` +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Pod, Zeroable)] +#[repr(C)] +pub struct UpdateInstructionData { + /// The new account address that holds the group + pub member_address: OptionalNonZeroPubkey, +} + +/// Create an `Initialize` instruction +pub fn initialize( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: Option, + member_address: Option, +) -> Result { + check_program_account(token_program_id)?; + let accounts = vec![AccountMeta::new(*mint, false)]; + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::GroupMemberPointerExtension, + GroupMemberPointerInstruction::Initialize, + &InitializeInstructionData { + authority: authority.try_into()?, + member_address: member_address.try_into()?, + }, + )) +} + +/// Create an `Update` instruction +pub fn update( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: &Pubkey, + signers: &[&Pubkey], + member_address: Option, +) -> Result { + check_program_account(token_program_id)?; + let mut accounts = vec![ + AccountMeta::new(*mint, false), + AccountMeta::new_readonly(*authority, signers.is_empty()), + ]; + for signer_pubkey in signers.iter() { + accounts.push(AccountMeta::new_readonly(**signer_pubkey, true)); + } + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::GroupMemberPointerExtension, + GroupMemberPointerInstruction::Update, + &UpdateInstructionData { + member_address: member_address.try_into()?, + }, + )) +} diff --git a/token/program-2022/src/extension/group_member_pointer/mod.rs b/token/program-2022/src/extension/group_member_pointer/mod.rs new file mode 100644 index 00000000000..52767b96e08 --- /dev/null +++ b/token/program-2022/src/extension/group_member_pointer/mod.rs @@ -0,0 +1,28 @@ +use { + crate::extension::{Extension, ExtensionType}, + bytemuck::{Pod, Zeroable}, + spl_pod::optional_keys::OptionalNonZeroPubkey, +}; + +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; + +/// Instructions for the GroupMemberPointer extension +pub mod instruction; +/// Instruction processor for the GroupMemberPointer extension +pub mod processor; + +/// Group member pointer extension data for mints. +#[repr(C)] +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] +pub struct GroupMemberPointer { + /// Authority that can set the group address + pub authority: OptionalNonZeroPubkey, + /// Account address that holds the group + pub member_address: OptionalNonZeroPubkey, +} + +impl Extension for GroupMemberPointer { + const TYPE: ExtensionType = ExtensionType::GroupMemberPointer; +} diff --git a/token/program-2022/src/extension/group_member_pointer/processor.rs b/token/program-2022/src/extension/group_member_pointer/processor.rs new file mode 100644 index 00000000000..aea7cb895f7 --- /dev/null +++ b/token/program-2022/src/extension/group_member_pointer/processor.rs @@ -0,0 +1,103 @@ +use { + crate::{ + check_program_account, + error::TokenError, + extension::{ + group_member_pointer::{ + instruction::{ + GroupMemberPointerInstruction, InitializeInstructionData, UpdateInstructionData, + }, + GroupMemberPointer, + }, + StateWithExtensionsMut, + }, + instruction::{decode_instruction_data, decode_instruction_type}, + processor::Processor, + state::Mint, + }, + solana_program::{ + account_info::{next_account_info, AccountInfo}, + entrypoint::ProgramResult, + msg, + pubkey::Pubkey, + }, + spl_pod::optional_keys::OptionalNonZeroPubkey, +}; + +fn process_initialize( + _program_id: &Pubkey, + accounts: &[AccountInfo], + authority: &OptionalNonZeroPubkey, + member_address: &OptionalNonZeroPubkey, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mint_account_info = next_account_info(account_info_iter)?; + let mut mint_data = mint_account_info.data.borrow_mut(); + let mut mint = StateWithExtensionsMut::::unpack_uninitialized(&mut mint_data)?; + + let extension = mint.init_extension::(true)?; + extension.authority = *authority; + + if Option::::from(*authority).is_none() + && Option::::from(*member_address).is_none() + { + msg!( + "The group member pointer extension requires at least an authority or an address for \ + initialization, neither was provided" + ); + Err(TokenError::InvalidInstruction)?; + } + extension.member_address = *member_address; + Ok(()) +} + +fn process_update( + program_id: &Pubkey, + accounts: &[AccountInfo], + new_member_address: &OptionalNonZeroPubkey, +) -> ProgramResult { + let account_info_iter = &mut accounts.iter(); + let mint_account_info = next_account_info(account_info_iter)?; + let owner_info = next_account_info(account_info_iter)?; + let owner_info_data_len = owner_info.data_len(); + + let mut mint_data = mint_account_info.data.borrow_mut(); + let mut mint = StateWithExtensionsMut::::unpack(&mut mint_data)?; + let extension = mint.get_extension_mut::()?; + let authority = + Option::::from(extension.authority).ok_or(TokenError::NoAuthorityExists)?; + + Processor::validate_owner( + program_id, + &authority, + owner_info, + owner_info_data_len, + account_info_iter.as_slice(), + )?; + + extension.member_address = *new_member_address; + Ok(()) +} + +pub(crate) fn process_instruction( + program_id: &Pubkey, + accounts: &[AccountInfo], + input: &[u8], +) -> ProgramResult { + check_program_account(program_id)?; + match decode_instruction_type(input)? { + GroupMemberPointerInstruction::Initialize => { + msg!("GroupMemberPointerInstruction::Initialize"); + let InitializeInstructionData { + authority, + member_address, + } = decode_instruction_data(input)?; + process_initialize(program_id, accounts, authority, member_address) + } + GroupMemberPointerInstruction::Update => { + msg!("GroupMemberPointerInstruction::Update"); + let UpdateInstructionData { member_address } = decode_instruction_data(input)?; + process_update(program_id, accounts, member_address) + } + } +} diff --git a/token/program-2022/src/extension/mod.rs b/token/program-2022/src/extension/mod.rs index c6326e2746b..77064a818b6 100644 --- a/token/program-2022/src/extension/mod.rs +++ b/token/program-2022/src/extension/mod.rs @@ -10,6 +10,7 @@ use { }, cpi_guard::CpiGuard, default_account_state::DefaultAccountState, + group_member_pointer::GroupMemberPointer, group_pointer::GroupPointer, immutable_owner::ImmutableOwner, interest_bearing_mint::InterestBearingConfig, @@ -54,6 +55,8 @@ pub mod confidential_transfer_fee; pub mod cpi_guard; /// Default Account State extension pub mod default_account_state; +/// Group Member Pointer extension +pub mod group_member_pointer; /// Group Pointer extension pub mod group_pointer; /// Immutable Owner extension @@ -915,6 +918,9 @@ pub enum ExtensionType { GroupPointer, /// Mint contains token group configurations TokenGroup, + /// Mint contains a pointer to another account (or the same account) that holds group + /// member configurations + GroupMemberPointer, /// Test variable-length mint extension #[cfg(test)] VariableLenMintTest = u16::MAX - 2, @@ -991,6 +997,7 @@ impl ExtensionType { ExtensionType::TokenMetadata => unreachable!(), ExtensionType::GroupPointer => pod_get_packed_len::(), ExtensionType::TokenGroup => pod_get_packed_len::(), + ExtensionType::GroupMemberPointer => pod_get_packed_len::(), #[cfg(test)] ExtensionType::AccountPaddingTest => pod_get_packed_len::(), #[cfg(test)] @@ -1052,7 +1059,8 @@ impl ExtensionType { | ExtensionType::MetadataPointer | ExtensionType::TokenMetadata | ExtensionType::GroupPointer - | ExtensionType::TokenGroup => AccountType::Mint, + | ExtensionType::TokenGroup + | ExtensionType::GroupMemberPointer => AccountType::Mint, ExtensionType::ImmutableOwner | ExtensionType::TransferFeeAmount | ExtensionType::ConfidentialTransferAccount diff --git a/token/program-2022/src/instruction.rs b/token/program-2022/src/instruction.rs index 95dd690de18..b6445e91613 100644 --- a/token/program-2022/src/instruction.rs +++ b/token/program-2022/src/instruction.rs @@ -676,6 +676,12 @@ pub enum TokenInstruction<'a> { /// for further details about the extended instructions that share this instruction /// prefix GroupPointerExtension, + /// The common instruction prefix for group member pointer extension instructions. + /// + /// See `extension::group_member_pointer::instruction::GroupMemberPointerInstruction` + /// for further details about the extended instructions that share this instruction + /// prefix + GroupMemberPointerExtension, } impl<'a> TokenInstruction<'a> { /// Unpacks a byte buffer into a [TokenInstruction](enum.TokenInstruction.html). @@ -816,6 +822,7 @@ impl<'a> TokenInstruction<'a> { 38 => Self::WithdrawExcessLamports, 39 => Self::MetadataPointerExtension, 40 => Self::GroupPointerExtension, + 41 => Self::GroupMemberPointerExtension, _ => return Err(TokenError::InvalidInstruction.into()), }) } @@ -984,6 +991,9 @@ impl<'a> TokenInstruction<'a> { &Self::GroupPointerExtension => { buf.push(40); } + &Self::GroupMemberPointerExtension => { + buf.push(41); + } }; buf } @@ -1079,6 +1089,8 @@ pub enum AuthorityType { MetadataPointer, /// Authority to set the group address GroupPointer, + /// Authority to set the group member address + GroupMemberPointer, } impl AuthorityType { @@ -1098,6 +1110,7 @@ impl AuthorityType { AuthorityType::ConfidentialTransferFeeConfig => 11, AuthorityType::MetadataPointer => 12, AuthorityType::GroupPointer => 13, + AuthorityType::GroupMemberPointer => 14, } } @@ -1117,6 +1130,7 @@ impl AuthorityType { 11 => Ok(AuthorityType::ConfidentialTransferFeeConfig), 12 => Ok(AuthorityType::MetadataPointer), 13 => Ok(AuthorityType::GroupPointer), + 14 => Ok(AuthorityType::GroupMemberPointer), _ => Err(TokenError::InvalidInstruction.into()), } } diff --git a/token/program-2022/src/processor.rs b/token/program-2022/src/processor.rs index 77a3bb5191a..6b4d3f78b2c 100644 --- a/token/program-2022/src/processor.rs +++ b/token/program-2022/src/processor.rs @@ -11,6 +11,7 @@ use { }, cpi_guard::{self, in_cpi, CpiGuard}, default_account_state::{self, DefaultAccountState}, + group_member_pointer::{self, GroupMemberPointer}, group_pointer::{self, GroupPointer}, immutable_owner::ImmutableOwner, interest_bearing_mint::{self, InterestBearingConfig}, @@ -861,6 +862,19 @@ impl Processor { )?; extension.authority = new_authority.try_into()?; } + AuthorityType::GroupMemberPointer => { + let extension = mint.get_extension_mut::()?; + let maybe_authority: Option = extension.authority.into(); + let authority = maybe_authority.ok_or(TokenError::AuthorityTypeNotSupported)?; + Self::validate_owner( + program_id, + &authority, + authority_info, + authority_info_data_len, + account_info_iter.as_slice(), + )?; + extension.authority = new_authority.try_into()?; + } _ => { return Err(TokenError::AuthorityTypeNotSupported.into()); } @@ -1679,6 +1693,13 @@ impl Processor { TokenInstruction::GroupPointerExtension => { group_pointer::processor::process_instruction(program_id, accounts, &input[1..]) } + TokenInstruction::GroupMemberPointerExtension => { + group_member_pointer::processor::process_instruction( + program_id, + accounts, + &input[1..], + ) + } } } else if let Ok(instruction) = TokenMetadataInstruction::unpack(input) { token_metadata::processor::process_instruction(program_id, accounts, instruction)