diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts index 9d14a22b5f0..df95a51cbdf 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 00000000000..8bf2a08d1f9 --- /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 00000000000..fa389cff0b6 --- /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 00000000000..57384f0fa81 --- /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 | 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 c6518b611ea..2d0ba58bb51 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 d597a96711a..ca17645205c 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, } 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 00000000000..bf5d664ffc5 --- /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 00000000000..c6ceefb2ef2 --- /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, + }); + }); +});