Repository: solana-labs/solana-program-library
Pull request #5912 (2023-11-28 20:51:18)

[token cli]: add support for group member pointer
This PR adds support to the SPL Token CLI for Token Group Member Pointer!


buffalojoec: Current dependencies on/for this PR:
* master
  * **PR #5911** <a href="https://app.graphite.dev/github/pr/solana-labs/solana-program-library/5911?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 
    * **PR #5912** <a href="https://app.graphite.dev/github/pr/solana-labs/solana-program-library/5912?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>  👈

This [stack of pull requests](https://stacking.dev/?utm_source=stack-comment) is managed by [Graphite](https://app.graphite.dev/github/pr/solana-labs/solana-program-library/5912?utm_source=stack-comment).
buffalojoec: ### Merge activity

* **Nov 29, 12:49 PM**: @buffalojoec started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/solana-labs/solana-program-library/5912).


Patch:
diff --git a/token/cli/src/clap_app.rs b/token/cli/src/clap_app.rs
index b8b2cd1dd..8a1042c03 100644
--- a/token/cli/src/clap_app.rs
+++ b/token/cli/src/clap_app.rs
@@ -117,6 +117,7 @@ pub enum CommandName {
     WithdrawConfidentialTokens,
     ApplyPendingBalance,
     UpdateGroupAddress,
+    UpdateMemberAddress,
 }
 impl fmt::Display for CommandName {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
@@ -186,6 +187,7 @@ pub enum CliAuthorityType {
     MetadataPointer,
     Metadata,
     GroupPointer,
+    GroupMemberPointer,
 }
 impl TryFrom<CliAuthorityType> for AuthorityType {
     type Error = Error;
@@ -212,6 +214,7 @@ impl TryFrom<CliAuthorityType> for AuthorityType {
                 Err("Metadata authority does not map to a token authority type".into())
             }
             CliAuthorityType::GroupPointer => Ok(AuthorityType::GroupPointer),
+            CliAuthorityType::GroupMemberPointer => Ok(AuthorityType::GroupMemberPointer),
         }
     }
 }
@@ -684,6 +687,17 @@ pub fn app<'a, 'b>(
                             "Specify address that stores token group configurations."
                         ),
                 )
+                .arg(
+                    Arg::with_name("member_address")
+                        .long("member-address")
+                        .value_name("ADDRESS")
+                        .validator(is_valid_pubkey)
+                        .takes_value(true)
+                        .conflicts_with("enable_member")
+                        .help(
+                            "Specify address that stores token member configurations."
+                        ),
+                )
                 .arg(
                     Arg::with_name("enable_non_transferable")
                         .long("enable-non-transferable")
@@ -760,6 +774,15 @@ pub fn app<'a, 'b>(
                         .takes_value(false)
                         .help("Enables group configurations in the mint. The mint authority must initialize the group."),
                 )
+                .arg(
+                    Arg::with_name("enable_member")
+                        .long("enable-member")
+                        .conflicts_with("group_address")
+                        .conflicts_with("enable_group")
+                        .conflicts_with("member_address")
+                        .takes_value(false)
+                        .help("Enables group member configurations in the mint. The mint authority must initialize the member."),
+                )
                 .nonce_args(true)
                 .arg(memo_arg())
         )
@@ -1961,6 +1984,49 @@ pub fn app<'a, 'b>(
                 .arg(multisig_signer_arg())
                 .nonce_args(true)
         )
+        .subcommand(
+            SubCommand::with_name(CommandName::UpdateMemberAddress.into())
+                .about("Updates group member pointer address for the mint. Requires the group member pointer extension.")
+                .arg(
+                    Arg::with_name("token")
+                        .validator(is_valid_pubkey)
+                        .value_name("TOKEN_MINT_ADDRESS")
+                        .takes_value(true)
+                        .index(1)
+                        .required(true)
+                        .help("The address of the token mint to update the group member pointer address"),
+                )
+                .arg(
+                    Arg::with_name("member_address")
+                        .index(2)
+                        .validator(is_valid_pubkey)
+                        .value_name("MEMBER_ADDRESS")
+                        .takes_value(true)
+                        .required_unless("disable")
+                        .help("Specify address that stores token's group-member-pointer"),
+                )
+                .arg(
+                    Arg::with_name("disable")
+                        .long("disable")
+                        .takes_value(false)
+                        .conflicts_with("member_address")
+                        .help("Unset group member pointer address.")
+                )
+                .arg(
+                    Arg::with_name("authority")
+                        .long("authority")
+                        .value_name("KEYPAIR")
+                        .validator(is_valid_signer)
+                        .takes_value(true)
+                        .help(
+                            "Specify the token's group-member-pointer authority. \
+                            This may be a keypair file or the ASK keyword. \
+                            Defaults to the client keypair.",
+                        ),
+                )
+                .arg(multisig_signer_arg())
+                .nonce_args(true)
+        )
         .subcommand(
             SubCommand::with_name(CommandName::WithdrawWithheldTokens.into())
                 .about("Withdraw withheld transfer fee tokens from mint and / or account(s)")
diff --git a/token/cli/src/command.rs b/token/cli/src/command.rs
index 22d002380..8936d9ae1 100644
--- a/token/cli/src/command.rs
+++ b/token/cli/src/command.rs
@@ -46,6 +46,7 @@ use {
             confidential_transfer_fee::ConfidentialTransferFeeConfig,
             cpi_guard::CpiGuard,
             default_account_state::DefaultAccountState,
+            group_member_pointer::GroupMemberPointer,
             group_pointer::GroupPointer,
             interest_bearing_mint::InterestBearingConfig,
             memo_transfer::MemoTransfer,
@@ -176,6 +177,14 @@ fn native_token_client_from_config(
     }
 }
 
+#[derive(strum_macros::Display, Debug)]
+#[strum(serialize_all = "kebab-case")]
+enum Pointer {
+    Metadata,
+    Group,
+    GroupMember,
+}
+
 #[allow(clippy::too_many_arguments)]
 async fn command_create_token(
     config: &Config<'_>,
@@ -189,6 +198,7 @@ async fn command_create_token(
     memo: Option<String>,
     metadata_address: Option<Pubkey>,
     group_address: Option<Pubkey>,
+    member_address: Option<Pubkey>,
     rate_bps: Option<i16>,
     default_account_state: Option<AccountState>,
     transfer_fee: Option<(u16, u64)>,
@@ -196,6 +206,7 @@ async fn command_create_token(
     transfer_hook_program_id: Option<Pubkey>,
     enable_metadata: bool,
     enable_group: bool,
+    enable_member: bool,
     bulk_signers: Vec<Arc<dyn Signer>>,
 ) -> CommandResult {
     println_display(
@@ -296,6 +307,18 @@ async fn command_create_token(
         });
     }
 
+    if member_address.is_some() || enable_member {
+        let member_address = if enable_member {
+            Some(token_pubkey)
+        } else {
+            member_address
+        };
+        extensions.push(ExtensionInitializationParams::GroupMemberPointer {
+            authority: Some(authority),
+            member_address,
+        });
+    }
+
     let res = token
         .create_mint(
             &authority,
@@ -327,6 +350,15 @@ async fn command_create_token(
         );
     }
 
+    if enable_member {
+        println_display(
+            config,
+            format!(
+                "To initialize group member configurations inside the mint, please run `spl-token initialize-member {token_pubkey}`, and sign with the mint authority and the group's update authority.",
+            ),
+        );
+    }
+
     Ok(match tx_return {
         TransactionReturnData::CliSignature(cli_signature) => format_output(
             CliCreateToken {
@@ -871,6 +903,16 @@ async fn command_authorize(
                         ))
                     }
                 }
+                CliAuthorityType::GroupMemberPointer => {
+                    if let Ok(extension) = mint.get_extension::<GroupMemberPointer>() {
+                        Ok(Option::<Pubkey>::from(extension.authority))
+                    } else {
+                        Err(format!(
+                            "Mint `{}` does not support a group member pointer",
+                            account
+                        ))
+                    }
+                }
             }?;
 
             Ok((account, previous_authority))
@@ -910,7 +952,8 @@ async fn command_authorize(
                 | CliAuthorityType::ConfidentialTransferFee
                 | CliAuthorityType::MetadataPointer
                 | CliAuthorityType::Metadata
-                | CliAuthorityType::GroupPointer => Err(format!(
+                | CliAuthorityType::GroupPointer
+                | CliAuthorityType::GroupMemberPointer => Err(format!(
                     "Authority type `{auth_str}` not supported for SPL Token accounts",
                 )),
                 CliAuthorityType::Owner => {
@@ -2491,48 +2534,39 @@ async fn command_cpi_guard(
     })
 }
 
-async fn command_update_metadata_pointer_address(
+async fn command_update_pointer_address(
     config: &Config<'_>,
     token_pubkey: Pubkey,
     authority: Pubkey,
-    new_metadata_address: Option<Pubkey>,
+    new_address: Option<Pubkey>,
     bulk_signers: BulkSigners,
+    pointer: Pointer,
 ) -> CommandResult {
     if config.sign_only {
-        panic!("Config can not be sign-only for updating metadata pointer address.");
+        panic!(
+            "Config can not be sign-only for updating {} pointer address.",
+            pointer
+        );
     }
 
     let token = token_client_from_config(config, &token_pubkey, None)?;
-    let res = token
-        .update_metadata_address(&authority, new_metadata_address, &bulk_signers)
-        .await?;
-
-    let tx_return = finish_tx(config, &res, false).await?;
-    Ok(match tx_return {
-        TransactionReturnData::CliSignature(signature) => {
-            config.output_format.formatted_string(&signature)
+    let res = match pointer {
+        Pointer::Metadata => {
+            token
+                .update_metadata_address(&authority, new_address, &bulk_signers)
+                .await
         }
-        TransactionReturnData::CliSignOnlyData(sign_only_data) => {
-            config.output_format.formatted_string(&sign_only_data)
+        Pointer::Group => {
+            token
+                .update_group_address(&authority, new_address, &bulk_signers)
+                .await
         }
-    })
-}
-
-async fn command_update_group_pointer_address(
-    config: &Config<'_>,
-    token_pubkey: Pubkey,
-    authority: Pubkey,
-    new_group_address: Option<Pubkey>,
-    bulk_signers: BulkSigners,
-) -> CommandResult {
-    if config.sign_only {
-        panic!("Config can not be sign-only for updating group pointer address.");
-    }
-
-    let token = token_client_from_config(config, &token_pubkey, None)?;
-    let res = token
-        .update_group_address(&authority, new_group_address, &bulk_signers)
-        .await?;
+        Pointer::GroupMember => {
+            token
+                .update_group_member_address(&authority, new_address, &bulk_signers)
+                .await
+        }
+    }?;
 
     let tx_return = finish_tx(config, &res, false).await?;
     Ok(match tx_return {
@@ -3253,6 +3287,7 @@ pub async fn process_command<'a>(
             let rate_bps = value_t!(arg_matches, "interest_rate", i16).ok();
             let metadata_address = value_t!(arg_matches, "metadata_address", Pubkey).ok();
             let group_address = value_t!(arg_matches, "group_address", Pubkey).ok();
+            let member_address = value_t!(arg_matches, "member_address", Pubkey).ok();
 
             let transfer_fee = arg_matches.values_of("transfer_fee").map(|mut v| {
                 (
@@ -3298,6 +3333,7 @@ pub async fn process_command<'a>(
                 memo,
                 metadata_address,
                 group_address,
+                member_address,
                 rate_bps,
                 default_account_state,
                 transfer_fee,
@@ -3305,6 +3341,7 @@ pub async fn process_command<'a>(
                 transfer_hook_program_id,
                 arg_matches.is_present("enable_metadata"),
                 arg_matches.is_present("enable_group"),
+                arg_matches.is_present("enable_member"),
                 bulk_signers,
             )
             .await
@@ -3961,12 +3998,13 @@ pub async fn process_command<'a>(
             }
             let metadata_address = value_t!(arg_matches, "metadata_address", Pubkey).ok();
 
-            command_update_metadata_pointer_address(
+            command_update_pointer_address(
                 config,
                 token,
                 authority,
                 metadata_address,
                 bulk_signers,
+                Pointer::Metadata,
             )
             .await
         }
@@ -3983,12 +4021,36 @@ pub async fn process_command<'a>(
             }
             let group_address = value_t!(arg_matches, "group_address", Pubkey).ok();
 
-            command_update_group_pointer_address(
+            command_update_pointer_address(
                 config,
                 token,
                 authority,
                 group_address,
                 bulk_signers,
+                Pointer::Group,
+            )
+            .await
+        }
+        (CommandName::UpdateMemberAddress, arg_matches) => {
+            // Since account is required argument it will always be present
+            let token = pubkey_of_signer(arg_matches, "token", &mut wallet_manager)
+                .unwrap()
+                .unwrap();
+
+            let (authority_signer, authority) =
+                config.signer_or_default(arg_matches, "authority", &mut wallet_manager);
+            if config.multisigner_pubkeys.is_empty() {
+                push_signer_with_dedup(authority_signer, &mut bulk_signers);
+            }
+            let member_address = value_t!(arg_matches, "member_address", Pubkey).ok();
+
+            command_update_pointer_address(
+                config,
+                token,
+                authority,
+                member_address,
+                bulk_signers,
+                Pointer::GroupMember,
             )
             .await
         }


Test patch:
diff --git a/token/cli/tests/command.rs b/token/cli/tests/command.rs
index 1ab95eb19..f695e3988 100644
--- a/token/cli/tests/command.rs
+++ b/token/cli/tests/command.rs
@@ -20,6 +20,7 @@ use {
             confidential_transfer::{ConfidentialTransferAccount, ConfidentialTransferMint},
             cpi_guard::CpiGuard,
             default_account_state::DefaultAccountState,
+            group_member_pointer::GroupMemberPointer,
             group_pointer::GroupPointer,
             interest_bearing_mint::InterestBearingConfig,
             memo_transfer::MemoTransfer,
@@ -126,6 +127,7 @@ async fn main() {
         async_trial!(withdraw_excess_lamports_from_account, test_validator, payer),
         async_trial!(metadata_pointer, test_validator, payer),
         async_trial!(group_pointer, test_validator, payer),
+        async_trial!(group_member_pointer, test_validator, payer),
         async_trial!(transfer_hook, test_validator, payer),
         async_trial!(metadata, test_validator, payer),
         // GC messes with every other test, so have it on its own test validator
@@ -3360,6 +3362,91 @@ async fn group_pointer(test_validator: &TestValidator, payer: &Keypair) {
     );
 }
 
+async fn group_member_pointer(test_validator: &TestValidator, payer: &Keypair) {
+    let program_id = spl_token_2022::id();
+    let config = test_config_with_default_signer(test_validator, payer, &program_id);
+    let member_address = Pubkey::new_unique();
+
+    let result = process_test_command(
+        &config,
+        payer,
+        &[
+            "spl-token",
+            CommandName::CreateToken.into(),
+            "--program-id",
+            &program_id.to_string(),
+            "--member-address",
+            &member_address.to_string(),
+        ],
+    )
+    .await
+    .unwrap();
+
+    let value: serde_json::Value = serde_json::from_str(&result).unwrap();
+    let mint = Pubkey::from_str(value["commandOutput"]["address"].as_str().unwrap()).unwrap();
+    let account = config.rpc_client.get_account(&mint).await.unwrap();
+    let mint_state = StateWithExtensionsOwned::<Mint>::unpack(account.data).unwrap();
+
+    let extension = mint_state.get_extension::<GroupMemberPointer>().unwrap();
+
+    assert_eq!(
+        extension.member_address,
+        Some(member_address).try_into().unwrap()
+    );
+
+    let new_member_address = Pubkey::new_unique();
+
+    let _new_result = process_test_command(
+        &config,
+        payer,
+        &[
+            "spl-token",
+            CommandName::UpdateMemberAddress.into(),
+            &mint.to_string(),
+            &new_member_address.to_string(),
+        ],
+    )
+    .await
+    .unwrap();
+
+    let new_account = config.rpc_client.get_account(&mint).await.unwrap();
+    let new_mint_state = StateWithExtensionsOwned::<Mint>::unpack(new_account.data).unwrap();
+
+    let new_extension = new_mint_state
+        .get_extension::<GroupMemberPointer>()
+        .unwrap();
+
+    assert_eq!(
+        new_extension.member_address,
+        Some(new_member_address).try_into().unwrap()
+    );
+
+    let _result_with_disable = process_test_command(
+        &config,
+        payer,
+        &[
+            "spl-token",
+            CommandName::UpdateMemberAddress.into(),
+            &mint.to_string(),
+            "--disable",
+        ],
+    )
+    .await;
+
+    let new_account_disbale = config.rpc_client.get_account(&mint).await.unwrap();
+    let new_mint_state_disable =
+        StateWithExtensionsOwned::<Mint>::unpack(new_account_disbale.data).unwrap();
+
+    let new_extension_disable = new_mint_state_disable
+        .get_extension::<GroupMemberPointer>()
+        .unwrap();
+
+    assert_eq!(
+        new_extension_disable.member_address,
+        None.try_into().unwrap()
+    );
+}
+
 async fn transfer_hook(test_validator: &TestValidator, payer: &Keypair) {
     let program_id = spl_token_2022::id();
     let mut config = test_config_with_default_signer(test_validator, payer, &program_id);