Skip to content

Commit

Permalink
token-js: add GroupPointer extension
Browse files Browse the repository at this point in the history
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!
  • Loading branch information
Joe C authored Feb 24, 2024
1 parent 2062613 commit e3262a9
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_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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
}
}
Expand Down
2 changes: 2 additions & 0 deletions token/js/src/extensions/groupPointer/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/groupPointer/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 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 });
}
36 changes: 36 additions & 0 deletions token/js/src/extensions/groupPointer/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';

/** 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;
}
}
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 './groupPointer/index.js';
export * from './immutableOwner.js';
export * from './interestBearingMint/index.js';
export * from './memoTransfer/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 @@ -40,4 +40,5 @@ export enum TokenInstruction {
// ConfidentialTransferFeeExtension = 37,
// WithdrawalExcessLamports = 38,
MetadataPointerExtension = 39,
GroupPointerExtension = 40,
}
97 changes: 97 additions & 0 deletions token/js/test/e2e-2022/groupPointer.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,
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,
});
});
});
Loading

0 comments on commit e3262a9

Please sign in to comment.