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

token-js: add `GroupPointer` extension
As mentioned in #6175, the `GroupPointer` extension is live on Token-2022
mainnet-beta, but it's not currently supported in the `@solana/spl-token`.

This change adds that support!


buffalojoec: > Just one little micro-nit. Also, as follow-up work, can you add the group pointer and group member pointer authorities in `instructions/setAuthority.ts`?

Yep, I'll add to the stack!
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/6291).


Patch:
diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts
index be69a923b..9d14a22b5 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_POINTER_SIZE } from './groupPointer/state.js';
 import { IMMUTABLE_OWNER_SIZE } from './immutableOwner.js';
 import { INTEREST_BEARING_MINT_CONFIG_STATE_SIZE } from './interestBearingMint/state.js';
 import { MEMO_TRANSFER_SIZE } from './memoTransfer/index.js';
@@ -40,6 +41,8 @@ export enum ExtensionType {
     // ConfidentialTransferFeeAmount, // Not implemented yet
     MetadataPointer = 18, // Remove number once above extensions implemented
     TokenMetadata = 19, // Remove number once above extensions implemented
+    GroupPointer = 20,
+    // TokenGroup = 21, // Not implemented yet
 }
 
 export const TYPE_SIZE = 2;
@@ -96,6 +99,8 @@ export function getTypeLen(e: ExtensionType): number {
             return TRANSFER_HOOK_SIZE;
         case ExtensionType.TransferHookAccount:
             return TRANSFER_HOOK_ACCOUNT_SIZE;
+        case ExtensionType.GroupPointer:
+            return GROUP_POINTER_SIZE;
         case ExtensionType.TokenMetadata:
             throw Error(`Cannot get type length for variable extension type: ${e}`);
         default:
@@ -115,6 +120,7 @@ export function isMintExtension(e: ExtensionType): boolean {
         case ExtensionType.TransferHook:
         case ExtensionType.MetadataPointer:
         case ExtensionType.TokenMetadata:
+        case ExtensionType.GroupPointer:
             return true;
         case ExtensionType.Uninitialized:
         case ExtensionType.TransferFeeAmount:
@@ -151,6 +157,7 @@ export function isAccountExtension(e: ExtensionType): boolean {
         case ExtensionType.TransferHook:
         case ExtensionType.MetadataPointer:
         case ExtensionType.TokenMetadata:
+        case ExtensionType.GroupPointer:
             return false;
         default:
             throw Error(`Unknown extension type: ${e}`);
@@ -181,6 +188,7 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType {
         case ExtensionType.PermanentDelegate:
         case ExtensionType.NonTransferableAccount:
         case ExtensionType.TransferHookAccount:
+        case ExtensionType.GroupPointer:
             return ExtensionType.Uninitialized;
     }
 }
diff --git a/token/js/src/extensions/groupPointer/index.ts b/token/js/src/extensions/groupPointer/index.ts
new file mode 100644
index 000000000..8bf2a08d1
--- /dev/null
+++ b/token/js/src/extensions/groupPointer/index.ts
@@ -0,0 +1,2 @@
+export * from './instructions.js';
+export * from './state.js';
diff --git a/token/js/src/extensions/groupPointer/instructions.ts b/token/js/src/extensions/groupPointer/instructions.ts
new file mode 100644
index 000000000..e48b7d8ae
--- /dev/null
+++ b/token/js/src/extensions/groupPointer/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 GroupPointerInstruction {
+    Initialize = 0,
+    Update = 1,
+}
+
+export const initializeGroupPointerData = struct<{
+    instruction: TokenInstruction.GroupPointerExtension;
+    groupPointerInstruction: number;
+    authority: PublicKey;
+    groupAddress: PublicKey;
+}>([
+    // prettier-ignore
+    u8('instruction'),
+    u8('groupPointerInstruction'),
+    publicKey('authority'),
+    publicKey('groupAddress'),
+]);
+
+/**
+ * Construct an Initialize GroupPointer instruction
+ *
+ * @param mint            Token mint account
+ * @param authority       Optional Authority that can set the group address
+ * @param groupAddress    Optional Account address that holds the group
+ * @param programId       SPL Token program account
+ *
+ * @return Instruction to add to a transaction
+ */
+export function createInitializeGroupPointerInstruction(
+    mint: PublicKey,
+    authority: PublicKey | null,
+    groupAddress: 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(initializeGroupPointerData.span);
+    initializeGroupPointerData.encode(
+        {
+            instruction: TokenInstruction.GroupPointerExtension,
+            groupPointerInstruction: GroupPointerInstruction.Initialize,
+            authority: authority ?? PublicKey.default,
+            groupAddress: groupAddress ?? PublicKey.default,
+        },
+        data
+    );
+
+    return new TransactionInstruction({ keys, programId, data: data });
+}
+
+export const updateGroupPointerData = struct<{
+    instruction: TokenInstruction.GroupPointerExtension;
+    groupPointerInstruction: number;
+    groupAddress: PublicKey;
+}>([
+    // prettier-ignore
+    u8('instruction'),
+    u8('groupPointerInstruction'),
+    publicKey('groupAddress'),
+]);
+
+export function createUpdateGroupPointerInstruction(
+    mint: PublicKey,
+    authority: PublicKey,
+    groupAddress: 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(updateGroupPointerData.span);
+    updateGroupPointerData.encode(
+        {
+            instruction: TokenInstruction.GroupPointerExtension,
+            groupPointerInstruction: GroupPointerInstruction.Update,
+            groupAddress: groupAddress ?? PublicKey.default,
+        },
+        data
+    );
+
+    return new TransactionInstruction({ keys, programId, data: data });
+}
diff --git a/token/js/src/extensions/groupPointer/state.ts b/token/js/src/extensions/groupPointer/state.ts
new file mode 100644
index 000000000..064c3c7dc
--- /dev/null
+++ b/token/js/src/extensions/groupPointer/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';
+
+/** GroupPointer as stored by the program */
+export interface GroupPointer {
+    /** Optional authority that can set the group address */
+    authority: PublicKey | null;
+    /** Optional account address that holds the group */
+    groupAddress: PublicKey | null;
+}
+
+/** Buffer layout for de/serializing a GroupPointer extension */
+export const GroupPointerLayout = struct<{ authority: PublicKey; groupAddress: PublicKey }>([
+    publicKey('authority'),
+    publicKey('groupAddress'),
+]);
+
+export const GROUP_POINTER_SIZE = GroupPointerLayout.span;
+
+export function getGroupPointerState(mint: Mint): Partial<GroupPointer> | null {
+    const extensionData = getExtensionData(ExtensionType.GroupPointer, mint.tlvData);
+    if (extensionData !== null) {
+        const { authority, groupAddress } = GroupPointerLayout.decode(extensionData);
+
+        // Explicity set None/Zero keys to null
+        return {
+            authority: authority.equals(PublicKey.default) ? null : authority,
+            groupAddress: groupAddress.equals(PublicKey.default) ? null : groupAddress,
+        };
+    } else {
+        return null;
+    }
+}
diff --git a/token/js/src/extensions/index.ts b/token/js/src/extensions/index.ts
index 725540692..c6518b611 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 './groupPointer/index.js';
 export * from './immutableOwner.js';
 export * from './interestBearingMint/index.js';
 export * from './memoTransfer/index.js';
diff --git a/token/js/src/instructions/types.ts b/token/js/src/instructions/types.ts
index 2a60bb041..d597a9671 100644
--- a/token/js/src/instructions/types.ts
+++ b/token/js/src/instructions/types.ts
@@ -40,4 +40,5 @@ export enum TokenInstruction {
     // ConfidentialTransferFeeExtension = 37,
     // WithdrawalExcessLamports = 38,
     MetadataPointerExtension = 39,
+    GroupPointerExtension = 40,
 }


Test patch:
diff --git a/token/js/test/e2e-2022/groupPointer.test.ts b/token/js/test/e2e-2022/groupPointer.test.ts
new file mode 100644
index 000000000..0f2322dfe
--- /dev/null
+++ b/token/js/test/e2e-2022/groupPointer.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,
+    createInitializeGroupPointerInstruction,
+    createInitializeMintInstruction,
+    createUpdateGroupPointerInstruction,
+    getGroupPointerState,
+    getMint,
+    getMintLen,
+} from '../../src';
+import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common';
+
+const TEST_TOKEN_DECIMALS = 2;
+const EXTENSIONS = [ExtensionType.GroupPointer];
+
+describe('Group pointer', () => {
+    let connection: Connection;
+    let payer: Signer;
+    let mint: Keypair;
+    let mintAuthority: Keypair;
+    let groupAddress: PublicKey;
+
+    before(async () => {
+        connection = await getConnection();
+        payer = await newAccountWithLamports(connection, 1000000000);
+        mintAuthority = Keypair.generate();
+    });
+
+    beforeEach(async () => {
+        mint = Keypair.generate();
+        groupAddress = 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,
+            }),
+            createInitializeGroupPointerInstruction(
+                mint.publicKey,
+                mintAuthority.publicKey,
+                groupAddress,
+                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 groupPointer = getGroupPointerState(mintInfo);
+
+        expect(groupPointer).to.deep.equal({
+            authority: mintAuthority.publicKey,
+            groupAddress,
+        });
+    });
+
+    it('can update to new address', async () => {
+        const newGroupAddress = PublicKey.unique();
+        const transaction = new Transaction().add(
+            createUpdateGroupPointerInstruction(
+                mint.publicKey,
+                mintAuthority.publicKey,
+                newGroupAddress,
+                undefined,
+                TEST_PROGRAM_ID
+            )
+        );
+        await sendAndConfirmTransaction(connection, transaction, [payer, mintAuthority], undefined);
+
+        const mintInfo = await getMint(connection, mint.publicKey, undefined, TEST_PROGRAM_ID);
+        const groupPointer = getGroupPointerState(mintInfo);
+
+        expect(groupPointer).to.deep.equal({
+            authority: mintAuthority.publicKey,
+            groupAddress: newGroupAddress,
+        });
+    });
+});
diff --git a/token/js/test/unit/groupPointer.test.ts b/token/js/test/unit/groupPointer.test.ts
new file mode 100644
index 000000000..93a967c40
--- /dev/null
+++ b/token/js/test/unit/groupPointer.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,
+    createInitializeGroupPointerInstruction,
+    createUpdateGroupPointerInstruction,
+    getGroupPointerState,
+} from '../../src';
+
+const AUTHORITY_ADDRESS_BYTES = Buffer.alloc(32).fill(8);
+const GROUP_ADDRESS_BYTES = Buffer.alloc(32).fill(5);
+const NULL_OPTIONAL_NONZERO_PUBKEY_BYTES = Buffer.alloc(32).fill(0);
+
+describe('SPL Token 2022 GroupPointer Extension', () => {
+    it('can create InitializeGroupPointerInstruction', () => {
+        const mint = PublicKey.unique();
+        const authority = new PublicKey(AUTHORITY_ADDRESS_BYTES);
+        const groupAddress = new PublicKey(GROUP_ADDRESS_BYTES);
+        const instruction = createInitializeGroupPointerInstruction(
+            mint,
+            authority,
+            groupAddress,
+            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([
+                        40, // Token instruction discriminator
+                        0, // GroupPointer instruction discriminator
+                    ]),
+                    AUTHORITY_ADDRESS_BYTES,
+                    GROUP_ADDRESS_BYTES,
+                ]),
+            })
+        );
+    });
+    it('can create UpdateGroupPointerInstruction', () => {
+        const mint = PublicKey.unique();
+        const authority = PublicKey.unique();
+        const groupAddress = new PublicKey(GROUP_ADDRESS_BYTES);
+        const instruction = createUpdateGroupPointerInstruction(mint, authority, groupAddress);
+        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([
+                        40, // Token instruction discriminator
+                        1, // GroupPointer instruction discriminator
+                    ]),
+                    GROUP_ADDRESS_BYTES,
+                ]),
+            })
+        );
+    });
+    it('can create UpdateGroupPointerInstruction to none', () => {
+        const mint = PublicKey.unique();
+        const authority = PublicKey.unique();
+        const groupAddress = null;
+        const instruction = createUpdateGroupPointerInstruction(mint, authority, groupAddress);
+        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([
+                        40, // Token instruction discriminator
+                        1, // GroupPointer 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
+                    20, 0,
+                    // Extension length
+                    64, 0,
+                ]),
+                AUTHORITY_ADDRESS_BYTES,
+                GROUP_ADDRESS_BYTES,
+            ]),
+        } as Mint;
+        const groupPointer = getGroupPointerState(mintInfo);
+        expect(groupPointer).to.deep.equal({
+            authority: new PublicKey(AUTHORITY_ADDRESS_BYTES),
+            groupAddress: new PublicKey(GROUP_ADDRESS_BYTES),
+        });
+    });
+    it('can get state with only group address', async () => {
+        const mintInfo = {
+            tlvData: Buffer.concat([
+                Buffer.from([
+                    // Extension discriminator
+                    20, 0,
+                    // Extension length
+                    64, 0,
+                ]),
+                NULL_OPTIONAL_NONZERO_PUBKEY_BYTES,
+                GROUP_ADDRESS_BYTES,
+            ]),
+        } as Mint;
+        const groupPointer = getGroupPointerState(mintInfo);
+        expect(groupPointer).to.deep.equal({
+            authority: null,
+            groupAddress: new PublicKey(GROUP_ADDRESS_BYTES),
+        });
+    });
+    it('can get state with only authority address', async () => {
+        const mintInfo = {
+            tlvData: Buffer.concat([
+                Buffer.from([
+                    // Extension discriminator
+                    20, 0,
+                    // Extension length
+                    64, 0,
+                ]),
+                AUTHORITY_ADDRESS_BYTES,
+                NULL_OPTIONAL_NONZERO_PUBKEY_BYTES,
+            ]),
+        } as Mint;
+        const groupPointer = getGroupPointerState(mintInfo);
+        expect(groupPointer).to.deep.equal({
+            authority: new PublicKey(AUTHORITY_ADDRESS_BYTES),
+            groupAddress: null,
+        });
+    });
+});