From 83fc15fb2fd2fc1d64eae89fb3dc94ee2a3d6b43 Mon Sep 17 00:00:00 2001 From: Joe C Date: Tue, 24 Oct 2023 11:24:01 +0200 Subject: [PATCH] token 2022: add `GroupPointer` extension (#5621) --- token/client/src/token.rs | 39 ++- .../program-2022-test/tests/group_pointer.rs | 253 ++++++++++++++++++ .../extension/group_pointer/instruction.rs | 131 +++++++++ .../src/extension/group_pointer/mod.rs | 29 ++ .../src/extension/group_pointer/processor.rs | 103 +++++++ token/program-2022/src/extension/mod.rs | 10 +- token/program-2022/src/instruction.rs | 14 + token/program-2022/src/processor.rs | 17 ++ 8 files changed, 594 insertions(+), 2 deletions(-) create mode 100644 token/program-2022-test/tests/group_pointer.rs create mode 100644 token/program-2022/src/extension/group_pointer/instruction.rs create mode 100644 token/program-2022/src/extension/group_pointer/mod.rs create mode 100644 token/program-2022/src/extension/group_pointer/processor.rs diff --git a/token/client/src/token.rs b/token/client/src/token.rs index b5a8122973d..8233c57bce0 100644 --- a/token/client/src/token.rs +++ b/token/client/src/token.rs @@ -40,7 +40,7 @@ use { self, account_info::WithheldTokensInfo, ConfidentialTransferFeeAmount, ConfidentialTransferFeeConfig, }, - cpi_guard, default_account_state, interest_bearing_mint, memo_transfer, + cpi_guard, default_account_state, group_pointer, interest_bearing_mint, memo_transfer, metadata_pointer, transfer_fee, transfer_hook, BaseStateWithExtensions, ExtensionType, StateWithExtensionsOwned, }, @@ -178,6 +178,10 @@ pub enum ExtensionInitializationParams { authority: Option, withdraw_withheld_authority_elgamal_pubkey: PodElGamalPubkey, }, + GroupPointer { + authority: Option, + group_address: Option, + }, } impl ExtensionInitializationParams { /// Get the extension type associated with the init params @@ -195,6 +199,7 @@ impl ExtensionInitializationParams { Self::ConfidentialTransferFeeConfig { .. } => { ExtensionType::ConfidentialTransferFeeConfig } + Self::GroupPointer { .. } => ExtensionType::GroupPointer, } } /// Generate an appropriate initialization instruction for the given mint @@ -286,6 +291,15 @@ impl ExtensionInitializationParams { withdraw_withheld_authority_elgamal_pubkey, ) } + Self::GroupPointer { + authority, + group_address, + } => group_pointer::instruction::initialize( + token_program_id, + mint, + authority, + group_address, + ), } } } @@ -1666,6 +1680,29 @@ where .await } + /// Update group pointer address + pub async fn update_group_address( + &self, + authority: &Pubkey, + new_group_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_pointer::instruction::update( + &self.program_id, + self.get_address(), + authority, + &multisig_signers, + new_group_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_pointer.rs b/token/program-2022-test/tests/group_pointer.rs new file mode 100644 index 00000000000..5dc6922919f --- /dev/null +++ b/token/program-2022-test/tests/group_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_pointer::GroupPointer, 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, group_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::GroupPointer { + authority: Some(*authority), + group_address: Some(*group_address), + }], + None, + ) + .await + .unwrap(); + context +} + +#[tokio::test] +async fn success_init() { + let authority = Pubkey::new_unique(); + let group_address = Pubkey::new_unique(); + let mint_keypair = Keypair::new(); + let token = setup(mint_keypair, &group_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.group_address, + Some(group_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::GroupPointer { + authority: None, + group_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 group_address = Pubkey::new_unique(); + let mint_keypair = Keypair::new(); + let token = setup(mint_keypair, &group_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::GroupPointer, + &[&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::GroupPointer, + &[&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::GroupPointer, + &[&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::GroupPointer, + &[&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_address() { + let authority = Keypair::new(); + let group_address = Pubkey::new_unique(); + let mint_keypair = Keypair::new(); + let token = setup(mint_keypair, &group_address, &authority.pubkey()) + .await + .token_context + .take() + .unwrap() + .token; + let new_group_address = Pubkey::new_unique(); + + // fail, wrong signature + let wrong = Keypair::new(); + let err = token + .update_group_address(&wrong.pubkey(), Some(new_group_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_address(&authority.pubkey(), Some(new_group_address), &[&authority]) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!( + extension.group_address, + Some(new_group_address).try_into().unwrap(), + ); + + // set to none + token + .update_group_address(&authority.pubkey(), None, &[&authority]) + .await + .unwrap(); + let state = token.get_mint_info().await.unwrap(); + let extension = state.get_extension::().unwrap(); + assert_eq!(extension.group_address, None.try_into().unwrap(),); +} diff --git a/token/program-2022/src/extension/group_pointer/instruction.rs b/token/program-2022/src/extension/group_pointer/instruction.rs new file mode 100644 index 00000000000..331dc119eff --- /dev/null +++ b/token/program-2022/src/extension/group_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 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 GroupPointerInstruction { + /// Initialize a new mint with a group 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_pointer::instruction::InitializeInstructionData` + /// + Initialize, + /// Update the group pointer address. Only supported for mints that + /// include the `GroupPointer` extension. + /// + /// Accounts expected by this instruction: + /// + /// * Single authority + /// 0. `[writable]` The mint. + /// 1. `[signer]` The group pointer authority. + /// + /// * Multisignature authority + /// 0. `[writable]` The mint. + /// 1. `[]` The mint's group pointer authority. + /// 2. ..2+M `[signer]` M signer accounts. + /// + /// Data expected by this instruction: + /// `crate::extension::group_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 group_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 configurations + pub group_address: OptionalNonZeroPubkey, +} + +/// Create an `Initialize` instruction +pub fn initialize( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: Option, + group_address: Option, +) -> Result { + check_program_account(token_program_id)?; + let accounts = vec![AccountMeta::new(*mint, false)]; + Ok(encode_instruction( + token_program_id, + accounts, + TokenInstruction::GroupPointerExtension, + GroupPointerInstruction::Initialize, + &InitializeInstructionData { + authority: authority.try_into()?, + group_address: group_address.try_into()?, + }, + )) +} + +/// Create an `Update` instruction +pub fn update( + token_program_id: &Pubkey, + mint: &Pubkey, + authority: &Pubkey, + signers: &[&Pubkey], + group_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::GroupPointerExtension, + GroupPointerInstruction::Update, + &UpdateInstructionData { + group_address: group_address.try_into()?, + }, + )) +} diff --git a/token/program-2022/src/extension/group_pointer/mod.rs b/token/program-2022/src/extension/group_pointer/mod.rs new file mode 100644 index 00000000000..60f5aec3669 --- /dev/null +++ b/token/program-2022/src/extension/group_pointer/mod.rs @@ -0,0 +1,29 @@ +use { + crate::extension::{Extension, ExtensionType}, + bytemuck::{Pod, Zeroable}, + spl_pod::optional_keys::OptionalNonZeroPubkey, +}; + +#[cfg(feature = "serde-traits")] +use serde::{Deserialize, Serialize}; + +/// Instructions for the GroupPointer extension +pub mod instruction; +/// Instruction processor for the GroupPointer extension +pub mod processor; + +/// Group pointer extension data for mints. +#[repr(C)] +#[cfg_attr(feature = "serde-traits", derive(Serialize, Deserialize))] +#[cfg_attr(feature = "serde-traits", serde(rename_all = "camelCase"))] +#[derive(Clone, Copy, Debug, Default, PartialEq, Pod, Zeroable)] +pub struct GroupPointer { + /// Authority that can set the group address + pub authority: OptionalNonZeroPubkey, + /// Account address that holds the group + pub group_address: OptionalNonZeroPubkey, +} + +impl Extension for GroupPointer { + const TYPE: ExtensionType = ExtensionType::GroupPointer; +} diff --git a/token/program-2022/src/extension/group_pointer/processor.rs b/token/program-2022/src/extension/group_pointer/processor.rs new file mode 100644 index 00000000000..7c99afc0001 --- /dev/null +++ b/token/program-2022/src/extension/group_pointer/processor.rs @@ -0,0 +1,103 @@ +use { + crate::{ + check_program_account, + error::TokenError, + extension::{ + group_pointer::{ + instruction::{ + GroupPointerInstruction, InitializeInstructionData, UpdateInstructionData, + }, + GroupPointer, + }, + 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, + group_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(*group_address).is_none() + { + msg!( + "The group pointer extension requires at least an authority or an address for \ + initialization, neither was provided" + ); + Err(TokenError::InvalidInstruction)?; + } + extension.group_address = *group_address; + Ok(()) +} + +fn process_update( + program_id: &Pubkey, + accounts: &[AccountInfo], + new_group_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.group_address = *new_group_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)? { + GroupPointerInstruction::Initialize => { + msg!("GroupPointerInstruction::Initialize"); + let InitializeInstructionData { + authority, + group_address, + } = decode_instruction_data(input)?; + process_initialize(program_id, accounts, authority, group_address) + } + GroupPointerInstruction::Update => { + msg!("GroupPointerInstruction::Update"); + let UpdateInstructionData { group_address } = decode_instruction_data(input)?; + process_update(program_id, accounts, group_address) + } + } +} diff --git a/token/program-2022/src/extension/mod.rs b/token/program-2022/src/extension/mod.rs index 15b78bd8f4f..46a43a4562f 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_pointer::GroupPointer, immutable_owner::ImmutableOwner, interest_bearing_mint::InterestBearingConfig, memo_transfer::MemoTransfer, @@ -52,6 +53,8 @@ pub mod confidential_transfer_fee; pub mod cpi_guard; /// Default Account State extension pub mod default_account_state; +/// Group Pointer extension +pub mod group_pointer; /// Immutable Owner extension pub mod immutable_owner; /// Interest-Bearing Mint extension @@ -905,6 +908,9 @@ pub enum ExtensionType { /// Mint contains token-metadata TokenMetadata, /// Test variable-length mint extension + /// Mint contains a pointer to another account (or the same account) that holds group + /// configurations + GroupPointer, #[cfg(test)] VariableLenMintTest = u16::MAX - 2, /// Padding extension used to make an account exactly Multisig::LEN, used for testing @@ -978,6 +984,7 @@ impl ExtensionType { } ExtensionType::MetadataPointer => pod_get_packed_len::(), ExtensionType::TokenMetadata => unreachable!(), + ExtensionType::GroupPointer => pod_get_packed_len::(), #[cfg(test)] ExtensionType::AccountPaddingTest => pod_get_packed_len::(), #[cfg(test)] @@ -1037,7 +1044,8 @@ impl ExtensionType { | ExtensionType::TransferHook | ExtensionType::ConfidentialTransferFeeConfig | ExtensionType::MetadataPointer - | ExtensionType::TokenMetadata => AccountType::Mint, + | ExtensionType::TokenMetadata + | ExtensionType::GroupPointer => 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 890dea8fc1f..95dd690de18 100644 --- a/token/program-2022/src/instruction.rs +++ b/token/program-2022/src/instruction.rs @@ -670,6 +670,12 @@ pub enum TokenInstruction<'a> { /// for further details about the extended instructions that share this instruction /// prefix MetadataPointerExtension, + /// The common instruction prefix for group pointer extension instructions. + /// + /// See `extension::group_pointer::instruction::GroupPointerInstruction` + /// for further details about the extended instructions that share this instruction + /// prefix + GroupPointerExtension, } impl<'a> TokenInstruction<'a> { /// Unpacks a byte buffer into a [TokenInstruction](enum.TokenInstruction.html). @@ -809,6 +815,7 @@ impl<'a> TokenInstruction<'a> { 37 => Self::ConfidentialTransferFeeExtension, 38 => Self::WithdrawExcessLamports, 39 => Self::MetadataPointerExtension, + 40 => Self::GroupPointerExtension, _ => return Err(TokenError::InvalidInstruction.into()), }) } @@ -974,6 +981,9 @@ impl<'a> TokenInstruction<'a> { &Self::MetadataPointerExtension => { buf.push(39); } + &Self::GroupPointerExtension => { + buf.push(40); + } }; buf } @@ -1067,6 +1077,8 @@ pub enum AuthorityType { ConfidentialTransferFeeConfig, /// Authority to set the metadata address MetadataPointer, + /// Authority to set the group address + GroupPointer, } impl AuthorityType { @@ -1085,6 +1097,7 @@ impl AuthorityType { AuthorityType::TransferHookProgramId => 10, AuthorityType::ConfidentialTransferFeeConfig => 11, AuthorityType::MetadataPointer => 12, + AuthorityType::GroupPointer => 13, } } @@ -1103,6 +1116,7 @@ impl AuthorityType { 10 => Ok(AuthorityType::TransferHookProgramId), 11 => Ok(AuthorityType::ConfidentialTransferFeeConfig), 12 => Ok(AuthorityType::MetadataPointer), + 13 => Ok(AuthorityType::GroupPointer), _ => Err(TokenError::InvalidInstruction.into()), } } diff --git a/token/program-2022/src/processor.rs b/token/program-2022/src/processor.rs index baeca6a195e..4bcfb09b10c 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_pointer::{self, GroupPointer}, immutable_owner::ImmutableOwner, interest_bearing_mint::{self, InterestBearingConfig}, memo_transfer::{self, check_previous_sibling_instruction_is_memo, memo_required}, @@ -846,6 +847,19 @@ impl Processor { )?; extension.authority = new_authority.try_into()?; } + AuthorityType::GroupPointer => { + 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()); } @@ -1661,6 +1675,9 @@ impl Processor { &input[1..], ) } + TokenInstruction::GroupPointerExtension => { + group_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)