diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts index be69a923bf0..9d14a22b5f0 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 00000000000..8bf2a08d1f9 --- /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 00000000000..e48b7d8ae6e --- /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 00000000000..064c3c7dc55 --- /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 | 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 72554069215..c6518b611ea 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 2a60bb0416c..d597a96711a 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, } 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 00000000000..0f2322dfe4c --- /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 00000000000..93a967c405b --- /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, + }); + }); +});