Repository: solana-labs/solana-program-library
Pull request #6292 (2024-02-23 21:02:54)

token-js: add `GroupMemberPointer` extension
As mentioned in #6175, and building on the previous PR, the `GroupMemberPointer`
extension is live on Token-2022 mainnet-beta.

This change adds support for `GroupMemberPointer` in `@solana/spl-token`!


buffalojoec: > [!WARNING]
> <b>This pull request is not mergeable via GitHub because a downstack PR is open. Once all requirements are satisfied, merge this PR as a stack <a href="https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6292?utm_source=stack-comment-downstack-mergeability-warning" >on Graphite</a>.</b>
> <a href="https://graphite.dev/docs/merge-pull-requests">Learn more</a>

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

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/6292?utm_source=stack-comment).
buffalojoec: ### Merge activity

* **Feb 24, 10:24 AM EST**: @buffalojoec started a stack merge that includes this pull request via [Graphite](https://app.graphite.dev/github/pr/solana-labs/solana-program-library/6292).


Patch:
diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts
index 9d14a22b5..df95a51cb 100644
--- a/token/js/src/extensions/extensionType.ts
+++ b/token/js/src/extensions/extensionType.ts
@@ -7,6 +7,7 @@ import { MULTISIG_SIZE } from '../state/multisig.js';
 import { ACCOUNT_TYPE_SIZE } from './accountType.js';
 import { CPI_GUARD_SIZE } from './cpiGuard/index.js';
 import { DEFAULT_ACCOUNT_STATE_SIZE } from './defaultAccountState/index.js';
+import { GROUP_MEMBER_POINTER_SIZE } from './groupMemberPointer/state.js';
 import { GROUP_POINTER_SIZE } from './groupPointer/state.js';
 import { IMMUTABLE_OWNER_SIZE } from './immutableOwner.js';
 import { INTEREST_BEARING_MINT_CONFIG_STATE_SIZE } from './interestBearingMint/state.js';
@@ -43,6 +44,8 @@ export enum ExtensionType {
     TokenMetadata = 19, // Remove number once above extensions implemented
     GroupPointer = 20,
     // TokenGroup = 21, // Not implemented yet
+    GroupMemberPointer = 22,
+    // TokenGroupMember = 23, // Not implemented yet
 }
 
 export const TYPE_SIZE = 2;
@@ -101,6 +104,8 @@ export function getTypeLen(e: ExtensionType): number {
             return TRANSFER_HOOK_ACCOUNT_SIZE;
         case ExtensionType.GroupPointer:
             return GROUP_POINTER_SIZE;
+        case ExtensionType.GroupMemberPointer:
+            return GROUP_MEMBER_POINTER_SIZE;
         case ExtensionType.TokenMetadata:
             throw Error(`Cannot get type length for variable extension type: ${e}`);
         default:
@@ -121,6 +126,7 @@ export function isMintExtension(e: ExtensionType): boolean {
         case ExtensionType.MetadataPointer:
         case ExtensionType.TokenMetadata:
         case ExtensionType.GroupPointer:
+        case ExtensionType.GroupMemberPointer:
             return true;
         case ExtensionType.Uninitialized:
         case ExtensionType.TransferFeeAmount:
@@ -158,6 +164,7 @@ export function isAccountExtension(e: ExtensionType): boolean {
         case ExtensionType.MetadataPointer:
         case ExtensionType.TokenMetadata:
         case ExtensionType.GroupPointer:
+        case ExtensionType.GroupMemberPointer:
             return false;
         default:
             throw Error(`Unknown extension type: ${e}`);
@@ -189,6 +196,7 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType {
         case ExtensionType.NonTransferableAccount:
         case ExtensionType.TransferHookAccount:
         case ExtensionType.GroupPointer:
+        case ExtensionType.GroupMemberPointer:
             return ExtensionType.Uninitialized;
     }
 }
diff --git a/token/js/src/extensions/groupMemberPointer/index.ts b/token/js/src/extensions/groupMemberPointer/index.ts
new file mode 100644
index 000000000..8bf2a08d1
--- /dev/null
+++ b/token/js/src/extensions/groupMemberPointer/index.ts
@@ -0,0 +1,2 @@
+export * from './instructions.js';
+export * from './state.js';
diff --git a/token/js/src/extensions/groupMemberPointer/instructions.ts b/token/js/src/extensions/groupMemberPointer/instructions.ts
new file mode 100644
index 000000000..fa389cff0
--- /dev/null
+++ b/token/js/src/extensions/groupMemberPointer/instructions.ts
@@ -0,0 +1,98 @@
+import { struct, u8 } from '@solana/buffer-layout';
+import { publicKey } from '@solana/buffer-layout-utils';
+import type { Signer } from '@solana/web3.js';
+import { PublicKey, TransactionInstruction } from '@solana/web3.js';
+import { TOKEN_2022_PROGRAM_ID, programSupportsExtensions } from '../../constants.js';
+import { TokenUnsupportedInstructionError } from '../../errors.js';
+import { TokenInstruction } from '../../instructions/types.js';
+import { addSigners } from '../../instructions/internal.js';
+
+export enum GroupMemberPointerInstruction {
+    Initialize = 0,
+    Update = 1,
+}
+
+export const initializeGroupMemberPointerData = struct<{
+    instruction: TokenInstruction.GroupMemberPointerExtension;
+    groupMemberPointerInstruction: number;
+    authority: PublicKey;
+    memberAddress: PublicKey;
+}>([
+    // prettier-ignore
+    u8('instruction'),
+    u8('groupMemberPointerInstruction'),
+    publicKey('authority'),
+    publicKey('memberAddress'),
+]);
+
+/**
+ * Construct an Initialize GroupMemberPointer instruction
+ *
+ * @param mint            Token mint account
+ * @param authority       Optional Authority that can set the member address
+ * @param memberAddress   Optional Account address that holds the member
+ * @param programId       SPL Token program account
+ *
+ * @return Instruction to add to a transaction
+ */
+export function createInitializeGroupMemberPointerInstruction(
+    mint: PublicKey,
+    authority: PublicKey | null,
+    memberAddress: PublicKey | null,
+    programId: PublicKey = TOKEN_2022_PROGRAM_ID
+): TransactionInstruction {
+    if (!programSupportsExtensions(programId)) {
+        throw new TokenUnsupportedInstructionError();
+    }
+    const keys = [{ pubkey: mint, isSigner: false, isWritable: true }];
+
+    const data = Buffer.alloc(initializeGroupMemberPointerData.span);
+    initializeGroupMemberPointerData.encode(
+        {
+            instruction: TokenInstruction.GroupMemberPointerExtension,
+            groupMemberPointerInstruction: GroupMemberPointerInstruction.Initialize,
+            authority: authority ?? PublicKey.default,
+            memberAddress: memberAddress ?? PublicKey.default,
+        },
+        data
+    );
+
+    return new TransactionInstruction({ keys, programId, data: data });
+}
+
+export const updateGroupMemberPointerData = struct<{
+    instruction: TokenInstruction.GroupMemberPointerExtension;
+    groupMemberPointerInstruction: number;
+    memberAddress: PublicKey;
+}>([
+    // prettier-ignore
+    u8('instruction'),
+    u8('groupMemberPointerInstruction'),
+    publicKey('memberAddress'),
+]);
+
+export function createUpdateGroupMemberPointerInstruction(
+    mint: PublicKey,
+    authority: PublicKey,
+    memberAddress: PublicKey | null,
+    multiSigners: (Signer | PublicKey)[] = [],
+    programId: PublicKey = TOKEN_2022_PROGRAM_ID
+): TransactionInstruction {
+    if (!programSupportsExtensions(programId)) {
+        throw new TokenUnsupportedInstructionError();
+    }
+
+    const keys = addSigners([{ pubkey: mint, isSigner: false, isWritable: true }], authority, multiSigners);
+
+    const data = Buffer.alloc(updateGroupMemberPointerData.span);
+    updateGroupMemberPointerData.encode(
+        {
+            instruction: TokenInstruction.GroupMemberPointerExtension,
+            groupMemberPointerInstruction: GroupMemberPointerInstruction.Update,
+            memberAddress: memberAddress ?? PublicKey.default,
+        },
+        data
+    );
+
+    return new TransactionInstruction({ keys, programId, data: data });
+}
diff --git a/token/js/src/extensions/groupMemberPointer/state.ts b/token/js/src/extensions/groupMemberPointer/state.ts
new file mode 100644
index 000000000..57384f0fa
--- /dev/null
+++ b/token/js/src/extensions/groupMemberPointer/state.ts
@@ -0,0 +1,36 @@
+import { struct } from '@solana/buffer-layout';
+import { publicKey } from '@solana/buffer-layout-utils';
+import { PublicKey } from '@solana/web3.js';
+import type { Mint } from '../../state/mint.js';
+import { ExtensionType, getExtensionData } from '../extensionType.js';
+
+/** GroupMemberPointer as stored by the program */
+export interface GroupMemberPointer {
+    /** Optional authority that can set the member address */
+    authority: PublicKey | null;
+    /** Optional account address that holds the member */
+    memberAddress: PublicKey | null;
+}
+
+/** Buffer layout for de/serializing a Group Pointer extension */
+export const GroupMemberPointerLayout = struct<{ authority: PublicKey; memberAddress: PublicKey }>([
+    publicKey('authority'),
+    publicKey('memberAddress'),
+]);
+
+export const GROUP_MEMBER_POINTER_SIZE = GroupMemberPointerLayout.span;
+
+export function getGroupMemberPointerState(mint: Mint): Partial<GroupMemberPointer> | null {
+    const extensionData = getExtensionData(ExtensionType.GroupMemberPointer, mint.tlvData);
+    if (extensionData !== null) {
+        const { authority, memberAddress } = GroupMemberPointerLayout.decode(extensionData);
+
+        // Explicity set None/Zero keys to null
+        return {
+            authority: authority.equals(PublicKey.default) ? null : authority,
+            memberAddress: memberAddress.equals(PublicKey.default) ? null : memberAddress,
+        };
+    } else {
+        return null;
+    }
+}
diff --git a/token/js/src/extensions/index.ts b/token/js/src/extensions/index.ts
index c6518b611..2d0ba58bb 100644
--- a/token/js/src/extensions/index.ts
+++ b/token/js/src/extensions/index.ts
@@ -2,6 +2,7 @@ export * from './accountType.js';
 export * from './cpiGuard/index.js';
 export * from './defaultAccountState/index.js';
 export * from './extensionType.js';
+export * from './groupMemberPointer/index.js';
 export * from './groupPointer/index.js';
 export * from './immutableOwner.js';
 export * from './interestBearingMint/index.js';
diff --git a/token/js/src/instructions/types.ts b/token/js/src/instructions/types.ts
index d597a9671..ca1764520 100644
--- a/token/js/src/instructions/types.ts
+++ b/token/js/src/instructions/types.ts
@@ -41,4 +41,5 @@ export enum TokenInstruction {
     // WithdrawalExcessLamports = 38,
     MetadataPointerExtension = 39,
     GroupPointerExtension = 40,
+    GroupMemberPointerExtension = 41,
 }


Test patch:
diff --git a/token/js/test/e2e-2022/groupMemberPointer.test.ts b/token/js/test/e2e-2022/groupMemberPointer.test.ts
new file mode 100644
index 000000000..bf5d664ff
--- /dev/null
+++ b/token/js/test/e2e-2022/groupMemberPointer.test.ts
@@ -0,0 +1,97 @@
+import { expect } from 'chai';
+import type { Connection, Signer } from '@solana/web3.js';
+import { PublicKey } from '@solana/web3.js';
+import { sendAndConfirmTransaction, Keypair, SystemProgram, Transaction } from '@solana/web3.js';
+
+import {
+    ExtensionType,
+    createInitializeGroupMemberPointerInstruction,
+    createInitializeMintInstruction,
+    createUpdateGroupMemberPointerInstruction,
+    getGroupMemberPointerState,
+    getMint,
+    getMintLen,
+} from '../../src';
+import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common';
+
+const TEST_TOKEN_DECIMALS = 2;
+const EXTENSIONS = [ExtensionType.GroupMemberPointer];
+
+describe('GroupMember pointer', () => {
+    let connection: Connection;
+    let payer: Signer;
+    let mint: Keypair;
+    let mintAuthority: Keypair;
+    let memberAddress: PublicKey;
+
+    before(async () => {
+        connection = await getConnection();
+        payer = await newAccountWithLamports(connection, 1000000000);
+        mintAuthority = Keypair.generate();
+    });
+
+    beforeEach(async () => {
+        mint = Keypair.generate();
+        memberAddress = PublicKey.unique();
+
+        const mintLen = getMintLen(EXTENSIONS);
+        const lamports = await connection.getMinimumBalanceForRentExemption(mintLen);
+
+        const transaction = new Transaction().add(
+            SystemProgram.createAccount({
+                fromPubkey: payer.publicKey,
+                newAccountPubkey: mint.publicKey,
+                space: mintLen,
+                lamports,
+                programId: TEST_PROGRAM_ID,
+            }),
+            createInitializeGroupMemberPointerInstruction(
+                mint.publicKey,
+                mintAuthority.publicKey,
+                memberAddress,
+                TEST_PROGRAM_ID
+            ),
+            createInitializeMintInstruction(
+                mint.publicKey,
+                TEST_TOKEN_DECIMALS,
+                mintAuthority.publicKey,
+                null,
+                TEST_PROGRAM_ID
+            )
+        );
+
+        await sendAndConfirmTransaction(connection, transaction, [payer, mint], undefined);
+    });
+
+    it('can successfully initialize', async () => {
+        const mintInfo = await getMint(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        const groupMemberPointer = getGroupMemberPointerState(mintInfo);
+
+        expect(groupMemberPointer).to.deep.equal({
+            authority: mintAuthority.publicKey,
+            memberAddress,
+        });
+    });
+
+    it('can update to new address', async () => {
+        const newGroupMemberAddress = PublicKey.unique();
+        const transaction = new Transaction().add(
+            createUpdateGroupMemberPointerInstruction(
+                mint.publicKey,
+                mintAuthority.publicKey,
+                newGroupMemberAddress,
+                undefined,
+                TEST_PROGRAM_ID
+            )
+        );
+        await sendAndConfirmTransaction(connection, transaction, [payer, mintAuthority], undefined);
+
+        const mintInfo = await getMint(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        const groupMemberPointer = getGroupMemberPointerState(mintInfo);
+
+        expect(groupMemberPointer).to.deep.equal({
+            authority: mintAuthority.publicKey,
+            memberAddress: newGroupMemberAddress,
+        });
+    });
+});
diff --git a/token/js/test/unit/groupMemberPointer.test.ts b/token/js/test/unit/groupMemberPointer.test.ts
new file mode 100644
index 000000000..c6ceefb2e
--- /dev/null
+++ b/token/js/test/unit/groupMemberPointer.test.ts
@@ -0,0 +1,142 @@
+import { PublicKey, TransactionInstruction } from '@solana/web3.js';
+import { expect } from 'chai';
+import type { Mint } from '../../src';
+import {
+    TOKEN_2022_PROGRAM_ID,
+    createInitializeGroupMemberPointerInstruction,
+    createUpdateGroupMemberPointerInstruction,
+    getGroupMemberPointerState,
+} from '../../src';
+
+const AUTHORITY_ADDRESS_BYTES = Buffer.alloc(32).fill(8);
+const GROUP_MEMBER_ADDRESS_BYTES = Buffer.alloc(32).fill(5);
+const NULL_OPTIONAL_NONZERO_PUBKEY_BYTES = Buffer.alloc(32).fill(0);
+
+describe('SPL Token 2022 GroupMemberPointer Extension', () => {
+    it('can create InitializeGroupMemberPointerInstruction', () => {
+        const mint = PublicKey.unique();
+        const authority = new PublicKey(AUTHORITY_ADDRESS_BYTES);
+        const memberAddress = new PublicKey(GROUP_MEMBER_ADDRESS_BYTES);
+        const instruction = createInitializeGroupMemberPointerInstruction(
+            mint,
+            authority,
+            memberAddress,
+            TOKEN_2022_PROGRAM_ID
+        );
+        expect(instruction).to.deep.equal(
+            new TransactionInstruction({
+                programId: TOKEN_2022_PROGRAM_ID,
+                keys: [{ isSigner: false, isWritable: true, pubkey: mint }],
+                data: Buffer.concat([
+                    Buffer.from([
+                        41, // Token instruction discriminator
+                        0, // GroupMemberPointer instruction discriminator
+                    ]),
+                    AUTHORITY_ADDRESS_BYTES,
+                    GROUP_MEMBER_ADDRESS_BYTES,
+                ]),
+            })
+        );
+    });
+    it('can create UpdateGroupMemberPointerInstruction', () => {
+        const mint = PublicKey.unique();
+        const authority = PublicKey.unique();
+        const memberAddress = new PublicKey(GROUP_MEMBER_ADDRESS_BYTES);
+        const instruction = createUpdateGroupMemberPointerInstruction(mint, authority, memberAddress);
+        expect(instruction).to.deep.equal(
+            new TransactionInstruction({
+                programId: TOKEN_2022_PROGRAM_ID,
+                keys: [
+                    { isSigner: false, isWritable: true, pubkey: mint },
+                    { isSigner: true, isWritable: false, pubkey: authority },
+                ],
+                data: Buffer.concat([
+                    Buffer.from([
+                        41, // Token instruction discriminator
+                        1, // GroupMemberPointer instruction discriminator
+                    ]),
+                    GROUP_MEMBER_ADDRESS_BYTES,
+                ]),
+            })
+        );
+    });
+    it('can create UpdateGroupMemberPointerInstruction to none', () => {
+        const mint = PublicKey.unique();
+        const authority = PublicKey.unique();
+        const memberAddress = null;
+        const instruction = createUpdateGroupMemberPointerInstruction(mint, authority, memberAddress);
+        expect(instruction).to.deep.equal(
+            new TransactionInstruction({
+                programId: TOKEN_2022_PROGRAM_ID,
+                keys: [
+                    { isSigner: false, isWritable: true, pubkey: mint },
+                    { isSigner: true, isWritable: false, pubkey: authority },
+                ],
+                data: Buffer.concat([
+                    Buffer.from([
+                        41, // Token instruction discriminator
+                        1, // GroupMemberPointer instruction discriminator
+                    ]),
+                    NULL_OPTIONAL_NONZERO_PUBKEY_BYTES,
+                ]),
+            })
+        );
+    });
+    it('can get state with authority and group address', async () => {
+        const mintInfo = {
+            tlvData: Buffer.concat([
+                Buffer.from([
+                    // Extension discriminator
+                    22, 0,
+                    // Extension length
+                    64, 0,
+                ]),
+                AUTHORITY_ADDRESS_BYTES,
+                GROUP_MEMBER_ADDRESS_BYTES,
+            ]),
+        } as Mint;
+        const groupPointer = getGroupMemberPointerState(mintInfo);
+        expect(groupPointer).to.deep.equal({
+            authority: new PublicKey(AUTHORITY_ADDRESS_BYTES),
+            memberAddress: new PublicKey(GROUP_MEMBER_ADDRESS_BYTES),
+        });
+    });
+    it('can get state with only group address', async () => {
+        const mintInfo = {
+            tlvData: Buffer.concat([
+                Buffer.from([
+                    // Extension discriminator
+                    22, 0,
+                    // Extension length
+                    64, 0,
+                ]),
+                NULL_OPTIONAL_NONZERO_PUBKEY_BYTES,
+                GROUP_MEMBER_ADDRESS_BYTES,
+            ]),
+        } as Mint;
+        const groupPointer = getGroupMemberPointerState(mintInfo);
+        expect(groupPointer).to.deep.equal({
+            authority: null,
+            memberAddress: new PublicKey(GROUP_MEMBER_ADDRESS_BYTES),
+        });
+    });
+    it('can get state with only authority address', async () => {
+        const mintInfo = {
+            tlvData: Buffer.concat([
+                Buffer.from([
+                    // Extension discriminator
+                    22, 0,
+                    // Extension length
+                    64, 0,
+                ]),
+                AUTHORITY_ADDRESS_BYTES,
+                NULL_OPTIONAL_NONZERO_PUBKEY_BYTES,
+            ]),
+        } as Mint;
+        const groupPointer = getGroupMemberPointerState(mintInfo);
+        expect(groupPointer).to.deep.equal({
+            authority: new PublicKey(AUTHORITY_ADDRESS_BYTES),
+            memberAddress: null,
+        });
+    });
+});