Skip to content

Commit

Permalink
token-js: add GroupMemberPointer extension
Browse files Browse the repository at this point in the history
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`!
  • Loading branch information
Joe C authored Feb 24, 2024
1 parent e3262a9 commit b7949fc
Show file tree
Hide file tree
Showing 8 changed files with 385 additions and 0 deletions.
8 changes: 8 additions & 0 deletions token/js/src/extensions/extensionType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions token/js/src/extensions/groupMemberPointer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './instructions.js';
export * from './state.js';
98 changes: 98 additions & 0 deletions token/js/src/extensions/groupMemberPointer/instructions.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
36 changes: 36 additions & 0 deletions token/js/src/extensions/groupMemberPointer/state.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions token/js/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions token/js/src/instructions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ export enum TokenInstruction {
// WithdrawalExcessLamports = 38,
MetadataPointerExtension = 39,
GroupPointerExtension = 40,
GroupMemberPointerExtension = 41,
}
97 changes: 97 additions & 0 deletions token/js/test/e2e-2022/groupMemberPointer.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading

0 comments on commit b7949fc

Please sign in to comment.