From 5d22bf8756143d6fafc92e44c278b915e7be8259 Mon Sep 17 00:00:00 2001 From: mistersimon <779959+mistersimon@users.noreply.github.com> Date: Sat, 18 Nov 2023 15:28:08 +0800 Subject: [PATCH] added init, update and emit token metadata actions --- pnpm-lock.yaml | 11 +- token/js/package.json | 1 + token/js/src/extensions/extensionType.ts | 5 + token/js/src/extensions/index.ts | 1 + .../src/extensions/tokenMetadata/actions.ts | 133 ++++++++++++++++ .../js/src/extensions/tokenMetadata/index.ts | 2 + .../js/src/extensions/tokenMetadata/state.ts | 62 ++++++++ token/js/src/instructions/index.ts | 8 + token/js/test/e2e-2022/tokenMetadata.test.ts | 150 ++++++++++++++++++ 9 files changed, 369 insertions(+), 4 deletions(-) create mode 100644 token/js/src/extensions/tokenMetadata/actions.ts create mode 100644 token/js/src/extensions/tokenMetadata/index.ts create mode 100644 token/js/src/extensions/tokenMetadata/state.ts create mode 100644 token/js/test/e2e-2022/tokenMetadata.test.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ade368cf6a5..d3e4911a975 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + importers: .: @@ -613,6 +617,9 @@ importers: '@solana/buffer-layout-utils': specifier: ^0.2.0 version: 0.2.0 + '@solana/spl-token-metadata': + specifier: ^0.1.1 + version: link:../../token-metadata/js buffer: specifier: ^6.0.3 version: 6.0.3 @@ -7445,7 +7452,3 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: true - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false diff --git a/token/js/package.json b/token/js/package.json index a05189f503b..32ab11d6573 100644 --- a/token/js/package.json +++ b/token/js/package.json @@ -56,6 +56,7 @@ "dependencies": { "@solana/buffer-layout": "^4.0.0", "@solana/buffer-layout-utils": "^0.2.0", + "@solana/spl-token-metadata": "^0.1.1", "buffer": "^6.0.3" }, "devDependencies": { diff --git a/token/js/src/extensions/extensionType.ts b/token/js/src/extensions/extensionType.ts index bec17bda84e..b18f8134fe0 100644 --- a/token/js/src/extensions/extensionType.ts +++ b/token/js/src/extensions/extensionType.ts @@ -36,6 +36,7 @@ export enum ExtensionType { // ConfidentialTransferFee, // Not implemented yet // ConfidentialTransferFeeAmount, // Not implemented yet MetadataPointer = 18, // Remove number once above extensions implemented + TokenMetadata = 19, // Remove number once above extensions implemented } export const TYPE_SIZE = 2; @@ -46,6 +47,7 @@ export const LENGTH_SIZE = 2; export function getTypeLen(e: ExtensionType): number { switch (e) { case ExtensionType.Uninitialized: + case ExtensionType.TokenMetadata: return 0; case ExtensionType.TransferFeeConfig: return TRANSFER_FEE_CONFIG_SIZE; @@ -95,6 +97,7 @@ export function isMintExtension(e: ExtensionType): boolean { case ExtensionType.PermanentDelegate: case ExtensionType.TransferHook: case ExtensionType.MetadataPointer: + case ExtensionType.TokenMetadata: return true; case ExtensionType.Uninitialized: case ExtensionType.TransferFeeAmount: @@ -130,6 +133,7 @@ export function isAccountExtension(e: ExtensionType): boolean { case ExtensionType.PermanentDelegate: case ExtensionType.TransferHook: case ExtensionType.MetadataPointer: + case ExtensionType.TokenMetadata: return false; default: throw Error(`Unknown extension type: ${e}`); @@ -154,6 +158,7 @@ export function getAccountTypeOfMintType(e: ExtensionType): ExtensionType { case ExtensionType.MemoTransfer: case ExtensionType.MintCloseAuthority: case ExtensionType.MetadataPointer: + case ExtensionType.TokenMetadata: 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 0cb111e6ed7..72554069215 100644 --- a/token/js/src/extensions/index.ts +++ b/token/js/src/extensions/index.ts @@ -6,6 +6,7 @@ export * from './immutableOwner.js'; export * from './interestBearingMint/index.js'; export * from './memoTransfer/index.js'; export * from './metadataPointer/index.js'; +export * from './tokenMetadata/index.js'; export * from './mintCloseAuthority.js'; export * from './nonTransferable.js'; export * from './transferFee/index.js'; diff --git a/token/js/src/extensions/tokenMetadata/actions.ts b/token/js/src/extensions/tokenMetadata/actions.ts new file mode 100644 index 00000000000..78a0178b82c --- /dev/null +++ b/token/js/src/extensions/tokenMetadata/actions.ts @@ -0,0 +1,133 @@ +import type { ConfirmOptions, Connection, PublicKey, Signer, TransactionSignature } from '@solana/web3.js'; +import type { Field } from '@solana/spl-token-metadata'; +import { + createInitializeInstruction, + createEmitInstruction, + createUpdateFieldInstruction, +} from '@solana/spl-token-metadata'; +import { sendAndConfirmTransaction, Transaction } from '@solana/web3.js'; + +import { TOKEN_2022_PROGRAM_ID } from '../../constants.js'; +import { getSigners } from '../../actions/internal.js'; + +/** + * Initializes a TLV entry with the basic token-metadata fields. + * + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param updateAuthority Update Authority + * @param mint Mint Account + * @param mintAuthority Mint Authority + * @param name Longer name of token + * @param symbol Shortened symbol of token + * @param uri URI pointing to more metadata (image, video, etc) + * @param multiSigners Signing accounts if `authority` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * + * @return Signature of the confirmed transaction + */ +export async function tokenMetadataInitialize( + connection: Connection, + payer: Signer, + updateAuthority: PublicKey, + mint: PublicKey, + mintAuthority: PublicKey | Signer, + name: string, + symbol: string, + uri: string, + multiSigners: Signer[] = [], + confirmOptions?: ConfirmOptions, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const [mintAuthorityPublicKey, signers] = getSigners(mintAuthority, multiSigners); + + const transaction = new Transaction().add( + createInitializeInstruction({ + programId, + metadata: mint, + updateAuthority, + mint, + mintAuthority: mintAuthorityPublicKey, + name, + symbol, + uri, + }) + ); + + return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions); +} + +/** + * Updates a field in a token-metadata account. + * If the field does not exist on the account, it will be created. + * If the field does exist, it will be overwritten. + * + * The field can be one of the required fields (name, symbol, URI), or a + * totally new field denoted by a "key" string. + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param updateAuthority Update Authority + * @param mint Mint Account + * @param field Longer name of token + * @param value Shortened symbol of token + * @param multiSigners Signing accounts if `authority` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * + * @return Signature of the confirmed transaction + */ +export async function tokenMetadataUpdateField( + connection: Connection, + payer: Signer, + updateAuthority: PublicKey | Signer, + mint: PublicKey, + field: string | Field, + value: string, + multiSigners: Signer[] = [], + confirmOptions?: ConfirmOptions, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const [updateAuthorityPublicKey, signers] = getSigners(updateAuthority, multiSigners); + + const transaction = new Transaction().add( + createUpdateFieldInstruction({ + programId, + metadata: mint, + updateAuthority: updateAuthorityPublicKey, + field, + value, + }) + ); + + return await sendAndConfirmTransaction(connection, transaction, [payer, ...signers], confirmOptions); +} + +/** + * Emits the token-metadata as return data + * + * @param connection Connection to use + * @param payer Payer of the transaction fees + * @param mint Mint Account + * @param multiSigners Signing accounts if `authority` is a multisig + * @param confirmOptions Options for confirming the transaction + * @param programId SPL Token program account + * + * @return Signature of the confirmed transaction + */ +export async function tokenMetadataEmit( + connection: Connection, + payer: Signer, + mint: PublicKey, + confirmOptions?: ConfirmOptions, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const transaction = new Transaction().add( + createEmitInstruction({ + programId, + metadata: mint, + }) + ); + + return await sendAndConfirmTransaction(connection, transaction, [payer], confirmOptions); +} diff --git a/token/js/src/extensions/tokenMetadata/index.ts b/token/js/src/extensions/tokenMetadata/index.ts new file mode 100644 index 00000000000..898210857d0 --- /dev/null +++ b/token/js/src/extensions/tokenMetadata/index.ts @@ -0,0 +1,2 @@ +export * from './actions.js'; +export * from './state.js'; diff --git a/token/js/src/extensions/tokenMetadata/state.ts b/token/js/src/extensions/tokenMetadata/state.ts new file mode 100644 index 00000000000..5dd71a8616b --- /dev/null +++ b/token/js/src/extensions/tokenMetadata/state.ts @@ -0,0 +1,62 @@ +import type { Commitment, Connection, Finality, PublicKey } from '@solana/web3.js'; +import type { TokenMetadata } from '@solana/spl-token-metadata'; +import { unpack } from '@solana/spl-token-metadata'; + +import { TOKEN_2022_PROGRAM_ID } from '../../constants.js'; +import { ExtensionType, getExtensionData } from '../extensionType.js'; +import { getMint } from '../../state/mint.js'; + +/** + * Retrieve Token Metadata Information + * + * @param connection Connection to use + * @param address Mint account + * @param commitment Desired level of commitment for querying the state + * @param programId SPL Token program account + * + * @return Token Metadata information + */ +export async function getTokenMetadata( + connection: Connection, + address: PublicKey, + commitment?: Commitment, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const mintInfo = await getMint(connection, address, commitment, programId); + const data = getExtensionData(ExtensionType.TokenMetadata, mintInfo.tlvData); + + if (data === null) { + return null; + } + + return unpack(data); +} + +/** + * Retrieve Token Metadata Information emitted in transaction + * + * @param connection Connection to use + * @param signature Transaction signature + * @param commitment Desired level of commitment for querying the state + * + * @return Token Metadata information + */ +export async function getEmittedTokenMetadata( + connection: Connection, + signature: string, + commitment?: Finality, + programId = TOKEN_2022_PROGRAM_ID +): Promise { + const tx: any = await connection.getTransaction(signature, { + commitment: commitment, + maxSupportedTransactionVersion: 2, + }); + + const data = Buffer.from(tx?.meta?.returnData?.data?.[0], 'base64'); + + if (data === null) { + return null; + } + + return unpack(data); +} diff --git a/token/js/src/instructions/index.ts b/token/js/src/instructions/index.ts index 0a21c050a47..b2a18decac6 100644 --- a/token/js/src/instructions/index.ts +++ b/token/js/src/instructions/index.ts @@ -1,3 +1,11 @@ +export { + createInitializeInstruction, + createUpdateFieldInstruction, + createRemoveKeyInstruction, + createUpdateAuthorityInstruction, + createEmitInstruction, +} from '@solana/spl-token-metadata'; + export * from './associatedTokenAccount.js'; export * from './decode.js'; export * from './types.js'; diff --git a/token/js/test/e2e-2022/tokenMetadata.test.ts b/token/js/test/e2e-2022/tokenMetadata.test.ts new file mode 100644 index 00000000000..b4ba98046f6 --- /dev/null +++ b/token/js/test/e2e-2022/tokenMetadata.test.ts @@ -0,0 +1,150 @@ +import { expect } from 'chai'; + +import type { Connection, Signer } from '@solana/web3.js'; +import { sendAndConfirmTransaction, PublicKey, Keypair, SystemProgram, Transaction } from '@solana/web3.js'; + +import { + ExtensionType, + createInitializeMetadataPointerInstruction, + createInitializeMintInstruction, + tokenMetadataEmit, + tokenMetadataInitialize, + tokenMetadataUpdateField, + getMintLen, + getTokenMetadata, + getEmittedTokenMetadata, +} from '../../src'; +import { TEST_PROGRAM_ID, newAccountWithLamports, getConnection } from '../common'; + +const TEST_TOKEN_DECIMALS = 2; + +describe('Token Metadata', () => { + let connection: Connection; + let payer: Signer; + let mint: Keypair; + const authority = Keypair.fromSecretKey( + new Uint8Array([ + 118, 177, 37, 231, 15, 88, 210, 92, 79, 231, 202, 22, 11, 15, 121, 54, 95, 229, 149, 119, 48, 177, 187, 198, + 223, 51, 225, 74, 12, 54, 172, 36, 207, 107, 122, 208, 209, 168, 61, 177, 190, 137, 23, 156, 84, 32, 34, 82, + 158, 176, 55, 51, 236, 66, 130, 167, 118, 31, 120, 107, 100, 192, 147, 10, + ]) + ); + + before(async () => { + connection = await getConnection(); + payer = await newAccountWithLamports(connection, 1000000000); + }); + + beforeEach(async () => { + mint = Keypair.generate(); + + const EXTENSIONS = [ExtensionType.MetadataPointer]; + 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: lamports * 10, //TODO:- Handle rent + programId: TEST_PROGRAM_ID, + }), + createInitializeMetadataPointerInstruction( + mint.publicKey, + authority.publicKey, + mint.publicKey, + TEST_PROGRAM_ID + ), + createInitializeMintInstruction( + mint.publicKey, + TEST_TOKEN_DECIMALS, + authority.publicKey, + null, + TEST_PROGRAM_ID + ) + ); + + await sendAndConfirmTransaction(connection, transaction, [payer, mint], undefined); + + await tokenMetadataInitialize( + connection, + payer, + authority.publicKey, + mint.publicKey, + authority, + 'name', + 'symbol', + 'uri', + undefined, + undefined, + TEST_PROGRAM_ID + ); + }); + + it('can successfully initialize', async () => { + const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID); + expect(meta).to.deep.equal({ + updateAuthority: new PublicKey('ExgT3gCWXJzY4a9SHqTqsTk6dPAj37WNq2uWNbmMG1JR'), + mint: mint.publicKey, + name: 'name', + symbol: 'symbol', + uri: 'uri', + additionalMetadata: [], + }); + }); + + it('can successfully emit', async () => { + const signature = await tokenMetadataEmit(connection, payer, mint.publicKey); + + const meta = await getEmittedTokenMetadata(connection, signature); + + expect(meta).to.deep.equal({ + updateAuthority: new PublicKey('ExgT3gCWXJzY4a9SHqTqsTk6dPAj37WNq2uWNbmMG1JR'), + mint: mint.publicKey, + name: 'name', + symbol: 'symbol', + uri: 'uri', + additionalMetadata: [], + }); + }); + + it('can successfully update', async () => { + await Promise.all([ + tokenMetadataUpdateField( + connection, + payer, + authority, + mint.publicKey, + 'TVL', + '1,000,000', + undefined, + undefined, + TEST_PROGRAM_ID + ), + tokenMetadataUpdateField( + connection, + payer, + authority, + mint.publicKey, + 'name', + 'TEST', + undefined, + undefined, + TEST_PROGRAM_ID + ), + ]); + + const meta = await getTokenMetadata(connection, mint.publicKey, undefined, TEST_PROGRAM_ID); + + expect(meta).to.deep.equal({ + updateAuthority: new PublicKey('ExgT3gCWXJzY4a9SHqTqsTk6dPAj37WNq2uWNbmMG1JR'), + mint: mint.publicKey, + name: 'TEST', + symbol: 'symbol', + uri: 'uri', + additionalMetadata: [['TVL', '1,000,000']], + }); + }); +});