diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts index 75172abec48..bec17bda84e 100644 --- a/token/js/src/extensions/extensionType.ts +++ b/token/js/src/extensions/extensionType.ts @@ -8,12 +8,14 @@ import { DEFAULT_ACCOUNT_STATE_SIZE } from './defaultAccountState/index.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'; +import { METADATA_POINTER_SIZE } from './metadataPointer/state.js'; import { MINT_CLOSE_AUTHORITY_SIZE } from './mintCloseAuthority.js'; import { NON_TRANSFERABLE_SIZE, NON_TRANSFERABLE_ACCOUNT_SIZE } from './nonTransferable.js'; import { PERMANENT_DELEGATE_SIZE } from './permanentDelegate.js'; import { TRANSFER_FEE_AMOUNT_SIZE, TRANSFER_FEE_CONFIG_SIZE } from './transferFee/index.js'; import { TRANSFER_HOOK_ACCOUNT_SIZE, TRANSFER_HOOK_SIZE } from './transferHook/index.js'; +// Sequence from https://github.com/solana-labs/solana-program-library/blob/master/token/program-2022/src/extension/mod.rs#L903 export enum ExtensionType { Uninitialized, TransferFeeConfig, @@ -31,6 +33,9 @@ export enum ExtensionType { NonTransferableAccount, TransferHook, TransferHookAccount, + // ConfidentialTransferFee, // Not implemented yet + // ConfidentialTransferFeeAmount, // Not implemented yet + MetadataPointer = 18, // Remove number once above extensions implemented } export const TYPE_SIZE = 2; @@ -60,6 +65,8 @@ export function getTypeLen(e: ExtensionType): number { return IMMUTABLE_OWNER_SIZE; case ExtensionType.MemoTransfer: return MEMO_TRANSFER_SIZE; + case ExtensionType.MetadataPointer: + return METADATA_POINTER_SIZE; case ExtensionType.NonTransferable: return NON_TRANSFERABLE_SIZE; case ExtensionType.InterestBearingConfig: @@ -87,6 +94,7 @@ export function isMintExtension(e: ExtensionType): boolean { case ExtensionType.InterestBearingConfig: case ExtensionType.PermanentDelegate: case ExtensionType.TransferHook: + case ExtensionType.MetadataPointer: return true; case ExtensionType.Uninitialized: case ExtensionType.TransferFeeAmount: @@ -121,6 +129,7 @@ export function isAccountExtension(e: ExtensionType): boolean { case ExtensionType.InterestBearingConfig: case ExtensionType.PermanentDelegate: case ExtensionType.TransferHook: + case ExtensionType.MetadataPointer: return false; default: throw Error(`Unknown extension type: ${e}`); @@ -144,6 +153,7 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType { case ExtensionType.ImmutableOwner: case ExtensionType.MemoTransfer: case ExtensionType.MintCloseAuthority: + case ExtensionType.MetadataPointer: case ExtensionType.Uninitialized: case ExtensionType.InterestBearingConfig: case ExtensionType.PermanentDelegate: diff --git a/token/js/src/extensions/index.ts b/token/js/src/extensions/index.ts index 08aa516c5cc..0cb111e6ed7 100644 --- a/token/js/src/extensions/index.ts +++ b/token/js/src/extensions/index.ts @@ -5,6 +5,7 @@ export * from './extensionType.js'; export * from './immutableOwner.js'; export * from './interestBearingMint/index.js'; export * from './memoTransfer/index.js'; +export * from './metadataPointer/index.js'; export * from './mintCloseAuthority.js'; export * from './nonTransferable.js'; export * from './transferFee/index.js'; diff --git a/token/js/src/extensions/metadataPointer/index.ts b/token/js/src/extensions/metadataPointer/index.ts new file mode 100644 index 00000000000..8bf2a08d1f9 --- /dev/null +++ b/token/js/src/extensions/metadataPointer/index.ts @@ -0,0 +1,2 @@ +export * from './instructions.js'; +export * from './state.js'; diff --git a/token/js/src/extensions/metadataPointer/instructions.ts b/token/js/src/extensions/metadataPointer/instructions.ts new file mode 100644 index 00000000000..2d4b83541b4 --- /dev/null +++ b/token/js/src/extensions/metadataPointer/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 MetadataPointerInstruction { + Initialize = 0, + Update = 1, +} + +export const initializeMetadataPointerData = struct<{ + instruction: TokenInstruction.MetadataPointerExtension; + metadataPointerInstruction: number; + authority: PublicKey; + metadataAddress: PublicKey; +}>([ + // prettier-ignore + u8('instruction'), + u8('metadataPointerInstruction'), + publicKey('authority'), + publicKey('metadataAddress'), +]); + +/** + * Construct an Initialize MetadataPointer instruction + * + * @param mint Token mint account + * @param authority Optional Authority that can set the metadata address + * @param metadataAddress Optional Account address that holds the metadata + * @param programId SPL Token program account + * + * @return Instruction to add to a transaction + */ +export function createInitializeMetadataPointerInstruction( + mint: PublicKey, + authority: PublicKey | null, + metadataAddress: PublicKey | null, + programId: PublicKey +): TransactionInstruction { + if (!programSupportsExtensions(programId)) { + throw new TokenUnsupportedInstructionError(); + } + const keys = [{ pubkey: mint, isSigner: false, isWritable: true }]; + + const data = Buffer.alloc(initializeMetadataPointerData.span); + initializeMetadataPointerData.encode( + { + instruction: TokenInstruction.MetadataPointerExtension, + metadataPointerInstruction: MetadataPointerInstruction.Initialize, + authority: authority ?? PublicKey.default, + metadataAddress: metadataAddress ?? PublicKey.default, + }, + data + ); + + return new TransactionInstruction({ keys, programId, data: data }); +} + +export const updateMetadataPointerData = struct<{ + instruction: TokenInstruction.MetadataPointerExtension; + metadataPointerInstruction: number; + metadataAddress: PublicKey; +}>([ + // prettier-ignore + u8('instruction'), + u8('metadataPointerInstruction'), + publicKey('metadataAddress'), +]); + +export function createUpdateMetadataPointerInstruction( + mint: PublicKey, + authority: PublicKey, + metadataAddress: 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(updateMetadataPointerData.span); + updateMetadataPointerData.encode( + { + instruction: TokenInstruction.MetadataPointerExtension, + metadataPointerInstruction: MetadataPointerInstruction.Update, + metadataAddress: metadataAddress ?? PublicKey.default, + }, + data + ); + + return new TransactionInstruction({ keys, programId, data: data }); +} diff --git a/token/js/src/extensions/metadataPointer/state.ts b/token/js/src/extensions/metadataPointer/state.ts new file mode 100644 index 00000000000..5eb319818f3 --- /dev/null +++ b/token/js/src/extensions/metadataPointer/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'; + +/** MetadataPointer as stored by the program */ +export interface MetadataPointer { + /** Optional authority that can set the metadata address */ + authority: PublicKey | null; + /** Optional Account Address that holds the metadata */ + metadataAddress: PublicKey | null; +} + +/** Buffer layout for de/serializing a Metadata Pointer extension */ +export const MetadataPointerLayout = struct<{ authority: PublicKey; metadataAddress: PublicKey }>([ + publicKey('authority'), + publicKey('metadataAddress'), +]); + +export const METADATA_POINTER_SIZE = MetadataPointerLayout.span; + +export function getMetadataPointerState(mint: Mint): Partial | null { + const extensionData = getExtensionData(ExtensionType.MetadataPointer, mint.tlvData); + if (extensionData !== null) { + const { authority, metadataAddress } = MetadataPointerLayout.decode(extensionData); + + // Explicity set None/Zero keys to null + return { + authority: authority.equals(PublicKey.default) ? null : authority, + metadataAddress: metadataAddress.equals(PublicKey.default) ? null : metadataAddress, + }; + } else { + return null; + } +} diff --git a/token/js/src/instructions/types.ts b/token/js/src/instructions/types.ts index 4448cbf3469..2a60bb0416c 100644 --- a/token/js/src/instructions/types.ts +++ b/token/js/src/instructions/types.ts @@ -37,4 +37,7 @@ export enum TokenInstruction { CpiGuardExtension = 34, InitializePermanentDelegate = 35, TransferHookExtension = 36, + // ConfidentialTransferFeeExtension = 37, + // WithdrawalExcessLamports = 38, + MetadataPointerExtension = 39, } diff --git a/token/js/test/e2e-2022/metadataPointer.test.ts b/token/js/test/e2e-2022/metadataPointer.test.ts new file mode 100644 index 00000000000..18eeeefa97c --- /dev/null +++ b/token/js/test/e2e-2022/metadataPointer.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, + createInitializeMetadataPointerInstruction, + createInitializeMintInstruction, + createUpdateMetadataPointerInstruction, + getMetadataPointerState, + getMint, + getMintLen, +} from '../../src'; +import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common'; + +const TEST_TOKEN_DECIMALS = 2; +const EXTENSIONS = [ExtensionType.MetadataPointer]; + +describe('Metadata pointer', () => { + let connection: Connection; + let payer: Signer; + let mint: Keypair; + let mintAuthority: Keypair; + let metadataAddress: PublicKey; + + before(async () => { + connection = await getConnection(); + payer = await newAccountWithLamports(connection, 1000000000); + mintAuthority = Keypair.generate(); + }); + + beforeEach(async () => { + mint = Keypair.generate(); + metadataAddress = 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, + }), + createInitializeMetadataPointerInstruction( + mint.publicKey, + mintAuthority.publicKey, + metadataAddress, + 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 metadataPointer = getMetadataPointerState(mintInfo); + + expect(metadataPointer).to.deep.equal({ + authority: mintAuthority.publicKey, + metadataAddress, + }); + }); + + it('can update to new address', async () => { + const newMetadataAddress = PublicKey.unique(); + const transaction = new Transaction().add( + createUpdateMetadataPointerInstruction( + mint.publicKey, + mintAuthority.publicKey, + newMetadataAddress, + undefined, + TEST_PROGRAM_ID + ) + ); + await sendAndConfirmTransaction(connection, transaction, [payer, mintAuthority], undefined); + + const mintInfo = await getMint(connection, mint.publicKey, undefined, TEST_PROGRAM_ID); + const metadataPointer = getMetadataPointerState(mintInfo); + + expect(metadataPointer).to.deep.equal({ + authority: mintAuthority.publicKey, + metadataAddress: newMetadataAddress, + }); + }); +}); diff --git a/token/js/test/unit/metadataPointer.test.ts b/token/js/test/unit/metadataPointer.test.ts new file mode 100644 index 00000000000..d7469421a54 --- /dev/null +++ b/token/js/test/unit/metadataPointer.test.ts @@ -0,0 +1,141 @@ +import { PublicKey, TransactionInstruction } from '@solana/web3.js'; +import { expect } from 'chai'; +import type { Mint } from '../../src'; +import { + TOKEN_2022_PROGRAM_ID, + createInitializeMetadataPointerInstruction, + createUpdateMetadataPointerInstruction, + getMetadataPointerState, +} from '../../src'; + +describe('SPL Token 2022 Metadata Extension', () => { + it('can create createInitializeMetadataPointerInstruction', () => { + const mint = PublicKey.unique(); + const authority = new PublicKey('1111111QLbz7JHiBTspS962RLKV8GndWFwiEaqKM'); + const metadataAddress = new PublicKey('1111111ogCyDbaRMvkdsHB3qfdyFYaG1WtRUAfdh'); + + const instruction = createInitializeMetadataPointerInstruction( + mint, + authority, + metadataAddress, + 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.from([ + // Output of rust implementation + 39, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + }) + ); + }); + + it('can create createUpdateMetadataPointerInstruction', () => { + const mint = PublicKey.unique(); + const authority = PublicKey.unique(); + const metadataAddress = new PublicKey('11111112cMQwSC9qirWGjZM6gLGwW69X22mqwLLGP'); + + const instruction = createUpdateMetadataPointerInstruction(mint, authority, metadataAddress); + + 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.from([ + // Output of rust implementation + 39, 1, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, + ]), + }) + ); + }); + + it('can create createUpdateMetadataPointerInstruction to none', () => { + const mint = PublicKey.unique(); + const authority = PublicKey.unique(); + const metadataAddress = null; + + const instruction = createUpdateMetadataPointerInstruction(mint, authority, metadataAddress); + + 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.from([ + // Output of rust implementation + 39, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, + ]), + }) + ); + }); + + it('can get state with authority and metadata address', async () => { + const mintInfo = { + tlvData: Buffer.from([ + 18, 0, 64, 0, 134, 125, 9, 16, 205, 223, 26, 224, 220, 174, 52, 213, 193, 216, 9, 80, 82, 181, 8, 228, + 75, 112, 233, 116, 2, 183, 51, 228, 88, 64, 179, 158, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]), + } as Mint; + + const metadataPointer = getMetadataPointerState(mintInfo); + + expect(metadataPointer).to.deep.equal({ + authority: new PublicKey([ + 134, 125, 9, 16, 205, 223, 26, 224, 220, 174, 52, 213, 193, 216, 9, 80, 82, 181, 8, 228, 75, 112, 233, + 116, 2, 183, 51, 228, 88, 64, 179, 158, + ]), + metadataAddress: new PublicKey([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]), + }); + }); + it('can get state with only metadata address', async () => { + const mintInfo = { + tlvData: Buffer.from([ + 18, 0, 64, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]), + } as Mint; + + const metadataPointer = getMetadataPointerState(mintInfo); + + expect(metadataPointer).to.deep.equal({ + authority: null, + metadataAddress: new PublicKey([ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, + ]), + }); + }); + + it('can get state with only authority address', async () => { + const mintInfo = { + tlvData: Buffer.from([ + 18, 0, 64, 0, 16, 218, 238, 42, 17, 19, 152, 173, 216, 24, 229, 204, 215, 108, 49, 98, 233, 115, 53, + 252, 9, 156, 216, 23, 14, 157, 139, 132, 28, 182, 4, 191, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]), + } as Mint; + + const metadataPointer = getMetadataPointerState(mintInfo); + + expect(metadataPointer).to.deep.equal({ + authority: new PublicKey([ + 16, 218, 238, 42, 17, 19, 152, 173, 216, 24, 229, 204, 215, 108, 49, 98, 233, 115, 53, 252, 9, 156, 216, + 23, 14, 157, 139, 132, 28, 182, 4, 191, + ]), + metadataAddress: null, + }); + }); +});